Files
PiCloud/crates/manager-core/src/admin_users_api.rs
MechaCat02 0c9f11558a feat(manager-core,picloud): accept email on admin create + patch
The /admins create/patch endpoints now plumb email through to the
repo so the dashboard's invite + edit forms aren't silently dropping
it on the floor. Discovered during smoke testing — the database
column existed and was exposed in the response DTO, but neither
the request DTO nor the repo's create() accepted it.

CreateAdminRequest gains optional email; PatchAdminRequest gains
email with JSON Merge Patch semantics:
  absent     → don't change
  null       → clear (write NULL)
  "<string>" → set to that value

The tri-state needs Option<Option<String>> with a tiny custom
deserializer; serde collapses absent and null otherwise.

normalize_email() trims, treats blanks as None, and rejects
obviously bogus values (no '@', >254 chars) with a 422. Real
email verification is a future concern.

Repo trait gains an email parameter on create() and a new
update_email() method. The unique-violation branch in create now
inspects constraint() to distinguish duplicate username from
duplicate email.

Integration test exercises create-with-email, PATCH null clears,
PATCH value sets, PATCH without email key no-ops on email.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 19:27:52 +02:00

534 lines
18 KiB
Rust

//! `/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<dyn AdminUserRepository>,
pub sessions: Arc<dyn AdminSessionRepository>,
/// 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<dyn ApiKeyRepository>,
/// Capability gate: every endpoint here requires
/// `InstanceManageUsers` (owner / admin).
pub authz: Arc<dyn AuthzRepo>,
}
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<String>,
pub created_at: DateTime<Utc>,
pub last_login_at: Option<DateTime<Utc>>,
}
impl From<AdminUserRow> 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<String>,
}
const fn default_create_role() -> InstanceRole {
InstanceRole::Admin
}
#[derive(Debug, Deserialize, Default)]
pub struct PatchAdminRequest {
pub username: Option<String>,
pub password: Option<String>,
pub is_active: Option<bool>,
pub instance_role: Option<InstanceRole>,
/// JSON Merge Patch (RFC 7396) semantics for email:
/// absent → don't change
/// null → clear (set DB column to NULL)
/// "<string>" → set to that string
/// `Option<Option<T>>` 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<Option<String>>,
}
#[allow(clippy::option_option)]
fn deserialize_present_optional<'de, T, D>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
where
T: serde::Deserialize<'de>,
D: serde::Deserializer<'de>,
{
Ok(Some(Option::<T>::deserialize(deserializer)?))
}
// ----------------------------------------------------------------------------
// Handlers
// ----------------------------------------------------------------------------
async fn list_admins(
State(state): State<AdminsState>,
Extension(principal): Extension<Principal>,
) -> Result<Json<Vec<AdminDto>>, 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<AdminsState>,
Extension(principal): Extension<Principal>,
Path(id): Path<AdminUserId>,
) -> Result<Json<AdminDto>, 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<AdminsState>,
Extension(principal): Extension<Principal>,
Json(input): Json<CreateAdminRequest>,
) -> Result<(StatusCode, Json<AdminDto>), 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<AdminsState>,
Extension(principal): Extension<Principal>,
Path(id): Path<AdminUserId>,
Json(input): Json<PatchAdminRequest>,
) -> Result<Json<AdminDto>, 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<AdminUserRow> = 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<AdminsState>,
Extension(principal): Extension<Principal>,
Path(id): Path<AdminUserId>,
) -> Result<StatusCode, AdminApiError> {
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<Option<String>, 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<AuthzDenied> 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());
}
}