Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

..
Author SHA1 Message Date
atlas
f4b7a1e357 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
2026-09-15 23:03:58 +02:00
atlas
68cf67423f 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
2026-09-15 23:03:58 +02:00

View file

@ -186,6 +186,43 @@ 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::*;
@ -408,4 +445,100 @@ 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}"
);
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));
}
}