swarm: let every hive read every agent's credential, and say so
A hive reads its agents' credentials with its own certificate, and nothing said which paths that certificate may read, so the read half of a delivery answered 403. The grant is wide on purpose. An agent's path does not name the hive hosting it -- agents move -- so a per-hive grant has to be an enumeration the controller re-emits whenever the roster changes, and an enumeration that can drift or land out of order advertises a boundary it does not hold. A wide grant that says what it is beats a narrow one that only looks narrow. mara's call, on the PR: rather a too-lax scope than one that pretends to be strict. What that buys, beyond honesty: the document is identical for every hive and depends on nothing, so it is written once at startup beside the rest of a hive's provisioning instead of on every declaration. No derived state, no re-emission, and the ordering hazard that came with one stops existing. What still holds is read-only. A hive cannot write an agent's credential, so it cannot hand itself an agent's identity, and the grant reaches nothing in the store outside the agent-credential prefix. The fact is documented where someone meets the boundary rather than only in this message, and the two ways to narrow it later -- scope per hive, or give agents their own store identity -- are tracked.
This commit is contained in:
parent
e638db262e
commit
3752482524
5 changed files with 119 additions and 306 deletions
|
|
@ -4,11 +4,16 @@
|
|||
//! this one says who is allowed to read it, and the two have to agree on the
|
||||
//! same path or a delivery fails with a 403 that names nothing.
|
||||
//!
|
||||
//! Rendering is separate from writing on purpose: the text is a pure function
|
||||
//! of a hive name and its agent set, so the shape that matters can be asserted
|
||||
//! without a store to talk to.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
//! ⚠️ Every hive gets the same document, and it grants read on **every**
|
||||
//! agent's credentials rather than on the ones that hive hosts. That is a
|
||||
//! decision, not an oversight: an agent's path does not name its hive, so a
|
||||
//! per-hive grant has to be enumerated and re-emitted, and an enumeration that
|
||||
//! can silently drift advertises a boundary it does not hold. A wide grant that
|
||||
//! says so beats a narrow one that only looks narrow. The narrower shapes, and
|
||||
//! what they would cost, are in `docs/trust-boundary/security.md`.
|
||||
//!
|
||||
//! Rendering stays separate from writing so the text can be asserted with no
|
||||
//! store to talk to.
|
||||
|
||||
use crate::{
|
||||
Error,
|
||||
|
|
@ -32,30 +37,17 @@ pub fn hive_object_name(hive: &str) -> Result<String, Error> {
|
|||
Ok(format!("{HIVE_PREFIX}{hive}"))
|
||||
}
|
||||
|
||||
/// Render the policy granting `hive` read on exactly the agents it hosts.
|
||||
/// Render the document every hive's policy holds: read on every agent's
|
||||
/// credentials.
|
||||
///
|
||||
/// One stanza per agent rather than a prefix grant: an agent's credential path
|
||||
/// does not name the hive hosting it (agents migrate), so "this hive's agents"
|
||||
/// has no prefix expression and has to be enumerated.
|
||||
/// Takes no arguments because it depends on nothing — same text for every
|
||||
/// hive, unchanged by which agents exist. That is what makes it a deploy-time
|
||||
/// object rather than derived state with a re-emission to get wrong.
|
||||
///
|
||||
/// An empty `agents` renders an empty policy, which grants nothing. That is the
|
||||
/// correct reading of a hive with no agents, and it fails closed.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::PathSegment`] when `hive` or any agent name holds anything but
|
||||
/// `[A-Za-z0-9_-]` — which is what stops a name from closing the stanza and
|
||||
/// opening a wider one.
|
||||
pub fn render(hive: &str, agents: &[&str]) -> Result<String, Error> {
|
||||
checked_segment("hive", hive)?;
|
||||
let mut out = String::new();
|
||||
for agent in agents {
|
||||
checked_segment("agent", agent)?;
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"path \"{MOUNT}/data/{AGENT_PREFIX}/{agent}/*\" {{\n capabilities = [\"read\"]\n}}"
|
||||
);
|
||||
}
|
||||
Ok(out)
|
||||
/// Read-only: the controller mints these and never reads one back.
|
||||
#[must_use]
|
||||
pub fn render() -> String {
|
||||
format!("path \"{MOUNT}/data/{AGENT_PREFIX}/*\" {{\n capabilities = [\"read\"]\n}}\n")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -63,75 +55,52 @@ mod tests {
|
|||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn one_agent_renders_one_read_stanza_under_the_agent_prefix() {
|
||||
let p = render("pr1ma", &["atlas"]).expect("both segments are legal");
|
||||
fn the_document_grants_read_over_the_whole_agent_prefix() {
|
||||
assert_eq!(
|
||||
p,
|
||||
"path \"secret/data/swarm/agents/atlas/*\" {\n capabilities = [\"read\"]\n}\n"
|
||||
render(),
|
||||
"path \"secret/data/swarm/agents/*\" {\n capabilities = [\"read\"]\n}\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_hosted_agent_gets_its_own_stanza_and_nothing_else_does() {
|
||||
let p = render("pr1ma", &["atlas", "iris"]).expect("legal");
|
||||
assert_eq!(p.matches("path \"").count(), 2, "one stanza per agent");
|
||||
assert!(p.contains("/atlas/*"));
|
||||
assert!(p.contains("/iris/*"));
|
||||
assert!(!p.contains("argus"), "an agent not passed is not granted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_grant_is_read_only_and_never_a_prefix_over_all_agents() {
|
||||
// Both halves of what makes this a least-privilege policy rather than
|
||||
// the broad grant that was considered and rejected.
|
||||
let p = render("pr1ma", &["atlas"]).expect("legal");
|
||||
fn the_grant_is_read_only() {
|
||||
// The half of the old policy that survives the widening: a hive reads
|
||||
// credentials, and a hive that could write one could hand itself an
|
||||
// agent's identity.
|
||||
let p = render();
|
||||
assert!(!p.contains("create"));
|
||||
assert!(!p.contains("update"));
|
||||
assert!(!p.contains("delete"));
|
||||
assert!(
|
||||
!p.contains(&format!("{MOUNT}/data/{AGENT_PREFIX}/*")),
|
||||
"a wildcard directly under the agent prefix would grant every agent"
|
||||
);
|
||||
assert!(!p.contains("list"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_cannot_close_the_stanza_and_open_a_wider_one() {
|
||||
// The reason `checked_segment` runs before the name reaches HCL: these
|
||||
// are policy injection, not path traversal, and a `contains("..")`
|
||||
// check catches none of them.
|
||||
for bad in [
|
||||
"atlas/*\" { capabilities = [\"root\"] }\npath \"secret/data",
|
||||
"*",
|
||||
"../argus",
|
||||
"a b",
|
||||
"",
|
||||
] {
|
||||
assert!(
|
||||
render("pr1ma", &[bad]).is_err(),
|
||||
"agent name {bad:?} must be refused"
|
||||
);
|
||||
assert!(
|
||||
render(bad, &["atlas"]).is_err(),
|
||||
"hive name {bad:?} must be refused"
|
||||
);
|
||||
}
|
||||
fn no_name_reaches_the_document_at_all() {
|
||||
// Why the injection cases that used to live here are gone rather than
|
||||
// relaxed: nothing interpolates into the text any more, so there is no
|
||||
// stanza for a name to close. `hive_object_name` still validates,
|
||||
// because a name does reach the policy's *identifier*.
|
||||
let p = render();
|
||||
assert!(!p.contains("pr1ma"));
|
||||
assert_eq!(p.matches("path \"").count(), 1, "one stanza, no per-agent");
|
||||
assert!(
|
||||
hive_object_name("atlas/*\" { capabilities = [\"root\"] }").is_err(),
|
||||
"the object NAME is still a place a name can do damage"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_legal_charset_is_actually_reachable() {
|
||||
// The control for the case above: if every name were refused, that test
|
||||
// would pass while proving nothing.
|
||||
assert!(render("a-b_C9", &["d-e_F0"]).is_ok());
|
||||
// The control for the case above: if every name were refused, that
|
||||
// assertion would pass while proving nothing.
|
||||
assert!(hive_object_name("a-b_C9").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hive_with_no_agents_grants_nothing() {
|
||||
let p = render("pr1ma", &[]).expect("a hive may legitimately host none");
|
||||
assert!(
|
||||
p.is_empty(),
|
||||
"no stanza means no capability, which is closed"
|
||||
);
|
||||
fn every_hive_gets_a_byte_identical_document() {
|
||||
// The property the deploy-time write depends on: nothing about a hive
|
||||
// or its agents changes the text, so there is nothing to re-emit.
|
||||
assert_eq!(render(), render());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
Loading…
Reference in a new issue