hyperhive/swarm-secret-client/src/policy.rs
atlas 3752482524 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.
2026-09-09 18:40:41 +02:00

115 lines
4.5 KiB
Rust

//! 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.
//!
//! ⚠️ 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,
path::{AGENT_PREFIX, MOUNT, checked_segment},
};
/// Namespace for a hive's own policy and cert-auth role.
///
/// The controller's own grant is scoped to `hive-*` for both, so this prefix is
/// the difference between a hive the controller may provision and a policy it
/// must not be able to rewrite — including its own.
pub const HIVE_PREFIX: &str = "hive-";
/// The policy and cert-auth role name for `hive`. One name, both objects: the
/// role attaches the policy by spelling it identically.
///
/// # Errors
/// [`Error::PathSegment`] when `hive` holds anything but `[A-Za-z0-9_-]`.
pub fn hive_object_name(hive: &str) -> Result<String, Error> {
checked_segment("hive", hive)?;
Ok(format!("{HIVE_PREFIX}{hive}"))
}
/// Render the document every hive's policy holds: read on every agent's
/// credentials.
///
/// 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.
///
/// 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)]
mod tests {
use super::*;
#[test]
fn the_document_grants_read_over_the_whole_agent_prefix() {
assert_eq!(
render(),
"path \"secret/data/swarm/agents/*\" {\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();
assert!(!p.contains("create"));
assert!(!p.contains("update"));
assert!(!p.contains("delete"));
assert!(!p.contains("list"));
}
#[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");
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
// 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());
}
#[test]
fn the_object_name_sits_inside_the_namespace_the_controller_may_write() {
// `hive-` is what the controller's own policy scopes both
// `sys/policies/acl/` and `auth/cert/certs/` to, so a name outside it
// is one the controller cannot create at all.
let n = hive_object_name("pr1ma").expect("legal");
assert_eq!(n, "hive-pr1ma");
assert!(n.starts_with(HIVE_PREFIX));
}
}