//! The forge agreement: where an agent's forge tokens live in the store, and //! what the objects at those paths hold. //! //! Two kinds. The agent's own token on the swarm's forge //! ([`agent_token_path`]): `swarm-controller` mints it with the forge's admin //! API, and `nix/agent-modules/forge-token.nix` reads it back under the agent's //! own certificate. And the agent's accounts on external forges //! ([`account_path`]): an operator hands the controller a URL and a token, the //! controller stores them there, and `nix/agent-modules/forge-accounts.nix` //! lists [`accounts_dir`] and reads each account. Neither end is senior, so //! both halves are stated once, here, beside [`crate::matrix`]. use serde::{Deserialize, Serialize}; use crate::{ Error, path::{Kind, checked_segment, principal_prefix}, }; /// The path holding `agent`'s own forge access token. /// /// A flat leaf under the agent's prefix, like its controller-minted siblings /// [`crate::queue::agent_queue_path`] and [`crate::mtls::identity_path`], so /// the agent's own read stanza ([`crate::policy::render_agent`]) /// already covers it. /// /// # Errors /// [`Error::PathSegment`] when `agent` contains anything but `[A-Za-z0-9_-]`, /// which is what keeps one agent's name from addressing another agent's secret. pub fn agent_token_path(agent: &str) -> Result { let prefix = principal_prefix(Kind::Agent, agent)?; Ok(format!("{prefix}/forge-token")) } /// What an agent's forge-token path holds. /// /// No `Debug` derive: `value` is a live forge credential, and a derived /// `Debug` is one `{:?}` away from a log line. #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Credential { /// The token itself. `forge-token.nix` reads the store with /// `bao kv get -field=value`, so this name is load-bearing for a reader /// this crate does not control, the same as [`crate::matrix::Credential`]'s. pub value: String, /// The forge's name for the token — not secret. Stored beside the value /// so whoever rotates it can name the token it replaces. pub name: String, } impl std::fmt::Debug for Credential { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Credential") .field("value", &"") .field("name", &self.name) .finish() } } /// The path holding `agent`'s account on the external forge it calls `label`. /// /// # Errors /// [`Error::PathSegment`] when either name contains anything but /// `[A-Za-z0-9_-]`, which is what keeps one agent's name from addressing /// another agent's secret. pub fn account_path(agent: &str, label: &str) -> Result { checked_segment("label", label)?; Ok(format!("{}/{label}", accounts_dir(agent)?)) } /// The directory every one of `agent`'s [`account_path`]s is under, in the /// form [`crate::SecretStore::list`] takes. Named apart from /// [`agent_token_path`]'s `forge-token` key, so no key is also a directory. /// /// # Errors /// [`Error::PathSegment`] when `agent` contains anything but `[A-Za-z0-9_-]`. pub fn accounts_dir(agent: &str) -> Result { let prefix = principal_prefix(Kind::Agent, agent)?; Ok(format!("{prefix}/forge")) } /// What an [`account_path`] holds. /// /// No `Debug` derive, for [`Credential`]'s reason. #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Account { /// The token. `forge-accounts.nix` reads this field by name. pub value: String, /// The forge's base URL, without a trailing slash. Not secret. /// `forge-accounts.nix` reads this field by name too. pub url: String, } impl std::fmt::Debug for Account { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Account") .field("value", &"") .field("url", &self.url) .finish() } } #[cfg(test)] mod tests { use super::*; #[test] fn an_account_and_its_directory_land_under_the_agent_prefix() { // Spelled out: `forge-accounts.nix` spells the same strings. assert_eq!( account_path("atlas", "codeberg").expect("both segments are legal"), "swarm/agents/atlas/forge/codeberg" ); assert_eq!( accounts_dir("atlas").expect("legal"), "swarm/agents/atlas/forge" ); } #[test] fn no_forge_key_is_also_a_directory() { // KV-v2 cannot hold a key whose name is also a prefix of another key. let dir = accounts_dir("atlas").expect("legal"); let key = agent_token_path("atlas").expect("legal"); assert_ne!(key, dir); assert!(!key.starts_with(&format!("{dir}/")), "{key}"); assert!(!dir.starts_with(&format!("{key}/")), "{dir}"); } #[test] fn a_label_that_could_escape_the_directory_is_refused() { for bad in ["../argus", "a/b", "a.b", ""] { assert!(account_path("atlas", bad).is_err(), "label {bad:?}"); assert!(account_path(bad, "codeberg").is_err(), "agent {bad:?}"); } // The control: the legal charset stays reachable. assert!(account_path("a-b_C9", "d-e0").is_ok()); } #[test] fn the_agents_own_grants_list_its_accounts_and_read_each() { let policy = crate::policy::render_agent("atlas").expect("legal"); let covers = |path: &str| { policy.lines().any(|line| { line.strip_prefix("path \"") .and_then(|rest| rest.split_once("\" {")) .and_then(|(p, _)| p.strip_suffix('*')) .is_some_and(|prefix| path.starts_with(prefix)) }) }; let dir = accounts_dir("atlas").expect("legal"); assert!(covers(&format!("secret/metadata/{dir}/"))); assert!(covers(&format!( "secret/data/{}", account_path("atlas", "codeberg").expect("legal") ))); // The control: another agent's accounts are outside both grants. let other = accounts_dir("argus").expect("legal"); assert!(!covers(&format!("secret/metadata/{other}/"))); assert!(!covers(&format!( "secret/data/{}", account_path("argus", "codeberg").expect("legal") ))); } #[test] fn the_account_fields_match_what_the_nix_reader_asks_for() { let json = serde_json::to_string(&Account { value: "t".to_owned(), url: "https://codeberg.org".to_owned(), }) .expect("two Strings serialise"); assert_eq!(json, r#"{"value":"t","url":"https://codeberg.org"}"#); } #[test] fn debug_never_prints_an_accounts_value() { let a = Account { value: "0123456789abcdef".to_owned(), url: "https://codeberg.org".to_owned(), }; let shown = format!("{a:?}"); assert!(!shown.contains("0123456789abcdef"), "{shown}"); assert!(shown.contains("codeberg.org"), "{shown}"); } #[test] fn the_token_lands_under_the_agent_prefix() { // Spelled out rather than rebuilt from the pieces the code uses: the // nix reader spells the same string, and a rename here that moved it // would leave that reader fetching nothing. assert_eq!( agent_token_path("atlas").expect("a plain name is legal"), "swarm/agents/atlas/forge-token" ); } #[test] fn a_traversal_in_the_agent_name_is_refused() { for bad in ["../argus", "a/b", "atlas/../argus", "a.b", ""] { let e = agent_token_path(bad).expect_err("a traversal is not legal"); assert!(matches!(e, Error::PathSegment { kind: "agent", .. }), "{e}"); } } #[test] fn the_legal_charset_is_actually_reachable() { // The control for the test above: a guard that refused everything // would pass it for the wrong reason. assert!(agent_token_path("a-b_C9").is_ok()); } #[test] fn the_agents_own_read_grant_covers_the_path() { // The agent reads this path under `hive-agent-`, and that // policy is rendered elsewhere. If the path ever moved out from under // it, the agent's fetch would 403 at boot, naming neither. let path = agent_token_path("atlas").expect("legal"); let policy = crate::policy::render_agent("atlas").expect("legal"); let covered = policy.lines().any(|line| { line.strip_prefix("path \"") .and_then(|rest| rest.split_once("\" {")) .and_then(|(p, _)| p.strip_suffix('*')) .is_some_and(|prefix| format!("secret/data/{path}").starts_with(prefix)) }); assert!(covered, "no stanza in\n{policy}\ncovers {path}"); } #[test] fn another_agents_read_grant_does_not_cover_the_path() { // Control for the test above: the prefix match must actually be // discriminating, or it proves nothing. let path = agent_token_path("atlas").expect("legal"); let policy = crate::policy::render_agent("argus").expect("legal"); let covered = policy.lines().any(|line| { line.strip_prefix("path \"") .and_then(|rest| rest.split_once("\" {")) .and_then(|(p, _)| p.strip_suffix('*')) .is_some_and(|prefix| format!("secret/data/{path}").starts_with(prefix)) }); assert!(!covered, "argus's policy must not reach atlas's token"); } #[test] fn the_value_field_matches_what_the_nix_reader_asks_for() { let json = serde_json::to_string(&Credential { value: "t".to_owned(), name: "swarm-agent".to_owned(), }) .expect("two Strings serialise"); assert_eq!(json, r#"{"value":"t","name":"swarm-agent"}"#); } #[test] fn debug_never_prints_the_value() { let c = Credential { value: "0123456789abcdef".to_owned(), name: "swarm-agent".to_owned(), }; let shown = format!("{c:?}"); assert!(!shown.contains("0123456789abcdef"), "{shown}"); assert!(shown.contains("swarm-agent"), "{shown}"); } }