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>
534 lines
18 KiB
Rust
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());
|
|
}
|
|
}
|