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:
293
crates/manager-core/src/auth_bootstrap.rs
Normal file
293
crates/manager-core/src/auth_bootstrap.rs
Normal file
@@ -0,0 +1,293 @@
|
||||
//! First-run admin seeding from env vars. Idempotent: if any admin
|
||||
//! already exists, this is a no-op (and a warning is logged when the
|
||||
//! env vars are also set, so the operator notices the inert state).
|
||||
//!
|
||||
//! On a fresh install, exactly one row is inserted from:
|
||||
//! - `PICLOUD_ADMIN_USERNAME` (required)
|
||||
//! - `PICLOUD_ADMIN_PASSWORD_HASH` (preferred — pre-computed PHC) OR
|
||||
//! - `PICLOUD_ADMIN_PASSWORD` (fallback — raw, hashed on the way in)
|
||||
//!
|
||||
//! After that initial seed, the env vars become inert. This is
|
||||
//! deliberate: the env var is a one-time setup hatch, not a permanent
|
||||
//! override (which would let anyone with systemd/compose access change
|
||||
//! any admin's password without authentication). Recovery is the CLI
|
||||
//! subcommand `picloud admin reset-password <username>`.
|
||||
//!
|
||||
//! The env-var reading is factored into `BootstrapEnv::from_process`
|
||||
//! so the core logic stays pure (and testable) — the only side effect
|
||||
//! in `bootstrap_first_admin` is the DB write and a tracing log.
|
||||
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::admin_user_repo::AdminUserRepository;
|
||||
use crate::auth::{hash_password, validate_password_hash};
|
||||
|
||||
pub const ENV_USERNAME: &str = "PICLOUD_ADMIN_USERNAME";
|
||||
pub const ENV_PASSWORD: &str = "PICLOUD_ADMIN_PASSWORD";
|
||||
pub const ENV_PASSWORD_HASH: &str = "PICLOUD_ADMIN_PASSWORD_HASH";
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum BootstrapError {
|
||||
#[error("repository error: {0}")]
|
||||
Repo(#[from] crate::admin_user_repo::AdminUserRepositoryError),
|
||||
|
||||
#[error("{ENV_USERNAME} not set (required to bootstrap the first admin)")]
|
||||
MissingUsername,
|
||||
|
||||
#[error(
|
||||
"no admin password env var set; provide {ENV_PASSWORD_HASH} (preferred) or {ENV_PASSWORD}"
|
||||
)]
|
||||
MissingPassword,
|
||||
|
||||
#[error("{ENV_PASSWORD_HASH} is not a valid Argon2id PHC string")]
|
||||
InvalidHash,
|
||||
|
||||
#[error("failed to hash password: {0}")]
|
||||
HashFailure(String),
|
||||
}
|
||||
|
||||
/// Captured-at-call-site env values. The fields map 1:1 to the bootstrap
|
||||
/// env vars. Read from the live process with `from_process`, or build
|
||||
/// directly in tests to keep them free of process-env races.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct BootstrapEnv {
|
||||
pub username: Option<String>,
|
||||
pub password: Option<String>,
|
||||
pub password_hash: Option<String>,
|
||||
}
|
||||
|
||||
impl BootstrapEnv {
|
||||
/// Snapshot the bootstrap env vars from the current process.
|
||||
#[must_use]
|
||||
pub fn from_process() -> Self {
|
||||
Self {
|
||||
username: std::env::var(ENV_USERNAME).ok(),
|
||||
password: std::env::var(ENV_PASSWORD).ok(),
|
||||
password_hash: std::env::var(ENV_PASSWORD_HASH).ok(),
|
||||
}
|
||||
}
|
||||
|
||||
fn any_set(&self) -> bool {
|
||||
self.username.is_some() || self.password.is_some() || self.password_hash.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the bootstrap. Reads env vars from the live process — the
|
||||
/// canonical wiring for the binary.
|
||||
pub async fn bootstrap_first_admin<R: AdminUserRepository + ?Sized>(
|
||||
repo: &R,
|
||||
) -> Result<(), BootstrapError> {
|
||||
bootstrap_first_admin_with(repo, BootstrapEnv::from_process()).await
|
||||
}
|
||||
|
||||
/// Run the bootstrap against an explicit env. Used by tests to keep
|
||||
/// the bootstrap logic independent of process state.
|
||||
pub async fn bootstrap_first_admin_with<R: AdminUserRepository + ?Sized>(
|
||||
repo: &R,
|
||||
env: BootstrapEnv,
|
||||
) -> Result<(), BootstrapError> {
|
||||
if repo.count_active().await? > 0 {
|
||||
if env.any_set() {
|
||||
warn!(
|
||||
"{ENV_USERNAME}/{ENV_PASSWORD}/{ENV_PASSWORD_HASH} set but admin_users \
|
||||
already populated — env values ignored. Use \
|
||||
`picloud admin reset-password <user>` to change a password."
|
||||
);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let username = env.username.ok_or(BootstrapError::MissingUsername)?;
|
||||
|
||||
let password_hash = match (env.password_hash, env.password) {
|
||||
(Some(hash), maybe_raw) => {
|
||||
if maybe_raw.is_some() {
|
||||
warn!(
|
||||
"both {ENV_PASSWORD_HASH} and {ENV_PASSWORD} set — \
|
||||
using the pre-computed hash; raw password ignored."
|
||||
);
|
||||
}
|
||||
validate_password_hash(&hash).map_err(|_| BootstrapError::InvalidHash)?;
|
||||
hash
|
||||
}
|
||||
(None, Some(raw)) => {
|
||||
hash_password(&raw).map_err(|e| BootstrapError::HashFailure(e.to_string()))?
|
||||
}
|
||||
(None, None) => return Err(BootstrapError::MissingPassword),
|
||||
};
|
||||
|
||||
repo.create(&username, &password_hash).await?;
|
||||
info!(username = %username, "bootstrapped initial admin user");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! These tests use an in-memory `AdminUserRepository` and the
|
||||
//! `bootstrap_first_admin_with` overload so they never touch
|
||||
//! process-global env vars. They can run in parallel safely.
|
||||
|
||||
use super::*;
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use picloud_shared::AdminUserId;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::admin_user_repo::{AdminUserCredentials, AdminUserRepositoryError, AdminUserRow};
|
||||
|
||||
#[derive(Default)]
|
||||
struct InMemoryRepo {
|
||||
rows: Mutex<Vec<AdminUserRow>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AdminUserRepository for InMemoryRepo {
|
||||
async fn get(
|
||||
&self,
|
||||
_id: AdminUserId,
|
||||
) -> Result<Option<AdminUserRow>, AdminUserRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_by_username(
|
||||
&self,
|
||||
_u: &str,
|
||||
) -> Result<Option<AdminUserRow>, AdminUserRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_credentials_by_username(
|
||||
&self,
|
||||
_u: &str,
|
||||
) -> Result<Option<AdminUserCredentials>, AdminUserRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn list(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn create(
|
||||
&self,
|
||||
username: &str,
|
||||
_password_hash: &str,
|
||||
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||
let row = AdminUserRow {
|
||||
id: AdminUserId::new(),
|
||||
username: username.to_string(),
|
||||
is_active: true,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
last_login_at: None,
|
||||
};
|
||||
self.rows.lock().unwrap().push(row.clone());
|
||||
Ok(row)
|
||||
}
|
||||
async fn update_username(
|
||||
&self,
|
||||
_i: AdminUserId,
|
||||
_u: &str,
|
||||
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn update_password_hash(
|
||||
&self,
|
||||
_i: AdminUserId,
|
||||
_h: &str,
|
||||
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn set_active(
|
||||
&self,
|
||||
_i: AdminUserId,
|
||||
_a: bool,
|
||||
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn delete(&self, _i: AdminUserId) -> Result<(), AdminUserRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn touch_last_login(&self, _i: AdminUserId) -> Result<(), AdminUserRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn count_active(&self) -> Result<i64, AdminUserRepositoryError> {
|
||||
Ok(i64::try_from(self.rows.lock().unwrap().len()).unwrap_or(i64::MAX))
|
||||
}
|
||||
async fn count_active_excluding(
|
||||
&self,
|
||||
_i: AdminUserId,
|
||||
) -> Result<i64, AdminUserRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_db_creates_admin_from_raw_password() {
|
||||
let repo = InMemoryRepo::default();
|
||||
let env = BootstrapEnv {
|
||||
username: Some("alice".into()),
|
||||
password: Some("supersecret".into()),
|
||||
password_hash: None,
|
||||
};
|
||||
bootstrap_first_admin_with(&repo, env).await.unwrap();
|
||||
assert_eq!(repo.rows.lock().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_db_with_pre_hashed_password_succeeds() {
|
||||
let repo = InMemoryRepo::default();
|
||||
let prehashed = hash_password("pw").unwrap();
|
||||
let env = BootstrapEnv {
|
||||
username: Some("alice".into()),
|
||||
password: None,
|
||||
password_hash: Some(prehashed),
|
||||
};
|
||||
bootstrap_first_admin_with(&repo, env).await.unwrap();
|
||||
assert_eq!(repo.rows.lock().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn populated_db_is_noop() {
|
||||
let repo = InMemoryRepo::default();
|
||||
repo.create("seeded", "x").await.unwrap();
|
||||
let env = BootstrapEnv {
|
||||
username: Some("alice".into()),
|
||||
password: Some("supersecret".into()),
|
||||
password_hash: None,
|
||||
};
|
||||
bootstrap_first_admin_with(&repo, env).await.unwrap();
|
||||
assert_eq!(repo.rows.lock().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_username_fails() {
|
||||
let repo = InMemoryRepo::default();
|
||||
let env = BootstrapEnv {
|
||||
username: None,
|
||||
password: Some("supersecret".into()),
|
||||
password_hash: None,
|
||||
};
|
||||
let err = bootstrap_first_admin_with(&repo, env).await.unwrap_err();
|
||||
assert!(matches!(err, BootstrapError::MissingUsername));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_password_fails() {
|
||||
let repo = InMemoryRepo::default();
|
||||
let env = BootstrapEnv {
|
||||
username: Some("alice".into()),
|
||||
password: None,
|
||||
password_hash: None,
|
||||
};
|
||||
let err = bootstrap_first_admin_with(&repo, env).await.unwrap_err();
|
||||
assert!(matches!(err, BootstrapError::MissingPassword));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_hash_fails() {
|
||||
let repo = InMemoryRepo::default();
|
||||
let env = BootstrapEnv {
|
||||
username: Some("alice".into()),
|
||||
password: None,
|
||||
password_hash: Some("not a phc hash".into()),
|
||||
};
|
||||
let err = bootstrap_first_admin_with(&repo, env).await.unwrap_err();
|
||||
assert!(matches!(err, BootstrapError::InvalidHash));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user