Adds /api/v1/admin/users list / DELETE / PATCH guarded by RequireAdmin, plus the audit-log substrate every future destructive admin endpoint will reuse. Safety properties: - Cannot self-delete or self-demote (409 conflict, message calls out "yourself" so the UI can render an explanation). - Cannot remove the last admin via either DELETE or demote. The check takes pg_advisory_xact_lock(ADMIN_INVARIANT_LOCK_KEY) and re-counts admins inside the same tx, closing the parallel-demote race that a bare "if count > 1" check would let through. The HTTP-serial path to this guard is structurally unreachable (the actor would have to be the lone admin demoting themselves, which the self-guard fires on first); the parallel race test exercises it via repo calls. Audit log (admin_audit table) records the action inside the same tx as the action itself, so a rolled-back action never leaves an orphan audit row. actor_user_id is ON DELETE SET NULL so the log outlives a later-deleted admin. target_id is not a FK because future audit kinds will target non-user rows.
33 lines
805 B
Rust
33 lines
805 B
Rust
//! Admin-action audit log writes.
|
|
//!
|
|
//! Insert is always called from inside the same transaction as the
|
|
//! action it audits — the executor parameter is `PgExecutor` so the
|
|
//! caller passes `&mut *tx` directly.
|
|
|
|
use sqlx::PgExecutor;
|
|
use uuid::Uuid;
|
|
|
|
use crate::error::AppResult;
|
|
|
|
pub async fn insert<'e, E: PgExecutor<'e>>(
|
|
executor: E,
|
|
actor_user_id: Uuid,
|
|
action: &str,
|
|
target_kind: &str,
|
|
target_id: Option<Uuid>,
|
|
payload: serde_json::Value,
|
|
) -> AppResult<()> {
|
|
sqlx::query(
|
|
"INSERT INTO admin_audit (actor_user_id, action, target_kind, target_id, payload) \
|
|
VALUES ($1, $2, $3, $4, $5)",
|
|
)
|
|
.bind(actor_user_id)
|
|
.bind(action)
|
|
.bind(target_kind)
|
|
.bind(target_id)
|
|
.bind(payload)
|
|
.execute(executor)
|
|
.await?;
|
|
Ok(())
|
|
}
|