//! `forge admin create-user [--admin]`: creates a user from the CLI. //! The first user on an instance is always an admin. //! `forge admin set-username `: changes only the capitalization of a username. //! `forge admin reset-password `: sets a random temporary password and signs the //! user out everywhere. //! `forge admin trash-list`: lists deleted repos in `data_dir/trash`, oldest first. //! `forge admin trash-purge --older-than-days N`: permanently removes trash older than N days. //! `forge admin audit-purge --older-than-days N`: deletes audit log entries older than N days. //! `forge admin backup --out DIR [--include-key] [--repos-only | --db-only]`: writes a backup //! set under DIR (see `docs/backup.md`). Exits non-zero on any failure. //! `forge admin fsck [--full]`: git fsck on every repo plus the database integrity check. //! `forge admin lfs-gc`: deletes LFS object files no repo links, once they are a day old. //! `forge admin backup-verify PATH`: checks a backup set (hashes, bundles, database). Exits //! non-zero if any check fails. //! `forge admin restore --from SET --data-dir NEW_DIR`: restores a verified backup set into a //! new data dir (see `docs/backup.md`). //! `forge admin delete-user [--yes]`: deletes an account and moves its content to //! the ghost user. Without `--yes` it only prints what would happen and fails. //! Actions taken here are recorded in the audit log with actor `cli`. use std::path::{Path, PathBuf}; use std::time::Duration; use forge_core::store::user_delete::{DeletionReport, GHOST_USERNAME}; use forge_core::{AuditAction, Config, Error, Store, names, token}; use forge_git::GitEnv; use crate::admin_client::running_server; use crate::audit::{self, Actor, Target}; use crate::auth::hash_password_async; use crate::backup::{ BackupOptions, BackupSource, BackupSummary, FsckSummary, Scope, create_backup, restore_backup, run_fsck, verify_backup, }; use crate::boot::{MIN_GIT, find_git}; use forge_core::store::backup_runs::{RunKind, RunTrigger}; /// Length of a temporary password from `admin reset-password`. const TEMPORARY_PASSWORD_CHARS: usize = 20; const MIN_PASSWORD_CHARS: usize = 12; /// Creates a user. The password comes from FORGE_PASSWORD, or is prompted for twice on the tty. /// Refusal text for admin commands that would give a login to an organization. const ORG_NO_LOGIN: &str = "an organization cannot sign in, so it has no password or login name here"; /// Refusal text for `create-user` on a name that an organization already holds. const ORG_NAME_TAKEN: &str = "that name belongs to an organization"; pub async fn create_user( config_path: &Path, username: &str, make_admin: bool, ) -> forge_core::Result<()> { let (username, _) = names::validate_username(username)?; let pw = read_password()?; if pw.chars().count() < MIN_PASSWORD_CHARS { return Err(Error::Invalid(format!( "password must be at least {MIN_PASSWORD_CHARS} characters" ))); } let config = Config::load(config_path)?; if let Some(parent) = config.database.parent() { std::fs::create_dir_all(parent)?; } let store = Store::open(&config.database).await?; if store .user_by_username(&username) .await? .is_some_and(|existing| existing.is_org()) { return Err(Error::Conflict(ORG_NAME_TAKEN.into())); } let hash = hash_password_async(pw).await?; // The first user is always an admin. The insert is atomic, so a concurrent creator falls // through to the ordinary path instead of racing into a second admin. let user = match store.create_first_admin(&username, None, &hash).await? { Some(user) => user, None => { store .create_user(&username, None, &hash, make_admin) .await? } }; audit::record( &store, None, Actor::Cli, AuditAction::UserCreated, Target { kind: "user", id: &user.id, name: &user.username, }, serde_json::json!({ "admin": user.is_admin }), ) .await; println!( "created user {} (id {}{})", user.username, user.id, if user.is_admin { ", admin" } else { "" } ); Ok(()) } /// Deletes a user account and moves its authored content to the ghost user. Without `yes` this /// is a dry run: it prints what would happen, changes nothing and fails. pub async fn delete_user(config_path: &Path, username: &str, yes: bool) -> forge_core::Result<()> { let config = Config::load(config_path)?; let store = Store::open(&config.database).await?; let report = delete_user_in(&store, username, yes).await?; print_deletion_report(&format!("deleted {username}"), &report); Ok(()) } /// The store-level part of [`delete_user`]. Returns the report of what was (or, with `yes` /// unset, would be) done. Without `yes` nothing is written and an error is returned, so the /// command exits nonzero. The ghost account is not created by a dry run. pub async fn delete_user_in( store: &Store, username: &str, yes: bool, ) -> forge_core::Result { let user = store .user_by_username(username) .await? .ok_or(Error::NotFound)?; if names::normalize_name(&user.username) == GHOST_USERNAME { return Err(Error::Forbidden( "the ghost account cannot be deleted".into(), )); } let ghost_id = store.user_by_username(GHOST_USERNAME).await?.map(|g| g.id); let preview = store .deletion_preview(&user.id, ghost_id.as_deref()) .await?; if !yes { print_deletion_report(&format!("would delete {}", user.username), &preview); return Err(Error::Invalid(format!( "dry run: nothing was deleted. Run again with --yes to delete {}", user.username ))); } let ghost = store.ensure_ghost_user().await?; let report = store.delete_user(&user.id, &ghost.id).await?; audit::record( store, None, Actor::Cli, AuditAction::UserDeleted, Target { kind: "user", id: &user.id, name: &user.username, }, serde_json::json!({ "reassigned_to": GHOST_USERNAME }), ) .await; tracing::info!(user = %user.username, "admin deleted a user account"); Ok(report) } fn print_deletion_report(header: &str, report: &DeletionReport) { println!("{header}:"); println!(" moved to the ghost user:"); for (label, n) in &report.reassigned { println!(" {n} {label}"); } println!(" removed with the account:"); for (label, n) in &report.removed { println!(" {n} {label}"); } } /// Changes the capitalization of an existing user's username. `current` is matched /// case-insensitively; `new_display` must differ from it only in case. pub async fn set_username( config_path: &Path, current: &str, new_display: &str, ) -> forge_core::Result<()> { let config = Config::load(config_path)?; let store = Store::open(&config.database).await?; let user = store .user_by_username(current) .await? .ok_or(Error::NotFound)?; if user.is_org() { return Err(Error::Forbidden(ORG_NO_LOGIN.into())); } let display = names::capitalization_change(&user.username, new_display)?; store.set_username_display(&user.id, &display).await?; audit::record( &store, None, Actor::Cli, AuditAction::UsernameChanged, Target { kind: "user", id: &user.id, name: &display, }, serde_json::json!({ "from": user.username }), ) .await; println!("renamed {} to {}", user.username, display); Ok(()) } /// Sets a random temporary password for `username`, revokes every session of that user and /// prints the password. Returns the password. Nothing else changes. pub async fn reset_password(config_path: &Path, username: &str) -> forge_core::Result<()> { let config = Config::load(config_path)?; let store = Store::open(&config.database).await?; let (password, display) = reset_password_in(&store, username).await?; println!("temporary password for {display}: {password}"); println!("all sessions of {display} were signed out; change this password after logging in"); Ok(()) } /// The store-level part of [`reset_password`]. Returns the new password and the display name. pub async fn reset_password_in( store: &Store, username: &str, ) -> forge_core::Result<(String, String)> { let user = store .user_by_username(username) .await? .ok_or(Error::NotFound)?; if user.is_org() { return Err(Error::Forbidden(ORG_NO_LOGIN.into())); } if names::normalize_name(&user.username) == GHOST_USERNAME { return Err(Error::Forbidden("the ghost account has no password".into())); } let password = token::random_readable(TEMPORARY_PASSWORD_CHARS); let hash = hash_password_async(password.clone()).await?; store.set_password_hash(&user.id, &hash).await?; store.delete_user_sessions(&user.id).await?; audit::record( store, None, Actor::Cli, AuditAction::PasswordResetByAdmin, Target { kind: "user", id: &user.id, name: &user.username, }, serde_json::Value::Null, ) .await; tracing::info!(user = %user.username, "admin reset a password and revoked sessions"); Ok((password, user.username)) } /// Prints each trash entry as `\t\t`. pub fn trash_list(config_path: &Path) -> forge_core::Result<()> { let config = Config::load(config_path)?; let entries = forge_git::repo::list_trash(&config.data_dir)?; if entries.is_empty() { println!("trash is empty"); } for entry in entries { println!( "{}\t{}\t{}", entry.deleted_at, entry.repo_id, entry.path.display() ); } Ok(()) } /// Permanently removes trash entries deleted at least `days` days ago. pub fn trash_purge(config_path: &Path, days: u64) -> forge_core::Result<()> { let config = Config::load(config_path)?; let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) .map_err(|e| Error::Invalid(format!("system clock is before 1970: {e}")))?; let older_than = days.saturating_mul(86_400); let removed = forge_git::repo::purge_trash(&config.data_dir, older_than, now)?; for path in &removed { tracing::info!(path = %path.display(), "purged trashed repository"); println!("purged {}", path.display()); } println!("purged {} trashed repositories", removed.len()); Ok(()) } /// Deletes audit log entries older than `days` days. The purge itself is logged to tracing, not /// to the audit log, so the log it shrinks does not record its own removal. pub async fn audit_purge(config_path: &Path, days: u64) -> forge_core::Result<()> { let config = Config::load(config_path)?; let store = Store::open(&config.database).await?; let cutoff = forge_core::time::stamp_ago(Duration::from_secs(days.saturating_mul(86_400))); let removed = store.purge_audit_before(&cutoff).await?; tracing::info!(removed, cutoff = %cutoff, "purged audit log entries"); println!("purged {removed} audit log entries older than {days} days"); Ok(()) } fn read_password() -> forge_core::Result { if let Ok(pw) = std::env::var("FORGE_PASSWORD") { return Ok(pw); } let first = rpassword::prompt_password("Password: ")?; let second = rpassword::prompt_password("Repeat password: ")?; if first != second { return Err(Error::Invalid("passwords do not match".to_string())); } Ok(first) } /// Parses the flags of `admin backup`. `--out DIR` is required. `--repos-only` and `--db-only` /// cannot be combined. The key is left out unless `--include-key` is given. pub fn parse_backup_args(args: &[&str]) -> std::result::Result { let mut out = None; let mut include_key = false; let mut scope = None; let mut rest = args.iter(); while let Some(arg) = rest.next() { match *arg { "--out" => { let dir = rest.next().ok_or("--out needs a directory")?; out = Some(PathBuf::from(dir)); } "--include-key" => include_key = true, "--repos-only" | "--db-only" => { let wanted = if *arg == "--repos-only" { Scope::ReposOnly } else { Scope::DbOnly }; if scope.replace(wanted).is_some() { return Err("--repos-only and --db-only cannot be combined".into()); } } other => return Err(format!("unknown backup option: {other}")), } } let out = out.ok_or("backup needs --out DIR")?; Ok(BackupOptions { out, include_key, scope: scope.unwrap_or(Scope::All), }) } /// Writes a backup set (`admin backup`). If the server is running, it writes the set and records /// the run (trigger `cli`). Otherwise the database is opened directly, so the server must be /// stopped. pub async fn backup(config_path: &Path, opts: BackupOptions) -> forge_core::Result<()> { let config = Config::load(config_path)?; let out = std::path::absolute(&opts.out)?; if let Some(server) = running_server(&config).await? { let body = serde_json::json!({ "out": out, "include_key": opts.include_key, "scope": opts.scope, }); let summary: BackupSummary = server.post("/internal/admin/backup", &body).await?; print_backup_summary(&summary); return Ok(()); } let data_dir = config.data_dir.canonicalize()?; let store = Store::open(&config.database).await?; let git = GitEnv { git_bin: find_git(), data_dir, }; forge_git::version::require_min_version(&git, MIN_GIT).await?; let source = BackupSource { store: store.clone(), git, base_url: config.base_url.clone(), }; let target_name = out.to_string_lossy().into_owned(); audit::record( &store, None, Actor::Cli, AuditAction::BackupStarted, Target { kind: "backup", id: "", name: &target_name, }, serde_json::json!({ "trigger": "cli" }), ) .await; let run = store.start_run(RunKind::Backup, RunTrigger::Cli).await?; let outcome = create_backup(&source, &BackupOptions { out, ..opts }).await; let (ok, path, bytes, error) = match &outcome { Ok(summary) => ( true, Some(summary.path.to_string_lossy().into_owned()), Some(summary.bytes), None, ), Err(e) => (false, None, None, Some(e.to_string())), }; if let Err(e) = store .finish_run(&run, ok, path.as_deref(), bytes, error.as_deref()) .await { tracing::error!(error = %e, "could not record the outcome of an offline backup"); } let summary = outcome?; tracing::info!(path = %summary.path.display(), bytes = summary.bytes, "backup set written"); print_backup_summary(&summary); Ok(()) } fn print_backup_summary(summary: &BackupSummary) { println!("backup written: {}", summary.path.display()); println!( " scope: {:?}, files: {} ({} bytes)", summary.scope, summary.files, summary.bytes ); println!( " repos: {} (bundles: {}, empty: {})", summary.repos, summary.bundles, summary.empty_repos ); match summary.schema_version { Some(v) => println!(" database: snapshot at schema version {v}"), None => println!(" database: not included"), } println!( " key: {}", if summary.key_included { "included (keep this set private)" } else { "not included (back up forge.key separately)" } ); } /// Runs `git fsck` on every repo and the database integrity check (`admin fsck [--full]`). The /// server runs it when it is up, otherwise the database is opened directly. Fails when any check /// fails. pub async fn fsck(config_path: &Path, full: bool) -> forge_core::Result<()> { let config = Config::load(config_path)?; let summary: FsckSummary = match running_server(&config).await? { Some(server) => { server .post("/internal/admin/fsck", &serde_json::json!({ "full": full })) .await? } None => { let store = Store::open(&config.database).await?; let git = GitEnv { git_bin: find_git(), data_dir: config.data_dir.canonicalize()?, }; audit::record( &store, None, Actor::Cli, AuditAction::FsckStarted, Target { kind: "fsck", id: "", name: "", }, serde_json::json!({ "trigger": "cli", "full": full }), ) .await; let run = store.start_run(RunKind::Fsck, RunTrigger::Cli).await?; let outcome = run_fsck(&store, &git, full).await; let (ok, error) = match &outcome { Ok(summary) if summary.is_ok() => (true, None), Ok(summary) => (false, Some(summary.problems.join("\n"))), Err(e) => (false, Some(e.to_string())), }; if let Err(e) = store .finish_run(&run, ok, None, None, error.as_deref()) .await { tracing::error!(error = %e, "could not record the outcome of an offline fsck"); } outcome? } }; println!( "fsck ({}): {} repos checked", if summary.full { "full" } else { "quick" }, summary.repos ); for problem in &summary.problems { println!(" problem: {problem}"); } if summary.is_ok() { println!(" database: integrity check ok"); Ok(()) } else { Err(Error::Invalid(format!( "fsck found {} problem(s)", summary.problems.len() ))) } } /// Deletes unlinked LFS objects and abandoned upload temp files (`admin lfs-gc`). The server runs /// it when it is up, otherwise the database is opened directly. pub async fn lfs_gc(config_path: &Path) -> forge_core::Result<()> { let config = Config::load(config_path)?; let summary: crate::lfs::gc::GcSummary = match running_server(&config).await? { Some(server) => { server .post("/internal/admin/lfs-gc", &serde_json::json!({})) .await? } None => { let store = Store::open(&config.database).await?; crate::lfs::gc::gc(&store, &config.data_dir, crate::lfs::gc::GC_MIN_AGE).await? } }; println!( "lfs-gc: {} object file(s) checked, {} removed ({} bytes), {} abandoned upload(s) removed", summary.checked, summary.removed, summary.bytes_freed, summary.temp_removed ); Ok(()) } /// Verifies a backup set (`admin backup-verify`). Prints every problem found and fails if there /// is any. Scratch repos go next to the set, not in `/tmp`. pub async fn backup_verify(path: &Path) -> forge_core::Result<()> { let path = std::path::absolute(path)?; let parent = path .parent() .ok_or_else(|| Error::Invalid("the backup set has no parent directory".into()))? .to_path_buf(); // Git runs with the set's parent as its HOME, so no user gitconfig is read. let git = GitEnv { git_bin: find_git(), data_dir: parent, }; forge_git::version::require_min_version(&git, MIN_GIT).await?; let report = verify_backup(&path, &git).await?; println!("backup set: {}", report.path.display()); println!( " scope: {:?}, files: {} ({} bytes)", report.scope, report.files, report.bytes ); println!( " repos: {} (bundles verified: {}, empty: {})", report.repos, report.bundles_verified, report.empty_repos ); if let Some(v) = report.schema_version { println!(" database: schema version {v}, integrity_check ok"); } if report.is_ok() { println!("result: OK"); return Ok(()); } for problem in &report.problems { println!(" problem: {problem}"); } println!("result: FAILED ({} problem(s))", report.problems.len()); Err(Error::Invalid(format!( "backup set failed verification: {}", report.path.display() ))) } /// Parses the flags of `admin restore`: `--from SET --data-dir NEW_DIR`, both required. pub fn parse_restore_args(args: &[&str]) -> std::result::Result<(PathBuf, PathBuf), String> { let mut from = None; let mut data_dir = None; let mut rest = args.iter(); while let Some(arg) = rest.next() { match *arg { "--from" => { let set = rest.next().ok_or("--from needs a backup set path")?; from = Some(PathBuf::from(set)); } "--data-dir" => { let dir = rest.next().ok_or("--data-dir needs a directory")?; data_dir = Some(PathBuf::from(dir)); } other => return Err(format!("unknown restore option: {other}")), } } match (from, data_dir) { (Some(from), Some(dir)) => Ok((from, dir)), _ => Err("restore needs --from SET and --data-dir NEW_DIR".into()), } } /// Restores a verified backup set into a new data dir (`admin restore`). Needs no config and no /// running server. The restored database is written to `NEW_DIR/forge.db`, the layout a fresh /// install uses. Point `database` in the config at it before starting the server. pub async fn restore(set: &Path, new_data_dir: &Path) -> forge_core::Result<()> { let git_bin = find_git(); let set_abs = std::path::absolute(set)?; let probe = GitEnv { git_bin: git_bin.clone(), data_dir: set_abs.parent().unwrap_or(&set_abs).to_path_buf(), }; forge_git::version::require_min_version(&probe, MIN_GIT).await?; match restore_backup(&set_abs, new_data_dir, &git_bin).await { Ok(report) => { println!("restored into: {}", report.data_dir.display()); println!(" database: {}", report.database.display()); println!( " repos: {} from bundles, {} empty", report.repos, report.empty_repos ); println!( " key: {}", if report.key_restored { "restored (forge.key, mode 0600)" } else { "NOT restored" } ); for warning in &report.warnings { println!("WARNING: {warning}"); } println!( "next: set `database = \"{}\"` and `data_dir = \"{}\"` in the config,", report.database.display(), report.data_dir.display() ); println!(" then run `forge admin fsck --full` before starting the server."); Ok(()) } Err(e) => { eprintln!("RESTORE FAILED: {e}"); if new_data_dir.exists() { eprintln!( "The directory {} was left as it is for inspection. Do not start the server from it.", new_data_dir.display() ); } Err(e) } } } #[cfg(test)] mod tests { use super::*; use crate::auth::verify_password_async; #[tokio::test] async fn reset_password_sets_a_20_char_password_and_revokes_sessions() { let dir = tempfile::tempdir().unwrap(); let store = Store::open(&dir.path().join("forge.db")).await.unwrap(); let hash = hash_password_async("the original password".into()) .await .unwrap(); let user = store .create_user("Alice", None, &hash, false) .await .unwrap(); let expiry = forge_core::time::stamp_after(std::time::Duration::from_secs(3600)); store .create_session(&user.id, "h1", "c1", &expiry, None) .await .unwrap(); store .create_session(&user.id, "h2", "c2", &expiry, None) .await .unwrap(); let (password, display) = reset_password_in(&store, "alice").await.unwrap(); assert_eq!(password.chars().count(), TEMPORARY_PASSWORD_CHARS); assert_eq!(display, "Alice"); assert!(store.list_sessions(&user.id).await.unwrap().is_empty()); let fresh = store.user_by_id(&user.id).await.unwrap().unwrap(); assert!( verify_password_async(password, fresh.password_hash.clone()) .await .unwrap() ); let old_ok = verify_password_async("the original password".into(), fresh.password_hash) .await .unwrap(); assert!(!old_ok); } #[tokio::test] async fn delete_user_dry_run_changes_nothing() { let dir = tempfile::tempdir().unwrap(); let store = Store::open(&dir.path().join("forge.db")).await.unwrap(); store.create_user("Dana", None, "hash", true).await.unwrap(); store .create_user("Alice", None, "hash", false) .await .unwrap(); let dry = delete_user_in(&store, "alice", false).await; assert!(matches!(dry, Err(Error::Invalid(_))), "{dry:?}"); assert!(store.user_by_username("alice").await.unwrap().is_some()); assert!( store .user_by_username(GHOST_USERNAME) .await .unwrap() .is_none(), "a dry run must not create the ghost account" ); delete_user_in(&store, "alice", true).await.unwrap(); assert!(store.user_by_username("alice").await.unwrap().is_none()); assert!( store .user_by_username(GHOST_USERNAME) .await .unwrap() .is_some() ); } #[tokio::test] async fn reset_password_for_an_unknown_user_is_not_found() { let dir = tempfile::tempdir().unwrap(); let store = Store::open(&dir.path().join("forge.db")).await.unwrap(); let err = reset_password_in(&store, "nobody").await; assert!(matches!(err, Err(Error::NotFound))); } } #[cfg(test)] mod org_account_tests { use super::*; /// An organization has no password to reset, and deleting its last owner is refused with the /// message the admin page and the CLI show. #[tokio::test] async fn org_accounts_refuse_password_reset_and_last_owner_deletion() { let dir = tempfile::tempdir().unwrap(); let store = Store::open(&dir.path().join("forge.db")).await.unwrap(); store.create_user("Dana", None, "hash", true).await.unwrap(); let alice = store .create_user("Alice", None, "hash", false) .await .unwrap(); let org = store.create_org("acme", &alice.id).await.unwrap(); assert!(matches!( reset_password_in(&store, "acme").await, Err(Error::Forbidden(_)) )); let untouched = store.user_by_id(&org.id).await.unwrap().unwrap(); assert_eq!(untouched.password_hash, "!org"); let refused = delete_user_in(&store, "alice", true).await; assert!( matches!(&refused, Err(Error::Conflict(msg)) if msg == forge_core::store::user_delete::LAST_ORG_OWNER), "{refused:?}" ); assert!(store.user_by_id(&alice.id).await.unwrap().is_some()); } }