Argon2id hashing (~15-50ms, 19 MiB) ran synchronously inside async handlers, stalling every task sharing those runtime worker threads under concurrent auth load. Add spawn_blocking wrappers hash_password_async / verify_password_async and switch all async call sites; sync primitives stay for the login timing-equaliser and unit tests. Bump to 0.124.1. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
129 lines
3.7 KiB
Rust
129 lines
3.7 KiB
Rust
//! 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::auth::{validate_password, validate_username};
|
|
use crate::api::pagination::PagedResponse;
|
|
use crate::app::AppState;
|
|
use crate::auth::extractor::RequireAdmin;
|
|
use crate::auth::password::hash_password_async;
|
|
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).post(create_user))
|
|
.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)
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct CreateUserInput {
|
|
pub username: String,
|
|
pub password: String,
|
|
/// Defaults to false; admins may mint other admins in a single
|
|
/// call. Doing it as one POST avoids a second audit row for the
|
|
/// common "invite a co-admin" flow.
|
|
#[serde(default)]
|
|
pub is_admin: bool,
|
|
}
|
|
|
|
async fn create_user(
|
|
State(state): State<AppState>,
|
|
RequireAdmin(actor): RequireAdmin,
|
|
Json(input): Json<CreateUserInput>,
|
|
) -> AppResult<(StatusCode, Json<User>)> {
|
|
let username = input.username.trim();
|
|
// Reuse the canonical self-register validators so the admin-create
|
|
// path can never produce a username that self-register would
|
|
// reject (and vice versa).
|
|
validate_username(username)?;
|
|
validate_password(&input.password)?;
|
|
let pwhash = hash_password_async(input.password.clone()).await?;
|
|
let user = repo::user::admin_create_user(
|
|
&state.db,
|
|
actor.id,
|
|
username,
|
|
&pwhash,
|
|
input.is_admin,
|
|
)
|
|
.await?;
|
|
Ok((StatusCode::CREATED, Json(user)))
|
|
}
|