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:
MechaCat02
2026-05-25 19:30:25 +02:00
parent 646bd55174
commit 6891496589
23 changed files with 3103 additions and 37 deletions

View File

@@ -1,17 +1,36 @@
//! PiCloud all-in-one binary — see `lib.rs` for the actual app
//! composition; this file is only the runtime shell (env config,
//! logger, migrations, listener).
//! logger, migrations, listener) plus the small `admin` CLI subcommand
//! used for out-of-band password recovery.
use std::io::{BufRead, Write};
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use picloud::{build_app, init_db};
use picloud_manager_core::migrations;
use picloud::{build_app, init_db, AuthDeps};
use picloud_manager_core::{
auth::{hash_password, validate_password_hash},
bootstrap_first_admin, migrations, AdminSessionRepository, AdminUserRepository,
};
use tracing_subscriber::EnvFilter;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
init_tracing();
// Subcommand dispatch — `picloud admin reset-password <username>`.
// Kept handwritten to avoid pulling clap in just for one verb. Falls
// through to the server when no subcommand is given.
let args: Vec<String> = std::env::args().collect();
if args.get(1).map(String::as_str) == Some("admin") {
return run_admin_cli(&args[2..]).await;
}
run_server().await
}
async fn run_server() -> anyhow::Result<()> {
let addr: SocketAddr = std::env::var("PICLOUD_BIND")
.unwrap_or_else(|_| "0.0.0.0:8080".into())
.parse()?;
@@ -22,7 +41,15 @@ async fn main() -> anyhow::Result<()> {
migrations::run(&pool).await?;
tracing::info!("migrations applied");
let app = build_app(pool).await?;
let auth = AuthDeps::from_pool(pool.clone());
bootstrap_first_admin(&*auth.users).await?;
// Background session-prune sweep. Cheap; keeps the table from
// growing unbounded. Expired rows are also rejected at lookup time,
// so a delayed sweep can't extend session lifetimes.
spawn_session_pruner(auth.sessions.clone());
let app = build_app(pool, auth).await?;
let listener = tokio::net::TcpListener::bind(addr).await?;
tracing::info!(%addr, "picloud all-in-one listening");
@@ -33,6 +60,112 @@ async fn main() -> anyhow::Result<()> {
Ok(())
}
fn spawn_session_pruner(sessions: Arc<dyn AdminSessionRepository>) {
tokio::spawn(async move {
let mut ticker = tokio::time::interval(Duration::from_secs(600));
// First tick fires immediately; skip it so we don't race startup.
ticker.tick().await;
loop {
ticker.tick().await;
match sessions.prune_expired().await {
Ok(n) if n > 0 => tracing::debug!(pruned = n, "expired admin sessions pruned"),
Ok(_) => {}
Err(err) => tracing::warn!(?err, "admin session prune failed"),
}
}
});
}
// ----------------------------------------------------------------------------
// `admin` subcommand
// ----------------------------------------------------------------------------
async fn run_admin_cli(args: &[String]) -> anyhow::Result<()> {
match args.first().map(String::as_str) {
Some("reset-password") => {
let username = args.get(1).ok_or_else(|| {
anyhow::anyhow!(
"usage: picloud admin reset-password <username> [--password-hash <hash>]"
)
})?;
// Optional inline hash via --password-hash <hash>; otherwise
// read a raw password from stdin.
let hash_arg = parse_flag(&args[2..], "--password-hash");
cmd_reset_password(username, hash_arg).await
}
Some(other) => Err(anyhow::anyhow!("unknown admin subcommand: {other}")),
None => Err(anyhow::anyhow!(
"usage: picloud admin reset-password <username>"
)),
}
}
fn parse_flag(args: &[String], name: &str) -> Option<String> {
let mut it = args.iter();
while let Some(a) = it.next() {
if a == name {
return it.next().cloned();
}
}
None
}
async fn cmd_reset_password(username: &str, password_hash: Option<String>) -> anyhow::Result<()> {
let database_url =
std::env::var("DATABASE_URL").map_err(|_| anyhow::anyhow!("DATABASE_URL is required"))?;
let pool = init_db(&database_url).await?;
migrations::run(&pool).await?;
let users = picloud_manager_core::PostgresAdminUserRepository::new(pool.clone());
let sessions = picloud_manager_core::PostgresAdminSessionRepository::new(pool);
let target = users
.get_by_username(username)
.await?
.ok_or_else(|| anyhow::anyhow!("no admin user named {username:?}"))?;
let hash = if let Some(h) = password_hash {
validate_password_hash(&h)
.map_err(|_| anyhow::anyhow!("--password-hash is not a valid Argon2id PHC string"))?;
h
} else {
let raw = prompt_password_from_stdin()?;
hash_password(&raw).map_err(|e| anyhow::anyhow!("failed to hash password: {e}"))?
};
users.update_password_hash(target.id, &hash).await?;
// Recovery implies the operator already lost control of the account;
// re-activate it (so a deactivated admin can also recover) and wipe
// any pre-existing sessions in case the original holder is still
// signed in elsewhere.
if !target.is_active {
users.set_active(target.id, true).await?;
}
let dropped = sessions.delete_for_user(target.id).await?;
println!("Password reset for {username}. Sessions dropped: {dropped}. Active: true.");
Ok(())
}
fn prompt_password_from_stdin() -> anyhow::Result<String> {
eprint!("New password (will be read from stdin, no echo): ");
std::io::stderr().flush().ok();
let mut line = String::new();
std::io::stdin()
.lock()
.read_line(&mut line)
.map_err(|e| anyhow::anyhow!("failed to read stdin: {e}"))?;
let pw = line.trim_end_matches(['\n', '\r']).to_string();
if pw.is_empty() {
return Err(anyhow::anyhow!("password must not be empty"));
}
Ok(pw)
}
// ----------------------------------------------------------------------------
// Misc
// ----------------------------------------------------------------------------
fn init_tracing() {
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))