swarm-secret-client: name the hive queue credential, and grant a hive its own kind

The agreement half of delivering the agent queue principal's client secret
through the store. No producer yet, so nothing writes this path — the unit
that does lands in the same PR, with the write grant it needs.

queue.rs is the sibling matrix.rs prescribes for a second kind of secret
rather than another field on a shared struct. Keyed per HIVE, not per agent:
the queue identity is minted once per hive at deploy time and says which hive
an agent belongs to, never which agent.

The client id rides with the secret for matrix.rs's stated reason — a
credential has to be reconstructable from the store alone, and deriving
`hive-<name>-agent` on the reading side is the split spelling the authelia
module warns denies every agent as a timeout.

policy.rs's render() takes the hive name now and emits a second, narrow
stanza for that hive's own path. The agent stanza is untouched: an agent's
path does not name its hive, so narrowing it still needs the enumeration
docs/trust-boundary/security.md rejects. A hive path does name its principal,
so scoping it costs nothing and drifts nowhere.

every_hive_gets_a_byte_identical_document is replaced rather than deleted.
Its surviving half is that the text is a function of the deploy-time name
alone, so a re-emission cannot drift; the new arms are that one hive's
document cannot reach another's path, and that a name which could close the
stanza is refused — live again now that a name reaches the document text.

Refs #3853
This commit is contained in:
atlas 2026-09-11 22:03:59 +02:00
commit 395ecbdf41
5 changed files with 196 additions and 55 deletions

View file

@ -1,16 +1,25 @@
//! The read agreement: which credentials a hive's own token may fetch.
//!
//! The mirror of [`crate::matrix`]. That module says where a credential lives;
//! 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.
//! The mirror of [`crate::matrix`] and [`crate::queue`]. Those modules say
//! where a credential lives; 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.
//!
//! ⚠️ 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`.
//! The document has one stanza per kind a hive reads, and the two are scoped
//! differently on purpose:
//!
//! ⚠️ The **agent** stanza 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`.
//!
//! The **hive** stanza has no such problem and is therefore narrow: that path
//! names its principal, so scoping it to the reader's own name costs nothing
//! and drifts nowhere. Do not widen it to match its neighbour — the asymmetry
//! is the point.
//!
//! Rendering stays separate from writing so the text can be asserted with no
//! store to talk to.
@ -37,25 +46,46 @@ pub fn hive_object_name(hive: &str) -> Result<String, Error> {
Ok(format!("{HIVE_PREFIX}{hive}"))
}
/// Render the document every hive's policy holds: read on every agent's
/// credentials.
/// One read stanza. The only shape this module emits, so "read-only" is a
/// property of the renderer rather than of each call site.
fn read_stanza(path: &str) -> String {
format!("path \"{path}\" {{\n capabilities = [\"read\"]\n}}\n")
}
/// Render `hive`'s policy document: read on every agent's credentials, and on
/// this hive's own.
///
/// 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.
/// The name is the only input, and it is deploy-time — so the document is
/// still a deploy-time object rather than derived state with a re-emission to
/// get wrong. Nothing about which agents exist changes the text.
///
/// Read-only: the controller mints these and never reads one back.
/// ⚠️ Still the agent kind alone. The other kinds are deliberately absent: a
/// hive has no business reading a service's or the controller's credentials,
/// and what a hive may read of its *own* kind is a boundary question this
/// module's header answers only for agents. Widening it is a decision, not a
/// consequence of the namespace growing.
#[must_use]
pub fn render() -> String {
format!(
"path \"{MOUNT}/data/{ROOT}/{}/*\" {{\n capabilities = [\"read\"]\n}}\n",
///
/// ⚠️ The service and controller kinds are deliberately absent: a hive has no
/// business reading a service's or the controller's credentials. Adding either
/// is a boundary decision, not a consequence of the namespace growing.
///
/// The hive's *own* kind is granted, and that is the decision the agent-only
/// version of this grant said had to be made rather than assumed: a hive holds
/// the queue credential its own agents authenticate with, so it has to read the
/// one principal named after itself — and only that one, which is why the path
/// interpolates the name instead of widening to the whole kind.
///
/// # Errors
/// [`Error::PathSegment`] when `hive` holds anything but `[A-Za-z0-9_-]` — the
/// name is interpolated into a policy path, so a name that could close the
/// stanza could grant itself anything.
pub fn render(hive: &str) -> Result<String, Error> {
checked_segment("hive", hive)?;
let agents = read_stanza(&format!(
"{MOUNT}/data/{ROOT}/{}/*",
<&str>::from(Kind::Agent)
)
));
let own = read_stanza(&format!(
"{MOUNT}/data/{ROOT}/{}/{hive}/*",
<&str>::from(Kind::Hive)
));
Ok(format!("{agents}{own}"))
}
#[cfg(test)]
@ -63,19 +93,19 @@ mod tests {
use super::*;
#[test]
fn the_document_grants_read_over_the_whole_agent_prefix() {
fn the_document_grants_the_whole_agent_prefix_and_this_hive_alone() {
assert_eq!(
render(),
"path \"secret/data/swarm/agents/*\" {\n capabilities = [\"read\"]\n}\n"
render("pr1ma").expect("a plain name is legal"),
"path \"secret/data/swarm/agents/*\" {\n capabilities = [\"read\"]\n}\n\
path \"secret/data/swarm/hives/pr1ma/*\" {\n capabilities = [\"read\"]\n}\n"
);
}
#[test]
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();
// A hive reads credentials; a hive that could write one could hand
// itself an agent's identity.
let p = render("pr1ma").expect("legal");
assert!(!p.contains("create"));
assert!(!p.contains("update"));
assert!(!p.contains("delete"));
@ -83,17 +113,36 @@ mod tests {
}
#[test]
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");
fn one_hives_document_does_not_reach_another_hives_path() {
// Replaces `every_hive_gets_a_byte_identical_document`: the hive stanza
// is per-reader now, so identical text is no longer the property. This
// is what that test was protecting — that a document says only what the
// deploy-time name puts in it.
let a = render("alpha").expect("legal");
assert!(a.contains("swarm/hives/alpha/*"));
assert!(!a.contains("beta"));
assert!(!a.contains("swarm/hives/*"), "the hive stanza stays narrow");
}
#[test]
fn the_same_name_still_renders_byte_identically() {
// The half of the old property that survives: the text is a function of
// the deploy-time name alone, so a re-emission cannot drift.
assert_eq!(
render("pr1ma").expect("legal"),
render("pr1ma").expect("legal")
);
}
#[test]
fn a_name_that_could_close_the_stanza_is_refused() {
// The name reaches the document now, which it did not before — so the
// injection case is live again in the policy TEXT, not just in the
// policy's identifier.
assert!(render("alpha/*\" { capabilities = [\"root\"] }").is_err());
assert!(
hive_object_name("atlas/*\" { capabilities = [\"root\"] }").is_err(),
"the object NAME is still a place a name can do damage"
"the object NAME is a place a name can do damage too"
);
}
@ -102,13 +151,7 @@ mod tests {
// 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 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());
assert!(render("a-b_C9").is_ok());
}
#[test]