//! 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 `. //! //! 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, pub password: Option, pub password_hash: Option, } 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( 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( 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 ` 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>, } #[async_trait] impl AdminUserRepository for InMemoryRepo { async fn get( &self, _id: AdminUserId, ) -> Result, AdminUserRepositoryError> { unimplemented!() } async fn get_by_username( &self, _u: &str, ) -> Result, AdminUserRepositoryError> { unimplemented!() } async fn get_credentials_by_username( &self, _u: &str, ) -> Result, AdminUserRepositoryError> { unimplemented!() } async fn list(&self) -> Result, AdminUserRepositoryError> { unimplemented!() } async fn create( &self, username: &str, _password_hash: &str, instance_role: InstanceRole, email: Option<&str>, ) -> Result { 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 { unimplemented!() } async fn update_password_hash( &self, _i: AdminUserId, _h: &str, ) -> Result { unimplemented!() } async fn update_email( &self, _i: AdminUserId, _e: Option<&str>, ) -> Result { unimplemented!() } async fn update_instance_role( &self, _i: AdminUserId, _r: InstanceRole, ) -> Result { unimplemented!() } async fn set_active( &self, _i: AdminUserId, _a: bool, ) -> Result { 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 { Ok(i64::try_from(self.rows.lock().unwrap().len()).unwrap_or(i64::MAX)) } async fn count_active_excluding( &self, _i: AdminUserId, ) -> Result { unimplemented!() } async fn list_active_owners(&self) -> Result, AdminUserRepositoryError> { unimplemented!() } async fn count_other_active_owners( &self, _i: AdminUserId, ) -> Result { 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)); } }