Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
Cargo.lock +1 −0
| @@ -466,6 +466,7 @@ dependencies = [ | |||
| 466 | 466 | "auth", | |
| 467 | 467 | "clap", | |
| 468 | 468 | "config", | |
| 469 | + | "git", | |
| 469 | 470 | "model", | |
| 470 | 471 | "rpassword", | |
| 471 | 472 | "serde", | |
crates/auth/src/lib.rs +1 −1
| @@ -32,7 +32,7 @@ pub use access::{Access, Permission, Viewer, access}; | |||
| 32 | 32 | pub use password::{HashParams, hash_password, verify_password}; | |
| 33 | 33 | pub use scope::{Scope, format_scopes, parse_scopes}; | |
| 34 | 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 | 37 | /// Errors produced by the authentication primitives. | |
| 38 | 38 | /// | |
crates/auth/src/token.rs +11 −0
| @@ -18,6 +18,7 @@ | |||
| 18 | 18 | use base64::Engine; | |
| 19 | 19 | use base64::engine::general_purpose::URL_SAFE_NO_PAD; | |
| 20 | 20 | use hmac::{Hmac, Mac}; | |
| 21 | + | use rand_core::{OsRng, RngCore}; | |
| 21 | 22 | use serde::{Deserialize, Serialize}; | |
| 22 | 23 | use sha2::Sha256; | |
| 23 | 24 | ||
| @@ -25,6 +26,16 @@ use crate::AuthError; | |||
| 25 | 26 | ||
| 26 | 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 | 39 | /// The fixed JOSE header for every token we mint: `{"alg":"HS256","typ":"JWT"}`. | |
| 29 | 40 | /// Pre-encoded so minting never re-serializes it, and matched exactly on verify | |
| 30 | 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 | 13 | config = { workspace = true } | |
| 14 | 14 | store = { workspace = true } | |
| 15 | 15 | auth = { workspace = true } | |
| 16 | + | git = { workspace = true } | |
| 16 | 17 | model = { workspace = true } | |
| 17 | 18 | anyhow = { workspace = true } | |
| 18 | 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 | 10 | //! block on async work. | |
| 11 | 11 | ||
| 12 | 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 | 16 | use config::Config; | |
| 16 | 17 | use store::Store; | |
| @@ -42,6 +43,47 @@ pub(crate) async fn open_store(config_path: Option<&Path>) -> anyhow::Result<(Co | |||
| 42 | 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 | 87 | /// Run an async command body to completion on a fresh current-thread runtime. | |
| 46 | 88 | /// | |
| 47 | 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 | 9 | //! `fabrica` binary. The command tree is built out over the implementation | |
| 10 | 10 | //! phases; today it covers only `config check` and `config show`. | |
| 11 | 11 | ||
| 12 | + | mod admin_cmd; | |
| 12 | 13 | mod config_cmd; | |
| 13 | 14 | mod context; | |
| 15 | + | mod group_cmd; | |
| 16 | + | mod key_cmd; | |
| 14 | 17 | mod prompt; | |
| 18 | + | mod repo_cmd; | |
| 19 | + | #[cfg(test)] | |
| 20 | + | mod testutil; | |
| 21 | + | mod token_cmd; | |
| 15 | 22 | mod user_cmd; | |
| 16 | 23 | ||
| 17 | 24 | use std::path::PathBuf; | |
| @@ -55,6 +62,38 @@ enum Command { | |||
| 55 | 62 | #[command(subcommand)] | |
| 56 | 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 | 99 | /// Parse arguments and dispatch to the requested subcommand. | |
| @@ -79,5 +118,11 @@ fn dispatch(cli: &Cli) -> anyhow::Result<ExitCode> { | |||
| 79 | 118 | match &cli.command { | |
| 80 | 119 | Command::Config { command } => config_cmd::run(command, cli.config.as_deref(), cli.json), | |
| 81 | 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 | 363 | documented best-effort gap to close alongside the signature cache wiring in the web | |
| 364 | 364 | phase. Expired/revoked keys therefore currently verify as `Verified` if the | |
| 365 | 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. | |