//! `/api/v1/admin/admins/*` โ€” admin user CRUD. Guarded by //! `require_admin`; every authenticated admin can call all of these. //! Role/permission walls land later (see blueprint ยง11.4 โ€” no //! privilege levels in this cut). //! //! "Last active admin" protection lives at the service layer (not just //! the DB) so it can produce a clean 422 with a human-readable message //! rather than a SQL constraint violation. Deactivating a user also //! wipes their sessions; deleting cascades through the FK. use std::sync::Arc; use axum::extract::{Path, State}; use axum::http::StatusCode; use axum::response::{IntoResponse, Json, Response}; use axum::routing::get; use axum::Router; use chrono::{DateTime, Utc}; use picloud_shared::AdminUserId; use serde::{Deserialize, Serialize}; use serde_json::json; use crate::admin_session_repo::AdminSessionRepository; use crate::admin_user_repo::{AdminUserRepository, AdminUserRepositoryError, AdminUserRow}; use crate::auth::hash_password; /// Validation knobs are tuned by NIST 800-63B-ish guidance: username is /// a strict ASCII subset so the lookup column stays predictable, and /// password has a minimum length but no complexity rules (complexity /// rules push users to predictable patterns). const USERNAME_MIN: usize = 2; const USERNAME_MAX: usize = 32; const PASSWORD_MIN: usize = 8; #[derive(Clone)] pub struct AdminsState { pub users: Arc, pub sessions: Arc, } pub fn admins_router(state: AdminsState) -> Router { Router::new() .route("/admins", get(list_admins).post(create_admin)) .route( "/admins/{id}", get(get_admin).patch(patch_admin).delete(delete_admin), ) .with_state(state) } // ---------------------------------------------------------------------------- // DTOs // ---------------------------------------------------------------------------- #[derive(Debug, Serialize)] pub struct AdminDto { pub id: AdminUserId, pub username: String, pub is_active: bool, pub created_at: DateTime, pub last_login_at: Option>, } impl From for AdminDto { fn from(r: AdminUserRow) -> Self { Self { id: r.id, username: r.username, is_active: r.is_active, created_at: r.created_at, last_login_at: r.last_login_at, } } } #[derive(Debug, Deserialize)] pub struct CreateAdminRequest { pub username: String, pub password: String, } #[derive(Debug, Deserialize, Default)] pub struct PatchAdminRequest { pub username: Option, pub password: Option, pub is_active: Option, } // ---------------------------------------------------------------------------- // Handlers // ---------------------------------------------------------------------------- async fn list_admins( State(state): State, ) -> Result>, AdminApiError> { let rows = state.users.list().await?; Ok(Json(rows.into_iter().map(Into::into).collect())) } async fn get_admin( State(state): State, Path(id): Path, ) -> Result, AdminApiError> { state .users .get(id) .await? .map(AdminDto::from) .map(Json) .ok_or(AdminApiError::NotFound(id)) } async fn create_admin( State(state): State, Json(input): Json, ) -> Result<(StatusCode, Json), AdminApiError> { let username = input.username.trim(); validate_username(username)?; validate_password(&input.password)?; let hash = hash_password(&input.password).map_err(|e| AdminApiError::Hash(e.to_string()))?; let row = state.users.create(username, &hash).await?; Ok((StatusCode::CREATED, Json(row.into()))) } async fn patch_admin( State(state): State, Path(id): Path, Json(input): Json, ) -> Result, AdminApiError> { // Verify the target exists upfront โ€” keeps the error path uniform // for "rename a missing user" etc. let _ = state .users .get(id) .await? .ok_or(AdminApiError::NotFound(id))?; let mut latest: Option = None; if let Some(raw_username) = input.username.as_deref() { let new_username = raw_username.trim(); validate_username(new_username)?; latest = Some(state.users.update_username(id, new_username).await?); } if let Some(new_password) = input.password.as_deref() { validate_password(new_password)?; let hash = hash_password(new_password).map_err(|e| AdminApiError::Hash(e.to_string()))?; latest = Some(state.users.update_password_hash(id, &hash).await?); // Best practice: rotating your own password should still keep // your session alive, so we don't wipe sessions here. (If we // wanted "log everyone else out on password change", that'd be // a `delete_for_user` + re-issue current session. Out of scope // for the initial cut.) } if let Some(new_active) = input.is_active { // Last-active-admin guard: only when transitioning to inactive. if !new_active { let remaining = state.users.count_active_excluding(id).await?; if remaining == 0 { return Err(AdminApiError::LastActiveAdmin); } } latest = Some(state.users.set_active(id, new_active).await?); // Deactivation invalidates all of the user's sessions. Cheap // and safer than waiting for sliding-window expiry. if !new_active { if let Err(err) = state.sessions.delete_for_user(id).await { tracing::error!(?err, "failed to delete sessions for deactivated admin"); } } } let row = match latest { Some(r) => r, None => state .users .get(id) .await? .ok_or(AdminApiError::NotFound(id))?, }; Ok(Json(row.into())) } async fn delete_admin( State(state): State, Path(id): Path, ) -> Result { let target = state .users .get(id) .await? .ok_or(AdminApiError::NotFound(id))?; if target.is_active { let remaining = state.users.count_active_excluding(id).await?; if remaining == 0 { return Err(AdminApiError::LastActiveAdmin); } } state.users.delete(id).await?; // Sessions cascade via FK; no explicit delete needed. Ok(StatusCode::NO_CONTENT) } // ---------------------------------------------------------------------------- // Validation // ---------------------------------------------------------------------------- fn validate_username(s: &str) -> Result<(), AdminApiError> { if s.len() < USERNAME_MIN || s.len() > USERNAME_MAX { return Err(AdminApiError::InvalidUsername(format!( "username must be {USERNAME_MIN}-{USERNAME_MAX} characters" ))); } if !s .bytes() .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'.' | b'_' | b'-')) { return Err(AdminApiError::InvalidUsername( "username may contain only lowercase letters, digits, dot, underscore, and hyphen" .to_string(), )); } Ok(()) } fn validate_password(s: &str) -> Result<(), AdminApiError> { if s.chars().count() < PASSWORD_MIN { return Err(AdminApiError::InvalidPassword(format!( "password must be at least {PASSWORD_MIN} characters" ))); } Ok(()) } // ---------------------------------------------------------------------------- // Errors // ---------------------------------------------------------------------------- #[derive(Debug, thiserror::Error)] pub enum AdminApiError { #[error("admin user not found: {0}")] NotFound(AdminUserId), #[error("{0}")] InvalidUsername(String), #[error("{0}")] InvalidPassword(String), #[error("cannot leave the system with zero active admins")] LastActiveAdmin, #[error("failed to hash password: {0}")] Hash(String), #[error("repository error: {0}")] Repo(#[from] AdminUserRepositoryError), } impl IntoResponse for AdminApiError { fn into_response(self) -> Response { let (status, message) = match &self { Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()), Self::Repo(AdminUserRepositoryError::DuplicateUsername(_)) => { (StatusCode::CONFLICT, self.to_string()) } Self::InvalidUsername(_) | Self::InvalidPassword(_) | Self::LastActiveAdmin => { (StatusCode::UNPROCESSABLE_ENTITY, self.to_string()) } Self::Repo(AdminUserRepositoryError::NotFound(_)) => { (StatusCode::NOT_FOUND, self.to_string()) } Self::Repo(AdminUserRepositoryError::Db(e)) => { tracing::error!(error = %e, "admin_users db error"); ( StatusCode::INTERNAL_SERVER_ERROR, "internal error".to_string(), ) } Self::Hash(_) => { tracing::error!(error = %self, "password hashing failed"); ( StatusCode::INTERNAL_SERVER_ERROR, "internal error".to_string(), ) } }; (status, Json(json!({ "error": message }))).into_response() } } #[cfg(test)] mod tests { use super::*; #[test] fn username_validation_accepts_valid() { for u in ["ab", "alice", "user.name", "a_b-c", "00bot00"] { assert!(validate_username(u).is_ok(), "should accept {u}"); } } #[test] fn username_validation_rejects_invalid() { for u in ["", "a", "Alice", "user name", "user@domain", "user!"] { assert!(validate_username(u).is_err(), "should reject {u:?}"); } let too_long = "x".repeat(33); assert!(validate_username(&too_long).is_err()); } #[test] fn password_validation_enforces_min_length() { assert!(validate_password("1234567").is_err()); assert!(validate_password("12345678").is_ok()); assert!(validate_password("a-very-long-password-with-spaces and stuff").is_ok()); } }