//! `/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::{Extension, Router}; use chrono::{DateTime, Utc}; use picloud_shared::{AdminUserId, InstanceRole, Principal}; use serde::{Deserialize, Serialize}; use serde_json::json; use crate::admin_session_repo::AdminSessionRepository; use crate::admin_user_repo::{AdminUserRepository, AdminUserRepositoryError, AdminUserRow}; use crate::api_key_repo::ApiKeyRepository; use crate::auth::hash_password; use crate::authz::{require, AuthzDenied, AuthzRepo, Capability}; /// 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, /// Phase 3.5 deactivation symmetry — flipping `is_active = false` /// also expires every active API key for that user so cookie and /// bearer credentials become inert at the same moment. pub keys: Arc, /// Capability gate: every endpoint here requires /// `InstanceManageUsers` (owner / admin). pub authz: 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 instance_role: InstanceRole, pub email: Option, 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, instance_role: r.instance_role, email: r.email, created_at: r.created_at, last_login_at: r.last_login_at, } } } #[derive(Debug, Deserialize)] pub struct CreateAdminRequest { pub username: String, pub password: String, /// Defaults to `Admin` when absent — minting an owner via the API /// is a deliberate step. The env-var bootstrap path is the only /// channel that defaults to `Owner`. #[serde(default = "default_create_role")] pub instance_role: InstanceRole, /// Optional contact email. Blank/whitespace is normalized to None. #[serde(default)] pub email: Option, } const fn default_create_role() -> InstanceRole { InstanceRole::Admin } #[derive(Debug, Deserialize, Default)] pub struct PatchAdminRequest { pub username: Option, pub password: Option, pub is_active: Option, pub instance_role: Option, /// JSON Merge Patch (RFC 7396) semantics for email: /// absent → don't change /// null → clear (set DB column to NULL) /// "" → set to that string /// `Option>` is the idiomatic Rust shape for that /// tri-state; the custom deserializer below distinguishes the /// "missing" case from the "present-and-null" case that serde /// would otherwise collapse together. #[allow(clippy::option_option)] #[serde(default, deserialize_with = "deserialize_present_optional")] pub email: Option>, } #[allow(clippy::option_option)] fn deserialize_present_optional<'de, T, D>(deserializer: D) -> Result>, D::Error> where T: serde::Deserialize<'de>, D: serde::Deserializer<'de>, { Ok(Some(Option::::deserialize(deserializer)?)) } // ---------------------------------------------------------------------------- // Handlers // ---------------------------------------------------------------------------- async fn list_admins( State(state): State, Extension(principal): Extension, ) -> Result>, AdminApiError> { require( state.authz.as_ref(), &principal, Capability::InstanceManageUsers, ) .await?; let rows = state.users.list().await?; Ok(Json(rows.into_iter().map(Into::into).collect())) } async fn get_admin( State(state): State, Extension(principal): Extension, Path(id): Path, ) -> Result, AdminApiError> { require( state.authz.as_ref(), &principal, Capability::InstanceManageUsers, ) .await?; state .users .get(id) .await? .map(AdminDto::from) .map(Json) .ok_or(AdminApiError::NotFound(id)) } async fn create_admin( State(state): State, Extension(principal): Extension, Json(input): Json, ) -> Result<(StatusCode, Json), AdminApiError> { require( state.authz.as_ref(), &principal, Capability::InstanceManageUsers, ) .await?; // Minting an owner via the API requires the caller to ALSO be an // owner — admin cannot self-elevate (or elevate someone else) // beyond their own ceiling. Owner-creation by env-var bootstrap // bypasses this path. if input.instance_role == InstanceRole::Owner && principal.instance_role != InstanceRole::Owner { return Err(AdminApiError::CannotEscalate); } let username = input.username.trim(); validate_username(username)?; validate_password(&input.password)?; let email = normalize_email(input.email.as_deref())?; let hash = hash_password(&input.password).map_err(|e| AdminApiError::Hash(e.to_string()))?; let row = state .users .create(username, &hash, input.instance_role, email.as_deref()) .await?; Ok((StatusCode::CREATED, Json(row.into()))) } async fn patch_admin( State(state): State, Extension(principal): Extension, Path(id): Path, Json(input): Json, ) -> Result, AdminApiError> { require( state.authz.as_ref(), &principal, Capability::InstanceManageUsers, ) .await?; // Verify the target exists upfront — keeps the error path uniform // for "rename a missing user" etc. let current = 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(email_patch) = input.email.as_ref() { // email_patch is Some(None) → clear, Some(Some(s)) → set. let normalized = normalize_email(email_patch.as_deref())?; latest = Some(state.users.update_email(id, normalized.as_deref()).await?); } if let Some(new_role) = input.instance_role { // Self-elevation guard: only an owner can promote anyone TO // owner. An admin cannot turn themselves (or anyone else) // into one. if new_role == InstanceRole::Owner && principal.instance_role != InstanceRole::Owner { return Err(AdminApiError::CannotEscalate); } // Last-active-owner guard: a transition off of `Owner` cannot // leave the install with zero owners. The check is on the // source role (current.instance_role) so demoting an // already-non-owner is always fine. if current.instance_role == InstanceRole::Owner && new_role != InstanceRole::Owner { let remaining = state.users.count_other_active_owners(id).await?; if remaining == 0 { return Err(AdminApiError::LastActiveOwner); } } latest = Some(state.users.update_instance_role(id, new_role).await?); } 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); } // ALSO: if the target is currently the last active owner, // deactivating them leaves no owner. Belt-and-suspenders to // the role guard above (which only triggers on an explicit // role transition). let target_role = latest .as_ref() .map_or(current.instance_role, |r| r.instance_role); if target_role == InstanceRole::Owner { let remaining_owners = state.users.count_other_active_owners(id).await?; if remaining_owners == 0 { return Err(AdminApiError::LastActiveOwner); } } } latest = Some(state.users.set_active(id, new_active).await?); // Deactivation invalidates BOTH credential surfaces — sessions // (cookie / session bearer) and API keys. Both writes are // logged on failure but do not undo the deactivation; the // alternative (leaving the user active when one cascade fails) // is worse than slightly stale credential rows on a DB blip. if !new_active { if let Err(err) = state.sessions.delete_for_user(id).await { tracing::error!(?err, "failed to delete sessions for deactivated admin"); } match state.keys.expire_all_for_user(id).await { Ok(n) => { if n > 0 { tracing::info!(user_id = %id, expired = n, "expired api keys on deactivation"); } } Err(err) => { tracing::error!(?err, "failed to expire api keys 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, Extension(principal): Extension, Path(id): Path, ) -> Result { require( state.authz.as_ref(), &principal, Capability::InstanceManageUsers, ) .await?; 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); } // Last-owner guard mirrors the role-transition guard in // patch_admin — deleting the only owner is just as bad as // demoting them. if target.instance_role == InstanceRole::Owner { let remaining_owners = state.users.count_other_active_owners(id).await?; if remaining_owners == 0 { return Err(AdminApiError::LastActiveOwner); } } } state.users.delete(id).await?; // Sessions + api_keys 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(()) } /// Trim and reject empty / pathological emails, returning the /// canonical form (or None when the input was blank). The shape /// check is intentionally loose — we mainly want to reject blanks /// and obvious junk; real verification is a future concern. fn normalize_email(raw: Option<&str>) -> Result, AdminApiError> { let Some(raw) = raw else { return Ok(None); }; let trimmed = raw.trim(); if trimmed.is_empty() { return Ok(None); } if trimmed.len() > 254 || !trimmed.contains('@') { return Err(AdminApiError::InvalidEmail( "email must contain '@' and be at most 254 characters".to_string(), )); } Ok(Some(trimmed.to_string())) } // ---------------------------------------------------------------------------- // 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("{0}")] InvalidEmail(String), #[error("cannot leave the system with zero active admins")] LastActiveAdmin, #[error("cannot leave the system with zero active owners")] LastActiveOwner, #[error("only an owner can grant the owner role")] CannotEscalate, #[error("forbidden")] Forbidden, #[error("authorization repo error: {0}")] AuthzRepo(String), #[error("failed to hash password: {0}")] Hash(String), #[error("repository error: {0}")] Repo(#[from] AdminUserRepositoryError), } impl From for AdminApiError { fn from(d: AuthzDenied) -> Self { match d { AuthzDenied::Denied => Self::Forbidden, AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()), } } } 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(_) | AdminUserRepositoryError::DuplicateEmail(_), ) => (StatusCode::CONFLICT, self.to_string()), Self::InvalidUsername(_) | Self::InvalidPassword(_) | Self::InvalidEmail(_) | Self::LastActiveAdmin | Self::LastActiveOwner | Self::CannotEscalate | Self::Repo(AdminUserRepositoryError::InvalidInstanceRole(_)) => { (StatusCode::UNPROCESSABLE_ENTITY, self.to_string()) } Self::Forbidden => (StatusCode::FORBIDDEN, self.to_string()), Self::AuthzRepo(e) => { tracing::error!(error = %e, "admin_users authz error"); ( StatusCode::INTERNAL_SERVER_ERROR, "internal error".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()); } }