swarm-secret-client: give the store one namespace instead of one prefix

The crate had a single path convention and it was per-agent:
`swarm/agents/<agent>/matrix/<account>`. The secrets still to move into the
store do not fit it — one belongs to a hive, one to a swarm service, one to
the controller itself — so each would have picked its own shape, and each
would have been a separate grant to get wrong.

mara ruled the scheme on the epic: `swarm/<kind>/<name>/<secret>`, over
`agents`, `hives`, `services` and `controller`. This lands it.

`Kind` is an enum rather than free strings for one reason: the store's grant
is written in nix and cannot be reached from Rust, so a misspelled kind is a
403 at provision time and not a compile error. `Kind::ALL` lets a test
enumerate the set instead of restating it, which is what makes adding a kind
a deliberate edit rather than an accidental grant.

Note `Kind` sits beside `checked_segment`'s existing `kind` argument, which
means something else entirely — the label of the name being validated. They
are not the same concept and should not be merged.

Nothing about the rendered policy changes. `policy::render` still grants read
on the agent kind alone; the other kinds are absent on purpose, because what a
hive may read of its own kind is a boundary question and not a consequence of
the namespace growing. The controller's write grant likewise stays scoped to
`agents/` — it widens when a path outside it gains a writer, not when the
kinds are declared.

Verified: `cargo test -p swarm-secret-client` 23 passed, 0 failed. The two
tests pinning the rendered strings (`the_document_grants_read_over_the_whole_agent_prefix`
and matrix's path assertion) still assert the same literals they did before,
which is what shows this is a faithful port rather than a reshape. `nix fmt`
710 emitted, 10 formatted, 0 changed; the three scripts/check-*.sh lints pass
with the change staged. No reference to the removed `path::AGENT_PREFIX`
survives in the crate or in nix — checked with a scoped pattern, because the
unqualified name also belongs to hive-host-sock's container prefix and greps
for it are answering a different question.
This commit is contained in:
atlas 2026-09-11 20:19:47 +02:00
commit 2979fcf5d5
5 changed files with 135 additions and 19 deletions

View file

@ -15,8 +15,73 @@ use crate::Error;
/// be two ways to say one thing.
pub const MOUNT: &str = "secret";
/// The prefix under [`MOUNT`] owned by per-agent credentials.
pub const AGENT_PREFIX: &str = "swarm/agents";
/// The root under [`MOUNT`] that every swarm secret lives beneath.
pub const ROOT: &str = "swarm";
/// Whose secret it is — the second segment of every path.
///
/// An enum rather than free strings so the set is closed: the store's grant is
/// written against these segments and cannot be reached from Rust, so a
/// misspelled kind is a 403 at provision time rather than anything a compiler
/// sees. [`Kind::ALL`] exists so a test can enumerate the set instead of
/// restating it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
/// One agent container's own secrets.
Agent,
/// One hive's secrets, held on behalf of whatever runs there. An identity
/// minted per hive rather than per agent lands here even though an agent
/// is what uses it.
Hive,
/// One swarm service — the things a swarm runs beside the controller.
Service,
/// The controller itself. There is one per swarm, so the name segment
/// below it does not vary; the shape stays uniform anyway, because one
/// grant pattern over `<kind>/<name>` is cheaper than a special case.
Controller,
}
impl Kind {
/// Every kind, so callers that must cover the whole set can iterate rather
/// than restate it — a second list is a list that drifts.
pub const ALL: [Kind; 4] = [Kind::Agent, Kind::Hive, Kind::Service, Kind::Controller];
/// The path segment, which is also what the store's grant is written
/// against.
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Kind::Agent => "agents",
Kind::Hive => "hives",
Kind::Service => "services",
Kind::Controller => "controller",
}
}
/// What to call the name in an error — singular, because the message reads
/// "hive name ... is not a single path segment".
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Kind::Agent => "agent",
Kind::Hive => "hive",
Kind::Service => "service",
Kind::Controller => "controller",
}
}
}
/// `swarm/<kind>/<name>` — everything one principal owns, and the only way to
/// build the head of a path.
///
/// # Errors
/// [`Error::PathSegment`] when `name` is not a single segment of
/// `[A-Za-z0-9_-]`, which is what keeps one principal's name from addressing
/// another's secrets.
pub fn principal_prefix(kind: Kind, name: &str) -> Result<String, Error> {
checked_segment(kind.label(), name)?;
Ok(format!("{ROOT}/{}/{name}", kind.as_str()))
}
/// A path segment that cannot change the path's shape.
///
@ -100,13 +165,47 @@ mod tests {
}
#[test]
fn the_bao_policy_grants_exactly_these_two_values() {
// Renaming either constant is a silent 403 at provision time, not a
// compile error: the controller's grant spells them out in
// `nix/host-modules/swarm-bao.nix` (`controllerPolicyText`), which no
// Rust change can reach. Editing here means editing there, and
// `nix/module-eval.nix` asserts the other side of the same pair.
fn the_bao_policy_is_written_against_exactly_these_segments() {
// Renaming any of these is a silent 403 at provision time, not a
// compile error: the controller's grant spells the mount and root out
// in `nix/host-modules/swarm-bao.nix` (`controllerPolicyText`), which
// no Rust change can reach. Editing here means editing there, and
// `nix/module-eval.nix` asserts the other side.
assert_eq!(MOUNT, "secret");
assert_eq!(AGENT_PREFIX, "swarm/agents");
assert_eq!(ROOT, "swarm");
// Iterated, not listed: the grant covers `<root>/*`, so a kind added
// without a segment here would be granted by accident rather than by
// decision. Spelling each one out is what makes adding a kind a
// deliberate edit.
let segments: Vec<&str> = Kind::ALL.iter().map(|k| k.as_str()).collect();
assert_eq!(segments, ["agents", "hives", "services", "controller"]);
}
#[test]
fn a_kind_cannot_share_a_segment_or_a_label_with_another() {
// Two kinds resolving to one segment would silently merge two
// principals' secrets into one directory; two sharing a label would
// make the error name the wrong one.
for (i, a) in Kind::ALL.iter().enumerate() {
for b in &Kind::ALL[i + 1..] {
assert_ne!(a.as_str(), b.as_str(), "{a:?} and {b:?} share a segment");
assert_ne!(a.label(), b.label(), "{a:?} and {b:?} share a label");
}
}
}
#[test]
fn a_principal_prefix_refuses_a_name_that_would_escape_it() {
// The control for the arm below: a well-formed name really does build.
assert_eq!(
principal_prefix(Kind::Hive, "alpha").expect("a plain name is legal"),
"swarm/hives/alpha"
);
let e = principal_prefix(Kind::Hive, "../atlas").expect_err("a traversal is not");
assert!(
matches!(e, Error::PathSegment { kind, .. } if kind == "hive"),
"the error must name the principal in the singular, got {e:?}"
);
}
}