Files
PiCloud/crates/manager-core/src/app_user_repo.rs
MechaCat02 fea95bd63b fix(manager-core): F-P-012 keyset cursor on app_user_repo::list (created_at, id) tiebreaker
ORDER BY created_at DESC, id DESC but cursor was `WHERE created_at <
$2` — when two users were created at the same instant, pagination
could skip the second row at a page boundary or return it twice.

- Add ListCursor { created_at, id } with `<rfc3339>_<uuid>` encode /
  decode helpers.
- Change WHERE predicate to `(created_at, id) < ($2, $3)` matching the
  ORDER BY.
- Surface the opaque cursor string through UsersListOpts /
  UsersListPage / users_admin_api ListUsersResponse instead of the raw
  DateTime — keeps the wire format stable while the id half stops the
  boundary bug.

Same shape as F-P-005 (execution_logs).

AUDIT.md anchor: F-P-012.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 20:50:25 +02:00

432 lines
14 KiB
Rust

//! CRUD over the `app_users` table (v1.1.8 data-plane user management).
//!
//! Distinct from `admin_user_repo.rs`: that's the control-plane
//! operator table (you / me / the dashboard login). This is the
//! script-end-user surface — created by app scripts via `users::*`,
//! managed by the dashboard's per-app Users tab.
//!
//! Identity tuple is `(app_id, id)`; uniqueness is enforced on
//! `(app_id, lower(email))` so the same email can exist in different
//! apps but not twice in the same app. The repo always filters by
//! `app_id` — there is no "get any user" query. Cross-app reads must
//! pass two `app_id` values explicitly, which keeps the v1.1.3-style
//! cross-app discipline obvious at the call site.
//!
//! Password hashes go in and come out as opaque Argon2id PHC strings —
//! the repo never computes or inspects them; that's `auth.rs`'s job.
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use picloud_shared::{AppId, AppUserId};
use sqlx::PgPool;
#[derive(Debug, thiserror::Error)]
pub enum AppUserRepositoryError {
#[error("database error: {0}")]
Db(#[from] sqlx::Error),
#[error("not found: {0}")]
NotFound(AppUserId),
#[error("email already in use: {0}")]
DuplicateEmail(String),
}
/// Row returned to handlers and the service layer. Never includes the
/// password hash — that ships out of [`AppUserCredentials`] on the
/// dedicated login lookup, mirroring how `admin_user_repo` splits its
/// public row from its credential row.
#[derive(Debug, Clone)]
pub struct AppUserRow {
pub id: AppUserId,
pub app_id: AppId,
pub email: String,
pub display_name: Option<String>,
pub email_verified_at: Option<DateTime<Utc>>,
pub last_login_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// Credentials fetched for the login path only. Splitting the hash off
/// from the public row makes it obvious in service code which calls
/// touch a secret.
#[derive(Debug, Clone)]
pub struct AppUserCredentials {
pub id: AppUserId,
pub app_id: AppId,
pub email: String,
pub password_hash: String,
}
#[async_trait]
pub trait AppUserRepository: Send + Sync {
async fn get(
&self,
app_id: AppId,
id: AppUserId,
) -> Result<Option<AppUserRow>, AppUserRepositoryError>;
/// Case-insensitive email lookup, scoped to a single app.
async fn find_by_email(
&self,
app_id: AppId,
email: &str,
) -> Result<Option<AppUserRow>, AppUserRepositoryError>;
/// Credentials lookup for the login path. Case-insensitive on email.
/// Returns `None` for missing — callers run a timing-flat dummy
/// verify on miss to avoid leaking which path was taken.
async fn get_credentials_by_email(
&self,
app_id: AppId,
email: &str,
) -> Result<Option<AppUserCredentials>, AppUserRepositoryError>;
async fn list(
&self,
app_id: AppId,
opts: ListOpts,
) -> Result<ListPage<AppUserRow>, AppUserRepositoryError>;
async fn create(
&self,
app_id: AppId,
email: &str,
password_hash: &str,
display_name: Option<&str>,
) -> Result<AppUserRow, AppUserRepositoryError>;
async fn update_display_name(
&self,
app_id: AppId,
id: AppUserId,
display_name: Option<&str>,
) -> Result<AppUserRow, AppUserRepositoryError>;
async fn update_password_hash(
&self,
app_id: AppId,
id: AppUserId,
password_hash: &str,
) -> Result<AppUserRow, AppUserRepositoryError>;
async fn mark_email_verified(
&self,
app_id: AppId,
id: AppUserId,
) -> Result<AppUserRow, AppUserRepositoryError>;
async fn touch_last_login(
&self,
app_id: AppId,
id: AppUserId,
) -> Result<(), AppUserRepositoryError>;
async fn delete(&self, app_id: AppId, id: AppUserId) -> Result<bool, AppUserRepositoryError>;
}
/// F-P-012: cursor carries `(created_at, id)` for keyset pagination.
/// Without the `id` tiebreaker, two users created at the same instant
/// could be skipped or duplicated at a page boundary.
#[derive(Debug, Clone, Copy)]
pub struct ListCursor {
pub created_at: DateTime<Utc>,
pub id: picloud_shared::AppUserId,
}
impl ListCursor {
/// Encode as `<rfc3339>_<uuid>` — what we surface to script-land
/// via `users::list`'s `next_cursor` field.
#[must_use]
pub fn encode(&self) -> String {
format!("{}_{}", self.created_at.to_rfc3339(), self.id.into_inner())
}
/// Decode the wire format. Returns `None` on any parse error — the
/// caller treats that as "start of page" rather than 400'ing on a
/// stale cursor from a previous schema.
#[must_use]
pub fn decode(s: &str) -> Option<Self> {
let (ts, id) = s.rsplit_once('_')?;
let created_at = DateTime::parse_from_rfc3339(ts).ok()?.to_utc();
let id = uuid::Uuid::parse_str(id).ok()?;
Some(Self {
created_at,
id: picloud_shared::AppUserId::from(id),
})
}
}
#[derive(Debug, Clone, Default)]
pub struct ListOpts {
pub cursor: Option<ListCursor>,
pub limit: i64,
}
#[derive(Debug, Clone)]
pub struct ListPage<T> {
pub items: Vec<T>,
pub next_cursor: Option<ListCursor>,
}
pub struct PostgresAppUserRepository {
pool: PgPool,
}
impl PostgresAppUserRepository {
#[must_use]
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl AppUserRepository for PostgresAppUserRepository {
async fn get(
&self,
app_id: AppId,
id: AppUserId,
) -> Result<Option<AppUserRow>, AppUserRepositoryError> {
let row = sqlx::query_as::<_, AppUserRecord>(
"SELECT id, app_id, email, display_name, email_verified_at, \
last_login_at, created_at, updated_at \
FROM app_users WHERE app_id = $1 AND id = $2",
)
.bind(app_id.into_inner())
.bind(id.into_inner())
.fetch_optional(&self.pool)
.await?;
Ok(row.map(Into::into))
}
async fn find_by_email(
&self,
app_id: AppId,
email: &str,
) -> Result<Option<AppUserRow>, AppUserRepositoryError> {
let row = sqlx::query_as::<_, AppUserRecord>(
"SELECT id, app_id, email, display_name, email_verified_at, \
last_login_at, created_at, updated_at \
FROM app_users WHERE app_id = $1 AND lower(email) = lower($2)",
)
.bind(app_id.into_inner())
.bind(email)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(Into::into))
}
async fn get_credentials_by_email(
&self,
app_id: AppId,
email: &str,
) -> Result<Option<AppUserCredentials>, AppUserRepositoryError> {
let row = sqlx::query_as::<_, AppUserCredsRecord>(
"SELECT id, app_id, email, password_hash \
FROM app_users WHERE app_id = $1 AND lower(email) = lower($2)",
)
.bind(app_id.into_inner())
.bind(email)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(Into::into))
}
async fn list(
&self,
app_id: AppId,
opts: ListOpts,
) -> Result<ListPage<AppUserRow>, AppUserRepositoryError> {
let limit = opts.limit.clamp(1, 500);
// Fetch one extra to detect whether more pages exist.
let rows = if let Some(cursor) = opts.cursor {
sqlx::query_as::<_, AppUserRecord>(
"SELECT id, app_id, email, display_name, email_verified_at, \
last_login_at, created_at, updated_at \
FROM app_users WHERE app_id = $1 \
AND (created_at, id) < ($2, $3) \
ORDER BY created_at DESC, id DESC LIMIT $4",
)
.bind(app_id.into_inner())
.bind(cursor.created_at)
.bind(cursor.id.into_inner())
.bind(limit + 1)
.fetch_all(&self.pool)
.await?
} else {
sqlx::query_as::<_, AppUserRecord>(
"SELECT id, app_id, email, display_name, email_verified_at, \
last_login_at, created_at, updated_at \
FROM app_users WHERE app_id = $1 \
ORDER BY created_at DESC, id DESC LIMIT $2",
)
.bind(app_id.into_inner())
.bind(limit + 1)
.fetch_all(&self.pool)
.await?
};
let mut items: Vec<AppUserRow> = rows.into_iter().map(Into::into).collect();
let next_cursor = if i64::try_from(items.len()).unwrap_or(i64::MAX) > limit {
let cursor_row = items.pop().expect("len > limit so there is a row");
Some(ListCursor {
created_at: cursor_row.created_at,
id: cursor_row.id,
})
} else {
None
};
Ok(ListPage { items, next_cursor })
}
async fn create(
&self,
app_id: AppId,
email: &str,
password_hash: &str,
display_name: Option<&str>,
) -> Result<AppUserRow, AppUserRepositoryError> {
let res = sqlx::query_as::<_, AppUserRecord>(
"INSERT INTO app_users (app_id, email, password_hash, display_name) \
VALUES ($1, $2, $3, $4) \
RETURNING id, app_id, email, display_name, email_verified_at, \
last_login_at, created_at, updated_at",
)
.bind(app_id.into_inner())
.bind(email)
.bind(password_hash)
.bind(display_name)
.fetch_one(&self.pool)
.await;
match res {
Ok(row) => Ok(row.into()),
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
Err(AppUserRepositoryError::DuplicateEmail(email.to_string()))
}
Err(e) => Err(e.into()),
}
}
async fn update_display_name(
&self,
app_id: AppId,
id: AppUserId,
display_name: Option<&str>,
) -> Result<AppUserRow, AppUserRepositoryError> {
let row = sqlx::query_as::<_, AppUserRecord>(
"UPDATE app_users SET display_name = $3, updated_at = NOW() \
WHERE app_id = $1 AND id = $2 \
RETURNING id, app_id, email, display_name, email_verified_at, \
last_login_at, created_at, updated_at",
)
.bind(app_id.into_inner())
.bind(id.into_inner())
.bind(display_name)
.fetch_optional(&self.pool)
.await?;
row.map(Into::into)
.ok_or(AppUserRepositoryError::NotFound(id))
}
async fn update_password_hash(
&self,
app_id: AppId,
id: AppUserId,
password_hash: &str,
) -> Result<AppUserRow, AppUserRepositoryError> {
let row = sqlx::query_as::<_, AppUserRecord>(
"UPDATE app_users SET password_hash = $3, updated_at = NOW() \
WHERE app_id = $1 AND id = $2 \
RETURNING id, app_id, email, display_name, email_verified_at, \
last_login_at, created_at, updated_at",
)
.bind(app_id.into_inner())
.bind(id.into_inner())
.bind(password_hash)
.fetch_optional(&self.pool)
.await?;
row.map(Into::into)
.ok_or(AppUserRepositoryError::NotFound(id))
}
async fn mark_email_verified(
&self,
app_id: AppId,
id: AppUserId,
) -> Result<AppUserRow, AppUserRepositoryError> {
let row = sqlx::query_as::<_, AppUserRecord>(
"UPDATE app_users SET email_verified_at = NOW(), updated_at = NOW() \
WHERE app_id = $1 AND id = $2 \
RETURNING id, app_id, email, display_name, email_verified_at, \
last_login_at, created_at, updated_at",
)
.bind(app_id.into_inner())
.bind(id.into_inner())
.fetch_optional(&self.pool)
.await?;
row.map(Into::into)
.ok_or(AppUserRepositoryError::NotFound(id))
}
async fn touch_last_login(
&self,
app_id: AppId,
id: AppUserId,
) -> Result<(), AppUserRepositoryError> {
sqlx::query("UPDATE app_users SET last_login_at = NOW() WHERE app_id = $1 AND id = $2")
.bind(app_id.into_inner())
.bind(id.into_inner())
.execute(&self.pool)
.await?;
Ok(())
}
async fn delete(&self, app_id: AppId, id: AppUserId) -> Result<bool, AppUserRepositoryError> {
let res = sqlx::query("DELETE FROM app_users WHERE app_id = $1 AND id = $2")
.bind(app_id.into_inner())
.bind(id.into_inner())
.execute(&self.pool)
.await?;
Ok(res.rows_affected() > 0)
}
}
#[derive(sqlx::FromRow)]
struct AppUserRecord {
id: uuid::Uuid,
app_id: uuid::Uuid,
email: String,
display_name: Option<String>,
email_verified_at: Option<DateTime<Utc>>,
last_login_at: Option<DateTime<Utc>>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
impl From<AppUserRecord> for AppUserRow {
fn from(r: AppUserRecord) -> Self {
Self {
id: r.id.into(),
app_id: r.app_id.into(),
email: r.email,
display_name: r.display_name,
email_verified_at: r.email_verified_at,
last_login_at: r.last_login_at,
created_at: r.created_at,
updated_at: r.updated_at,
}
}
}
#[derive(sqlx::FromRow)]
struct AppUserCredsRecord {
id: uuid::Uuid,
app_id: uuid::Uuid,
email: String,
password_hash: String,
}
impl From<AppUserCredsRecord> for AppUserCredentials {
fn from(r: AppUserCredsRecord) -> Self {
Self {
id: r.id.into(),
app_id: r.app_id.into(),
email: r.email,
password_hash: r.password_hash,
}
}
}