feat(manager-core): admin auth gate (Phase 3a)
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>
This commit is contained in:
320
crates/manager-core/src/admin_users_api.rs
Normal file
320
crates/manager-core/src/admin_users_api.rs
Normal file
@@ -0,0 +1,320 @@
|
||||
//! `/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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user