Closes the regression risk of the admin API and dashboard being open
to anyone reaching the bound port. Required foundation before v1.1
data-plane services land.
Per-user accounts (admin_users), Argon2id passwords, env-var bootstrap
of the first admin that becomes inert once any admin exists, opaque
32-byte session token doubling as bearer credential, 24h sliding TTL
configurable via PICLOUD_SESSION_TTL_HOURS. is_active column lets
admins be deactivated without losing audit history; last-active-admin
guard on DELETE and on PATCH that flips is_active to false (sessions
also wiped on deactivation).
require_admin middleware fronts every /api/v1/admin/* route. The data
plane (/api/v1/execute/{id}), /healthz, /version, and user routes
stay open. picloud admin reset-password <username> subcommand handles
recovery without going through HTTP.
Dashboard gains /admin/login and /admin/admins surfaces, a top-bar
user menu, and a token store with a localStorage echo so refreshes
don't sign you out. Cookie-based auth works in parallel for non-SPA
clients.
Forward compatibility: future RBAC tables (admin_roles,
admin_user_roles) join on admin_users.id; the auth middleware is the
seam where role checks slot in. Email, 2FA, passkeys, and personal
API tokens are all additive without touching admin_users.
Blueprint §11.4 updated to reflect what actually shipped.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
321 lines
10 KiB
Rust
321 lines
10 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::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<dyn AdminUserRepository>,
|
|
pub sessions: Arc<dyn AdminSessionRepository>,
|
|
}
|
|
|
|
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<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,
|
|
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<String>,
|
|
pub password: Option<String>,
|
|
pub is_active: Option<bool>,
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Handlers
|
|
// ----------------------------------------------------------------------------
|
|
|
|
async fn list_admins(
|
|
State(state): State<AdminsState>,
|
|
) -> Result<Json<Vec<AdminDto>>, AdminApiError> {
|
|
let rows = state.users.list().await?;
|
|
Ok(Json(rows.into_iter().map(Into::into).collect()))
|
|
}
|
|
|
|
async fn get_admin(
|
|
State(state): State<AdminsState>,
|
|
Path(id): Path<AdminUserId>,
|
|
) -> Result<Json<AdminDto>, AdminApiError> {
|
|
state
|
|
.users
|
|
.get(id)
|
|
.await?
|
|
.map(AdminDto::from)
|
|
.map(Json)
|
|
.ok_or(AdminApiError::NotFound(id))
|
|
}
|
|
|
|
async fn create_admin(
|
|
State(state): State<AdminsState>,
|
|
Json(input): Json<CreateAdminRequest>,
|
|
) -> Result<(StatusCode, Json<AdminDto>), 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<AdminsState>,
|
|
Path(id): Path<AdminUserId>,
|
|
Json(input): Json<PatchAdminRequest>,
|
|
) -> Result<Json<AdminDto>, 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<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(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<AdminsState>,
|
|
Path(id): Path<AdminUserId>,
|
|
) -> Result<StatusCode, AdminApiError> {
|
|
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());
|
|
}
|
|
}
|