feat(api): admin user management endpoints with audit log (0.38.0)
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.
This commit is contained in:
15
backend/src/api/admin/mod.rs
Normal file
15
backend/src/api/admin/mod.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
//! Admin-only endpoints. Mounted under `/api/v1/admin/*` by
|
||||
//! `crate::api::routes`. Every handler in this subtree is guarded by
|
||||
//! `RequireAdmin`, which only accepts session-cookie authentication —
|
||||
//! bot/API tokens cannot reach admin routes (see
|
||||
//! `crate::auth::extractor::RequireAdmin`).
|
||||
|
||||
pub mod users;
|
||||
|
||||
use axum::Router;
|
||||
|
||||
use crate::app::AppState;
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new().merge(users::routes())
|
||||
}
|
||||
92
backend/src/api/admin/users.rs
Normal file
92
backend/src/api/admin/users.rs
Normal file
@@ -0,0 +1,92 @@
|
||||
//! Admin user management: list, delete, promote/demote.
|
||||
//!
|
||||
//! All handlers are gated by `RequireAdmin` and rely on
|
||||
//! `repo::user::admin_safe_*` for self-protection and the last-admin
|
||||
//! invariant. Audit rows are written inside the same DB transaction as
|
||||
//! the action they record.
|
||||
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::routing::{delete, get};
|
||||
use axum::{Json, Router};
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::api::pagination::PagedResponse;
|
||||
use crate::app::AppState;
|
||||
use crate::auth::extractor::RequireAdmin;
|
||||
use crate::domain::User;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::repo;
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/admin/users", get(list_users))
|
||||
.route(
|
||||
"/admin/users/:id",
|
||||
delete(delete_user).patch(update_user),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
pub struct ListUsersParams {
|
||||
#[serde(default)]
|
||||
pub search: Option<String>,
|
||||
#[serde(default = "default_limit")]
|
||||
pub limit: i64,
|
||||
#[serde(default)]
|
||||
pub offset: i64,
|
||||
}
|
||||
|
||||
fn default_limit() -> i64 {
|
||||
50
|
||||
}
|
||||
|
||||
async fn list_users(
|
||||
State(state): State<AppState>,
|
||||
_admin: RequireAdmin,
|
||||
Query(params): Query<ListUsersParams>,
|
||||
) -> AppResult<Json<PagedResponse<User>>> {
|
||||
let limit = params.limit.clamp(1, 200);
|
||||
let offset = params.offset.max(0);
|
||||
let (items, total) = repo::user::list_with_total(
|
||||
&state.db,
|
||||
&repo::user::ListUsersQuery {
|
||||
search: params.search.filter(|s| !s.trim().is_empty()),
|
||||
limit,
|
||||
offset,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(PagedResponse::with_total(items, limit, offset, total)))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateUserInput {
|
||||
pub is_admin: Option<bool>,
|
||||
}
|
||||
|
||||
async fn update_user(
|
||||
State(state): State<AppState>,
|
||||
RequireAdmin(actor): RequireAdmin,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(input): Json<UpdateUserInput>,
|
||||
) -> AppResult<Json<User>> {
|
||||
let Some(is_admin) = input.is_admin else {
|
||||
return Err(AppError::InvalidInput(
|
||||
"no updatable fields supplied".into(),
|
||||
));
|
||||
};
|
||||
let updated =
|
||||
repo::user::admin_safe_set_is_admin(&state.db, actor.id, id, is_admin).await?;
|
||||
Ok(Json(updated))
|
||||
}
|
||||
|
||||
async fn delete_user(
|
||||
State(state): State<AppState>,
|
||||
RequireAdmin(actor): RequireAdmin,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> AppResult<StatusCode> {
|
||||
repo::user::admin_safe_delete(&state.db, actor.id, id).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
Reference in New Issue
Block a user