Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
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}; | |||
| 32 | pub use password::{HashParams, hash_password, verify_password}; | 32 | pub use password::{HashParams, hash_password, verify_password}; |
| 33 | pub use scope::{Scope, format_scopes, parse_scopes}; | 33 | pub use scope::{Scope, format_scopes, parse_scopes}; |
| 34 | pub use session::{SESSION_TOKEN_BYTES, SessionToken, new_session_token, session_id}; | 34 | pub use session::{SESSION_TOKEN_BYTES, SessionToken, new_session_token, session_id}; |
| 35 | pub use token::{Claims, mint_jwt, verify_jwt}; | 35 | pub use token::{Claims, mint_jwt, new_secret, verify_jwt}; |
| 36 | 36 | ||
| 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 @@ | |||
| 18 | use base64::Engine; | 18 | use base64::Engine; |
| 19 | use base64::engine::general_purpose::URL_SAFE_NO_PAD; | 19 | use base64::engine::general_purpose::URL_SAFE_NO_PAD; |
| 20 | use hmac::{Hmac, Mac}; | 20 | use hmac::{Hmac, Mac}; |
| 21 | use rand_core::{OsRng, RngCore}; | ||
| 21 | use serde::{Deserialize, Serialize}; | 22 | use serde::{Deserialize, Serialize}; |
| 22 | use sha2::Sha256; | 23 | use sha2::Sha256; |
| 23 | 24 | ||
| @@ -25,6 +26,16 @@ use crate::AuthError; | |||
| 25 | 26 | ||
| 26 | type HmacSha256 = Hmac<Sha256>; | 27 | type HmacSha256 = Hmac<Sha256>; |
| 27 | 28 | ||
| 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] | ||
| 33 | pub 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 verify | 40 | /// 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 (closing | 41 | /// 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 | |||
| 13 | config = { workspace = true } | 13 | config = { workspace = true } |
| 14 | store = { workspace = true } | 14 | store = { workspace = true } |
| 15 | auth = { workspace = true } | 15 | auth = { workspace = true } |
| 16 | git = { workspace = true } | ||
| 16 | model = { workspace = true } | 17 | model = { workspace = true } |
| 17 | anyhow = { workspace = true } | 18 | anyhow = { workspace = true } |
| 18 | clap = { workspace = true } | 19 | clap = { 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 | |||
| 8 | use std::path::Path; | ||
| 9 | use std::process::{Command, ExitCode}; | ||
| 10 | |||
| 11 | use config::Config; | ||
| 12 | |||
| 13 | use crate::context::block_on; | ||
| 14 | |||
| 15 | /// The minimum git version fabrica supports (pack transport relies on it). | ||
| 16 | const MIN_GIT: (u32, u32) = (2, 41); | ||
| 17 | |||
| 18 | /// Run `fabrica migrate`: connect and apply all pending migrations. | ||
| 19 | pub(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. | ||
| 33 | pub(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. | ||
| 75 | fn 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. | ||
| 96 | fn 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. | ||
| 105 | async 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)] | ||
| 117 | mod 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. |
| 11 | 11 | ||
| 12 | use std::future::Future; | 12 | use std::future::Future; |
| 13 | use std::path::Path; | 13 | use std::os::unix::fs::PermissionsExt; |
| 14 | use std::path::{Path, PathBuf}; | ||
| 14 | 15 | ||
| 15 | use config::Config; | 16 | use config::Config; |
| 16 | use store::Store; | 17 | use 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 | } |
| 44 | 45 | ||
| 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. | ||
| 58 | pub(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. | ||
| 83 | pub(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 is | 89 | /// 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 | |||
| 7 | use std::path::Path; | ||
| 8 | use std::process::ExitCode; | ||
| 9 | |||
| 10 | use clap::Subcommand; | ||
| 11 | use model::Group; | ||
| 12 | use store::Store; | ||
| 13 | |||
| 14 | use crate::context::{block_on, open_store}; | ||
| 15 | use crate::prompt::confirm; | ||
| 16 | |||
| 17 | /// Actions under `fabrica group`. | ||
| 18 | #[derive(Debug, Subcommand)] | ||
| 19 | pub(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. | ||
| 46 | pub(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. | ||
| 59 | async 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 | |||
| 67 | async 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 | |||
| 75 | async 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 | |||
| 100 | async 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`. | ||
| 121 | fn 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)] | ||
| 132 | mod 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 | |||
| 7 | use std::io::Read as _; | ||
| 8 | use std::path::Path; | ||
| 9 | use std::process::ExitCode; | ||
| 10 | |||
| 11 | use clap::Subcommand; | ||
| 12 | use model::{Key, KeyKind}; | ||
| 13 | use store::{NewKey, Store}; | ||
| 14 | |||
| 15 | use crate::context::{block_on, open_store}; | ||
| 16 | use crate::prompt::confirm; | ||
| 17 | |||
| 18 | /// Actions under `fabrica key`. | ||
| 19 | #[derive(Debug, Subcommand)] | ||
| 20 | pub(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`]. | ||
| 55 | fn 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. | ||
| 60 | pub(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). | ||
| 78 | fn 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. | ||
| 91 | async 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 | |||
| 99 | async 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 | |||
| 140 | async 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 | |||
| 165 | async 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). | ||
| 194 | fn 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)] | ||
| 206 | mod tests { | ||
| 207 | #![allow(clippy::unwrap_used)] | ||
| 208 | |||
| 209 | use super::*; | ||
| 210 | use crate::testutil::env; | ||
| 211 | |||
| 212 | const SSH_KEY: &str = "ssh-ed25519 \ | ||
| 213 | AAAAC3NzaC1lZDI1NTE5AAAAIJqq6nD0E4SlM+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 implementation | 9 | //! `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`. |
| 11 | 11 | ||
| 12 | mod admin_cmd; | ||
| 12 | mod config_cmd; | 13 | mod config_cmd; |
| 13 | mod context; | 14 | mod context; |
| 15 | mod group_cmd; | ||
| 16 | mod key_cmd; | ||
| 14 | mod prompt; | 17 | mod prompt; |
| 18 | mod repo_cmd; | ||
| 19 | #[cfg(test)] | ||
| 20 | mod testutil; | ||
| 21 | mod token_cmd; | ||
| 15 | mod user_cmd; | 22 | mod user_cmd; |
| 16 | 23 | ||
| 17 | use std::path::PathBuf; | 24 | use 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 | } |
| 59 | 98 | ||
| 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 | |||
| 8 | use std::path::Path; | ||
| 9 | use std::process::ExitCode; | ||
| 10 | |||
| 11 | use clap::Subcommand; | ||
| 12 | use model::Repo; | ||
| 13 | use store::{NewRepo, Store}; | ||
| 14 | |||
| 15 | use crate::context::{block_on, hook_binary, open_store}; | ||
| 16 | use crate::prompt::confirm; | ||
| 17 | |||
| 18 | /// Actions under `fabrica repo`. | ||
| 19 | #[derive(Debug, Subcommand)] | ||
| 20 | pub(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. | ||
| 79 | pub(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. | ||
| 119 | async 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. | ||
| 128 | async 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. | ||
| 141 | fn 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 | |||
| 148 | async 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 | |||
| 194 | async 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 | |||
| 248 | async 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 | |||
| 299 | async 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 | |||
| 319 | async 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`. | ||
| 330 | fn 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`. | ||
| 338 | fn 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)] | ||
| 352 | mod 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 | |||
| 11 | use std::path::Path; | ||
| 12 | |||
| 13 | use store::{NewUser, Store}; | ||
| 14 | use tempfile::TempDir; | ||
| 15 | |||
| 16 | /// A throwaway environment for a CLI command test. | ||
| 17 | pub(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. | ||
| 24 | pub(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 | |||
| 44 | impl 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 | |||
| 7 | use std::path::Path; | ||
| 8 | use std::process::ExitCode; | ||
| 9 | use std::time::{SystemTime, UNIX_EPOCH}; | ||
| 10 | |||
| 11 | use auth::Claims; | ||
| 12 | use clap::Subcommand; | ||
| 13 | use model::ApiToken; | ||
| 14 | use store::{NewToken, Store}; | ||
| 15 | |||
| 16 | use crate::context::{block_on, ensure_secret, open_store}; | ||
| 17 | use crate::prompt::confirm; | ||
| 18 | |||
| 19 | /// Actions under `fabrica token`. | ||
| 20 | #[derive(Debug, Subcommand)] | ||
| 21 | pub(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. | ||
| 56 | pub(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. | ||
| 80 | async 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. | ||
| 89 | fn 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. | ||
| 96 | fn 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 | |||
| 113 | async 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 | |||
| 161 | async 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 | |||
| 184 | async 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). | ||
| 220 | fn 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)] | ||
| 233 | mod 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 | |||
| 363 | documented best-effort gap to close alongside the signature cache wiring in the web | 363 | documented best-effort gap to close alongside the signature cache wiring in the web |
| 364 | phase. Expired/revoked keys therefore currently verify as `Verified` if the | 364 | phase. Expired/revoked keys therefore currently verify as `Verified` if the |
| 365 | signature is otherwise valid — tracked as a known limitation. | 365 | signature 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** | ||
| 370 | of 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 | ||
| 375 | the index shown by `key list` must be unambiguous across a user's keys. Per-user | ||
| 376 | ordinals give exactly that while still satisfying the schema's | ||
| 377 | `UNIQUE (user_id, kind, ordinal)` (a per-user-unique value is a fortiori | ||
| 378 | per-`(user,kind)`-unique). Deleting a key never renumbers the survivors, as the | ||
| 379 | spec 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 | ||
| 384 | HS256 signing secret via `context::ensure_secret`: inline `auth.secret`, else the | ||
| 385 | contents of `auth.secret_file` (default `{data_dir}/secret`), else a freshly | ||
| 386 | generated 32-byte secret written to that path at mode `0600`. | ||
| 387 | |||
| 388 | **Alternatives:** Require the operator to pre-provision a secret before any | ||
| 389 | token can be minted. | ||
| 390 | |||
| 391 | **Rationale:** The spec has the secret "generated on first run"; the CLI operates | ||
| 392 | directly on the data directory with no running server, so first-run generation must | ||
| 393 | happen there too. Centralizing it in one helper means `token add` today and `serve` | ||
| 394 | later sign with the same key. `auth::new_secret` provides the 32-byte base64url | ||
| 395 | value, keeping randomness in the auth crate. | ||