Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-secret-client/src/forge.rs
iris 78e9f2ca67 use a neutral forge name in link-form placeholders, schema examples and tests
Codeberg does not accept AI-assisted contributions, so it should not be
the example we put in front of operators. The placeholders now read
myforge / forge.example.org; the tests use the same neutral names.
2026-10-04 15:51:20 +02:00

265 lines
10 KiB
Rust

//! 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<String, Error> {
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", &"<redacted>")
.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<String, Error> {
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<String, Error> {
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", &"<redacted>")
.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", "myforge").expect("both segments are legal"),
"swarm/agents/atlas/forge/myforge"
);
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, "myforge").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", "myforge").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", "myforge").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://forge.example.org".to_owned(),
})
.expect("two Strings serialise");
assert_eq!(json, r#"{"value":"t","url":"https://forge.example.org"}"#);
}
#[test]
fn debug_never_prints_an_accounts_value() {
let a = Account {
value: "0123456789abcdef".to_owned(),
url: "https://forge.example.org".to_owned(),
};
let shown = format!("{a:?}");
assert!(!shown.contains("0123456789abcdef"), "{shown}");
assert!(shown.contains("forge.example.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-<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}");
}
}