swarm-controller: mint each agent's forge token and store it in bao
A MintAgentForgeToken node mints a fixed-name swarm-agent token with the admin API, keeps it when the stored value's last eight and the normalised scopes match the forge's list, and otherwise deletes and re-creates it. The token is stored at swarm/agents/<agent>/forge-token. Agent creation inserts the node, and a pass at start and every five minutes inserts it for every agent holding a store identity whose token is missing or stale. Refs #3782
This commit is contained in:
parent
2fda529ca8
commit
52c8c0b0de
7 changed files with 977 additions and 4 deletions
|
|
@ -260,6 +260,25 @@ impl SecretStore {
|
|||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The names of every cert-auth role under `mount`, or none when the
|
||||
/// mount has no roles at all.
|
||||
///
|
||||
/// **Only a 404 is "none".** The store answers a `LIST` on an empty
|
||||
/// directory with a 404; a denial is a 403 and stays an error, so a caller
|
||||
/// iterating the result never mistakes "not allowed to look" for "nothing
|
||||
/// there".
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::Vault`] for anything that is not a 404: a denial, or an
|
||||
/// unreachable store.
|
||||
pub async fn list_cert_roles(&self, mount: &str) -> Result<Vec<String>, Error> {
|
||||
match vaultrs::auth::cert::ca_cert_role::list(&self.inner, mount).await {
|
||||
Ok(listed) => Ok(listed.keys),
|
||||
Err(vaultrs::error::ClientError::APIError { code: 404, .. }) => Ok(Vec::new()),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write an ACL policy, spelled out here because [`vaultrs`] does not have it:
|
||||
|
|
|
|||
137
swarm-secret-client/src/forge.rs
Normal file
137
swarm-secret-client/src/forge.rs
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
//! 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_with_queue`])
|
||||
/// 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_with_queue("atlas", "pr1ma").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_with_queue("argus", "pr1ma").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}");
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@
|
|||
//! the rules every path obeys ([`path`]), the translation from this
|
||||
//! deployment's environment into a logged-in client ([`client`]), and, per kind
|
||||
//! of secret, the path it lives at together with the fields it holds
|
||||
//! ([`matrix`], [`queue`], [`mtls`]). Each of those is a thing the controller
|
||||
//! ([`matrix`], [`queue`], [`mtls`], [`forge`]). Each of those is a thing the controller
|
||||
//! and a hive must say identically, so it is said once here.
|
||||
//!
|
||||
//! [`policy`] is the same kind of agreement seen from the other side: which of
|
||||
|
|
@ -23,6 +23,7 @@
|
|||
//! value inside it, and its doc explains why that is not circular.
|
||||
|
||||
pub mod client;
|
||||
pub mod forge;
|
||||
pub mod matrix;
|
||||
pub mod mtls;
|
||||
pub mod path;
|
||||
|
|
|
|||
|
|
@ -96,6 +96,23 @@ pub fn agent_object_name(agent: &str) -> Result<String, Error> {
|
|||
Ok(format!("{AGENT_PREFIX}{agent}"))
|
||||
}
|
||||
|
||||
/// The agents among `names`, a listing of cert-auth roles: the inverse of
|
||||
/// [`agent_object_name`], for a caller that needs the set of agents holding a
|
||||
/// store identity.
|
||||
///
|
||||
/// A name that carries the prefix but whose suffix is not a legal segment is
|
||||
/// dropped rather than returned: [`agent_object_name`] could not have produced
|
||||
/// it, so it is not an agent this crate minted.
|
||||
#[must_use]
|
||||
pub fn agents_from_role_names(names: &[String]) -> Vec<String> {
|
||||
names
|
||||
.iter()
|
||||
.filter_map(|name| name.strip_prefix(AGENT_PREFIX))
|
||||
.filter(|agent| checked_segment("agent", agent).is_ok())
|
||||
.map(str::to_owned)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One read stanza. The only shape this module emits, so "read-only" is a
|
||||
/// property of the renderer rather than of each call site.
|
||||
fn read_stanza(path: &str) -> String {
|
||||
|
|
@ -595,4 +612,33 @@ mod tests {
|
|||
let expected_queue_path = crate::queue::agent_client_path("pr1ma").expect("legal");
|
||||
assert!(p.contains(&expected_queue_path));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_agent_roles_come_back_and_without_their_prefix() {
|
||||
let roles: Vec<String> = [
|
||||
"hive-agent-atlas",
|
||||
"hive-pr1ma",
|
||||
"swarm-controller",
|
||||
"hive-agent-argus",
|
||||
]
|
||||
.map(str::to_owned)
|
||||
.to_vec();
|
||||
assert_eq!(agents_from_role_names(&roles), ["atlas", "argus"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_role_agent_object_name_could_not_have_produced_is_dropped() {
|
||||
// An empty suffix and one carrying a dot are both names the prefix
|
||||
// matches and no agent can have.
|
||||
let roles: Vec<String> = ["hive-agent-", "hive-agent-a.b", "hive-agent-ok"]
|
||||
.map(str::to_owned)
|
||||
.to_vec();
|
||||
assert_eq!(agents_from_role_names(&roles), ["ok"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_listing_inverts_agent_object_name() {
|
||||
let name = agent_object_name("atlas").expect("legal");
|
||||
assert_eq!(agents_from_role_names(&[name]), ["atlas"]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue