Files
PiCloud/crates/manager-core/src/auth_bootstrap.rs
MechaCat02 0c9f11558a feat(manager-core,picloud): accept email on admin create + patch
The /admins create/patch endpoints now plumb email through to the
repo so the dashboard's invite + edit forms aren't silently dropping
it on the floor. Discovered during smoke testing — the database
column existed and was exposed in the response DTO, but neither
the request DTO nor the repo's create() accepted it.

CreateAdminRequest gains optional email; PatchAdminRequest gains
email with JSON Merge Patch semantics:
  absent     → don't change
  null       → clear (write NULL)
  "<string>" → set to that value

The tri-state needs Option<Option<String>> with a tiny custom
deserializer; serde collapses absent and null otherwise.

normalize_email() trims, treats blanks as None, and rejects
obviously bogus values (no '@', >254 chars) with a 422. Real
email verification is a future concern.

Repo trait gains an email parameter on create() and a new
update_email() method. The unique-violation branch in create now
inspects constraint() to distinguish duplicate username from
duplicate email.

Integration test exercises create-with-email, PATCH null clears,
PATCH value sets, PATCH without email key no-ops on email.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 19:27:52 +02:00

332 lines
11 KiB
Rust

//! 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),
};
// Bootstrap admin is always seeded as Owner — Phase 3.5 keys the
// first row to full instance control. Subsequent admins minted via
// the API default to Admin and can be promoted explicitly.
repo.create(
&username,
&password_hash,
picloud_shared::InstanceRole::Owner,
None,
)
.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, InstanceRole};
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,
instance_role: InstanceRole,
email: Option<&str>,
) -> Result<AdminUserRow, AdminUserRepositoryError> {
let row = AdminUserRow {
id: AdminUserId::new(),
username: username.to_string(),
is_active: true,
instance_role,
email: email.map(str::to_string),
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 update_email(
&self,
_i: AdminUserId,
_e: Option<&str>,
) -> Result<AdminUserRow, AdminUserRepositoryError> {
unimplemented!()
}
async fn update_instance_role(
&self,
_i: AdminUserId,
_r: InstanceRole,
) -> 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!()
}
async fn list_active_owners(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError> {
unimplemented!()
}
async fn count_other_active_owners(
&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", InstanceRole::Owner, None)
.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));
}
}