Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4b7a1e357 | ||
|
|
68cf67423f |
1 changed files with 133 additions and 0 deletions
|
|
@ -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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -408,4 +445,100 @@ 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}"
|
||||||
|
);
|
||||||
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue