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
This commit is contained in:
atlas 2026-09-12 19:46:04 +02:00
commit 14305255f0
4 changed files with 70 additions and 4 deletions

View file

@ -42,7 +42,17 @@ let
# Where the token lives in the store. A path, not a convention to guess at: # Where the token lives in the store. A path, not a convention to guess at:
# whoever writes it and whoever reads it must agree, and the agreement # whoever writes it and whoever reads it must agree, and the agreement
# belongs in one visible place. # belongs in one visible place.
tokenPath = "secret/swarm/matrix/registration-token"; #
# ⚠️ The `hives/<name>` segment is not decoration — it is what the reader's
# own grant covers. The store's read policy grants `swarm/agents/*` and
# `swarm/hives/<this hive>/*` and nothing else, so a path outside those is a
# 403 rather than a miss, however correct it looks. `swarm-secret-client`'s
# `matrix::registration_token_path` builds the same string from the same
# pieces; this literal is the nix half of that one agreement.
#
# `hiveName` has no fallback here for the reason ./glue-bao-tls.nix gives at
# its own use of it: it is asserted set for every hyperhive host.
tokenPath = "secret/swarm/hives/${hyperhiveCfg.hiveName}/matrix/registration-token";
# A literal, not an option — ./hive-matrix.nix names its container # A literal, not an option — ./hive-matrix.nix names its container
# `containers.hive-matrix` directly and declares no `machine` to derive it # `containers.hive-matrix` directly and declares no `machine` to derive it

View file

@ -1148,6 +1148,22 @@ let
&& lib.hasInfix "exit 1" loginBranch && lib.hasInfix "exit 1" loginBranch
&& lib.hasInfix "exit 0" (lib.last (lib.splitString "bao kv get" u.script)); && lib.hasInfix "exit 0" (lib.last (lib.splitString "bao kv get" u.script));
} }
{
# The reader's own grant covers `swarm/hives/<this hive>/*` and
# `swarm/agents/*`; a path outside those answers 403, not "no such key".
# So the hive segment is what makes the read reachable, and a rename that
# drops it looks correct and fails identically on every boot.
name = "the matrix token path sits inside the prefix the reader is granted";
ok =
let
s = baoWithMatrix.systemd.services.swarm-bao-matrix-token.script;
in
lib.hasInfix "secret/swarm/hives/" s
&& lib.hasInfix "/matrix/registration-token" s
# The shape it used to have: `matrix` where a principal kind belongs,
# which no grant covers.
&& !(lib.hasInfix "secret/swarm/matrix/" s);
}
{ {
# The doctrine three glue files state, as a property a rewrite has to # The doctrine three glue files state, as a property a rewrite has to
# keep: a client is defined by holding a certificate the store accepts, # keep: a client is defined by holding a certificate the store accepts,

View file

@ -26,6 +26,20 @@ pub fn account_path(agent: &str, account: &str) -> Result<String, Error> {
Ok(format!("{prefix}/matrix/{account}")) Ok(format!("{prefix}/matrix/{account}"))
} }
/// The path holding `hive`'s matrix registration token.
///
/// Keyed per **hive**, not per agent, like [`crate::queue::agent_client_path`]
/// and unlike [`account_path`] above: one homeserver admits one hive's
/// accounts, so the token that creates them is the hive's.
///
/// # Errors
/// [`Error::PathSegment`] when `hive` contains anything but `[A-Za-z0-9_-]`,
/// which is what keeps one hive's name from addressing another hive's secret.
pub fn registration_token_path(hive: &str) -> Result<String, Error> {
let prefix = principal_prefix(Kind::Hive, hive)?;
Ok(format!("{prefix}/matrix/registration-token"))
}
/// What an account's path holds: the token, plus the homeserver it belongs to. /// What an account's path holds: the token, plus the homeserver it belongs to.
/// ///
/// The homeserver rides with the token rather than on the queue notice that /// The homeserver rides with the token rather than on the queue notice that
@ -65,6 +79,32 @@ mod tests {
assert_eq!(p, "swarm/agents/atlas/matrix/ops-relay"); assert_eq!(p, "swarm/agents/atlas/matrix/ops-relay");
} }
#[test]
fn the_registration_token_lands_under_the_hive_prefix_the_grant_covers() {
// Spelled out for the same reason as above, and with a second job here:
// the read policy grants `secret/data/swarm/hives/<hive>/*`, so this
// string is what makes the path reachable at all.
assert_eq!(
registration_token_path("pr1ma").expect("a plain name is legal"),
"swarm/hives/pr1ma/matrix/registration-token"
);
}
#[test]
fn the_registration_token_is_not_a_top_level_namespace() {
// The path it used to hold. `Kind` is a closed set and `matrix` is not
// one of its members, so a path with `matrix` as the second segment is
// outside every grant — which is how it came to 403 on every read.
let p = registration_token_path("pr1ma").expect("legal");
assert!(!p.starts_with("swarm/matrix/"), "{p}");
}
#[test]
fn a_traversal_in_the_hive_name_is_refused() {
let e = registration_token_path("../beta").expect_err("a traversal is not");
assert!(matches!(e, Error::PathSegment { kind: "hive", .. }), "{e}");
}
#[test] #[test]
fn a_segment_cannot_escape_its_own_directory() { fn a_segment_cannot_escape_its_own_directory() {
// Each of these is a *different* way to address another agent's tree, // Each of these is a *different* way to address another agent's tree,

View file

@ -10,9 +10,9 @@ use crate::Error;
/// The KV v2 mount every swarm secret lives under. /// The KV v2 mount every swarm secret lives under.
/// ///
/// A literal because the store's existing reader already hardcodes the same /// A literal because the nix-side reader spells the same one in
/// one (`secret/swarm/matrix/registration-token`); an option nobody sets would /// `glue-matrix-bao-token.nix`; an option nobody sets would be two ways to say
/// be two ways to say one thing. /// one thing.
pub const MOUNT: &str = "secret"; pub const MOUNT: &str = "secret";
/// The root under [`MOUNT`] that every swarm secret lives beneath. /// The root under [`MOUNT`] that every swarm secret lives beneath.