Pairs with the ALLOW_SELF_REGISTER toggle from 0.42.0: admins can mint
accounts regardless of the toggle state, so a closed-membership
deployment still has a working enrollment path. The endpoint accepts
{ username, password, is_admin? } so admins can mint co-admins in one
call (avoiding a separate promote + extra audit row for the common
"invite a co-admin" flow).
Implementation:
- POST /api/v1/admin/users guarded by RequireAdmin
- Reuses validate_username / validate_password from api::auth (made
pub(crate)) so the admin path can never produce an account self-
register would reject and vice versa
- repo::user::admin_create_user wraps INSERT + admin_audit insert in
a single tx — same "audit reflects what committed" semantics as the
existing admin_safe_* fns
- Audit row: action="create_user", payload={username, is_admin}
Frontend:
- createAdminUser() in lib/api/admin.ts
- /admin/users grows a collapsible "Create user" form above the table
(username, password, "Make admin" checkbox). Errors surface inline;
the list reloads on success.
Backend tests: 7 new, including the headline
`create_user_works_even_when_self_register_disabled` that pins the
admin-create path is NOT gated by the public toggle.
373 lines
12 KiB
Rust
373 lines
12 KiB
Rust
//! 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<User> {
|
|
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<Option<User>> {
|
|
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<Option<User>> {
|
|
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<String>,
|
|
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<User>, 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<User> {
|
|
// 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<User> = 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<User> = 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<User> {
|
|
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(())
|
|
}
|