// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. //! `fabrica user` — account administration, operating directly on the store. use std::path::Path; use std::process::ExitCode; use clap::Subcommand; use config::Config; use model::User; use store::{NewInvite, NewUser, Store}; use crate::context::{block_on, open_store}; use crate::prompt::{confirm, resolve_password}; /// Actions under `fabrica user`. #[derive(Debug, Subcommand)] pub(crate) enum UserCommand { /// Create a user. Without `--pass` the account is created with no password /// and cannot log in until one is set with `fabrica user passwd`. Add { /// Login name (validated: see the name rules). name: String, /// Primary email address. email: String, /// Set this password now instead of leaving the account inactive. Omit /// the value to be prompted; omit the flag entirely to create an /// inactive account. #[arg(long)] pass: Option, /// Grant site-administrator privileges. #[arg(long)] admin: bool, /// Human-friendly display name. #[arg(long = "display-name")] display_name: Option, }, /// Set a user's password. With no `--pass`, prompts twice on a terminal, or /// reads a single line from stdin when piped. Rotating a password logs the /// user out of every existing session. Passwd { /// The user whose password to set. name: String, /// The new password. Omit to be prompted (or to read it from stdin). #[arg(long)] pass: Option, }, /// List all users. List, /// Disable a user: they can no longer log in, and existing sessions are /// dropped. Disable { /// The user to disable. name: String, }, /// Re-enable a previously disabled user. Enable { /// The user to enable. name: String, }, /// Manually mark a user's primary email verified (for when SMTP is not set /// up, or to vouch for an account). Verify { /// The user to verify. name: String, }, /// Grant (or, with `--revoke`, remove) a user's site-administrator flag. Admin { /// The user to promote or demote. name: String, /// Revoke admin instead of granting it. #[arg(long)] revoke: bool, }, /// Delete a user. Refuses if the user still owns repositories unless /// `--purge` is given. Del { /// The user to delete. name: String, /// Also delete every repository the user owns. #[arg(long)] purge: bool, /// Skip the confirmation prompt (required when stdin is not a terminal). #[arg(long)] yes: bool, }, } /// Dispatch a `user` subcommand, blocking on the async store work. pub(crate) fn run( command: &UserCommand, config_path: Option<&Path>, json: bool, ) -> anyhow::Result { match command { UserCommand::Add { name, email, pass, admin, display_name, } => block_on(add( config_path, name, email, pass.clone(), *admin, display_name.clone(), )), UserCommand::Passwd { name, pass } => block_on(passwd(config_path, name, pass.clone())), UserCommand::List => block_on(list(config_path, json)), UserCommand::Disable { name } => block_on(set_disabled(config_path, name, true)), UserCommand::Enable { name } => block_on(set_disabled(config_path, name, false)), UserCommand::Verify { name } => block_on(verify(config_path, name)), UserCommand::Admin { name, revoke } => block_on(set_admin(config_path, name, !*revoke)), UserCommand::Del { name, purge, yes } => block_on(del(config_path, name, *purge, *yes)), } } /// Hash a plaintext password with the instance's configured Argon2id cost. fn hash_password(config: &Config, password: &str) -> anyhow::Result { let params = auth::HashParams { m_cost: config.auth.argon2_m_cost, t_cost: config.auth.argon2_t_cost, p_cost: config.auth.argon2_p_cost, }; Ok(auth::hash_password(password, params)?) } /// Look a user up by name, turning "not found" into a clean error. async fn require_user(store: &Store, name: &str) -> anyhow::Result { store .user_by_username(name) .await? .ok_or_else(|| anyhow::anyhow!("no such user {name:?}")) } async fn add( config_path: Option<&Path>, name: &str, email: &str, pass: Option, admin: bool, display_name: Option, ) -> anyhow::Result { let (config, store) = open_store(config_path).await?; // A password is set eagerly only when `--pass` is present. `--pass` with no // value prompts; the flag being absent leaves the account inactive. let password_hash = match pass { Some(explicit) => { let password = resolve_password(Some(explicit))?; Some(hash_password(&config, &password)?) } None => None, }; let has_password = password_hash.is_some(); let user = store .create_user(NewUser { username: name.to_string(), email: email.to_string(), display_name, password_hash, is_admin: admin, must_change_password: false, }) .await?; println!( "created user {} <{}>{}", user.username, user.email, if user.is_admin { " (admin)" } else { "" } ); if !has_password { // No password was set, so mint an activation invite, try to email it, and // always print the link so an operator can deliver it when SMTP is off. send_activation(&config, &store, &user).await?; } Ok(ExitCode::SUCCESS) } /// Mint an activation invite for `user`, attempt to email it (best-effort), and /// print the activation URL to stdout regardless. async fn send_activation(config: &Config, store: &Store, user: &User) -> anyhow::Result<()> { // The emailed token is opaque 32-byte randomness; only its SHA-256 is stored. let token = auth::new_session_token(); let ttl_ms = i64::from(config.auth.invite_ttl_hours).saturating_mul(3_600_000); store .create_invite(NewInvite { user_id: user.id.clone(), token_hash: token.id, purpose: mail::Purpose::Activate.as_str().to_string(), expires_at: now_ms().saturating_add(ttl_ms), }) .await?; let link = format!( "{}/invite/{}", config.instance.url.trim_end_matches('/'), token.cookie ); match mail::Mailer::build(&config.mail) { Ok(mailer) => match mailer .send_invite( &config.instance.name, &user.email, user.display_name.as_deref(), &link, mail::Purpose::Activate, ) .await { Ok(()) => println!("sent activation email to {}", user.email), Err(err) => eprintln!("warning: could not send activation email: {err}"), }, Err(err) => eprintln!("warning: mail not configured: {err}"), } println!( "activation link (valid {}h): {link}", config.auth.invite_ttl_hours ); Ok(()) } /// The current time in Unix milliseconds. fn now_ms() -> i64 { use std::time::{SystemTime, UNIX_EPOCH}; SystemTime::now() .duration_since(UNIX_EPOCH) .map_or(0, |d| i64::try_from(d.as_millis()).unwrap_or(i64::MAX)) } async fn passwd( config_path: Option<&Path>, name: &str, pass: Option, ) -> anyhow::Result { let (config, store) = open_store(config_path).await?; let user = require_user(&store, name).await?; let password = resolve_password(pass)?; let hash = hash_password(&config, &password)?; store.set_password(&user.id, Some(&hash)).await?; println!("password updated for {}", user.username); Ok(ExitCode::SUCCESS) } async fn list(config_path: Option<&Path>, json: bool) -> anyhow::Result { let (_config, store) = open_store(config_path).await?; let users = store.list_users().await?; if json { let view: Vec<_> = users.iter().map(user_json).collect(); println!("{}", serde_json::to_string_pretty(&view)?); return Ok(ExitCode::SUCCESS); } if users.is_empty() { println!("no users"); return Ok(ExitCode::SUCCESS); } let width = users .iter() .map(|u| u.username.len()) .max() .unwrap_or(0) .max("USERNAME".len()); println!("{:, name: &str, disabled: bool, ) -> anyhow::Result { let (_config, store) = open_store(config_path).await?; let user = require_user(&store, name).await?; store.set_disabled(&user.id, disabled).await?; println!( "{} user {}", if disabled { "disabled" } else { "enabled" }, user.username ); Ok(ExitCode::SUCCESS) } /// Manually mark a user's primary email verified. async fn verify(config_path: Option<&Path>, name: &str) -> anyhow::Result { let (_config, store) = open_store(config_path).await?; let user = require_user(&store, name).await?; if store.verify_primary_email(&user.id).await? { println!( "verified {}'s primary email ({})", user.username, user.email ); } else { println!("{} has no primary email to verify", user.username); } Ok(ExitCode::SUCCESS) } /// Grant or revoke a user's site-administrator flag. async fn set_admin( config_path: Option<&Path>, name: &str, admin: bool, ) -> anyhow::Result { let (_config, store) = open_store(config_path).await?; let user = require_user(&store, name).await?; if user.is_admin == admin { println!( "{} is already {}an administrator", user.username, if admin { "" } else { "not " } ); return Ok(ExitCode::SUCCESS); } store.set_admin(&user.id, admin).await?; println!( "{} admin for {}", if admin { "granted" } else { "revoked" }, user.username ); Ok(ExitCode::SUCCESS) } async fn del( config_path: Option<&Path>, name: &str, purge: bool, yes: bool, ) -> anyhow::Result { let (_config, store) = open_store(config_path).await?; let user = require_user(&store, name).await?; let repos = store.repos_by_owner(&user.id).await?; if !repos.is_empty() && !purge { eprintln!( "{} still owns {} repositor{}:", user.username, repos.len(), plural(repos.len()) ); for repo in &repos { eprintln!(" {}", repo.path); } anyhow::bail!( "refusing to delete a user who owns repositories; pass --purge to remove them" ); } let question = if repos.is_empty() { format!("Delete user {}?", user.username) } else { format!( "Delete user {} and {} owned repositor{}?", user.username, repos.len(), plural(repos.len()) ) }; if !confirm(&question, yes)? { println!("aborted"); return Ok(ExitCode::SUCCESS); } // Cascades to the user's repos, keys, tokens, and sessions in the database. // On-disk bare repositories are not yet created by `repo add`, so there is // nothing to remove from the filesystem here; that cleanup lands with the // git-storage phases. store.delete_user(&user.id).await?; println!("deleted user {}", user.username); Ok(ExitCode::SUCCESS) } /// The single-character-free flag labels shown by `user list`. fn flags(user: &User) -> Vec<&'static str> { let mut flags = Vec::new(); if user.is_admin { flags.push("admin"); } if user.disabled_at.is_some() { flags.push("disabled"); } if user.password_hash.is_none() { flags.push("no-password"); } if flags.is_empty() { flags.push("-"); } flags } /// Render a user for `--json`, deliberately exposing only whether a password is /// set (never the hash). fn user_json(user: &User) -> serde_json::Value { serde_json::json!({ "id": user.id, "username": user.username, "email": user.email, "display_name": user.display_name, "is_admin": user.is_admin, "has_password": user.password_hash.is_some(), "must_change_password": user.must_change_password, "disabled": user.disabled_at.is_some(), "created_at": user.created_at, "updated_at": user.updated_at, }) } /// The plural suffix `y`/`ies` helper for "repositor{y,ies}". fn plural(n: usize) -> &'static str { if n == 1 { "y" } else { "ies" } } #[cfg(test)] mod tests { #![allow(clippy::unwrap_used, clippy::expect_used)] use std::path::PathBuf; use store::NewRepo; use tempfile::TempDir; use super::*; /// A temp directory holding a SQLite database and a config file that points /// at it, so the handlers can run end-to-end with no external services. struct Env { _dir: TempDir, config_path: PathBuf, db_url: String, } fn env() -> Env { let dir = tempfile::tempdir().unwrap(); let repos = dir.path().join("repos"); let db = dir.path().join("fabrica.db"); let db_url = format!("sqlite://{}", db.display()); let config_path = dir.path().join("fabrica.toml"); let toml = format!( "[storage]\ndata_dir = {:?}\nrepo_dir = {:?}\n\n[database]\nurl = {:?}\n", dir.path().display(), repos.display(), db_url, ); std::fs::write(&config_path, toml).unwrap(); Env { _dir: dir, config_path, db_url, } } impl Env { /// The `--config` argument the handlers expect: `Some(path)`. The /// `Option` is intrinsic to the handler signature, not incidental. #[allow(clippy::unnecessary_wraps)] fn path(&self) -> Option<&Path> { Some(self.config_path.as_path()) } async fn store(&self) -> Store { Store::connect(&self.db_url, 1).await.unwrap() } } #[test] fn clap_command_tree_is_valid() { use clap::CommandFactory; crate::Cli::command().debug_assert(); } #[test] fn add_with_password_creates_a_usable_admin() { let env = env(); let code = block_on(add( env.path(), "alice", "alice@example.com", Some("s3cr3t".to_string()), true, Some("Alice".to_string()), )) .unwrap(); assert_eq!(code, ExitCode::SUCCESS); let created = block_on(async { let user = require_user(&env.store().await, "alice").await?; anyhow::Ok(user) }) .unwrap(); assert!(created.is_admin); assert_eq!(created.display_name.as_deref(), Some("Alice")); let hash = created.password_hash.expect("password should be set"); assert!(auth::verify_password("s3cr3t", Some(&hash))); } #[test] fn add_without_password_is_inactive_then_passwd_activates() { let env = env(); block_on(add(env.path(), "bob", "bob@example.com", None, false, None)).unwrap(); let before = block_on(async { anyhow::Ok(require_user(&env.store().await, "bob").await?) }).unwrap(); assert!(before.password_hash.is_none(), "no password until set"); // The SMTP-disabled fallback: set the password directly. block_on(passwd(env.path(), "bob", Some("hunter2".to_string()))).unwrap(); let after = block_on(async { anyhow::Ok(require_user(&env.store().await, "bob").await?) }).unwrap(); let hash = after.password_hash.expect("password now set"); assert!(auth::verify_password("hunter2", Some(&hash))); } #[test] fn disable_then_enable_toggles_the_flag() { let env = env(); block_on(add( env.path(), "carol", "carol@example.com", Some("pw".to_string()), false, None, )) .unwrap(); block_on(set_disabled(env.path(), "carol", true)).unwrap(); let disabled = block_on(async { anyhow::Ok(require_user(&env.store().await, "carol").await?) }) .unwrap(); assert!(disabled.disabled_at.is_some()); block_on(set_disabled(env.path(), "carol", false)).unwrap(); let enabled = block_on(async { anyhow::Ok(require_user(&env.store().await, "carol").await?) }) .unwrap(); assert!(enabled.disabled_at.is_none()); } #[test] fn admin_grants_then_revokes_the_flag() { let env = env(); block_on(add( env.path(), "erin", "erin@example.com", Some("pw".to_string()), false, None, )) .unwrap(); block_on(set_admin(env.path(), "erin", true)).unwrap(); let promoted = block_on(async { anyhow::Ok(require_user(&env.store().await, "erin").await?) }) .unwrap(); assert!(promoted.is_admin); block_on(set_admin(env.path(), "erin", false)).unwrap(); let demoted = block_on(async { anyhow::Ok(require_user(&env.store().await, "erin").await?) }) .unwrap(); assert!(!demoted.is_admin); } #[test] fn del_refuses_repo_owner_without_purge_then_purges() { let env = env(); block_on(add( env.path(), "dave", "dave@example.com", Some("pw".to_string()), false, None, )) .unwrap(); // Give Dave a repo directly through the store. let dave_id = block_on(async { let store = env.store().await; let dave = require_user(&store, "dave").await?; store .create_repo(NewRepo { owner_id: dave.id.clone(), group_id: None, name: "proj".to_string(), path: "proj".to_string(), description: None, visibility: model::Visibility::Private, default_branch: "main".to_string(), }) .await?; anyhow::Ok(dave.id) }) .unwrap(); // Without --purge the delete is refused and the user survives. let refused = block_on(del(env.path(), "dave", false, true)); assert!(refused.is_err(), "owning a repo should block deletion"); assert!( block_on(async { anyhow::Ok(env.store().await.user_by_id(&dave_id).await?) }) .unwrap() .is_some() ); // With --purge (and --yes to skip the prompt) the user and repo go. block_on(del(env.path(), "dave", true, true)).unwrap(); block_on(async { let store = env.store().await; assert!(store.user_by_id(&dave_id).await?.is_none()); assert!(store.repos_by_owner(&dave_id).await?.is_empty()); anyhow::Ok(()) }) .unwrap(); } #[test] fn passwd_on_missing_user_errors() { let env = env(); // Create the database (via one successful command) first. block_on(add( env.path(), "eve", "eve@example.com", Some("pw".to_string()), false, None, )) .unwrap(); let err = block_on(passwd(env.path(), "nobody", Some("x".to_string()))).unwrap_err(); assert!(err.to_string().contains("no such user"), "got {err}"); } }