From 68cf67423f8ea087f8e9f4a6cb8c25122e8a8eca Mon Sep 17 00:00:00 2001 From: atlas Date: Tue, 15 Sep 2026 22:23:22 +0200 Subject: [PATCH 1/2] 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//*) and the hive's shared queue credential (hives//queue/agent). The queue credential is hive-shared rather than per-agent, so a policy scoped strictly to agents//* 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 --- swarm-secret-client/src/policy.rs | 131 ++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) diff --git a/swarm-secret-client/src/policy.rs b/swarm-secret-client/src/policy.rs index 4144bcc9..b0b4fed5 100644 --- a/swarm-secret-client/src/policy.rs +++ b/swarm-secret-client/src/policy.rs @@ -186,6 +186,46 @@ pub fn render_agent(agent: &str) -> Result { ))) } +/// 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//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//*` 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 { + 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)] mod tests { use super::*; @@ -408,4 +448,95 @@ mod tests { // consistent with it. 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//*` 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//*` — 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")); + } } From f4b7a1e357e7465ce472a32eefa91fe77f9f45b4 Mon Sep 17 00:00:00 2001 From: atlas Date: Tue, 15 Sep 2026 22:50:38 +0200 Subject: [PATCH 2/2] Derive queue path from agent_client_path in render_agent_with_queue Make render_agent_with_queue derive the queue credential path from agent_client_path() rather than re-interpolating Kind::Hive and the queue/agent segment. This enforces path agreement through code reuse instead of two hand-written spellings that can diverge. The policy stanza needs the policy form (secret/data/...) while agent_client_path returns the client form (swarm/hives/...). Bridge this by wrapping agent_client_path's result with {MOUNT}/data/, the same mechanism render_agent uses for agent paths. Update tests to derive expected paths from agent_client_path instead of hard-coding them, so a future divergence fails the test rather than passing silently. Refs #4386 --- swarm-secret-client/src/policy.rs | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/swarm-secret-client/src/policy.rs b/swarm-secret-client/src/policy.rs index b0b4fed5..c314ce27 100644 --- a/swarm-secret-client/src/policy.rs +++ b/swarm-secret-client/src/policy.rs @@ -214,15 +214,12 @@ pub fn render_agent(agent: &str) -> Result { /// could close a stanza could grant itself anything. pub fn render_agent_with_queue(agent: &str, hive: &str) -> Result { 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) - )); + 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}")) } @@ -458,8 +455,12 @@ mod tests { 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("path \"secret/data/swarm/hives/pr1ma/queue/agent\""), + p.contains(&expected_queue_path), "must grant the hive's queue credential: {p}" ); assert_eq!( @@ -537,6 +538,7 @@ mod tests { !p.contains("swarm/hives/pr1ma/*"), "must not grant the hive's whole path: {p}" ); - assert!(p.contains("swarm/hives/pr1ma/queue/agent")); + let expected_queue_path = crate::queue::agent_client_path("pr1ma").expect("legal"); + assert!(p.contains(&expected_queue_path)); } }