`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
212 lines
8.2 KiB
Rust
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:?}"
|
|
);
|
|
}
|
|
}
|