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:
parent
47d53f5c23
commit
2979fcf5d5
5 changed files with 135 additions and 19 deletions
|
|
@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue