hyperhive/swarm-secret-client/src/path.rs
atlas 14305255f0 swarm: put the matrix registration token where the reader is granted
`swarm-bao-matrix-token` reads `secret/swarm/matrix/registration-token`
and is refused with `Code: 403 — permission denied`, measured on this
host at 16:17:49Z after a successful cert login.

path.rs makes every swarm path `swarm/<kind>/<name>/…` where Kind is a
closed set of four: agents, hives, services, controller. `matrix` sits
where a kind belongs, so policy.rs's read document — which emits exactly
`swarm/agents/*` and `swarm/hives/<hive>/*` — cannot cover it. The
module's own doc predicted this: "a misspelled kind is a 403 at provision
time rather than anything a compiler sees".

Moves the token to `swarm/hives/<hive>/matrix/registration-token`, built
through principal_prefix(Kind::Hive, …) like its per-hive sibling
queue::agent_client_path. The policy is untouched: render already grants
that prefix. mara chose this over widening the namespace.

The nix reader interpolates hyperhiveCfg.hiveName, with the no-fallback
reasoning glue-bao-tls.nix already gives at its own use of it.

path.rs's MOUNT doc justified itself by citing the old literal, which
this commit deletes; rewritten to cite the nix reader instead.

Scope: this makes the read reachable, not the value present. Nothing
writes that path yet, and a 403 says nothing about presence — the two
are separate findings and only the first is fixed here. No migration:
nothing ever wrote the old path and no read ever succeeded.

Gate: cargo fmt 0, cargo test -p swarm-secret-client 0 — 32 passed
against a 29-passed baseline with the change stashed, so the three new
tests are accounted for rather than assumed.

Refs #4308
2026-09-12 19:46:04 +02:00

212 lines
8.2 KiB
Rust

//! The rules every path into the store obeys, whatever kind of secret it
//! addresses.
//!
//! The controller writes and a hive reads, and neither is senior to the other,
//! so what they must agree on is stated once here rather than at each call
//! site. A *particular* kind of secret builds its path from these pieces in its
//! own module — [`crate::matrix`] is the one that exists.
use crate::Error;
/// The KV v2 mount every swarm secret lives under.
///
/// A literal because the nix-side reader spells the same one in
/// `glue-matrix-bao-token.nix`; an option nobody sets would be two ways to say
/// one thing.
pub const MOUNT: &str = "secret";
/// 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.
///
/// The segment string lives once, on the variant itself
/// (`#[strum(serialize = "...")]`), rather than a second time in a
/// hand-written `as_str()` match — [`strum::IntoStaticStr`] derives that
/// conversion, so the attribute is the only place a segment is spelled.
#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::IntoStaticStr)]
pub enum Kind {
/// One agent container's own secrets.
#[strum(serialize = "agents")]
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.
#[strum(serialize = "hives")]
Hive,
/// One swarm service — the things a swarm runs beside the controller.
#[strum(serialize = "services")]
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.
#[strum(serialize = "controller")]
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];
/// 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}", <&str>::from(kind)))
}
/// A path segment that cannot change the path's shape.
///
/// The charset is deliberately narrower than what the store accepts: a `/`
/// turns one agent's segment into another agent's directory, and `..` walks
/// out of the prefix entirely. Both are names this crate receives from
/// elsewhere — an agent name from the topology, an account name from an
/// agent's own config — so neither is trusted to be well-formed here.
///
/// Public because the same name is also used to build a path **on disk**, and
/// that guard must accept exactly what this one does. Two copies of a charset
/// are two charsets: they agree until one is edited, and the day they diverge
/// is the day a name is legal in the store and not on the filesystem, or the
/// reverse.
///
/// # Errors
/// [`Error::PathSegment`] when `value` is empty or holds anything outside
/// `[A-Za-z0-9_-]`.
pub fn checked_segment(kind: &'static str, value: &str) -> Result<(), Error> {
if value.is_empty() {
return Err(Error::PathSegment {
kind,
value: value.to_owned(),
});
}
if !value
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
{
return Err(Error::PathSegment {
kind,
value: value.to_owned(),
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_segment_that_could_change_a_paths_shape_is_refused() {
// Each of these is a *different* way to reach outside the segment the
// caller meant, and the last two are the ones a charset check catches
// but a `contains("..")` check does not.
for bad in [
"../argus",
"atlas/../argus",
"atlas/matrix",
"a b",
"a.b",
"",
] {
assert!(
checked_segment("agent", bad).is_err(),
"segment {bad:?} must be refused"
);
}
}
#[test]
fn the_legal_charset_is_actually_reachable() {
// The control for the test above: if `checked_segment` rejected
// everything, the escape cases would pass for the wrong reason. The
// charset is the one `hive-priv` accepts for the same names on disk,
// so uppercase and underscore have to stay legal here.
assert!(checked_segment("agent", "a-b_C9").is_ok());
assert!(checked_segment("account", "d-e_F0").is_ok());
}
#[test]
fn the_rejected_segment_is_named_in_the_error() {
// The caller usually got the name from config and needs to see which
// of the two it was.
let e = checked_segment("account", "a b").expect_err("a space is not legal");
assert!(
matches!(e, Error::PathSegment { kind, ref value } if kind == "account" && value == "a b"),
"got {e:?}"
);
}
#[test]
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!(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).into()).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!(
<&str>::from(*a),
<&str>::from(*b),
"{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:?}"
);
}
}