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:
@@ -6,10 +6,13 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::middleware::from_fn_with_state;
|
||||
use axum::{routing::get, Json, Router};
|
||||
use picloud_executor_core::{Engine, Limits};
|
||||
use picloud_manager_core::{
|
||||
admin_router, compile_routes, migrations, route_admin_router, AdminState,
|
||||
admin_router, admins_router, auth_router, compile_routes, migrations, require_admin,
|
||||
route_admin_router, AdminSessionRepository, AdminState, AdminUserRepository, AdminsState,
|
||||
AuthState, PostgresAdminSessionRepository, PostgresAdminUserRepository,
|
||||
PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresRouteRepository,
|
||||
PostgresScriptRepository, RepoResolver, RouteAdminState, RouteRepository, SandboxCeiling,
|
||||
};
|
||||
@@ -24,6 +27,38 @@ use sqlx::postgres::PgPoolOptions;
|
||||
use sqlx::PgPool;
|
||||
use tower_http::trace::TraceLayer;
|
||||
|
||||
/// Default session TTL when `PICLOUD_SESSION_TTL_HOURS` isn't set.
|
||||
const DEFAULT_SESSION_TTL_HOURS: u64 = 24;
|
||||
|
||||
/// Bundles the auth-related dependencies that both `build_app` and the
|
||||
/// startup bootstrap need. Built once in `main.rs` from the shared pool.
|
||||
pub struct AuthDeps {
|
||||
pub users: Arc<dyn AdminUserRepository>,
|
||||
pub sessions: Arc<dyn AdminSessionRepository>,
|
||||
pub ttl: Duration,
|
||||
}
|
||||
|
||||
impl AuthDeps {
|
||||
/// Construct from a pool with the binary's standard defaults.
|
||||
#[must_use]
|
||||
pub fn from_pool(pool: PgPool) -> Self {
|
||||
Self {
|
||||
users: Arc::new(PostgresAdminUserRepository::new(pool.clone())),
|
||||
sessions: Arc::new(PostgresAdminSessionRepository::new(pool)),
|
||||
ttl: read_session_ttl(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_session_ttl() -> Duration {
|
||||
let hours = std::env::var("PICLOUD_SESSION_TTL_HOURS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.filter(|h| *h > 0)
|
||||
.unwrap_or(DEFAULT_SESSION_TTL_HOURS);
|
||||
Duration::from_secs(hours * 3600)
|
||||
}
|
||||
|
||||
/// Compose the manager + orchestrator routes on top of a shared
|
||||
/// Postgres pool, returning an Axum router ready to be served.
|
||||
///
|
||||
@@ -31,7 +66,15 @@ use tower_http::trace::TraceLayer;
|
||||
/// is mounted by Caddy at `/admin/*` (its base path). Anything else
|
||||
/// falls through to the user-route table — user scripts can bind to
|
||||
/// arbitrary paths (subject to the reserved-prefix list).
|
||||
pub async fn build_app(pool: PgPool) -> anyhow::Result<Router> {
|
||||
///
|
||||
/// `auth` carries the admin user/session repositories and the
|
||||
/// configured session TTL. The manager-side admin endpoints
|
||||
/// (`/api/v1/admin/scripts/*`, `/api/v1/admin/routes/*`,
|
||||
/// `/api/v1/admin/admins/*`, `/api/v1/admin/auth/me`) are guarded by
|
||||
/// the `require_admin` middleware. The data plane
|
||||
/// (`/api/v1/execute/{id}`, the user-route fallthrough, `/healthz`,
|
||||
/// `/version`) stays open — it's the public ingress for user scripts.
|
||||
pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
|
||||
let engine = Arc::new(Engine::new(Limits::default()));
|
||||
|
||||
let script_repo = Arc::new(PostgresScriptRepository::new(pool.clone()));
|
||||
@@ -68,9 +111,29 @@ pub async fn build_app(pool: PgPool) -> anyhow::Result<Router> {
|
||||
routes: route_table,
|
||||
};
|
||||
|
||||
let auth_state = AuthState {
|
||||
users: auth.users.clone(),
|
||||
sessions: auth.sessions.clone(),
|
||||
ttl: auth.ttl,
|
||||
};
|
||||
let admins_state = AdminsState {
|
||||
users: auth.users,
|
||||
sessions: auth.sessions,
|
||||
};
|
||||
|
||||
// /admin/auth/login + /logout are unguarded by design (login is how
|
||||
// you get in). /admin/auth/me applies the middleware internally so
|
||||
// the same Router::with_state machinery composes cleanly. Everything
|
||||
// else under /admin gets the require_admin layer.
|
||||
let guarded_admin = Router::new()
|
||||
.merge(admin_router(admin))
|
||||
.merge(route_admin_router(route_admin))
|
||||
.merge(admins_router(admins_state))
|
||||
.layer(from_fn_with_state(auth_state.clone(), require_admin));
|
||||
|
||||
let api_v1 = Router::new()
|
||||
.nest("/admin", admin_router(admin))
|
||||
.nest("/admin", route_admin_router(route_admin))
|
||||
.nest("/admin", auth_router(auth_state))
|
||||
.nest("/admin", guarded_admin)
|
||||
.merge(data_plane_router(data_plane.clone()));
|
||||
|
||||
Ok(Router::new()
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -17,9 +17,35 @@ use axum_test::TestServer;
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::PgPool;
|
||||
|
||||
/// Build the all-in-one app over the test pool, seed a single admin
|
||||
/// directly through the repo (bypassing the env-var bootstrap path so
|
||||
/// tests don't contaminate the process environment), log in, and bake
|
||||
/// the bearer token into the TestServer as a default header so every
|
||||
/// request in the test passes the `require_admin` middleware.
|
||||
async fn server(pool: PgPool) -> TestServer {
|
||||
let app = picloud::build_app(pool).await.expect("build_app");
|
||||
TestServer::new(app).expect("TestServer should build")
|
||||
use picloud_manager_core::auth::hash_password;
|
||||
|
||||
let auth = picloud::AuthDeps::from_pool(pool.clone());
|
||||
let hash = hash_password("test-pw").expect("hash");
|
||||
auth.users
|
||||
.create("test-admin", &hash)
|
||||
.await
|
||||
.expect("seed admin");
|
||||
|
||||
let app = picloud::build_app(pool, auth).await.expect("build_app");
|
||||
let mut server = TestServer::new(app).expect("TestServer should build");
|
||||
|
||||
let resp = server
|
||||
.post("/api/v1/admin/auth/login")
|
||||
.json(&json!({ "username": "test-admin", "password": "test-pw" }))
|
||||
.await;
|
||||
resp.assert_status_ok();
|
||||
let token = resp.json::<Value>()["token"]
|
||||
.as_str()
|
||||
.expect("login should return token")
|
||||
.to_string();
|
||||
server.add_header("authorization", format!("Bearer {token}"));
|
||||
server
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -705,7 +731,7 @@ async fn version_includes_public_base_url(pool: PgPool) {
|
||||
let v: Value = r.json();
|
||||
assert!(v["public_base_url"].is_string());
|
||||
assert_eq!(v["api"], 1);
|
||||
assert_eq!(v["schema"], 3);
|
||||
assert_eq!(v["schema"], 4);
|
||||
assert_eq!(v["sdk"], "1.1");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user