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>
332 lines
11 KiB
Rust
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));
|
|
}
|
|
}
|