An agent's store identity was signed in swarm-controller's memory by a CA a controller-host unit generated on disk, and the listener never trusted that CA. Agent leaves now come from the store itself: a `pki-agents` PKI mount whose root openbao generates internally, so the agent CA's key never exists outside the store. - swarm-bao-agent-pki (new, store host, as the bao granter): enables and tunes the mount, generates the root once (guarded on an empty issuer list, no replace branch), upserts the `swarm-agent` role (client certificates named `hive-agent-*` only, 90 days), caches the CA at /var/lib/swarm-bao-tls/agent-ca.pem and composes the listener bundle. - The listener's tls_client_ca_file is a new listener-client-ca.pem (client-ca.pem, then the agent CA). Host cert-auth roles still pin client-ca.pem, so an agent leaf satisfies no host role. swarm-bao-certs composes the same bundle before openbao starts. - openbao reads tls_client_ca_file only at start, so when the bundle changed after openbao started, swarm-bao-agent-pki restarts openbao.service in the container; under `seal = "shamir"` it prints the step instead. Once swarm-bao-certs has a cached CA, later boots start openbao with it and do not restart. - The controller policy gains exactly `update` on pki-agents/issue/swarm-agent. mint_and_verify now asks that role for the leaf (the store generates the key), writes the agent's cert-auth role pinning the issuing CA bao returned, and writes the agent's policy as render_agent alone: the hive-shared queue credential stanza is gone. - deploy.bao.agentPkiRoleName (must start `swarm-`, asserted with the other pki role names); swarm-controller gets SWARM_CONTROLLER_AGENT_PKI_MOUNT/_ROLE from the deploy.bao options. Deleted: swarm-controller-agent-ca and its options (agentCaFile, agentCaKeyFile), env, LoadCredential entries and assertion; agent_identity's Authority, rcgen signing and validity window; the rcgen and time dependencies of swarm-controller (rcgen leaves the workspace); policy::render_agent_with_queue and its tests. The CN-prefix assertion policy.rs said was owed is not: agent and host roles pin different CAs. Migration is re-creating each agent after deploy; that overwrites the stale role and policy. Closes #4756
137 lines
5.2 KiB
Rust
137 lines
5.2 KiB
Rust
//! The forge agreement: where an agent's own forge token lives in the store,
|
|
//! and what the object at that path holds.
|
|
//!
|
|
//! `swarm-controller` mints the token with the forge's admin API and writes it
|
|
//! here; the agent container reads it back under its own certificate
|
|
//! (`nix/agent-modules/forge-token.nix`). Neither end is senior, so both halves
|
|
//! are stated once, here, beside [`crate::matrix`].
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::{
|
|
Error,
|
|
path::{Kind, 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()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[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}");
|
|
}
|
|
}
|