Closes #4124. The controller's policy granted only the bootstrap paths -- hive cert-auth roles and hive ACLs. #4113 then made it a secret WRITER, and nothing related the grants to the paths the code writes, so every matrix token provision answered 403. The two halves landed on different issues and neither looked wrong on its own. `secret/data/` is KV v2's ACL prefix and is absent from the path the code passes, so matching `swarm-secret-client`'s spelling literally would have granted nothing. Write-only: the controller mints these and never reads one back, and a read capability would let it recover every agent's credentials rather than only replace them. The gate is the point. Two module-eval arms -- the grant exists and is not a broader wildcard, and its capability list is pinned whole, because an ADDED capability is what a presence check misses -- plus a test in path.rs pinning MOUNT/AGENT_PREFIX and naming the nix file, since renaming either constant is a silent 403 rather than a compile error. setup.md carried two warnings this makes false: that nothing in the tree had ever authenticated to the store, and that no deployment shape mints a leaf whose CN reads swarm-controller. glue-bao-tls.nix has minted one since #3726 item 1.
112 lines
4.1 KiB
Rust
112 lines
4.1 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 store's existing reader already hardcodes the same
|
|
/// one (`secret/swarm/matrix/registration-token`); an option nobody sets would
|
|
/// 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";
|
|
|
|
/// 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_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.
|
|
assert_eq!(MOUNT, "secret");
|
|
assert_eq!(AGENT_PREFIX, "swarm/agents");
|
|
}
|
|
}
|