//! User persistence. use sqlx::PgPool; use uuid::Uuid; use crate::domain::User; use crate::error::{AppError, AppResult}; pub async fn create(pool: &PgPool, username: &str, password_hash: &str) -> AppResult { let result = sqlx::query_as::<_, User>( r#" INSERT INTO users (username, password_hash) VALUES ($1, $2) RETURNING id, username, password_hash, created_at, is_admin "#, ) .bind(username) .bind(password_hash) .fetch_one(pool) .await; match result { Ok(user) => Ok(user), Err(sqlx::Error::Database(ref db_err)) if db_err.is_unique_violation() => { Err(AppError::Conflict("username is already taken".into())) } Err(e) => Err(AppError::Database(e)), } } /// Case-insensitive lookup so login with "Alice" matches a user /// registered as "alice" (the unique index on `lower(username)` keeps /// the comparison cheap). Equivalent in spirit to ILIKE but uses the /// functional index directly. pub async fn find_by_username(pool: &PgPool, username: &str) -> AppResult> { let row = sqlx::query_as::<_, User>( r#" SELECT id, username, password_hash, created_at, is_admin FROM users WHERE lower(username) = lower($1) "#, ) .bind(username) .fetch_optional(pool) .await?; Ok(row) } pub async fn find_by_id(pool: &PgPool, id: Uuid) -> AppResult> { let row = sqlx::query_as::<_, User>( r#"SELECT id, username, password_hash, created_at, is_admin FROM users WHERE id = $1"#, ) .bind(id) .fetch_optional(pool) .await?; Ok(row) } /// Postgres advisory-lock key guarding admin-count-changing operations /// (demote, delete-admin). Without this lock two concurrent demotes of /// different admins could each pass their "more than one admin remains" /// check, then commit, leaving zero admins. The lock serialises any tx /// that might change the admin count so the recount under the lock is /// authoritative. /// /// Value is the bytes of "admininv" interpreted as a big-endian i64. /// Postgres' advisory-lock keyspace is global; collision risk with /// `CRON_LOCK_KEY` and friends is ~2^-64. pub const ADMIN_INVARIANT_LOCK_KEY: i64 = 0x61_64_6d_69_6e_69_6e_76; #[derive(Debug, Default)] pub struct ListUsersQuery { pub search: Option, pub limit: i64, pub offset: i64, } /// Paginated user list with total count. `search` is a case-insensitive /// substring match on `username`. Order is alphabetical by username so /// pagination is stable across concurrent writes (mangas changing /// is_admin doesn't reshuffle the page). pub async fn list_with_total( pool: &PgPool, q: &ListUsersQuery, ) -> AppResult<(Vec, i64)> { let pat = q .search .as_ref() .map(|s| format!("%{}%", s.trim())) .filter(|p| p.len() > 2); let items = sqlx::query_as::<_, User>( r#" SELECT id, username, password_hash, created_at, is_admin FROM users WHERE ($1::text IS NULL OR username ILIKE $1) ORDER BY username LIMIT $2 OFFSET $3 "#, ) .bind(&pat) .bind(q.limit) .bind(q.offset) .fetch_all(pool) .await?; let total: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM users WHERE ($1::text IS NULL OR username ILIKE $1)", ) .bind(&pat) .fetch_one(pool) .await?; Ok((items, total)) } /// Raw `is_admin` update with no safety checks, no audit log, and no /// advisory lock. Exists only as a test setup helper for the admin- /// feature integration suite — production code MUST go through /// [`admin_safe_set_is_admin`], which enforces self-protection, the /// last-admin invariant, and the audit log atomically. pub async fn set_is_admin_unchecked(pool: &PgPool, id: Uuid, value: bool) -> AppResult<()> { sqlx::query("UPDATE users SET is_admin = $1 WHERE id = $2") .bind(value) .bind(id) .execute(pool) .await?; Ok(()) } /// Ensure the user `username` exists and is an admin. Called at startup /// from `app::build` when `ADMIN_USERNAME` / `ADMIN_PASSWORD` are set. /// /// Semantics — see cross-cutting decision #2 in the feature plan: /// - If no row exists: create with the env-supplied password hashed via /// argon2id and `is_admin = true`. /// - If a row already exists: flip `is_admin` to true if needed; **never** /// touch the existing `password_hash`. Lets the operator rotate the /// admin password through the UI without env-var conflict. /// Wrapped in a transaction so a concurrent `register` for the same /// username can't slip an INSERT between the SELECT and UPDATE/INSERT. /// Set `is_admin` on a user with full safety checks: rejects self-demote, /// rejects demoting the only remaining admin (under `ADMIN_INVARIANT_LOCK_KEY` /// to close the parallel-demote race), and writes an `admin_audit` row /// in the same tx so the log mirrors what actually committed. /// /// Returns the freshly-written user row (so the handler can return it /// without a second SELECT). pub async fn admin_safe_set_is_admin( pool: &PgPool, actor_id: Uuid, target_id: Uuid, value: bool, ) -> AppResult { // Cheap pre-check before opening a tx — also covers the "demote me" // case which would otherwise pass the recount when other admins exist. if actor_id == target_id && !value { return Err(AppError::Conflict( "cannot demote yourself; ask another admin".into(), )); } let mut tx = pool.begin().await?; sqlx::query("SELECT pg_advisory_xact_lock($1)") .bind(ADMIN_INVARIANT_LOCK_KEY) .execute(&mut *tx) .await?; let target: Option = sqlx::query_as( "SELECT id, username, password_hash, created_at, is_admin \ FROM users WHERE id = $1 FOR UPDATE", ) .bind(target_id) .fetch_optional(&mut *tx) .await?; let Some(target) = target else { return Err(AppError::NotFound); }; // No-op: caller asked to set `is_admin` to its current value. Return // the row as-is without writing an audit entry — otherwise repeated // PATCH calls (browser retry, double-click) pile misleading // "promote_user" rows in `admin_audit` for actions that changed // nothing. if target.is_admin == value { tx.commit().await?; return Ok(target); } // Recount inside the lock — this is the authoritative read. if target.is_admin && !value { let admin_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE is_admin = true") .fetch_one(&mut *tx) .await?; if admin_count <= 1 { return Err(AppError::Conflict( "cannot demote the last admin; promote another user first".into(), )); } } let updated: User = sqlx::query_as( "UPDATE users SET is_admin = $1 WHERE id = $2 \ RETURNING id, username, password_hash, created_at, is_admin", ) .bind(value) .bind(target_id) .fetch_one(&mut *tx) .await?; let action = if value { "promote_user" } else { "demote_user" }; crate::repo::admin_audit::insert( &mut *tx, actor_id, action, "user", Some(target_id), serde_json::json!({ "username": target.username }), ) .await?; tx.commit().await?; Ok(updated) } /// Delete a user with full safety checks: rejects self-delete, rejects /// deleting the only remaining admin (under `ADMIN_INVARIANT_LOCK_KEY`), /// and writes an `admin_audit` row in the same tx. Captures the deleted /// username + admin status in the audit payload so the action is /// readable after the user row itself is gone. pub async fn admin_safe_delete( pool: &PgPool, actor_id: Uuid, target_id: Uuid, ) -> AppResult<()> { if actor_id == target_id { return Err(AppError::Conflict( "cannot delete yourself; ask another admin".into(), )); } let mut tx = pool.begin().await?; sqlx::query("SELECT pg_advisory_xact_lock($1)") .bind(ADMIN_INVARIANT_LOCK_KEY) .execute(&mut *tx) .await?; let target: Option = sqlx::query_as( "SELECT id, username, password_hash, created_at, is_admin \ FROM users WHERE id = $1 FOR UPDATE", ) .bind(target_id) .fetch_optional(&mut *tx) .await?; let Some(target) = target else { return Err(AppError::NotFound); }; if target.is_admin { let admin_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE is_admin = true") .fetch_one(&mut *tx) .await?; if admin_count <= 1 { return Err(AppError::Conflict( "cannot delete the last admin; promote another user first".into(), )); } } sqlx::query("DELETE FROM users WHERE id = $1") .bind(target_id) .execute(&mut *tx) .await?; crate::repo::admin_audit::insert( &mut *tx, actor_id, "delete_user", "user", Some(target_id), serde_json::json!({ "username": target.username, "was_admin": target.is_admin, }), ) .await?; tx.commit().await?; Ok(()) } /// Admin-initiated user creation. Wraps the INSERT + audit row in a /// single transaction so a rolled-back create never leaves an orphan /// audit entry. Caller (HTTP handler) is responsible for validating /// `username`/`password` and hashing — this fn assumes both are /// already vetted by the same `validate_*` rules used by self- /// registration. pub async fn admin_create_user( pool: &PgPool, actor_id: Uuid, username: &str, password_hash: &str, is_admin: bool, ) -> AppResult { let mut tx = pool.begin().await?; let user: User = match sqlx::query_as::<_, User>( "INSERT INTO users (username, password_hash, is_admin) VALUES ($1, $2, $3) \ RETURNING id, username, password_hash, created_at, is_admin", ) .bind(username) .bind(password_hash) .bind(is_admin) .fetch_one(&mut *tx) .await { Ok(u) => u, Err(sqlx::Error::Database(ref db_err)) if db_err.is_unique_violation() => { return Err(AppError::Conflict("username is already taken".into())); } Err(e) => return Err(AppError::Database(e)), }; crate::repo::admin_audit::insert( &mut *tx, actor_id, "create_user", "user", Some(user.id), serde_json::json!({ "username": user.username, "is_admin": user.is_admin, }), ) .await?; tx.commit().await?; Ok(user) } pub async fn bootstrap_admin( pool: &PgPool, username: &str, password: &str, ) -> AppResult<()> { let mut tx = pool.begin().await?; let existing: Option<(Uuid,)> = sqlx::query_as( "SELECT id FROM users WHERE lower(username) = lower($1) FOR UPDATE", ) .bind(username) .fetch_optional(&mut *tx) .await?; match existing { Some((id,)) => { sqlx::query("UPDATE users SET is_admin = true WHERE id = $1 AND is_admin = false") .bind(id) .execute(&mut *tx) .await?; } None => { let hash = crate::auth::password::hash_password(password)?; sqlx::query("INSERT INTO users (username, password_hash, is_admin) VALUES ($1, $2, true)") .bind(username) .bind(&hash) .execute(&mut *tx) .await?; } } tx.commit().await?; Ok(()) }