Add render_agent_with_queue policy renderer

Extends the agent policy renderer with a variant that grants read on both
the agent's own namespace (agents/<agent>/*) and the hive's shared queue
credential (hives/<hive>/queue/agent).

The queue credential is hive-shared rather than per-agent, so a policy
scoped strictly to agents/<agent>/* cannot read it. This renderer makes
the existing queue credential (already handed to every agent container on
the hive) reachable through the agent's own Vault token.

Tests cover the happy path (both grants render), traversal validation for
both agent and hive parameters, and read-only enforcement.

Refs #4386
This commit is contained in:
atlas 2026-09-15 22:23:22 +02:00 committed by mara
commit 68cf67423f

View file

@ -186,6 +186,46 @@ 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)?;
checked_segment("hive", hive)?;
let agent_stanza = read_stanza(&format!(
"{MOUNT}/data/{ROOT}/{}/{agent}/*",
<&str>::from(Kind::Agent)
));
let queue_stanza = read_stanza(&format!(
"{MOUNT}/data/{ROOT}/{}/{hive}/queue/agent",
<&str>::from(Kind::Hive)
));
Ok(format!("{agent_stanza}{queue_stanza}"))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -408,4 +448,95 @@ mod tests {
// consistent with it. // consistent with it.
assert!(hive.contains("path \"secret/data/swarm/agents/*\"")); 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}"
);
assert!(
p.contains("path \"secret/data/swarm/hives/pr1ma/queue/agent\""),
"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}"
);
assert!(p.contains("swarm/hives/pr1ma/queue/agent"));
}
} }