fabrica

hanna/fabrica

feat(cli): user account management commands

3bc5ed9 · hanna committed on 2026-07-25

Add the `fabrica user` group — add, passwd, list, disable, enable, del —
operating directly on the store with no running server. Highlights:

- `user passwd` sets a password from `--pass`, an interactive no-echo prompt
  (confirmed twice on a terminal), or a single piped stdin line for scripting.
  This is the SMTP-disabled path for activating an account.
- `user add` hashes `--pass` when given; without it the account is created
  inactive with guidance to run `user passwd` (email invites arrive in phase 9).
- `user del` refuses a repo owner unless `--purge`, and confirms on a TTY or
  requires `--yes` when piped.
- `--json user list` exposes only whether a password is set, never the hash.

Store-backed commands self-migrate on connect so a fresh install's first
`user add` bootstraps the database. End-to-end tests cover each command against
a temp SQLite database; decisions.md records the pull-forward and interim
`user add` behaviour.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
7 files changed · +825 −9UnifiedSplit
Cargo.lock +110 −9
@@ -44,7 +44,7 @@ version = "1.1.5"
44source = "registry+https://github.com/rust-lang/crates.io-index"44source = "registry+https://github.com/rust-lang/crates.io-index"
45checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"45checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
46dependencies = [46dependencies = [
47 "windows-sys",47 "windows-sys 0.61.2",
48]48]
4949
50[[package]]50[[package]]
@@ -55,7 +55,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
55dependencies = [55dependencies = [
56 "anstyle",56 "anstyle",
57 "once_cell_polyfill",57 "once_cell_polyfill",
58 "windows-sys",58 "windows-sys 0.61.2",
59]59]
6060
61[[package]]61[[package]]
@@ -263,9 +263,16 @@ name = "cli"
263version = "0.1.0"263version = "0.1.0"
264dependencies = [264dependencies = [
265 "anyhow",265 "anyhow",
266 "auth",
266 "clap",267 "clap",
267 "config",268 "config",
269 "model",
270 "rpassword",
271 "serde",
268 "serde_json",272 "serde_json",
273 "store",
274 "tempfile",
275 "tokio",
269]276]
270277
271[[package]]278[[package]]
@@ -437,7 +444,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
437checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"444checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
438dependencies = [445dependencies = [
439 "libc",446 "libc",
440 "windows-sys",447 "windows-sys 0.61.2",
441]448]
442449
443[[package]]450[[package]]
@@ -447,7 +454,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
447checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96"454checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96"
448dependencies = [455dependencies = [
449 "cfg-if",456 "cfg-if",
450 "windows-sys",457 "windows-sys 0.61.2",
451]458]
452459
453[[package]]460[[package]]
@@ -933,7 +940,7 @@ checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
933dependencies = [940dependencies = [
934 "libc",941 "libc",
935 "wasi",942 "wasi",
936 "windows-sys",943 "windows-sys 0.61.2",
937]944]
938945
939[[package]]946[[package]]
@@ -1171,6 +1178,27 @@ dependencies = [
1171]1178]
11721179
1173[[package]]1180[[package]]
1181name = "rpassword"
1182version = "7.5.4"
1183source = "registry+https://github.com/rust-lang/crates.io-index"
1184checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196"
1185dependencies = [
1186 "libc",
1187 "rtoolbox",
1188 "windows-sys 0.61.2",
1189]
1190
1191[[package]]
1192name = "rtoolbox"
1193version = "0.0.5"
1194source = "registry+https://github.com/rust-lang/crates.io-index"
1195checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844"
1196dependencies = [
1197 "libc",
1198 "windows-sys 0.59.0",
1199]
1200
1201[[package]]
1174name = "rustix"1202name = "rustix"
1175version = "1.1.4"1203version = "1.1.4"
1176source = "registry+https://github.com/rust-lang/crates.io-index"1204source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1180,7 +1208,7 @@ dependencies = [
1180 "errno",1208 "errno",
1181 "libc",1209 "libc",
1182 "linux-raw-sys",1210 "linux-raw-sys",
1183 "windows-sys",1211 "windows-sys 0.61.2",
1184]1212]
11851213
1186[[package]]1214[[package]]
@@ -1308,7 +1336,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1308checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"1336checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
1309dependencies = [1337dependencies = [
1310 "libc",1338 "libc",
1311 "windows-sys",1339 "windows-sys 0.61.2",
1312]1340]
13131341
1314[[package]]1342[[package]]
@@ -1578,7 +1606,7 @@ dependencies = [
1578 "getrandom 0.4.3",1606 "getrandom 0.4.3",
1579 "once_cell",1607 "once_cell",
1580 "rustix",1608 "rustix",
1581 "windows-sys",1609 "windows-sys 0.61.2",
1582]1610]
15831611
1584[[package]]1612[[package]]
@@ -1638,7 +1666,7 @@ dependencies = [
1638 "pin-project-lite",1666 "pin-project-lite",
1639 "socket2",1667 "socket2",
1640 "tokio-macros",1668 "tokio-macros",
1641 "windows-sys",1669 "windows-sys 0.61.2",
1642]1670]
16431671
1644[[package]]1672[[package]]
@@ -1912,6 +1940,15 @@ checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
19121940
1913[[package]]1941[[package]]
1914name = "windows-sys"1942name = "windows-sys"
1943version = "0.59.0"
1944source = "registry+https://github.com/rust-lang/crates.io-index"
1945checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
1946dependencies = [
1947 "windows-targets",
1948]
1949
1950[[package]]
1951name = "windows-sys"
1915version = "0.61.2"1952version = "0.61.2"
1916source = "registry+https://github.com/rust-lang/crates.io-index"1953source = "registry+https://github.com/rust-lang/crates.io-index"
1917checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"1954checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
@@ -1920,6 +1957,70 @@ dependencies = [
1920]1957]
19211958
1922[[package]]1959[[package]]
1960name = "windows-targets"
1961version = "0.52.6"
1962source = "registry+https://github.com/rust-lang/crates.io-index"
1963checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
1964dependencies = [
1965 "windows_aarch64_gnullvm",
1966 "windows_aarch64_msvc",
1967 "windows_i686_gnu",
1968 "windows_i686_gnullvm",
1969 "windows_i686_msvc",
1970 "windows_x86_64_gnu",
1971 "windows_x86_64_gnullvm",
1972 "windows_x86_64_msvc",
1973]
1974
1975[[package]]
1976name = "windows_aarch64_gnullvm"
1977version = "0.52.6"
1978source = "registry+https://github.com/rust-lang/crates.io-index"
1979checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
1980
1981[[package]]
1982name = "windows_aarch64_msvc"
1983version = "0.52.6"
1984source = "registry+https://github.com/rust-lang/crates.io-index"
1985checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
1986
1987[[package]]
1988name = "windows_i686_gnu"
1989version = "0.52.6"
1990source = "registry+https://github.com/rust-lang/crates.io-index"
1991checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
1992
1993[[package]]
1994name = "windows_i686_gnullvm"
1995version = "0.52.6"
1996source = "registry+https://github.com/rust-lang/crates.io-index"
1997checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
1998
1999[[package]]
2000name = "windows_i686_msvc"
2001version = "0.52.6"
2002source = "registry+https://github.com/rust-lang/crates.io-index"
2003checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
2004
2005[[package]]
2006name = "windows_x86_64_gnu"
2007version = "0.52.6"
2008source = "registry+https://github.com/rust-lang/crates.io-index"
2009checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
2010
2011[[package]]
2012name = "windows_x86_64_gnullvm"
2013version = "0.52.6"
2014source = "registry+https://github.com/rust-lang/crates.io-index"
2015checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
2016
2017[[package]]
2018name = "windows_x86_64_msvc"
2019version = "0.52.6"
2020source = "registry+https://github.com/rust-lang/crates.io-index"
2021checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
2022
2023[[package]]
1923name = "winnow"2024name = "winnow"
1924version = "0.7.15"2025version = "0.7.15"
1925source = "registry+https://github.com/rust-lang/crates.io-index"2026source = "registry+https://github.com/rust-lang/crates.io-index"
crates/cli/Cargo.toml +9 −0
@@ -11,9 +11,18 @@ publish.workspace = true
1111
12[dependencies]12[dependencies]
13config = { workspace = true }13config = { workspace = true }
14store = { workspace = true }
15auth = { workspace = true }
16model = { workspace = true }
14anyhow = { workspace = true }17anyhow = { workspace = true }
15clap = { workspace = true }18clap = { workspace = true }
19serde = { workspace = true }
16serde_json = { workspace = true }20serde_json = { workspace = true }
21tokio = { workspace = true }
22rpassword = { workspace = true }
23
24[dev-dependencies]
25tempfile = { workspace = true }
1726
18[lints]27[lints]
19workspace = true28workspace = true
crates/cli/src/context.rs +61 −0
@@ -0,0 +1,61 @@
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Shared plumbing for store-backed subcommands.
6//!
7//! Every CLI mutation operates directly on the database — there is no IPC with a
8//! running `serve`. These helpers turn the loaded configuration into an open
9//! [`Store`] and give the (otherwise synchronous) command handlers a place to
10//! block on async work.
11
12use std::future::Future;
13use std::path::Path;
14
15use config::Config;
16use store::Store;
17
18/// Load configuration, open the store, and apply any pending migrations.
19///
20/// Migrations run on every store-backed command so a fresh install is usable
21/// immediately: the first `fabrica user add` creates the database file, brings
22/// the schema up to date, and inserts the row in one step. Migrations are
23/// idempotent, so this is a cheap no-op once the schema is current.
24///
25/// Configuration warnings are printed to stderr, matching `config check`/`show`.
26///
27/// # Errors
28///
29/// Returns an error if the configuration is invalid, the database cannot be
30/// opened, or migrations fail.
31pub(crate) async fn open_store(config_path: Option<&Path>) -> anyhow::Result<(Config, Store)> {
32 let loaded = config::load(config_path)?;
33 for warning in &loaded.warnings {
34 eprintln!("warning: {warning}");
35 }
36 let store = Store::connect(
37 &loaded.config.database.url,
38 loaded.config.database.max_connections,
39 )
40 .await?;
41 store.migrate().await?;
42 Ok((loaded.config, store))
43}
44
45/// Run an async command body to completion on a fresh current-thread runtime.
46///
47/// The CLI is a short-lived, single-shot process, so a current-thread runtime is
48/// the right size — no worker pool to spin up and tear down per invocation.
49///
50/// # Errors
51///
52/// Propagates any error from building the runtime or from `fut` itself.
53pub(crate) fn block_on<F, T>(fut: F) -> anyhow::Result<T>
54where
55 F: Future<Output = anyhow::Result<T>>,
56{
57 let runtime = tokio::runtime::Builder::new_current_thread()
58 .enable_all()
59 .build()?;
60 runtime.block_on(fut)
61}
crates/cli/src/lib.rs +10 −0
@@ -10,6 +10,9 @@
10//! phases; today it covers only `config check` and `config show`.10//! phases; today it covers only `config check` and `config show`.
1111
12mod config_cmd;12mod config_cmd;
13mod context;
14mod prompt;
15mod user_cmd;
1316
14use std::path::PathBuf;17use std::path::PathBuf;
15use std::process::ExitCode;18use std::process::ExitCode;
@@ -46,6 +49,12 @@ enum Command {
46 #[command(subcommand)]49 #[command(subcommand)]
47 command: config_cmd::ConfigCommand,50 command: config_cmd::ConfigCommand,
48 },51 },
52 /// Manage user accounts.
53 User {
54 /// The user action to perform.
55 #[command(subcommand)]
56 command: user_cmd::UserCommand,
57 },
49}58}
5059
51/// Parse arguments and dispatch to the requested subcommand.60/// Parse arguments and dispatch to the requested subcommand.
@@ -69,5 +78,6 @@ pub fn run() -> ExitCode {
69fn dispatch(cli: &Cli) -> anyhow::Result<ExitCode> {78fn dispatch(cli: &Cli) -> anyhow::Result<ExitCode> {
70 match &cli.command {79 match &cli.command {
71 Command::Config { command } => config_cmd::run(command, cli.config.as_deref(), cli.json),80 Command::Config { command } => config_cmd::run(command, cli.config.as_deref(), cli.json),
81 Command::User { command } => user_cmd::run(command, cli.config.as_deref(), cli.json),
72 }82 }
73}83}
crates/cli/src/prompt.rs +78 −0
@@ -0,0 +1,78 @@
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Interactive terminal prompts: non-echoing password entry and destructive-
6//! action confirmation.
7//!
8//! Both degrade deliberately when stdin is not a terminal (a pipe, a systemd
9//! unit, CI). A password can still be supplied on a single stdin line for
10//! scripting; a confirmation, by contrast, refuses rather than guess, so a
11//! non-interactive destructive command must pass `--yes` explicitly.
12
13use std::io::{IsTerminal, Write};
14
15/// Resolve a password to use, in three ways depending on how the command was
16/// invoked:
17///
18/// * `explicit` (from `--pass`) is used verbatim when present;
19/// * otherwise, on a terminal, the user is prompted twice with no echo and the
20/// two entries must match;
21/// * otherwise (piped stdin) a single line is read, so
22/// `printf '%s' pw | fabrica user passwd alice` works.
23///
24/// An empty password is rejected in every case.
25///
26/// # Errors
27///
28/// Returns an error if the prompts cannot be read, the two interactive entries
29/// differ, or the resolved password is empty.
30pub(crate) fn resolve_password(explicit: Option<String>) -> anyhow::Result<String> {
31 let password = match explicit {
32 Some(pass) => pass,
33 None if std::io::stdin().is_terminal() => {
34 let first = rpassword::prompt_password("New password: ")?;
35 let second = rpassword::prompt_password("Confirm password: ")?;
36 if first != second {
37 anyhow::bail!("passwords did not match");
38 }
39 first
40 }
41 None => {
42 let mut line = String::new();
43 std::io::stdin().read_line(&mut line)?;
44 line.trim_end_matches(['\r', '\n']).to_string()
45 }
46 };
47 if password.is_empty() {
48 anyhow::bail!("password must not be empty");
49 }
50 Ok(password)
51}
52
53/// Ask the user to confirm a destructive action.
54///
55/// `assume_yes` (from `--yes`) short-circuits to `true`. Otherwise a terminal is
56/// prompted `[y/N]` and only `y`/`yes` (any case) confirms. When stdin is not a
57/// terminal and `--yes` was not given, the action is refused rather than assumed.
58///
59/// # Errors
60///
61/// Returns an error if stdin is not a terminal and `--yes` was not passed, or if
62/// the prompt cannot be read.
63pub(crate) fn confirm(question: &str, assume_yes: bool) -> anyhow::Result<bool> {
64 if assume_yes {
65 return Ok(true);
66 }
67 if !std::io::stdin().is_terminal() {
68 anyhow::bail!(
69 "refusing to proceed without confirmation; re-run with --yes for non-interactive use"
70 );
71 }
72 eprint!("{question} [y/N] ");
73 std::io::stderr().flush()?;
74 let mut line = String::new();
75 std::io::stdin().read_line(&mut line)?;
76 let answer = line.trim().to_ascii_lowercase();
77 Ok(answer == "y" || answer == "yes")
78}
crates/cli/src/user_cmd.rs +521 −0
@@ -0,0 +1,521 @@
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! `fabrica user` — account administration, operating directly on the store.
6
7use std::path::Path;
8use std::process::ExitCode;
9
10use clap::Subcommand;
11use config::Config;
12use model::User;
13use store::{NewUser, Store};
14
15use crate::context::{block_on, open_store};
16use crate::prompt::{confirm, resolve_password};
17
18/// Actions under `fabrica user`.
19#[derive(Debug, Subcommand)]
20pub(crate) enum UserCommand {
21 /// Create a user. Without `--pass` the account is created with no password
22 /// and cannot log in until one is set with `fabrica user passwd`.
23 Add {
24 /// Login name (validated: see the name rules).
25 name: String,
26 /// Primary email address.
27 email: String,
28 /// Set this password now instead of leaving the account inactive. Omit
29 /// the value to be prompted; omit the flag entirely to create an
30 /// inactive account.
31 #[arg(long)]
32 pass: Option<String>,
33 /// Grant site-administrator privileges.
34 #[arg(long)]
35 admin: bool,
36 /// Human-friendly display name.
37 #[arg(long = "display-name")]
38 display_name: Option<String>,
39 },
40 /// Set a user's password. With no `--pass`, prompts twice on a terminal, or
41 /// reads a single line from stdin when piped. Rotating a password logs the
42 /// user out of every existing session.
43 Passwd {
44 /// The user whose password to set.
45 name: String,
46 /// The new password. Omit to be prompted (or to read it from stdin).
47 #[arg(long)]
48 pass: Option<String>,
49 },
50 /// List all users.
51 List,
52 /// Disable a user: they can no longer log in, and existing sessions are
53 /// dropped.
54 Disable {
55 /// The user to disable.
56 name: String,
57 },
58 /// Re-enable a previously disabled user.
59 Enable {
60 /// The user to enable.
61 name: String,
62 },
63 /// Delete a user. Refuses if the user still owns repositories unless
64 /// `--purge` is given.
65 Del {
66 /// The user to delete.
67 name: String,
68 /// Also delete every repository the user owns.
69 #[arg(long)]
70 purge: bool,
71 /// Skip the confirmation prompt (required when stdin is not a terminal).
72 #[arg(long)]
73 yes: bool,
74 },
75}
76
77/// Dispatch a `user` subcommand, blocking on the async store work.
78pub(crate) fn run(
79 command: &UserCommand,
80 config_path: Option<&Path>,
81 json: bool,
82) -> anyhow::Result<ExitCode> {
83 match command {
84 UserCommand::Add {
85 name,
86 email,
87 pass,
88 admin,
89 display_name,
90 } => block_on(add(
91 config_path,
92 name,
93 email,
94 pass.clone(),
95 *admin,
96 display_name.clone(),
97 )),
98 UserCommand::Passwd { name, pass } => block_on(passwd(config_path, name, pass.clone())),
99 UserCommand::List => block_on(list(config_path, json)),
100 UserCommand::Disable { name } => block_on(set_disabled(config_path, name, true)),
101 UserCommand::Enable { name } => block_on(set_disabled(config_path, name, false)),
102 UserCommand::Del { name, purge, yes } => block_on(del(config_path, name, *purge, *yes)),
103 }
104}
105
106/// Hash a plaintext password with the instance's configured Argon2id cost.
107fn hash_password(config: &Config, password: &str) -> anyhow::Result<String> {
108 let params = auth::HashParams {
109 m_cost: config.auth.argon2_m_cost,
110 t_cost: config.auth.argon2_t_cost,
111 p_cost: config.auth.argon2_p_cost,
112 };
113 Ok(auth::hash_password(password, params)?)
114}
115
116/// Look a user up by name, turning "not found" into a clean error.
117async fn require_user(store: &Store, name: &str) -> anyhow::Result<User> {
118 store
119 .user_by_username(name)
120 .await?
121 .ok_or_else(|| anyhow::anyhow!("no such user {name:?}"))
122}
123
124async fn add(
125 config_path: Option<&Path>,
126 name: &str,
127 email: &str,
128 pass: Option<String>,
129 admin: bool,
130 display_name: Option<String>,
131) -> anyhow::Result<ExitCode> {
132 let (config, store) = open_store(config_path).await?;
133
134 // A password is set eagerly only when `--pass` is present. `--pass` with no
135 // value prompts; the flag being absent leaves the account inactive.
136 let password_hash = match pass {
137 Some(explicit) => {
138 let password = resolve_password(Some(explicit))?;
139 Some(hash_password(&config, &password)?)
140 }
141 None => None,
142 };
143 let has_password = password_hash.is_some();
144
145 let user = store
146 .create_user(NewUser {
147 username: name.to_string(),
148 email: email.to_string(),
149 display_name,
150 password_hash,
151 is_admin: admin,
152 must_change_password: false,
153 })
154 .await?;
155
156 println!(
157 "created user {} <{}>{}",
158 user.username,
159 user.email,
160 if user.is_admin { " (admin)" } else { "" }
161 );
162 if !has_password {
163 // Email invitations are not implemented yet (phase 9), so there is no
164 // out-of-band way to activate the account — point the operator at the
165 // direct path. This is the SMTP-disabled fallback made explicit.
166 eprintln!(
167 "note: no password set; this account cannot log in yet. \
168 Set one with: fabrica user passwd {}",
169 user.username
170 );
171 }
172 Ok(ExitCode::SUCCESS)
173}
174
175async fn passwd(
176 config_path: Option<&Path>,
177 name: &str,
178 pass: Option<String>,
179) -> anyhow::Result<ExitCode> {
180 let (config, store) = open_store(config_path).await?;
181 let user = require_user(&store, name).await?;
182 let password = resolve_password(pass)?;
183 let hash = hash_password(&config, &password)?;
184 store.set_password(&user.id, Some(&hash)).await?;
185 println!("password updated for {}", user.username);
186 Ok(ExitCode::SUCCESS)
187}
188
189async fn list(config_path: Option<&Path>, json: bool) -> anyhow::Result<ExitCode> {
190 let (_config, store) = open_store(config_path).await?;
191 let users = store.list_users().await?;
192
193 if json {
194 let view: Vec<_> = users.iter().map(user_json).collect();
195 println!("{}", serde_json::to_string_pretty(&view)?);
196 return Ok(ExitCode::SUCCESS);
197 }
198
199 if users.is_empty() {
200 println!("no users");
201 return Ok(ExitCode::SUCCESS);
202 }
203
204 let width = users
205 .iter()
206 .map(|u| u.username.len())
207 .max()
208 .unwrap_or(0)
209 .max("USERNAME".len());
210 println!("{:<width$} {:<28} FLAGS", "USERNAME", "EMAIL");
211 for user in &users {
212 println!(
213 "{:<width$} {:<28} {}",
214 user.username,
215 user.email,
216 flags(user).join(",")
217 );
218 }
219 Ok(ExitCode::SUCCESS)
220}
221
222async fn set_disabled(
223 config_path: Option<&Path>,
224 name: &str,
225 disabled: bool,
226) -> anyhow::Result<ExitCode> {
227 let (_config, store) = open_store(config_path).await?;
228 let user = require_user(&store, name).await?;
229 store.set_disabled(&user.id, disabled).await?;
230 println!(
231 "{} user {}",
232 if disabled { "disabled" } else { "enabled" },
233 user.username
234 );
235 Ok(ExitCode::SUCCESS)
236}
237
238async fn del(
239 config_path: Option<&Path>,
240 name: &str,
241 purge: bool,
242 yes: bool,
243) -> anyhow::Result<ExitCode> {
244 let (_config, store) = open_store(config_path).await?;
245 let user = require_user(&store, name).await?;
246 let repos = store.repos_by_owner(&user.id).await?;
247
248 if !repos.is_empty() && !purge {
249 eprintln!(
250 "{} still owns {} repositor{}:",
251 user.username,
252 repos.len(),
253 plural(repos.len())
254 );
255 for repo in &repos {
256 eprintln!(" {}", repo.path);
257 }
258 anyhow::bail!(
259 "refusing to delete a user who owns repositories; pass --purge to remove them"
260 );
261 }
262
263 let question = if repos.is_empty() {
264 format!("Delete user {}?", user.username)
265 } else {
266 format!(
267 "Delete user {} and {} owned repositor{}?",
268 user.username,
269 repos.len(),
270 plural(repos.len())
271 )
272 };
273 if !confirm(&question, yes)? {
274 println!("aborted");
275 return Ok(ExitCode::SUCCESS);
276 }
277
278 // Cascades to the user's repos, keys, tokens, and sessions in the database.
279 // On-disk bare repositories are not yet created by `repo add`, so there is
280 // nothing to remove from the filesystem here; that cleanup lands with the
281 // git-storage phases.
282 store.delete_user(&user.id).await?;
283 println!("deleted user {}", user.username);
284 Ok(ExitCode::SUCCESS)
285}
286
287/// The single-character-free flag labels shown by `user list`.
288fn flags(user: &User) -> Vec<&'static str> {
289 let mut flags = Vec::new();
290 if user.is_admin {
291 flags.push("admin");
292 }
293 if user.disabled_at.is_some() {
294 flags.push("disabled");
295 }
296 if user.password_hash.is_none() {
297 flags.push("no-password");
298 }
299 if flags.is_empty() {
300 flags.push("-");
301 }
302 flags
303}
304
305/// Render a user for `--json`, deliberately exposing only whether a password is
306/// set (never the hash).
307fn user_json(user: &User) -> serde_json::Value {
308 serde_json::json!({
309 "id": user.id,
310 "username": user.username,
311 "email": user.email,
312 "display_name": user.display_name,
313 "is_admin": user.is_admin,
314 "has_password": user.password_hash.is_some(),
315 "must_change_password": user.must_change_password,
316 "disabled": user.disabled_at.is_some(),
317 "created_at": user.created_at,
318 "updated_at": user.updated_at,
319 })
320}
321
322/// The plural suffix `y`/`ies` helper for "repositor{y,ies}".
323fn plural(n: usize) -> &'static str {
324 if n == 1 { "y" } else { "ies" }
325}
326
327#[cfg(test)]
328mod tests {
329 #![allow(clippy::unwrap_used, clippy::expect_used)]
330
331 use std::path::PathBuf;
332
333 use store::NewRepo;
334 use tempfile::TempDir;
335
336 use super::*;
337
338 /// A temp directory holding a SQLite database and a config file that points
339 /// at it, so the handlers can run end-to-end with no external services.
340 struct Env {
341 _dir: TempDir,
342 config_path: PathBuf,
343 db_url: String,
344 }
345
346 fn env() -> Env {
347 let dir = tempfile::tempdir().unwrap();
348 let repos = dir.path().join("repos");
349 let db = dir.path().join("fabrica.db");
350 let db_url = format!("sqlite://{}", db.display());
351 let config_path = dir.path().join("fabrica.toml");
352 let toml = format!(
353 "[storage]\ndata_dir = {:?}\nrepo_dir = {:?}\n\n[database]\nurl = {:?}\n",
354 dir.path().display(),
355 repos.display(),
356 db_url,
357 );
358 std::fs::write(&config_path, toml).unwrap();
359 Env {
360 _dir: dir,
361 config_path,
362 db_url,
363 }
364 }
365
366 impl Env {
367 /// The `--config` argument the handlers expect: `Some(path)`. The
368 /// `Option` is intrinsic to the handler signature, not incidental.
369 #[allow(clippy::unnecessary_wraps)]
370 fn path(&self) -> Option<&Path> {
371 Some(self.config_path.as_path())
372 }
373
374 async fn store(&self) -> Store {
375 Store::connect(&self.db_url, 1).await.unwrap()
376 }
377 }
378
379 #[test]
380 fn clap_command_tree_is_valid() {
381 use clap::CommandFactory;
382 crate::Cli::command().debug_assert();
383 }
384
385 #[test]
386 fn add_with_password_creates_a_usable_admin() {
387 let env = env();
388 let code = block_on(add(
389 env.path(),
390 "alice",
391 "alice@example.com",
392 Some("s3cr3t".to_string()),
393 true,
394 Some("Alice".to_string()),
395 ))
396 .unwrap();
397 assert_eq!(code, ExitCode::SUCCESS);
398
399 let created = block_on(async {
400 let user = require_user(&env.store().await, "alice").await?;
401 anyhow::Ok(user)
402 })
403 .unwrap();
404 assert!(created.is_admin);
405 assert_eq!(created.display_name.as_deref(), Some("Alice"));
406 let hash = created.password_hash.expect("password should be set");
407 assert!(auth::verify_password("s3cr3t", Some(&hash)));
408 }
409
410 #[test]
411 fn add_without_password_is_inactive_then_passwd_activates() {
412 let env = env();
413 block_on(add(env.path(), "bob", "bob@example.com", None, false, None)).unwrap();
414
415 let before =
416 block_on(async { anyhow::Ok(require_user(&env.store().await, "bob").await?) }).unwrap();
417 assert!(before.password_hash.is_none(), "no password until set");
418
419 // The SMTP-disabled fallback: set the password directly.
420 block_on(passwd(env.path(), "bob", Some("hunter2".to_string()))).unwrap();
421 let after =
422 block_on(async { anyhow::Ok(require_user(&env.store().await, "bob").await?) }).unwrap();
423 let hash = after.password_hash.expect("password now set");
424 assert!(auth::verify_password("hunter2", Some(&hash)));
425 }
426
427 #[test]
428 fn disable_then_enable_toggles_the_flag() {
429 let env = env();
430 block_on(add(
431 env.path(),
432 "carol",
433 "carol@example.com",
434 Some("pw".to_string()),
435 false,
436 None,
437 ))
438 .unwrap();
439
440 block_on(set_disabled(env.path(), "carol", true)).unwrap();
441 let disabled =
442 block_on(async { anyhow::Ok(require_user(&env.store().await, "carol").await?) })
443 .unwrap();
444 assert!(disabled.disabled_at.is_some());
445
446 block_on(set_disabled(env.path(), "carol", false)).unwrap();
447 let enabled =
448 block_on(async { anyhow::Ok(require_user(&env.store().await, "carol").await?) })
449 .unwrap();
450 assert!(enabled.disabled_at.is_none());
451 }
452
453 #[test]
454 fn del_refuses_repo_owner_without_purge_then_purges() {
455 let env = env();
456 block_on(add(
457 env.path(),
458 "dave",
459 "dave@example.com",
460 Some("pw".to_string()),
461 false,
462 None,
463 ))
464 .unwrap();
465
466 // Give Dave a repo directly through the store.
467 let dave_id = block_on(async {
468 let store = env.store().await;
469 let dave = require_user(&store, "dave").await?;
470 store
471 .create_repo(NewRepo {
472 owner_id: dave.id.clone(),
473 group_id: None,
474 name: "proj".to_string(),
475 path: "proj".to_string(),
476 description: None,
477 is_private: true,
478 default_branch: "main".to_string(),
479 })
480 .await?;
481 anyhow::Ok(dave.id)
482 })
483 .unwrap();
484
485 // Without --purge the delete is refused and the user survives.
486 let refused = block_on(del(env.path(), "dave", false, true));
487 assert!(refused.is_err(), "owning a repo should block deletion");
488 assert!(
489 block_on(async { anyhow::Ok(env.store().await.user_by_id(&dave_id).await?) })
490 .unwrap()
491 .is_some()
492 );
493
494 // With --purge (and --yes to skip the prompt) the user and repo go.
495 block_on(del(env.path(), "dave", true, true)).unwrap();
496 block_on(async {
497 let store = env.store().await;
498 assert!(store.user_by_id(&dave_id).await?.is_none());
499 assert!(store.repos_by_owner(&dave_id).await?.is_empty());
500 anyhow::Ok(())
501 })
502 .unwrap();
503 }
504
505 #[test]
506 fn passwd_on_missing_user_errors() {
507 let env = env();
508 // Create the database (via one successful command) first.
509 block_on(add(
510 env.path(),
511 "eve",
512 "eve@example.com",
513 Some("pw".to_string()),
514 false,
515 None,
516 ))
517 .unwrap();
518 let err = block_on(passwd(env.path(), "nobody", Some("x".to_string()))).unwrap_err();
519 assert!(err.to_string().contains("no such user"), "got {err}");
520 }
521}
docs/decisions.md +36 −0
@@ -206,3 +206,39 @@ explicit parameters keeps `access` a pure function of its inputs — which is wh
206makes the exhaustive matrix test possible — and keeps the collaborator lookup at206makes the exhaustive matrix test possible — and keeps the collaborator lookup at
207the call site (the store), where it belongs. The five ordered rules are unchanged207the call site (the store), where it belongs. The five ordered rules are unchanged
208from the spec.208from the spec.
209
210## 2026-07-24 — `user` CLI pulled forward; store-backed commands self-migrate
211
212**Decision:** The `fabrica user` command group (`add`, `passwd`, `list`,
213`disable`, `enable`, `del`) is implemented now, alongside phase 4, rather than
214waiting for the phase-8 CLI milestone. Every store-backed command applies pending
215migrations on connect (idempotent), so a fresh install's first `user add`
216bootstraps the database in one step.
217
218**Alternatives:** Keep strictly to phase order and ship the `user` commands only
219in phase 8; require a separate `fabrica migrate` before any `user` command works.
220
221**Rationale:** The requested capability — set a user's password from the CLI when
222SMTP is unavailable — is realized by `user passwd`, which needs both `auth`
223(hashing) and `store` (persistence) but nothing from the intervening phases
224(git/highlight). Building it now delivers that capability immediately and keeps
225the auth crate exercised by a real caller. Self-migrating on connect is what makes
226`user add` usable before `serve` has ever run; `serve --no-migrate` remains the
227opt-out for the server path.
228
229## 2026-07-24 — Interim `user add` without `--pass` creates an inactive account
230
231**Decision:** Until the mail/invite flow ships (phase 9), `fabrica user add`
232without `--pass` creates the user with a `NULL` password (unable to log in) and
233prints guidance to run `fabrica user passwd`. CLI-set passwords clear
234`must_change_password` (the operator chose a known credential).
235
236**Alternatives:** Block `user add` until invites exist; always require `--pass`.
237
238**Rationale:** The spec's `user add` mints an emailed activation link, which
239depends on `crates/mail` (a later phase). The inactive-account-plus-guidance
240behaviour is a faithful interim: no account is silently left in a login-capable
241state without a credential, and the operator has an obvious next step. When mail
242lands, this path grows the invite-and-email behaviour; the `passwd` fallback stays
243for SMTP-disabled instances (`mail.backend = "none"`), which is exactly the case
244that motivated it.