The controller's forge token never entered bao: the forge minted it in its container, hive-forge-swarm-controller-token copied it to a host file, and the controller LoadCredential'ed that file. forgeTokenFile defaulted to that path only where the forge ran, so a controller on any other host had no forge access unless an operator copied the file by hand. Now the forge's host publishes it and the controller reads it: - swarm-bao-forge-publisher (replaces hive-forge-swarm-controller-token) waits for the in-container token, logs in with its own forge-publisher leaf and writes it to swarm/controller/swarm-controller/forge/token. It runs on every forge container start, the old unit's trigger. - New principal swarm-forge-publisher: leaf minted by glue-bao-tls.nix, policy create/update on that one path, written by swarm-bao-forge-publisher-policy, CN reserved as a hive name. - The controller policy gains read on that path. swarm-controller reads it at start under its own certificate with the OIDC secret's retry, and runs without forge access if it still cannot. - forgeTokenFile, its LoadCredential and the forgeEnv gate are gone; SWARM_CONTROLLER_FORGE_URL is always set. hostSwarmControllerTokenFile is removed, and a tmpfiles rule deletes the old host copy on the forge host. The in-container file stays: the mint unit's only-once guard reads it, and it is the publisher's source. Refs #4998
395 lines
15 KiB
Rust
395 lines
15 KiB
Rust
//! The forge agreement: where agents' and hives' forge tokens live in the store,
|
|
//! and what the objects at those paths hold.
|
|
//!
|
|
//! Four 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. A hive's read-only token
|
|
//! ([`hive_token_path`]) is the third: `swarm-controller` mints it for the
|
|
//! hive's own forge account, and hive-c0re reads it under the hive's
|
|
//! certificate. Neither end is senior, so both halves are stated once, here,
|
|
//! beside [`crate::matrix`].
|
|
//!
|
|
//! `swarm-controller`'s own forge token ([`controller_token_path`]) is the
|
|
//! fourth: the forge mints it, `swarm-bao-forge-publisher` on the forge's host
|
|
//! writes it here, and the controller reads it as it starts.
|
|
|
|
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"))
|
|
}
|
|
|
|
/// The forge account `hive` reads the forge as: `swarm-controller` creates it
|
|
/// and mints [`hive_token_path`]'s token for it, and hive-c0re names it in the
|
|
/// basic-auth header that token travels in.
|
|
///
|
|
/// The `hive-` prefix keeps the account reading as a hive's rather than an
|
|
/// agent's, as [`crate::matrix::hive_localpart`] does on the homeserver.
|
|
/// `swarm-controller` refuses a new agent named with this prefix, and tells an
|
|
/// older one apart from the hive's account by the account's email.
|
|
#[must_use]
|
|
pub fn hive_login(hive: &str) -> String {
|
|
format!("hive-{hive}")
|
|
}
|
|
|
|
/// The path holding `hive`'s read-only token for the swarm's forge.
|
|
///
|
|
/// Under [`Kind::Hive`], so the grant that reaches it is the hive's own
|
|
/// `swarm/hives/<this hive>/*` stanza: hive `alpha` reads its own token and
|
|
/// gets a 403 on `beta`'s.
|
|
///
|
|
/// # Errors
|
|
/// [`Error::PathSegment`] when `hive` contains anything but `[A-Za-z0-9_-]`,
|
|
/// which is what keeps one hive's name from addressing another hive's secret.
|
|
pub fn hive_token_path(hive: &str) -> Result<String, Error> {
|
|
let prefix = principal_prefix(Kind::Hive, hive)?;
|
|
Ok(format!("{prefix}/forge/token"))
|
|
}
|
|
|
|
/// The path holding `swarm-controller`'s own forge token: the admin account it
|
|
/// provisions every agent's and hive's forge objects with.
|
|
///
|
|
/// Under [`Kind::Controller`], which no hive's policy reads. The nix half is
|
|
/// `swarm-bao.nix`'s `controllerForgeTokenLeaf`, which the publisher's write
|
|
/// grant and the controller's read grant both name.
|
|
///
|
|
/// # Errors
|
|
/// Never in practice: the name segment is a constant. The `Result` is
|
|
/// [`principal_prefix`]'s.
|
|
pub fn controller_token_path() -> Result<String, Error> {
|
|
let prefix = principal_prefix(Kind::Controller, crate::matrix::CONTROLLER)?;
|
|
Ok(format!("{prefix}/forge/token"))
|
|
}
|
|
|
|
/// What [`controller_token_path`] holds. No token name beside the value, unlike
|
|
/// [`Credential`]: the publisher copies a file that holds the token alone.
|
|
///
|
|
/// No `Debug` derive: `value` is the forge admin credential.
|
|
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct ControllerToken {
|
|
/// The token itself. `swarm-bao-forge-publisher` writes it with
|
|
/// `bao kv put … value=@<file>`, so this name is load-bearing for a writer
|
|
/// this crate does not control.
|
|
pub value: String,
|
|
}
|
|
|
|
/// What an agent's forge-token path, and a hive's [`hive_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 the_controller_token_is_under_the_controller_prefix() {
|
|
// Spelled out: `swarm-bao.nix` grants exactly this string.
|
|
assert_eq!(
|
|
controller_token_path().expect("a constant segment is legal"),
|
|
"swarm/controller/swarm-controller/forge/token"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn no_hive_policy_reaches_the_controller_token() {
|
|
let policy = crate::policy::render("alpha").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))
|
|
})
|
|
};
|
|
assert!(!covers(&format!(
|
|
"secret/data/{}",
|
|
controller_token_path().expect("legal")
|
|
)));
|
|
// The control: the same matcher finds the hive's own token.
|
|
assert!(covers(&format!(
|
|
"secret/data/{}",
|
|
hive_token_path("alpha").expect("legal")
|
|
)));
|
|
}
|
|
|
|
#[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_hive_token_lands_under_the_hive_prefix() {
|
|
// Spelled out: the controller's bao stanza names the same leaf.
|
|
assert_eq!(
|
|
hive_token_path("pr1ma").expect("a plain name is legal"),
|
|
"swarm/hives/pr1ma/forge/token"
|
|
);
|
|
assert_eq!(hive_login("pr1ma"), "hive-pr1ma");
|
|
}
|
|
|
|
#[test]
|
|
fn a_traversal_in_the_hive_name_is_refused() {
|
|
for bad in ["../beta", "a/b", "a.b", ""] {
|
|
let e = hive_token_path(bad).expect_err("a traversal is not legal");
|
|
assert!(matches!(e, Error::PathSegment { kind: "hive", .. }), "{e}");
|
|
}
|
|
// The control: the legal charset stays reachable.
|
|
assert!(hive_token_path("a-b_C9").is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn a_hive_reaches_its_own_forge_token_and_no_other_hives() {
|
|
let covered_by = |hive: &str, path: &str| {
|
|
crate::policy::render(hive)
|
|
.expect("legal")
|
|
.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))
|
|
})
|
|
};
|
|
let alpha = hive_token_path("alpha").expect("legal");
|
|
assert!(covered_by("alpha", &alpha), "alpha cannot read {alpha}");
|
|
assert!(!covered_by("beta", &alpha), "beta reaches {alpha}");
|
|
}
|
|
|
|
#[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}");
|
|
}
|
|
}
|