swarm-bao: agent certificates issued by a store-generated agent CA
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
This commit is contained in:
parent
5cd7f866f4
commit
6170e74a31
16 changed files with 894 additions and 925 deletions
|
|
@ -70,13 +70,12 @@ pub fn hive_object_name(hive: &str) -> Result<String, Error> {
|
|||
/// client ids; this is a second identifier family leaning on it, which is why
|
||||
/// the fragment list is what to read before renaming either.
|
||||
///
|
||||
/// ⚠️ The controller's and publisher's subjects are *not* covered by that: their
|
||||
/// policy names are literals outside `hive-`, but their **common names** are
|
||||
/// operator-set options (`deploy.bao.controllerCommonName`,
|
||||
/// `secretPublisherCommonName`) that nothing here can see, and an operator may
|
||||
/// spell one `hive-agent-atlas`. Whichever change first mints an agent leaf
|
||||
/// owes the assertion that neither starts with this prefix — `swarm.nix`'s
|
||||
/// `certAuthCns` is where the mirror-image check for hive names lives.
|
||||
/// Host principals' **common names** are operator-set options
|
||||
/// (`deploy.bao.controllerCommonName`, `secretPublisherCommonName`, …) that
|
||||
/// may spell this prefix, and that is harmless: an agent's cert-auth role pins
|
||||
/// the store's agent CA (`deploy.bao.agentPkiMountPath`), which signs no host
|
||||
/// leaf, and every host role pins `deploy.bao.clientCaFile`, which signs no
|
||||
/// agent leaf. A CN match alone logs nobody in.
|
||||
pub const AGENT_PREFIX: &str = "hive-agent-";
|
||||
|
||||
/// The policy and cert-auth role name for `agent`, and the common name of the
|
||||
|
|
@ -203,43 +202,6 @@ pub fn render_agent(agent: &str) -> Result<String, Error> {
|
|||
)))
|
||||
}
|
||||
|
||||
/// Render `agent`'s policy document: read on that one agent's credentials and
|
||||
/// on the hive's shared queue credential.
|
||||
///
|
||||
/// Extends [`render_agent`] with a second stanza granting read on
|
||||
/// `swarm/hives/<hive>/queue/agent`. The queue credential is **hive-shared,
|
||||
/// not per-agent** — every agent in a hive authenticates to the queue with the
|
||||
/// same client secret (`queue.rs:1-8`), so a policy scoped strictly to
|
||||
/// `agents/<agent>/*` cannot read it and an in-container pull would fail. That
|
||||
/// hive-shared credential is already handed to every agent container on that
|
||||
/// hive by the host today, so this grant adds no new authority — it merely
|
||||
/// makes the existing capability reachable through the agent's own token
|
||||
/// instead of requiring the credential to be delivered out of band.
|
||||
///
|
||||
/// ⚠️ **Every agent in a hive can read that hive's queue credential.** This is
|
||||
/// not new authority (the host already provides this exact value to all agents
|
||||
/// on the hive), but it is a documented property: an agent policy grants read
|
||||
/// on a path shared across every agent on its hive, not on a path unique to
|
||||
/// that agent alone.
|
||||
///
|
||||
/// Read-only, for the same reason [`render_agent`]'s is: an agent that could
|
||||
/// write credentials could hand itself an identity it was never issued.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::PathSegment`] when `agent` or `hive` holds anything but
|
||||
/// `[A-Za-z0-9_-]` — both are interpolated into policy paths, so a name that
|
||||
/// could close a stanza could grant itself anything.
|
||||
pub fn render_agent_with_queue(agent: &str, hive: &str) -> Result<String, Error> {
|
||||
checked_segment("agent", agent)?;
|
||||
let agent_stanza = read_stanza(&format!(
|
||||
"{MOUNT}/data/{ROOT}/{}/{agent}/*",
|
||||
<&str>::from(Kind::Agent)
|
||||
));
|
||||
let queue_path = crate::queue::agent_client_path(hive)?;
|
||||
let queue_stanza = read_stanza(&format!("{MOUNT}/data/{queue_path}"));
|
||||
Ok(format!("{agent_stanza}{queue_stanza}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -430,6 +392,22 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_agents_document_is_exactly_its_own_read_stanza() {
|
||||
// Pinned byte for byte: an added stanza (a hive's queue credential, a
|
||||
// second agent) is exactly what a presence check misses.
|
||||
assert_eq!(
|
||||
render_agent("atlas").expect("legal"),
|
||||
"path \"secret/data/swarm/agents/atlas/*\" {\n capabilities = [\"read\"]\n}\n"
|
||||
);
|
||||
// The control: the pin discriminates between agents.
|
||||
assert!(
|
||||
!render_agent("other")
|
||||
.expect("legal")
|
||||
.contains("agents/atlas/")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_agents_grant_is_read_only() {
|
||||
// An agent that could write its own credentials could hand itself an
|
||||
|
|
@ -517,102 +495,6 @@ mod tests {
|
|||
assert!(hive.contains("path \"secret/data/swarm/agents/*\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_agents_document_with_queue_grants_both_paths() {
|
||||
// The happy path: the document grants read on the agent's own namespace
|
||||
// and on the hive's queue credential.
|
||||
let p = render_agent_with_queue("atlas", "pr1ma").expect("legal");
|
||||
assert!(
|
||||
p.contains("path \"secret/data/swarm/agents/atlas/*\""),
|
||||
"must grant the agent's own path: {p}"
|
||||
);
|
||||
let expected_queue_path = format!(
|
||||
"path \"{MOUNT}/data/{}\"",
|
||||
crate::queue::agent_client_path("pr1ma").expect("legal")
|
||||
);
|
||||
assert!(
|
||||
p.contains(&expected_queue_path),
|
||||
"must grant the hive's queue credential: {p}"
|
||||
);
|
||||
assert_eq!(
|
||||
p.matches("path \"").count(),
|
||||
2,
|
||||
"two stanzas, one for the agent and one for the queue: {p}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_agents_document_with_queue_is_read_only() {
|
||||
// An agent that could write the queue credential could hand every agent
|
||||
// on its hive an identity they were never issued.
|
||||
let p = render_agent_with_queue("atlas", "pr1ma").expect("legal");
|
||||
for capability in ["create", "update", "delete", "list", "sudo", "patch"] {
|
||||
assert!(!p.contains(capability), "must not grant {capability}: {p}");
|
||||
}
|
||||
assert!(p.contains("capabilities = [\"read\"]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_agent_name_with_traversal_in_the_queue_variant_is_refused() {
|
||||
// The agent parameter is an injection surface in both renderers, so
|
||||
// refusing a traversal here proves the new one validates it.
|
||||
assert!(
|
||||
render_agent_with_queue("atlas/*\" { capabilities = [\"root\"] }", "pr1ma").is_err()
|
||||
);
|
||||
assert!(render_agent_with_queue("", "pr1ma").is_err());
|
||||
// The control: legal names still work.
|
||||
assert!(render_agent_with_queue("a-b_C9", "pr1ma").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hive_name_with_traversal_in_the_queue_variant_is_refused() {
|
||||
// The hive parameter is a second injection surface that only the queue
|
||||
// variant introduces, so this test proves that new parameter is
|
||||
// validated. A name that could close the stanza could grant the agent
|
||||
// anything.
|
||||
assert!(
|
||||
render_agent_with_queue("atlas", "pr1ma/*\" { capabilities = [\"root\"] }").is_err()
|
||||
);
|
||||
assert!(render_agent_with_queue("atlas", "").is_err());
|
||||
assert!(
|
||||
render_agent_with_queue("atlas", "../services/swarm-grafana").is_err(),
|
||||
"a path traversal that could reach a different kind"
|
||||
);
|
||||
// The control: legal names still work.
|
||||
assert!(render_agent_with_queue("atlas", "a-b_C9").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_queue_variant_does_not_widen_the_agent_stanza() {
|
||||
// The queue grant must not cause the agent stanza to widen from
|
||||
// `agents/<agent>/*` to `agents/*` — that would give every agent every
|
||||
// other agent's credentials.
|
||||
let p = render_agent_with_queue("atlas", "pr1ma").expect("legal");
|
||||
assert!(
|
||||
!p.contains("swarm/agents/*"),
|
||||
"must not grant the whole agent prefix: {p}"
|
||||
);
|
||||
assert!(p.contains("swarm/agents/atlas/*"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_queue_variant_does_not_grant_the_whole_hive_prefix() {
|
||||
// The queue stanza must grant only the queue credential path, not
|
||||
// `hives/<hive>/*` — the latter would give the agent read on every
|
||||
// secret of the hive that hosts it.
|
||||
let p = render_agent_with_queue("atlas", "pr1ma").expect("legal");
|
||||
assert!(
|
||||
!p.contains("swarm/hives/*"),
|
||||
"must not grant the whole hive prefix: {p}"
|
||||
);
|
||||
assert!(
|
||||
!p.contains("swarm/hives/pr1ma/*"),
|
||||
"must not grant the hive's whole path: {p}"
|
||||
);
|
||||
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> = [
|
||||
|
|
|
|||
Loading…
Reference in a new issue