swarm-secret-client: render a hive's read policy, and refuse names that inject

The controller writes an agent's credential; the hive reads it back with its
own token. Nothing says which paths that token may read, so the read half of
a delivery has no identity at all and answers 403.

This is the pure half of the fix: the policy text is a function of a hive name
and its agent set, so the shape can be asserted with no store to talk to.

One stanza per hosted agent rather than a prefix grant. An agent's credential
path does not name the hive hosting it -- deliberately, since agents migrate --
so "this hive's agents" has no prefix expression and must be enumerated. The
grant is read-only: the controller mints these and never reads one back.

The paths come from MOUNT and AGENT_PREFIX rather than literals, so the policy
cannot drift from the module that builds the paths it grants.

checked_segment runs before any name reaches HCL. That is policy injection
rather than path traversal -- a name can close a stanza and open a wider one --
so the test carries a real injection string, paired with the reachable-charset
control that stops it passing by refusing everything.

An empty agent set renders an empty policy, which grants nothing.
This commit is contained in:
atlas 2026-09-09 02:12:58 +02:00 committed by mara
commit 7792b62609
2 changed files with 152 additions and 0 deletions

View file

@ -8,6 +8,11 @@
//! ([`matrix`]). Each of those is a thing the controller and a hive must say
//! identically, so it is said once here.
//!
//! [`policy`] is the same kind of agreement seen from the other side: which of
//! those paths a hive's own token may read. It belongs here rather than in the
//! controller because the grant and the path are one statement — spelled
//! differently they produce a 403 that names neither.
//!
//! [`client`] is deliberately ignorant of all of it: it moves whatever type a
//! caller names, so a second kind of secret is a new module beside [`matrix`]
//! and not another field on a struct shared with it.
@ -15,6 +20,7 @@
pub mod client;
pub mod matrix;
pub mod path;
pub mod policy;
pub use client::SecretStore;

View file

@ -0,0 +1,146 @@
//! 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.
//!
//! 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 _;
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 policy granting `hive` read on exactly the agents it hosts.
///
/// 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.
///
/// 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)
}
#[cfg(test)]
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");
assert_eq!(
p,
"path \"secret/data/swarm/agents/atlas/*\" {\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");
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"
);
}
#[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"
);
}
}
#[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());
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"
);
}
#[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));
}
}