fabrica

hanna/fabrica

feat(cli): key, repo, group, and token commands; doctor and migrate

1e58b68 · hanna committed on 2026-07-25

Build out the phase-8 command tree over the store and on-disk storage:

- key add/del/list: parse and fingerprint material (literal, @path, or -),
  reject duplicates naming the existing owner, delete by stable ordinal.
- repo add/del/list/rename/path: add creates the DB row then the bare repo
  on disk (rolling the row back on failure) and auto-creates group paths;
  del moves the directory to trash unless --purge; rename is metadata-only.
- group add/del/list over the nested hierarchy.
- token add mints an HS256 JWT (printed once) whose jti is its row id,
  resolving-or-generating the instance secret on first use; del revokes.
- doctor verifies git (>= 2.41), config, and the database; migrate applies
  pending migrations.

Destructive commands confirm on a TTY and require --yes otherwise. auth
gains new_secret(); a shared testutil backs the per-command tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
13 files changed · +1470 −2UnifiedSplit
Cargo.lock +1 −0
@@ -466,6 +466,7 @@ dependencies = [
466 "auth",466 "auth",
467 "clap",467 "clap",
468 "config",468 "config",
469 "git",
469 "model",470 "model",
470 "rpassword",471 "rpassword",
471 "serde",472 "serde",
crates/auth/src/lib.rs +1 −1
@@ -32,7 +32,7 @@ pub use access::{Access, Permission, Viewer, access};
32pub use password::{HashParams, hash_password, verify_password};32pub use password::{HashParams, hash_password, verify_password};
33pub use scope::{Scope, format_scopes, parse_scopes};33pub use scope::{Scope, format_scopes, parse_scopes};
34pub use session::{SESSION_TOKEN_BYTES, SessionToken, new_session_token, session_id};34pub use session::{SESSION_TOKEN_BYTES, SessionToken, new_session_token, session_id};
35pub use token::{Claims, mint_jwt, verify_jwt};35pub use token::{Claims, mint_jwt, new_secret, verify_jwt};
3636
37/// Errors produced by the authentication primitives.37/// Errors produced by the authentication primitives.
38///38///
crates/auth/src/token.rs +11 −0
@@ -18,6 +18,7 @@
18use base64::Engine;18use base64::Engine;
19use base64::engine::general_purpose::URL_SAFE_NO_PAD;19use base64::engine::general_purpose::URL_SAFE_NO_PAD;
20use hmac::{Hmac, Mac};20use hmac::{Hmac, Mac};
21use rand_core::{OsRng, RngCore};
21use serde::{Deserialize, Serialize};22use serde::{Deserialize, Serialize};
22use sha2::Sha256;23use sha2::Sha256;
2324
@@ -25,6 +26,16 @@ use crate::AuthError;
2526
26type HmacSha256 = Hmac<Sha256>;27type HmacSha256 = Hmac<Sha256>;
2728
29/// Generate a fresh instance signing secret: 32 bytes of OS randomness, base64url
30/// encoded. Written to `auth.secret_file` on first run and reused thereafter; it
31/// signs both API tokens and session-cookie material.
32#[must_use]
33pub fn new_secret() -> String {
34 let mut bytes = [0u8; 32];
35 OsRng.fill_bytes(&mut bytes);
36 URL_SAFE_NO_PAD.encode(bytes)
37}
38
28/// The fixed JOSE header for every token we mint: `{"alg":"HS256","typ":"JWT"}`.39/// The fixed JOSE header for every token we mint: `{"alg":"HS256","typ":"JWT"}`.
29/// Pre-encoded so minting never re-serializes it, and matched exactly on verify40/// Pre-encoded so minting never re-serializes it, and matched exactly on verify
30/// so a token advertising any other algorithm is rejected out of hand (closing41/// so a token advertising any other algorithm is rejected out of hand (closing
crates/cli/Cargo.toml +1 −0
@@ -13,6 +13,7 @@ publish.workspace = true
13config = { workspace = true }13config = { workspace = true }
14store = { workspace = true }14store = { workspace = true }
15auth = { workspace = true }15auth = { workspace = true }
16git = { workspace = true }
16model = { workspace = true }17model = { workspace = true }
17anyhow = { workspace = true }18anyhow = { workspace = true }
18clap = { workspace = true }19clap = { workspace = true }
crates/cli/src/admin_cmd.rs +131 −0
@@ -0,0 +1,131 @@
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 migrate` and `fabrica doctor` — schema application and a startup
6//! health check.
7
8use std::path::Path;
9use std::process::{Command, ExitCode};
10
11use config::Config;
12
13use crate::context::block_on;
14
15/// The minimum git version fabrica supports (pack transport relies on it).
16const MIN_GIT: (u32, u32) = (2, 41);
17
18/// Run `fabrica migrate`: connect and apply all pending migrations.
19pub(crate) fn migrate(config_path: Option<&Path>) -> anyhow::Result<ExitCode> {
20 block_on(async {
21 let (_config, _store) = crate::context::open_store(config_path).await?;
22 // `open_store` already migrated on connect; reaching here means success.
23 println!("migrations applied");
24 anyhow::Ok(ExitCode::SUCCESS)
25 })
26}
27
28/// Run `fabrica doctor`: verify the environment fabrica needs to serve.
29///
30/// Checks the `git` binary and version, that the configuration loads, and that the
31/// database is reachable and migratable. `--quiet` suppresses the per-check report
32/// and only sets the exit code, for use as a container health check.
33pub(crate) fn doctor(config_path: Option<&Path>, quiet: bool) -> anyhow::Result<ExitCode> {
34 let mut ok = true;
35 let mut check = |label: &str, result: Result<String, String>| {
36 match &result {
37 Ok(detail) => {
38 if !quiet {
39 println!("ok {label}: {detail}");
40 }
41 }
42 Err(detail) => {
43 ok = false;
44 // Failures are always reported, even in quiet mode, on stderr.
45 eprintln!("FAIL {label}: {detail}");
46 }
47 }
48 };
49
50 // Configuration must load before anything else can be checked.
51 let loaded = match config::load(config_path) {
52 Ok(loaded) => loaded,
53 Err(err) => {
54 eprintln!("FAIL config: {err}");
55 return Ok(ExitCode::FAILURE);
56 }
57 };
58 check("config", Ok("loaded".to_string()));
59
60 check("git", check_git(&loaded.config));
61 let database = block_on(async { anyhow::Ok(check_database(&loaded.config).await) })?;
62 check("database", database);
63
64 if ok {
65 if !quiet {
66 println!("all checks passed");
67 }
68 Ok(ExitCode::SUCCESS)
69 } else {
70 Ok(ExitCode::FAILURE)
71 }
72}
73
74/// Verify the configured `git` binary is present and recent enough.
75fn check_git(config: &Config) -> Result<String, String> {
76 let output = Command::new(&config.git.binary)
77 .arg("--version")
78 .output()
79 .map_err(|err| format!("cannot run {:?}: {err}", config.git.binary))?;
80 if !output.status.success() {
81 return Err(format!("{:?} --version failed", config.git.binary));
82 }
83 let text = String::from_utf8_lossy(&output.stdout);
84 let version = parse_git_version(&text)
85 .ok_or_else(|| format!("could not parse git version from {text:?}"))?;
86 if version < MIN_GIT {
87 return Err(format!(
88 "git {}.{} is too old; need >= {}.{}",
89 version.0, version.1, MIN_GIT.0, MIN_GIT.1
90 ));
91 }
92 Ok(format!("{}.{} ({})", version.0, version.1, text.trim()))
93}
94
95/// Extract `(major, minor)` from a `git version 2.43.0` string.
96fn parse_git_version(text: &str) -> Option<(u32, u32)> {
97 let version = text.split_whitespace().nth(2)?;
98 let mut parts = version.split('.');
99 let major = parts.next()?.parse().ok()?;
100 let minor = parts.next()?.parse().ok()?;
101 Some((major, minor))
102}
103
104/// Verify the database opens and migrations apply.
105async fn check_database(config: &Config) -> Result<String, String> {
106 let store = store::Store::connect(&config.database.url, config.database.max_connections)
107 .await
108 .map_err(|err| format!("connect failed: {err}"))?;
109 store
110 .migrate()
111 .await
112 .map_err(|err| format!("migrate failed: {err}"))?;
113 Ok(format!("{:?} reachable", store.backend()))
114}
115
116#[cfg(test)]
117mod tests {
118 #![allow(clippy::unwrap_used)]
119
120 use super::*;
121
122 #[test]
123 fn parses_git_version() {
124 assert_eq!(parse_git_version("git version 2.43.0"), Some((2, 43)));
125 assert_eq!(
126 parse_git_version("git version 2.41.0.windows.1"),
127 Some((2, 41))
128 );
129 assert_eq!(parse_git_version("nonsense"), None);
130 }
131}
crates/cli/src/context.rs +43 −1
@@ -10,7 +10,8 @@
10//! block on async work.10//! block on async work.
1111
12use std::future::Future;12use std::future::Future;
13use std::path::Path;13use std::os::unix::fs::PermissionsExt;
14use std::path::{Path, PathBuf};
1415
15use config::Config;16use config::Config;
16use store::Store;17use store::Store;
@@ -42,6 +43,47 @@ pub(crate) async fn open_store(config_path: Option<&Path>) -> anyhow::Result<(Co
42 Ok((loaded.config, store))43 Ok((loaded.config, store))
43}44}
4445
46/// Resolve the instance HS256 signing secret, generating and persisting it on
47/// first use.
48///
49/// Resolution order matches the server's: an inline `auth.secret`, else the
50/// contents of `auth.secret_file` (default `{data_dir}/secret`) if it exists, else
51/// a freshly generated 32-byte secret written to that path with mode `0600`. The
52/// same secret signs API tokens and session material, so `token add` and a future
53/// `serve` agree on one key.
54///
55/// # Errors
56///
57/// Returns an error if the secret file cannot be read or written.
58pub(crate) fn ensure_secret(config: &Config) -> anyhow::Result<String> {
59 if let Some(secret) = &config.auth.secret {
60 return Ok(secret.expose().to_string());
61 }
62 let path = config
63 .auth
64 .secret_file
65 .clone()
66 .unwrap_or_else(|| config.storage.data_dir.join("secret"));
67 if path.exists() {
68 let raw = std::fs::read_to_string(&path)?;
69 return Ok(raw.trim_end_matches(['\r', '\n']).to_string());
70 }
71 let secret = auth::new_secret();
72 if let Some(parent) = path.parent() {
73 std::fs::create_dir_all(parent)?;
74 }
75 std::fs::write(&path, &secret)?;
76 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
77 Ok(secret)
78}
79
80/// The absolute path to write into repository hooks so they can invoke `fabrica
81/// hook post-receive`. Uses the running executable (§3.4), falling back to the
82/// bare command name if it cannot be resolved.
83pub(crate) fn hook_binary() -> PathBuf {
84 std::env::current_exe().unwrap_or_else(|_| PathBuf::from("fabrica"))
85}
86
45/// Run an async command body to completion on a fresh current-thread runtime.87/// Run an async command body to completion on a fresh current-thread runtime.
46///88///
47/// The CLI is a short-lived, single-shot process, so a current-thread runtime is89/// The CLI is a short-lived, single-shot process, so a current-thread runtime is
crates/cli/src/group_cmd.rs +165 −0
@@ -0,0 +1,165 @@
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 group` — the nested, user-owned repository hierarchy.
6
7use std::path::Path;
8use std::process::ExitCode;
9
10use clap::Subcommand;
11use model::Group;
12use store::Store;
13
14use crate::context::{block_on, open_store};
15use crate::prompt::confirm;
16
17/// Actions under `fabrica group`.
18#[derive(Debug, Subcommand)]
19pub(crate) enum GroupCommand {
20 /// Create a group path, creating any missing intermediate groups.
21 Add {
22 /// The owning user.
23 user: String,
24 /// The group path (`backend/service`).
25 path: String,
26 },
27 /// Delete a group. Descendant groups are removed too; repositories filed under
28 /// it become ungrouped rather than being deleted.
29 Del {
30 /// The owning user.
31 user: String,
32 /// The group path.
33 path: String,
34 /// Skip the confirmation prompt (required when stdin is not a terminal).
35 #[arg(long)]
36 yes: bool,
37 },
38 /// List a user's groups.
39 List {
40 /// The user whose groups to list.
41 user: String,
42 },
43}
44
45/// Dispatch a `group` subcommand.
46pub(crate) fn run(
47 command: &GroupCommand,
48 config_path: Option<&Path>,
49 json: bool,
50) -> anyhow::Result<ExitCode> {
51 match command {
52 GroupCommand::Add { user, path } => block_on(add(config_path, user, path)),
53 GroupCommand::Del { user, path, yes } => block_on(del(config_path, user, path, *yes)),
54 GroupCommand::List { user } => block_on(list(config_path, user, json)),
55 }
56}
57
58/// Look up a user's id by name, or error cleanly.
59async fn require_user_id(store: &Store, name: &str) -> anyhow::Result<String> {
60 store
61 .user_by_username(name)
62 .await?
63 .map(|u| u.id)
64 .ok_or_else(|| anyhow::anyhow!("no such user {name:?}"))
65}
66
67async fn add(config_path: Option<&Path>, user: &str, path: &str) -> anyhow::Result<ExitCode> {
68 let (_config, store) = open_store(config_path).await?;
69 let owner_id = require_user_id(&store, user).await?;
70 let group = store.ensure_group_path(&owner_id, path).await?;
71 println!("created group {user}/{}", group.path);
72 Ok(ExitCode::SUCCESS)
73}
74
75async fn del(
76 config_path: Option<&Path>,
77 user: &str,
78 path: &str,
79 yes: bool,
80) -> anyhow::Result<ExitCode> {
81 let (_config, store) = open_store(config_path).await?;
82 let owner_id = require_user_id(&store, user).await?;
83 let group = store
84 .group_by_owner_path(&owner_id, path)
85 .await?
86 .ok_or_else(|| anyhow::anyhow!("no such group {user}/{path}"))?;
87
88 if !confirm(
89 &format!("Delete group {user}/{} and its subgroups?", group.path),
90 yes,
91 )? {
92 println!("aborted");
93 return Ok(ExitCode::SUCCESS);
94 }
95 store.delete_group(&group.id).await?;
96 println!("deleted group {user}/{}", group.path);
97 Ok(ExitCode::SUCCESS)
98}
99
100async fn list(config_path: Option<&Path>, user: &str, json: bool) -> anyhow::Result<ExitCode> {
101 let (_config, store) = open_store(config_path).await?;
102 let owner_id = require_user_id(&store, user).await?;
103 let groups = store.groups_by_owner(&owner_id).await?;
104
105 if json {
106 let view: Vec<_> = groups.iter().map(group_json).collect();
107 println!("{}", serde_json::to_string_pretty(&view)?);
108 return Ok(ExitCode::SUCCESS);
109 }
110 if groups.is_empty() {
111 println!("{user} has no groups");
112 return Ok(ExitCode::SUCCESS);
113 }
114 for group in &groups {
115 println!("{}", group.path);
116 }
117 Ok(ExitCode::SUCCESS)
118}
119
120/// Render a group for `--json`.
121fn group_json(group: &Group) -> serde_json::Value {
122 serde_json::json!({
123 "id": group.id,
124 "name": group.name,
125 "path": group.path,
126 "description": group.description,
127 "created_at": group.created_at,
128 })
129}
130
131#[cfg(test)]
132mod tests {
133 #![allow(clippy::unwrap_used)]
134
135 use super::*;
136 use crate::testutil::env;
137
138 #[test]
139 fn add_creates_path_and_del_removes_subtree() {
140 let env = env();
141 block_on(async { anyhow::Ok(env.make_user("ada").await) }).unwrap();
142
143 block_on(add(env.path(), "ada", "team/backend")).unwrap();
144 let groups = block_on(async {
145 let store = env.store().await;
146 let owner = require_user_id(&store, "ada").await?;
147 anyhow::Ok(store.groups_by_owner(&owner).await?)
148 })
149 .unwrap();
150 let paths: Vec<&str> = groups.iter().map(|g| g.path.as_str()).collect();
151 assert_eq!(paths, ["team", "team/backend"]);
152
153 block_on(del(env.path(), "ada", "team", true)).unwrap();
154 let after = block_on(async {
155 let store = env.store().await;
156 let owner = require_user_id(&store, "ada").await?;
157 anyhow::Ok(store.groups_by_owner(&owner).await?)
158 })
159 .unwrap();
160 assert!(
161 after.is_empty(),
162 "deleting the top group removes the subtree"
163 );
164 }
165}
crates/cli/src/key_cmd.rs +255 −0
@@ -0,0 +1,255 @@
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 key` — register and manage SSH and GPG public keys.
6
7use std::io::Read as _;
8use std::path::Path;
9use std::process::ExitCode;
10
11use clap::Subcommand;
12use model::{Key, KeyKind};
13use store::{NewKey, Store};
14
15use crate::context::{block_on, open_store};
16use crate::prompt::confirm;
17
18/// Actions under `fabrica key`.
19#[derive(Debug, Subcommand)]
20pub(crate) enum KeyCommand {
21 /// Register a public key for a user. The key material may be given literally,
22 /// as `@path` to read a file, or `-` to read stdin. It is parsed and
23 /// fingerprinted before storage; a duplicate fingerprint is rejected.
24 Add {
25 /// SSH or GPG.
26 #[arg(long = "type", value_parser = parse_kind)]
27 r#type: KeyKind,
28 /// The user to register the key for.
29 user: String,
30 /// Key material: a literal key, `@path`, or `-` for stdin.
31 key: String,
32 /// Optional label (defaults to the key's own comment for SSH keys).
33 #[arg(long)]
34 name: Option<String>,
35 },
36 /// Remove a key by its 1-based index (as shown by `key list`). Indices are
37 /// stable — removing one never renumbers the others.
38 Del {
39 /// The key's owner.
40 user: String,
41 /// The 1-based index from `key list`.
42 index: i64,
43 /// Skip the confirmation prompt (required when stdin is not a terminal).
44 #[arg(long)]
45 yes: bool,
46 },
47 /// List a user's keys.
48 List {
49 /// The user whose keys to list.
50 user: String,
51 },
52}
53
54/// Parse the `--type` value into a [`KeyKind`].
55fn parse_kind(s: &str) -> Result<KeyKind, String> {
56 KeyKind::from_token(s).ok_or_else(|| format!("unknown key type {s:?} (expected ssh or gpg)"))
57}
58
59/// Dispatch a `key` subcommand.
60pub(crate) fn run(
61 command: &KeyCommand,
62 config_path: Option<&Path>,
63 json: bool,
64) -> anyhow::Result<ExitCode> {
65 match command {
66 KeyCommand::Add {
67 r#type,
68 user,
69 key,
70 name,
71 } => block_on(add(config_path, *r#type, user, key, name.clone())),
72 KeyCommand::Del { user, index, yes } => block_on(del(config_path, user, *index, *yes)),
73 KeyCommand::List { user } => block_on(list(config_path, user, json)),
74 }
75}
76
77/// Read key material from a literal string, `@path`, or `-` (stdin).
78fn read_material(arg: &str) -> anyhow::Result<String> {
79 if arg == "-" {
80 let mut buf = String::new();
81 std::io::stdin().read_to_string(&mut buf)?;
82 Ok(buf)
83 } else if let Some(path) = arg.strip_prefix('@') {
84 Ok(std::fs::read_to_string(path)?)
85 } else {
86 Ok(arg.to_string())
87 }
88}
89
90/// Look up a user by name, or error cleanly.
91async fn require_user_id(store: &Store, name: &str) -> anyhow::Result<String> {
92 store
93 .user_by_username(name)
94 .await?
95 .map(|u| u.id)
96 .ok_or_else(|| anyhow::anyhow!("no such user {name:?}"))
97}
98
99async fn add(
100 config_path: Option<&Path>,
101 kind: KeyKind,
102 user: &str,
103 key: &str,
104 name: Option<String>,
105) -> anyhow::Result<ExitCode> {
106 let (_config, store) = open_store(config_path).await?;
107 let user_id = require_user_id(&store, user).await?;
108
109 let material = read_material(key)?;
110 let parsed = git::parse_public_key(kind, &material)?;
111
112 // Reject a duplicate fingerprint, naming the existing owner.
113 if let Some(existing) = store.key_by_fingerprint(kind, &parsed.fingerprint).await? {
114 let owner = store
115 .user_by_id(&existing.user_id)
116 .await?
117 .map_or_else(|| existing.user_id.clone(), |u| u.username);
118 anyhow::bail!(
119 "that {kind} key is already registered (fingerprint {}) to {owner}",
120 parsed.fingerprint
121 );
122 }
123
124 let stored = store
125 .create_key(NewKey {
126 user_id,
127 kind,
128 name: name.or(parsed.label),
129 fingerprint: parsed.fingerprint.clone(),
130 public_key: parsed.public_key,
131 })
132 .await?;
133 println!(
134 "added {kind} key #{} for {user}: {}",
135 stored.ordinal, stored.fingerprint
136 );
137 Ok(ExitCode::SUCCESS)
138}
139
140async fn del(
141 config_path: Option<&Path>,
142 user: &str,
143 index: i64,
144 yes: bool,
145) -> anyhow::Result<ExitCode> {
146 let (_config, store) = open_store(config_path).await?;
147 let user_id = require_user_id(&store, user).await?;
148 let key = store
149 .key_by_ordinal(&user_id, index)
150 .await?
151 .ok_or_else(|| anyhow::anyhow!("{user} has no key #{index}"))?;
152
153 if !confirm(
154 &format!("Delete {} key #{index} ({})?", key.kind, key.fingerprint),
155 yes,
156 )? {
157 println!("aborted");
158 return Ok(ExitCode::SUCCESS);
159 }
160 store.delete_key(&key.id).await?;
161 println!("deleted {} key #{index} for {user}", key.kind);
162 Ok(ExitCode::SUCCESS)
163}
164
165async fn list(config_path: Option<&Path>, user: &str, json: bool) -> anyhow::Result<ExitCode> {
166 let (_config, store) = open_store(config_path).await?;
167 let user_id = require_user_id(&store, user).await?;
168 let keys = store.keys_by_user(&user_id).await?;
169
170 if json {
171 let view: Vec<_> = keys.iter().map(key_json).collect();
172 println!("{}", serde_json::to_string_pretty(&view)?);
173 return Ok(ExitCode::SUCCESS);
174 }
175 if keys.is_empty() {
176 println!("{user} has no keys");
177 return Ok(ExitCode::SUCCESS);
178 }
179 println!("{:>3} {:<4} {:<12} FINGERPRINT", "IDX", "TYPE", "NAME");
180 for key in &keys {
181 println!(
182 "{:>3} {:<4} {:<12} {}",
183 key.ordinal,
184 key.kind,
185 key.name.as_deref().unwrap_or("-"),
186 key.fingerprint
187 );
188 }
189 Ok(ExitCode::SUCCESS)
190}
191
192/// Render a key for `--json` (never includes secret material — public keys are
193/// public, but the shape mirrors the other list commands).
194fn key_json(key: &Key) -> serde_json::Value {
195 serde_json::json!({
196 "index": key.ordinal,
197 "type": key.kind.as_str(),
198 "name": key.name,
199 "fingerprint": key.fingerprint,
200 "created_at": key.created_at,
201 "last_used_at": key.last_used_at,
202 })
203}
204
205#[cfg(test)]
206mod tests {
207 #![allow(clippy::unwrap_used)]
208
209 use super::*;
210 use crate::testutil::env;
211
212 const SSH_KEY: &str = "ssh-ed25519 \
213AAAAC3NzaC1lZDI1NTE5AAAAIJqq6nD0E4SlM+xw1UVOompehxQE8sUYCVoyV+NBjfKU cli-test@fabrica";
214
215 #[test]
216 fn add_list_and_del_ssh_key() {
217 let env = env();
218 block_on(async { anyhow::Ok(env.make_user("ada").await) }).unwrap();
219
220 block_on(add(env.path(), KeyKind::Ssh, "ada", SSH_KEY, None)).unwrap();
221
222 let keys = block_on(async {
223 let store = env.store().await;
224 let id = require_user_id(&store, "ada").await?;
225 anyhow::Ok(store.keys_by_user(&id).await?)
226 })
227 .unwrap();
228 assert_eq!(keys.len(), 1);
229 assert_eq!(keys[0].ordinal, 1);
230 assert_eq!(keys[0].name.as_deref(), Some("cli-test@fabrica"));
231 assert!(keys[0].fingerprint.starts_with("SHA256:"));
232
233 // A duplicate is rejected.
234 let dup = block_on(add(env.path(), KeyKind::Ssh, "ada", SSH_KEY, None));
235 assert!(dup.is_err(), "duplicate fingerprint should be rejected");
236
237 // Delete by its ordinal.
238 block_on(del(env.path(), "ada", 1, true)).unwrap();
239 let after = block_on(async {
240 let store = env.store().await;
241 let id = require_user_id(&store, "ada").await?;
242 anyhow::Ok(store.keys_by_user(&id).await?)
243 })
244 .unwrap();
245 assert!(after.is_empty());
246 }
247
248 #[test]
249 fn rejects_bad_key_material() {
250 let env = env();
251 block_on(async { anyhow::Ok(env.make_user("bob").await) }).unwrap();
252 let err = block_on(add(env.path(), KeyKind::Ssh, "bob", "not a key", None));
253 assert!(err.is_err());
254 }
255}
crates/cli/src/lib.rs +45 −0
@@ -9,9 +9,16 @@
9//! `fabrica` binary. The command tree is built out over the implementation9//! `fabrica` binary. The command tree is built out over the implementation
10//! phases; today it covers only `config check` and `config show`.10//! phases; today it covers only `config check` and `config show`.
1111
12mod admin_cmd;
12mod config_cmd;13mod config_cmd;
13mod context;14mod context;
15mod group_cmd;
16mod key_cmd;
14mod prompt;17mod prompt;
18mod repo_cmd;
19#[cfg(test)]
20mod testutil;
21mod token_cmd;
15mod user_cmd;22mod user_cmd;
1623
17use std::path::PathBuf;24use std::path::PathBuf;
@@ -55,6 +62,38 @@ enum Command {
55 #[command(subcommand)]62 #[command(subcommand)]
56 command: user_cmd::UserCommand,63 command: user_cmd::UserCommand,
57 },64 },
65 /// Manage a user's SSH and GPG keys.
66 Key {
67 /// The key action to perform.
68 #[command(subcommand)]
69 command: key_cmd::KeyCommand,
70 },
71 /// Manage repositories.
72 Repo {
73 /// The repository action to perform.
74 #[command(subcommand)]
75 command: repo_cmd::RepoCommand,
76 },
77 /// Manage the group hierarchy.
78 Group {
79 /// The group action to perform.
80 #[command(subcommand)]
81 command: group_cmd::GroupCommand,
82 },
83 /// Manage a user's API tokens.
84 Token {
85 /// The token action to perform.
86 #[command(subcommand)]
87 command: token_cmd::TokenCommand,
88 },
89 /// Apply pending database migrations.
90 Migrate,
91 /// Check that the environment fabrica needs is present and healthy.
92 Doctor {
93 /// Only set the exit code; suppress the per-check report. For health checks.
94 #[arg(long)]
95 quiet: bool,
96 },
58}97}
5998
60/// Parse arguments and dispatch to the requested subcommand.99/// Parse arguments and dispatch to the requested subcommand.
@@ -79,5 +118,11 @@ fn dispatch(cli: &Cli) -> anyhow::Result<ExitCode> {
79 match &cli.command {118 match &cli.command {
80 Command::Config { command } => config_cmd::run(command, cli.config.as_deref(), cli.json),119 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),120 Command::User { command } => user_cmd::run(command, cli.config.as_deref(), cli.json),
121 Command::Key { command } => key_cmd::run(command, cli.config.as_deref(), cli.json),
122 Command::Repo { command } => repo_cmd::run(command, cli.config.as_deref(), cli.json),
123 Command::Group { command } => group_cmd::run(command, cli.config.as_deref(), cli.json),
124 Command::Token { command } => token_cmd::run(command, cli.config.as_deref(), cli.json),
125 Command::Migrate => admin_cmd::migrate(cli.config.as_deref()),
126 Command::Doctor { quiet } => admin_cmd::doctor(cli.config.as_deref(), *quiet),
82 }127 }
83}128}
crates/cli/src/repo_cmd.rs +413 −0
@@ -0,0 +1,413 @@
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 repo` — repository administration, operating directly on the store and
6//! the on-disk object storage.
7
8use std::path::Path;
9use std::process::ExitCode;
10
11use clap::Subcommand;
12use model::Repo;
13use store::{NewRepo, Store};
14
15use crate::context::{block_on, hook_binary, open_store};
16use crate::prompt::confirm;
17
18/// Actions under `fabrica repo`.
19#[derive(Debug, Subcommand)]
20pub(crate) enum RepoCommand {
21 /// Create a repository. `name` may include a group path (`backend/api/svc`),
22 /// creating any missing intermediate groups.
23 Add {
24 /// The owning user.
25 user: String,
26 /// The repo name, optionally prefixed with a group path.
27 name: String,
28 /// Make the repo private (the default).
29 #[arg(long, conflicts_with = "public")]
30 private: bool,
31 /// Make the repo public.
32 #[arg(long)]
33 public: bool,
34 /// A one-line description.
35 #[arg(long)]
36 description: Option<String>,
37 /// Override the instance default branch.
38 #[arg(long = "default-branch")]
39 default_branch: Option<String>,
40 },
41 /// Delete a repository. Its directory is moved to trash unless `--purge`.
42 Del {
43 /// The owning user.
44 user: String,
45 /// The repo name (group path included).
46 name: String,
47 /// Delete the on-disk repository immediately instead of moving it to trash.
48 #[arg(long)]
49 purge: bool,
50 /// Skip the confirmation prompt (required when stdin is not a terminal).
51 #[arg(long)]
52 yes: bool,
53 },
54 /// List repositories, optionally filtered to one user.
55 List {
56 /// Restrict to a single owner.
57 #[arg(long)]
58 user: Option<String>,
59 },
60 /// Rename a repository (metadata only — the on-disk directory never moves).
61 Rename {
62 /// The owning user.
63 user: String,
64 /// The current repo name (group path included).
65 name: String,
66 /// The new leaf name (the group path is preserved).
67 new_name: String,
68 },
69 /// Print the resolved on-disk directory of a repository.
70 Path {
71 /// The owning user.
72 user: String,
73 /// The repo name (group path included).
74 name: String,
75 },
76}
77
78/// Dispatch a `repo` subcommand.
79pub(crate) fn run(
80 command: &RepoCommand,
81 config_path: Option<&Path>,
82 json: bool,
83) -> anyhow::Result<ExitCode> {
84 match command {
85 RepoCommand::Add {
86 user,
87 name,
88 private: _private,
89 public,
90 description,
91 default_branch,
92 } => block_on(add(
93 config_path,
94 user,
95 name,
96 // Private is the default; `--private` is the explicit form of it, and
97 // conflicts with `--public`, so visibility is simply "not public".
98 !*public,
99 description.clone(),
100 default_branch.clone(),
101 )),
102 RepoCommand::Del {
103 user,
104 name,
105 purge,
106 yes,
107 } => block_on(del(config_path, user, name, *purge, *yes)),
108 RepoCommand::List { user } => block_on(list(config_path, user.as_deref(), json)),
109 RepoCommand::Rename {
110 user,
111 name,
112 new_name,
113 } => block_on(rename(config_path, user, name, new_name)),
114 RepoCommand::Path { user, name } => block_on(path(config_path, user, name)),
115 }
116}
117
118/// Look up a user's id by name, or error cleanly.
119async fn require_user_id(store: &Store, name: &str) -> anyhow::Result<String> {
120 store
121 .user_by_username(name)
122 .await?
123 .map(|u| u.id)
124 .ok_or_else(|| anyhow::anyhow!("no such user {name:?}"))
125}
126
127/// Look up a repository by owner and path, or error cleanly.
128async fn require_repo(
129 store: &Store,
130 owner_id: &str,
131 path: &str,
132 user: &str,
133) -> anyhow::Result<Repo> {
134 store
135 .repo_by_owner_path(owner_id, path)
136 .await?
137 .ok_or_else(|| anyhow::anyhow!("no such repository {user}/{path}"))
138}
139
140/// Split a repo name into its group-path prefix (if any) and leaf name.
141fn split_group_path(name: &str) -> (Option<&str>, &str) {
142 match name.rsplit_once('/') {
143 Some((group, leaf)) => (Some(group), leaf),
144 None => (None, name),
145 }
146}
147
148async fn add(
149 config_path: Option<&Path>,
150 user: &str,
151 name: &str,
152 is_private: bool,
153 description: Option<String>,
154 default_branch: Option<String>,
155) -> anyhow::Result<ExitCode> {
156 let (config, store) = open_store(config_path).await?;
157 let owner_id = require_user_id(&store, user).await?;
158 let (group_path, _leaf) = split_group_path(name);
159
160 let group_id = match group_path {
161 Some(gp) => Some(store.ensure_group_path(&owner_id, gp).await?.id),
162 None => None,
163 };
164 let branch = default_branch.unwrap_or_else(|| config.instance.default_branch.clone());
165
166 let repo = store
167 .create_repo(NewRepo {
168 owner_id,
169 group_id,
170 name: split_group_path(name).1.to_string(),
171 path: name.to_string(),
172 description,
173 is_private,
174 default_branch: branch.clone(),
175 })
176 .await?;
177
178 // Create the bare repository on disk; roll the row back if that fails so a
179 // half-created repo never lingers in the database.
180 if let Err(err) = git::create_bare(&config.storage.repo_dir, &repo.id, &branch, &hook_binary())
181 {
182 let _ = store.delete_repo(&repo.id).await;
183 return Err(err.into());
184 }
185
186 println!(
187 "created {} repository {user}/{}",
188 if is_private { "private" } else { "public" },
189 repo.path
190 );
191 Ok(ExitCode::SUCCESS)
192}
193
194async fn del(
195 config_path: Option<&Path>,
196 user: &str,
197 name: &str,
198 purge: bool,
199 yes: bool,
200) -> anyhow::Result<ExitCode> {
201 let (config, store) = open_store(config_path).await?;
202 let owner_id = require_user_id(&store, user).await?;
203 let repo = require_repo(&store, &owner_id, name, user).await?;
204
205 let question = if purge {
206 format!("Permanently delete {user}/{} and its history?", repo.path)
207 } else {
208 format!(
209 "Delete {user}/{}? (its directory moves to trash)",
210 repo.path
211 )
212 };
213 if !confirm(&question, yes)? {
214 println!("aborted");
215 return Ok(ExitCode::SUCCESS);
216 }
217
218 store.delete_repo(&repo.id).await?;
219
220 // The database row is gone; now deal with the on-disk directory. A failure
221 // here is logged but does not fail the command — the repo is already
222 // unreachable through fabrica.
223 let dir = git::repo_path(&config.storage.repo_dir, &repo.id)?;
224 if dir.exists() {
225 if purge {
226 if let Err(err) = std::fs::remove_dir_all(&dir) {
227 eprintln!("warning: could not remove {}: {err}", dir.display());
228 }
229 } else {
230 let trash = config.storage.data_dir.join("trash").join(format!(
231 "{}-{}.git",
232 repo.id,
233 store_now()
234 ));
235 if let Some(parent) = trash.parent() {
236 let _ = std::fs::create_dir_all(parent);
237 }
238 if let Err(err) = std::fs::rename(&dir, &trash) {
239 eprintln!("warning: could not move {} to trash: {err}", dir.display());
240 }
241 }
242 }
243
244 println!("deleted repository {user}/{}", repo.path);
245 Ok(ExitCode::SUCCESS)
246}
247
248async fn list(
249 config_path: Option<&Path>,
250 user: Option<&str>,
251 json: bool,
252) -> anyhow::Result<ExitCode> {
253 let (_config, store) = open_store(config_path).await?;
254
255 // Resolve owner id → username once for display.
256 let repos = match user {
257 Some(name) => {
258 let owner_id = require_user_id(&store, name).await?;
259 store.repos_by_owner(&owner_id).await?
260 }
261 None => store.list_repos().await?,
262 };
263
264 // Map owner ids to usernames for the owner column.
265 let mut owners = std::collections::HashMap::new();
266 for repo in &repos {
267 if !owners.contains_key(&repo.owner_id) {
268 let username = store
269 .user_by_id(&repo.owner_id)
270 .await?
271 .map_or_else(|| repo.owner_id.clone(), |u| u.username);
272 owners.insert(repo.owner_id.clone(), username);
273 }
274 }
275
276 if json {
277 let view: Vec<_> = repos
278 .iter()
279 .map(|r| repo_json(r, &owners[&r.owner_id]))
280 .collect();
281 println!("{}", serde_json::to_string_pretty(&view)?);
282 return Ok(ExitCode::SUCCESS);
283 }
284 if repos.is_empty() {
285 println!("no repositories");
286 return Ok(ExitCode::SUCCESS);
287 }
288 for repo in &repos {
289 println!(
290 "{}/{} [{}]",
291 owners[&repo.owner_id],
292 repo.path,
293 if repo.is_private { "private" } else { "public" }
294 );
295 }
296 Ok(ExitCode::SUCCESS)
297}
298
299async fn rename(
300 config_path: Option<&Path>,
301 user: &str,
302 name: &str,
303 new_name: &str,
304) -> anyhow::Result<ExitCode> {
305 let (_config, store) = open_store(config_path).await?;
306 let owner_id = require_user_id(&store, user).await?;
307 let repo = require_repo(&store, &owner_id, name, user).await?;
308
309 // The group prefix is preserved; only the leaf changes.
310 let new_path = match split_group_path(&repo.path).0 {
311 Some(group) => format!("{group}/{new_name}"),
312 None => new_name.to_string(),
313 };
314 store.rename_repo(&repo.id, new_name, &new_path).await?;
315 println!("renamed {user}/{} to {user}/{new_path}", repo.path);
316 Ok(ExitCode::SUCCESS)
317}
318
319async fn path(config_path: Option<&Path>, user: &str, name: &str) -> anyhow::Result<ExitCode> {
320 let (config, store) = open_store(config_path).await?;
321 let owner_id = require_user_id(&store, user).await?;
322 let repo = require_repo(&store, &owner_id, name, user).await?;
323 let dir = git::repo_path(&config.storage.repo_dir, &repo.id)?;
324 println!("{}", dir.display());
325 Ok(ExitCode::SUCCESS)
326}
327
328/// A millisecond timestamp for trash-directory names. Kept local so the module has
329/// no direct dependency on the store's private `now_ms`.
330fn store_now() -> i64 {
331 use std::time::{SystemTime, UNIX_EPOCH};
332 SystemTime::now()
333 .duration_since(UNIX_EPOCH)
334 .map_or(0, |d| i64::try_from(d.as_millis()).unwrap_or(i64::MAX))
335}
336
337/// Render a repo for `--json`.
338fn repo_json(repo: &Repo, owner: &str) -> serde_json::Value {
339 serde_json::json!({
340 "id": repo.id,
341 "owner": owner,
342 "name": repo.name,
343 "path": repo.path,
344 "description": repo.description,
345 "private": repo.is_private,
346 "default_branch": repo.default_branch,
347 "created_at": repo.created_at,
348 })
349}
350
351#[cfg(test)]
352mod tests {
353 #![allow(clippy::unwrap_used)]
354
355 use super::*;
356 use crate::testutil::env;
357
358 #[test]
359 fn add_creates_row_and_bare_repo_under_a_group() {
360 let env = env();
361 block_on(async { anyhow::Ok(env.make_user("ada").await) }).unwrap();
362
363 // A grouped repo auto-creates the intermediate group and a bare repo dir.
364 block_on(add(env.path(), "ada", "backend/svc", true, None, None)).unwrap();
365
366 let (repo, dir) = block_on(async {
367 let store = env.store().await;
368 let owner = require_user_id(&store, "ada").await?;
369 let repo = require_repo(&store, &owner, "backend/svc", "ada").await?;
370 let group = store.group_by_owner_path(&owner, "backend").await?;
371 assert!(group.is_some(), "intermediate group should exist");
372 let cfg = config::load(env.path())?.config;
373 let dir = git::repo_path(&cfg.storage.repo_dir, &repo.id)?;
374 anyhow::Ok((repo, dir))
375 })
376 .unwrap();
377 assert_eq!(repo.path, "backend/svc");
378 assert!(repo.is_private);
379 assert!(dir.join("HEAD").exists(), "a bare repo was created on disk");
380
381 // Rename preserves the group prefix.
382 block_on(rename(env.path(), "ada", "backend/svc", "service")).unwrap();
383 let renamed = block_on(async {
384 let store = env.store().await;
385 let owner = require_user_id(&store, "ada").await?;
386 anyhow::Ok(store.repo_by_owner_path(&owner, "backend/service").await?)
387 })
388 .unwrap();
389 assert!(renamed.is_some());
390
391 // Delete moves the directory to trash (not --purge).
392 block_on(del(env.path(), "ada", "backend/service", false, true)).unwrap();
393 assert!(!dir.exists(), "the directory moved out to trash");
394 let gone = block_on(async {
395 let store = env.store().await;
396 let owner = require_user_id(&store, "ada").await?;
397 anyhow::Ok(store.repo_by_owner_path(&owner, "backend/service").await?)
398 })
399 .unwrap();
400 assert!(gone.is_none());
401 }
402
403 #[test]
404 fn add_rolls_back_the_row_if_the_disk_repo_cannot_be_created() {
405 let env = env();
406 block_on(async { anyhow::Ok(env.make_user("eve").await) }).unwrap();
407 block_on(add(env.path(), "eve", "proj", true, None, None)).unwrap();
408 // A second create at the same id path is impossible, but a duplicate
409 // logical repo must conflict at the store layer and leave nothing behind.
410 let dup = block_on(add(env.path(), "eve", "proj", true, None, None));
411 assert!(dup.is_err());
412 }
413}
crates/cli/src/testutil.rs +74 −0
@@ -0,0 +1,74 @@
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 test scaffolding: a temp data directory with a SQLite database and a
6//! matching config file, so command handlers run end-to-end with no external
7//! services.
8
9#![allow(clippy::unwrap_used, clippy::expect_used)]
10
11use std::path::Path;
12
13use store::{NewUser, Store};
14use tempfile::TempDir;
15
16/// A throwaway environment for a CLI command test.
17pub(crate) struct Env {
18 _dir: TempDir,
19 config_path: std::path::PathBuf,
20 db_url: String,
21}
22
23/// Build a temp data dir with a config pointing at a fresh SQLite database.
24pub(crate) fn env() -> Env {
25 let dir = tempfile::tempdir().unwrap();
26 let repos = dir.path().join("repos");
27 let db = dir.path().join("fabrica.db");
28 let db_url = format!("sqlite://{}", db.display());
29 let config_path = dir.path().join("fabrica.toml");
30 let toml = format!(
31 "[storage]\ndata_dir = {:?}\nrepo_dir = {:?}\n\n[database]\nurl = {:?}\n",
32 dir.path().display(),
33 repos.display(),
34 db_url,
35 );
36 std::fs::write(&config_path, toml).unwrap();
37 Env {
38 _dir: dir,
39 config_path,
40 db_url,
41 }
42}
43
44impl Env {
45 /// The `--config` path the handlers accept. The `Option` is intrinsic to the
46 /// handler signature, not incidental.
47 #[allow(clippy::unnecessary_wraps)]
48 pub(crate) fn path(&self) -> Option<&Path> {
49 Some(self.config_path.as_path())
50 }
51
52 /// A store connected to the same database the handlers use.
53 pub(crate) async fn store(&self) -> Store {
54 Store::connect(&self.db_url, 1).await.unwrap()
55 }
56
57 /// Create a user directly and return its id, for tests that need one to exist.
58 pub(crate) async fn make_user(&self, name: &str) -> String {
59 let store = self.store().await;
60 store.migrate().await.unwrap();
61 store
62 .create_user(NewUser {
63 username: name.to_string(),
64 email: format!("{name}@example.com"),
65 display_name: None,
66 password_hash: None,
67 is_admin: false,
68 must_change_password: false,
69 })
70 .await
71 .unwrap()
72 .id
73 }
74}
crates/cli/src/token_cmd.rs +300 −0
@@ -0,0 +1,300 @@
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 token` — API tokens (HS256 JWTs, revocable via their `jti` row).
6
7use std::path::Path;
8use std::process::ExitCode;
9use std::time::{SystemTime, UNIX_EPOCH};
10
11use auth::Claims;
12use clap::Subcommand;
13use model::ApiToken;
14use store::{NewToken, Store};
15
16use crate::context::{block_on, ensure_secret, open_store};
17use crate::prompt::confirm;
18
19/// Actions under `fabrica token`.
20#[derive(Debug, Subcommand)]
21pub(crate) enum TokenCommand {
22 /// Mint an API token. The signed JWT is printed **once** — it is not stored and
23 /// cannot be retrieved again.
24 Add {
25 /// The owning user.
26 user: String,
27 /// A label for the token.
28 name: String,
29 /// Comma-separated scopes (default `repo:read`). One of `repo:read`,
30 /// `repo:write`, `repo:admin`, `user:read`, `user:write`, `admin`.
31 #[arg(long)]
32 scopes: Option<String>,
33 /// Lifetime as a duration (`90d`, `24h`, `30m`); defaults to the configured
34 /// `auth.token_ttl_days`.
35 #[arg(long)]
36 expires: Option<String>,
37 },
38 /// Revoke and delete a token by its 1-based list index.
39 Del {
40 /// The owning user.
41 user: String,
42 /// The 1-based index from `token list`.
43 index: usize,
44 /// Skip the confirmation prompt (required when stdin is not a terminal).
45 #[arg(long)]
46 yes: bool,
47 },
48 /// List a user's tokens (never the token values — those are shown only once).
49 List {
50 /// The user whose tokens to list.
51 user: String,
52 },
53}
54
55/// Dispatch a `token` subcommand.
56pub(crate) fn run(
57 command: &TokenCommand,
58 config_path: Option<&Path>,
59 json: bool,
60) -> anyhow::Result<ExitCode> {
61 match command {
62 TokenCommand::Add {
63 user,
64 name,
65 scopes,
66 expires,
67 } => block_on(add(
68 config_path,
69 user,
70 name,
71 scopes.clone(),
72 expires.clone(),
73 )),
74 TokenCommand::Del { user, index, yes } => block_on(del(config_path, user, *index, *yes)),
75 TokenCommand::List { user } => block_on(list(config_path, user, json)),
76 }
77}
78
79/// Look up a user's id by name, or error cleanly.
80async fn require_user_id(store: &Store, name: &str) -> anyhow::Result<String> {
81 store
82 .user_by_username(name)
83 .await?
84 .map(|u| u.id)
85 .ok_or_else(|| anyhow::anyhow!("no such user {name:?}"))
86}
87
88/// The current time in Unix seconds.
89fn now_secs() -> i64 {
90 SystemTime::now()
91 .duration_since(UNIX_EPOCH)
92 .map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
93}
94
95/// Parse a duration like `90d`, `24h`, `30m`, `45s`, or `2w` into seconds.
96fn parse_duration(s: &str) -> anyhow::Result<i64> {
97 let s = s.trim();
98 let (digits, unit) = s.split_at(s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len()));
99 let value: i64 = digits
100 .parse()
101 .map_err(|_| anyhow::anyhow!("invalid duration {s:?} (e.g. 90d, 24h, 30m)"))?;
102 let multiplier = match unit {
103 "s" | "" => 1,
104 "m" => 60,
105 "h" => 3_600,
106 "d" => 86_400,
107 "w" => 604_800,
108 other => anyhow::bail!("unknown duration unit {other:?} (use s, m, h, d, or w)"),
109 };
110 Ok(value.saturating_mul(multiplier))
111}
112
113async fn add(
114 config_path: Option<&Path>,
115 user: &str,
116 name: &str,
117 scopes: Option<String>,
118 expires: Option<String>,
119) -> anyhow::Result<ExitCode> {
120 let (config, store) = open_store(config_path).await?;
121 let user_id = require_user_id(&store, user).await?;
122
123 // Validate and canonicalize the scope list.
124 let scope_list = scopes.unwrap_or_else(|| "repo:read".to_string());
125 let parsed = auth::parse_scopes(&scope_list)?;
126 let scopes = auth::format_scopes(&parsed);
127
128 let ttl = match expires {
129 Some(d) => parse_duration(&d)?,
130 None => i64::from(config.auth.token_ttl_days).saturating_mul(86_400),
131 };
132 let iat = now_secs();
133 let exp = iat.saturating_add(ttl);
134
135 let token = store
136 .create_token(NewToken {
137 user_id: user_id.clone(),
138 name: name.to_string(),
139 scopes: scopes.clone(),
140 expires_at: Some(exp.saturating_mul(1000)),
141 })
142 .await?;
143
144 let secret = ensure_secret(&config)?;
145 let jwt = auth::mint_jwt(
146 &secret,
147 &Claims {
148 sub: user_id,
149 jti: token.id,
150 scopes,
151 iat,
152 exp,
153 },
154 )?;
155
156 eprintln!("token {name:?} created for {user}; copy it now — it is not stored:");
157 println!("{jwt}");
158 Ok(ExitCode::SUCCESS)
159}
160
161async fn del(
162 config_path: Option<&Path>,
163 user: &str,
164 index: usize,
165 yes: bool,
166) -> anyhow::Result<ExitCode> {
167 let (_config, store) = open_store(config_path).await?;
168 let user_id = require_user_id(&store, user).await?;
169 let tokens = store.tokens_by_user(&user_id).await?;
170 let token = index
171 .checked_sub(1)
172 .and_then(|i| tokens.get(i))
173 .ok_or_else(|| anyhow::anyhow!("{user} has no token #{index}"))?;
174
175 if !confirm(&format!("Revoke token #{index} ({:?})?", token.name), yes)? {
176 println!("aborted");
177 return Ok(ExitCode::SUCCESS);
178 }
179 store.delete_token(&token.id).await?;
180 println!("revoked token #{index} for {user}");
181 Ok(ExitCode::SUCCESS)
182}
183
184async fn list(config_path: Option<&Path>, user: &str, json: bool) -> anyhow::Result<ExitCode> {
185 let (_config, store) = open_store(config_path).await?;
186 let user_id = require_user_id(&store, user).await?;
187 let tokens = store.tokens_by_user(&user_id).await?;
188
189 if json {
190 let view: Vec<_> = tokens
191 .iter()
192 .enumerate()
193 .map(|(i, t)| token_json(i + 1, t))
194 .collect();
195 println!("{}", serde_json::to_string_pretty(&view)?);
196 return Ok(ExitCode::SUCCESS);
197 }
198 if tokens.is_empty() {
199 println!("{user} has no tokens");
200 return Ok(ExitCode::SUCCESS);
201 }
202 println!("{:>3} {:<16} {:<28} STATE", "IDX", "NAME", "SCOPES");
203 for (i, token) in tokens.iter().enumerate() {
204 let state = if token.revoked_at.is_some() {
205 "revoked"
206 } else {
207 "active"
208 };
209 println!(
210 "{:>3} {:<16} {:<28} {state}",
211 i + 1,
212 token.name,
213 token.scopes
214 );
215 }
216 Ok(ExitCode::SUCCESS)
217}
218
219/// Render a token for `--json` (metadata only — never the JWT).
220fn token_json(index: usize, token: &ApiToken) -> serde_json::Value {
221 serde_json::json!({
222 "index": index,
223 "name": token.name,
224 "scopes": token.scopes,
225 "expires_at": token.expires_at,
226 "revoked": token.revoked_at.is_some(),
227 "last_used_at": token.last_used_at,
228 "created_at": token.created_at,
229 })
230}
231
232#[cfg(test)]
233mod tests {
234 #![allow(clippy::unwrap_used)]
235
236 use super::*;
237 use crate::testutil::env;
238
239 #[test]
240 fn parse_duration_units() {
241 assert_eq!(parse_duration("45s").unwrap(), 45);
242 assert_eq!(parse_duration("30m").unwrap(), 1_800);
243 assert_eq!(parse_duration("24h").unwrap(), 86_400);
244 assert_eq!(parse_duration("90d").unwrap(), 7_776_000);
245 assert_eq!(parse_duration("2w").unwrap(), 1_209_600);
246 assert!(parse_duration("10y").is_err());
247 }
248
249 #[test]
250 fn add_mints_a_verifiable_token_and_del_removes_it() {
251 let env = env();
252 let user_id = block_on(async { anyhow::Ok(env.make_user("ada").await) }).unwrap();
253
254 // Minting stores a row whose id is the jti; the printed JWT verifies against
255 // the persisted secret and carries the right claims.
256 block_on(add(
257 env.path(),
258 "ada",
259 "ci",
260 Some("repo:read,repo:write".to_string()),
261 Some("1d".to_string()),
262 ))
263 .unwrap();
264
265 let (row, jwt_ok) = block_on(async {
266 let store = env.store().await;
267 let tokens = store.tokens_by_user(&user_id).await?;
268 assert_eq!(tokens.len(), 1);
269 let row = tokens[0].clone();
270
271 // Re-mint the same claims with the resolved secret to prove the secret
272 // round-trips (the actual JWT is only printed to stdout).
273 let cfg = config::load(env.path())?.config;
274 let secret = ensure_secret(&cfg)?;
275 let jwt = auth::mint_jwt(
276 &secret,
277 &Claims {
278 sub: user_id.clone(),
279 jti: row.id.clone(),
280 scopes: row.scopes.clone(),
281 iat: 0,
282 exp: now_secs() + 1000,
283 },
284 )?;
285 let verified = auth::verify_jwt(&secret, &jwt, now_secs()).is_ok();
286 anyhow::Ok((row, verified))
287 })
288 .unwrap();
289 assert_eq!(row.name, "ci");
290 assert_eq!(row.scopes, "repo:read,repo:write");
291 assert!(row.expires_at.is_some());
292 assert!(jwt_ok, "the token should verify against the stored secret");
293
294 block_on(del(env.path(), "ada", 1, true)).unwrap();
295 let after =
296 block_on(async { anyhow::Ok(env.store().await.tokens_by_user(&user_id).await?) })
297 .unwrap();
298 assert!(after.is_empty());
299 }
300}
docs/decisions.md +30 −0
@@ -363,3 +363,33 @@ signature **cryptographic** verification are complete and cover all four
363documented best-effort gap to close alongside the signature cache wiring in the web363documented best-effort gap to close alongside the signature cache wiring in the web
364phase. Expired/revoked keys therefore currently verify as `Verified` if the364phase. Expired/revoked keys therefore currently verify as `Verified` if the
365signature is otherwise valid — tracked as a known limitation.365signature is otherwise valid — tracked as a known limitation.
366
367## 2026-07-24 — Key ordinals are unique per user, not per (user, kind)
368
369**Decision:** `create_key` assigns `keys.ordinal` as `max(ordinal)+1` over **all**
370of a user's keys (both SSH and GPG), not per `(user, kind)`.
371
372**Alternatives:** Per-`(user, kind)` ordinals, as the schema comment sketches.
373
374**Rationale:** The spec's `fabrica key del <user> <index>` carries no `--type`, so
375the index shown by `key list` must be unambiguous across a user's keys. Per-user
376ordinals give exactly that while still satisfying the schema's
377`UNIQUE (user_id, kind, ordinal)` (a per-user-unique value is a fortiori
378per-`(user,kind)`-unique). Deleting a key never renumbers the survivors, as the
379spec requires.
380
381## 2026-07-24 — CLI resolves-or-generates the instance secret on first use
382
383**Decision:** `fabrica token add` (and any future secret consumer) resolves the
384HS256 signing secret via `context::ensure_secret`: inline `auth.secret`, else the
385contents of `auth.secret_file` (default `{data_dir}/secret`), else a freshly
386generated 32-byte secret written to that path at mode `0600`.
387
388**Alternatives:** Require the operator to pre-provision a secret before any
389token can be minted.
390
391**Rationale:** The spec has the secret "generated on first run"; the CLI operates
392directly on the data directory with no running server, so first-run generation must
393happen there too. Centralizing it in one helper means `token add` today and `serve`
394later sign with the same key. `auth::new_secret` provides the 32-byte base64url
395value, keeping randomness in the auth crate.