Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-secret-client/src/policy.rs
atlas 2505008b83 swarm-secret-client: hives no longer read swarm/services/*
The hive policy rendered by policy::render granted every hive cert read
on secret/data/swarm/services/*, which holds each swarm service's OIDC
client secret. No reader of that tree authenticates with a hive cert:
grafana, the otel collector and the store forwarder each read with
their own leaf, and the bao UI's secret is read by the store's granter.

render() now emits two stanzas: swarm/agents/* and the hive's own
swarm/hives/<hive>/*. Kind::Service had no other caller and is removed.
Docs and comments that described the grant as a decision are rewritten
to the current state.

Closes #4960
2026-10-04 23:11:11 +02:00

552 lines
24 KiB
Rust

//! The read agreement: which credentials a principal's own token may fetch.
//!
//! The mirror of [`crate::matrix`] and [`crate::queue`]. Those modules say
//! where a credential lives; this one says who is allowed to read it, and the
//! two have to agree on the same path or a delivery fails with a 403 that names
//! nothing.
//!
//! The document has one stanza per kind a hive reads, and they are not all
//! scoped alike — which is the point rather than an inconsistency:
//!
//! ⚠️ The **agent** stanza grants read on *every* agent's credentials rather
//! than on the ones that hive hosts. That is a decision, not an oversight: an
//! agent's path does not name its hive, so a per-hive grant has to be
//! enumerated and re-emitted, and an enumeration that can silently drift
//! advertises a boundary it does not hold. A wide grant that says so beats a
//! narrow one that only looks narrow. The narrower shapes, and what they would
//! cost, are in `docs/trust-boundary/security.md`.
//!
//! The **hive** stanza has no such problem and is therefore narrow: that path
//! names its principal, so scoping it to the reader's own name costs nothing
//! and drifts nowhere. Do not widen it to match the agent stanza — the
//! asymmetry is the point, and only holds if a one-per-hive credential is
//! stored under [`Kind::Hive`] — see docs/trust-boundary/security.md.
//!
//! A hive reads nothing under `swarm/services/` or `swarm/controller/`. A swarm
//! service's OIDC client secret is read under a certificate that is not a
//! hive's: `swarm-bao.nix`'s per-service readers, and its granter for the UI's.
//!
//! Rendering stays separate from writing so the text can be asserted with no store to talk to.
use crate::{
Error,
path::{Kind, MOUNT, ROOT, checked_segment},
};
/// Namespace for a hive's own policy and cert-auth role.
///
/// The controller's own grant is scoped to `hive-*` for both, so this prefix is
/// the difference between a hive the controller may provision and a policy it
/// must not be able to rewrite — including its own.
pub const HIVE_PREFIX: &str = "hive-";
/// The policy and cert-auth role name for `hive`. One name, both objects: the
/// role attaches the policy by spelling it identically.
///
/// # Errors
/// [`Error::PathSegment`] when `hive` holds anything but `[A-Za-z0-9_-]`.
pub fn hive_object_name(hive: &str) -> Result<String, Error> {
checked_segment("hive", hive)?;
Ok(format!("{HIVE_PREFIX}{hive}"))
}
/// Namespace for an agent's own policy and cert-auth role.
///
/// Deliberately *inside* [`HIVE_PREFIX`] rather than beside it.
/// `sys/policies/acl/hive-*` and `auth/cert/certs/hive-*` are the whole of what
/// the controller's grant lets it create (`swarm-bao.nix`'s
/// `controllerPolicyText`), and the controller is the only principal that
/// learns an agent exists — a name outside that prefix is one nothing can
/// write. It costs no authority: the controller already holds `create`/`update`
/// on `secret/data/swarm/agents/*`, so it can already replace every credential
/// this policy grants read on.
///
/// It still cannot collide with a hive's. [`hive_object_name`] renders
/// `hive-<hive>`, so a collision needs a hive named `agent-<agent>` — and
/// `nix/reserved-hive-fragments.nix` forbids the substring `agent` in any hive
/// name (and `hive` too), applied to every entry of the swarm directory in
/// `swarm.nix`. That guard was written for the queue's `hive-<name>-agent`
/// client ids; this is a second identifier family leaning on it, which is why
/// the fragment list is what to read before renaming either.
///
/// Host principals' **common names** are operator-set options
/// (`deploy.bao.controllerCommonName`, `secretPublisherCommonName`, …) that
/// may spell this prefix, and that is harmless: an agent's cert-auth role pins
/// the store's agent CA (`deploy.bao.agentPkiMountPath`), which signs no host
/// leaf, and every host role pins `deploy.bao.clientCaFile`, which signs no
/// agent leaf. A CN match alone logs nobody in.
pub const AGENT_PREFIX: &str = "hive-agent-";
/// The policy and cert-auth role name for `agent`, and the common name of the
/// certificate that authenticates it. One string, three objects: the role
/// attaches the policy and matches the subject by spelling all three the same.
///
/// Injective in `agent` — the prefix is fixed and [`checked_segment`] has
/// already refused anything that could re-punctuate the suffix. Agent names
/// are one swarm-wide namespace (an agent's path is `swarm/agents/<agent>`,
/// with no hive segment), so two agents sharing a name would share every
/// object this names. swarm-controller's `create_agent`, the one place an
/// agent is created, refuses a name the swarm has already placed on another
/// hive. An agent no hive's wanted state declares is invisible to that check.
///
/// # Errors
/// [`Error::PathSegment`] when `agent` holds anything but `[A-Za-z0-9_-]`.
pub fn agent_object_name(agent: &str) -> Result<String, Error> {
checked_segment("agent", agent)?;
Ok(format!("{AGENT_PREFIX}{agent}"))
}
/// The agents among `names`, a listing of cert-auth roles: the inverse of
/// [`agent_object_name`], for a caller that needs the set of agents holding a
/// store identity.
///
/// A name that carries the prefix but whose suffix is not a legal segment is
/// dropped rather than returned: [`agent_object_name`] could not have produced
/// it, so it is not an agent this crate minted.
#[must_use]
pub fn agents_from_role_names(names: &[String]) -> Vec<String> {
names
.iter()
.filter_map(|name| name.strip_prefix(AGENT_PREFIX))
.filter(|agent| checked_segment("agent", agent).is_ok())
.map(str::to_owned)
.collect()
}
/// One read stanza. With [`list_stanza`], the only shapes this module emits, so
/// "writes nothing" is a property of the renderers rather than of each call
/// site.
fn read_stanza(path: &str) -> String {
format!("path \"{path}\" {{\n capabilities = [\"read\"]\n}}\n")
}
/// One list stanza: the key names under a `metadata/` path. Neither a value
/// nor the metadata itself, which take `read` on `data/` and on `metadata/`.
fn list_stanza(path: &str) -> String {
format!("path \"{path}\" {{\n capabilities = [\"list\"]\n}}\n")
}
/// Render `hive`'s policy document: read on every agent's credentials and on
/// this hive's own.
///
/// The name is the only input, and it is deploy-time — so the document is
/// still a deploy-time object rather than derived state with a re-emission to
/// get wrong. Nothing about which agents exist changes the text.
///
/// Read-only: the controller mints these and never reads one back.
///
/// ⚠️ The controller kind and the `swarm/services/` tree are deliberately
/// absent: a hive has no business reading the credentials of the thing that
/// provisions it, and a swarm service's OIDC client secret is read under a
/// certificate that is not a hive's. Adding either is a boundary decision,
/// not a consequence of the namespace growing.
///
/// The hive's *own* kind is granted, and that is the decision the agent-only
/// version of this grant said had to be made rather than assumed: a hive holds
/// the queue credential its own agents authenticate with, so it has to read the
/// one principal named after itself — and only that one, which is why the path
/// interpolates the name instead of widening to the whole kind.
///
/// # Errors
/// [`Error::PathSegment`] when `hive` holds anything but `[A-Za-z0-9_-]` — the
/// name is interpolated into a policy path, so a name that could close the
/// stanza could grant itself anything.
pub fn render(hive: &str) -> Result<String, Error> {
checked_segment("hive", hive)?;
let agents = read_stanza(&format!(
"{MOUNT}/data/{ROOT}/{}/*",
<&str>::from(Kind::Agent)
));
let own = read_stanza(&format!(
"{MOUNT}/data/{ROOT}/{}/{hive}/*",
<&str>::from(Kind::Hive)
));
Ok(format!("{agents}{own}"))
}
/// Render `agent`'s policy document: read on that one agent's credentials, list
/// on their names (how `hive-matrix-daemon` finds its linked accounts), and
/// nothing else at all.
///
/// Two stanzas over one subtree, and the single interpolated name is the whole
/// document: an agent authenticating with its own certificate gets a token that
/// fetches `swarm/agents/<agent>/…`, lists the keys under it, and is refused
/// every other path in the store. That is the point — the reason to give an
/// agent an identity is that it then depends on its hive for one file (the
/// certificate) rather than for every credential it uses.
///
/// ⚠️ **None of [`render`]'s breadth is inherited.** A hive's document grants
/// read on `swarm/agents/*` — *every* agent's credentials, not the ones that
/// hive hosts; the module header says why that is a decision rather than an
/// oversight. The reason does not transfer: an agent's path names the agent,
/// so scoping costs nothing and drifts nowhere (the argument that keeps
/// [`render`]'s hive stanza narrow), and an agent has no business with another
/// agent's credentials, a hive's, a service's, or the controller's. Narrow
/// here does **not** narrow the hive's: a hive still reads this agent's
/// secrets, and closing that is its own decision.
/// Nothing here writes, for the reason the hive's does not: an agent that could
/// write its own credentials could hand itself an identity it was never issued.
///
/// ⚠️ Not deploy-time: hive-c0re creates agents at runtime (`hive_c0re::meta`).
/// swarm-controller rewrites every agent's document at its own start, so an
/// edit here reaches existing agents with the next controller restart.
///
/// # Errors
/// [`Error::PathSegment`] when `agent` holds anything but `[A-Za-z0-9_-]`: the
/// name is interpolated into the policy text, where it could close the stanza.
pub fn render_agent(agent: &str) -> Result<String, Error> {
checked_segment("agent", agent)?;
let kind = <&str>::from(Kind::Agent);
let read = read_stanza(&format!("{MOUNT}/data/{ROOT}/{kind}/{agent}/*"));
let list = list_stanza(&format!("{MOUNT}/metadata/{ROOT}/{kind}/{agent}/*"));
Ok(format!("{read}{list}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_document_grants_every_agent_and_this_hive_alone() {
assert_eq!(
render("pr1ma").expect("a plain name is legal"),
"path \"secret/data/swarm/agents/*\" {\n capabilities = [\"read\"]\n}\n\
path \"secret/data/swarm/hives/pr1ma/*\" {\n capabilities = [\"read\"]\n}\n"
);
}
#[test]
fn a_hive_reaches_no_swarm_services_secret() {
// A swarm service's OIDC client secret is read under a certificate that
// is not a hive's. Checked by walking the stanzas, so a wider grant
// that happened to cover the tree (`swarm/*`, say) fails too.
let p = render("pr1ma").expect("legal");
assert!(!p.contains("swarm/services"), "{p}");
let service = "swarm/services/swarm-grafana/oidc/client";
assert!(
!stanza_paths(&p).iter().any(|g| covers(g, service)),
"a hive's document reaches {service}:\n{p}"
);
// The control: the walk does find a path the document grants.
assert!(
stanza_paths(&p)
.iter()
.any(|g| covers(g, "swarm/hives/pr1ma/queue/agent"))
);
}
/// 🩸 The per-hive matrix sender-token scoping, asserted on the rendered
/// document rather than on the path function alone: it is the policy text
/// that decides what a hive may fetch, so "the path is per-hive" only
/// means something if the stanza that reaches it is too.
#[test]
fn a_hive_reaches_its_own_matrix_sender_token_and_no_other_hives() {
let alpha = render("alpha").expect("legal");
let own = crate::matrix::sender_token_path("alpha").expect("legal");
let other = crate::matrix::sender_token_path("beta").expect("legal");
// Reachable: the hive's own stanza is the prefix of its own path.
assert!(
alpha.contains("path \"secret/data/swarm/hives/alpha/*\""),
"{alpha}"
);
assert!(own.starts_with("swarm/hives/alpha/"), "{own}");
// Unreachable: no stanza in alpha's document is a prefix of beta's
// path. Checked by walking the stanzas rather than by asserting the
// absence of the string "beta", so a future stanza that happened to
// cover it — `swarm/hives/*`, say — would fail this too.
assert!(
!stanza_paths(&alpha)
.iter()
.any(|granted| covers(granted, &other)),
"alpha's document reaches {other}:\n{alpha}"
);
}
/// Every `path "…"` a rendered document grants, with the `secret/data/`
/// ACL prefix stripped so it compares against a store path.
fn stanza_paths(document: &str) -> Vec<String> {
document
.lines()
.filter_map(|line| line.strip_prefix("path \""))
.filter_map(|rest| rest.split('"').next())
.filter_map(|p| p.strip_prefix("secret/data/"))
.map(str::to_owned)
.collect()
}
/// Does a granted policy path cover `path`? Only the trailing-`*` form
/// this module renders, which is the only form it has to understand.
fn covers(granted: &str, path: &str) -> bool {
match granted.strip_suffix('*') {
Some(prefix) => path.starts_with(prefix),
None => granted == path,
}
}
#[test]
fn the_grant_is_read_only() {
// A hive reads credentials; a hive that could write one could hand
// itself an agent's identity.
let p = render("pr1ma").expect("legal");
assert!(!p.contains("create"));
assert!(!p.contains("update"));
assert!(!p.contains("delete"));
assert!(!p.contains("list"));
}
#[test]
fn one_hives_document_does_not_reach_another_hives_path() {
// Replaces `every_hive_gets_a_byte_identical_document`: the hive stanza
// is per-reader now, so identical text is no longer the property. This
// is what that test was protecting — that a document says only what the
// deploy-time name puts in it.
let a = render("alpha").expect("legal");
assert!(a.contains("swarm/hives/alpha/*"));
assert!(!a.contains("beta"));
assert!(!a.contains("swarm/hives/*"), "the hive stanza stays narrow");
}
#[test]
fn the_same_name_still_renders_byte_identically() {
// The half of the old property that survives: the text is a function of
// the deploy-time name alone, so a re-emission cannot drift.
assert_eq!(
render("pr1ma").expect("legal"),
render("pr1ma").expect("legal")
);
}
#[test]
fn a_name_that_could_close_the_stanza_is_refused() {
// The name reaches the document now, which it did not before — so the
// injection case is live again in the policy TEXT, not just in the
// policy's identifier.
assert!(render("alpha/*\" { capabilities = [\"root\"] }").is_err());
assert!(
hive_object_name("atlas/*\" { capabilities = [\"root\"] }").is_err(),
"the object NAME is a place a name can do damage too"
);
}
#[test]
fn the_legal_charset_is_actually_reachable() {
// The control for the case above: if every name were refused, that
// assertion would pass while proving nothing.
assert!(hive_object_name("a-b_C9").is_ok());
assert!(render("a-b_C9").is_ok());
}
#[test]
fn the_object_name_sits_inside_the_namespace_the_controller_may_write() {
// `hive-` is what the controller's own policy scopes both
// `sys/policies/acl/` and `auth/cert/certs/` to, so a name outside it
// is one the controller cannot create at all.
let n = hive_object_name("pr1ma").expect("legal");
assert_eq!(n, "hive-pr1ma");
assert!(n.starts_with(HIVE_PREFIX));
}
/// What `render_agent("atlas")` must render, byte for byte.
const ATLAS_DOCUMENT: &str = "path \"secret/data/swarm/agents/atlas/*\" {\n capabilities = [\"read\"]\n}\n\
path \"secret/metadata/swarm/agents/atlas/*\" {\n capabilities = [\"list\"]\n}\n";
#[test]
fn an_agents_document_is_two_stanzas_naming_that_agent() {
assert_eq!(
render_agent("atlas").expect("a plain name is legal"),
ATLAS_DOCUMENT
);
}
#[test]
fn an_agents_document_does_not_grant_the_whole_agent_prefix() {
// `render`'s agent stanza is `agents/*` on purpose, and inheriting one
// character of that here would give every agent every other agent's
// credentials while the document still read as per-agent.
let p = render_agent("atlas").expect("legal");
assert!(
!p.contains("swarm/agents/*"),
"the agent stanza must not widen to the kind: {p}"
);
assert!(p.contains("secret/data/swarm/agents/atlas/*"));
assert!(p.contains("secret/metadata/swarm/agents/atlas/*"));
}
#[test]
fn an_agents_document_reaches_nothing_but_that_agent() {
// Stated as an exhaustive check over the kinds rather than as a list of
// paths, so a kind added to `path::Kind` cannot be granted here by a
// renderer nobody re-read.
let p = render_agent("atlas").expect("legal");
for kind in Kind::ALL {
let segment = <&str>::from(kind);
let expected = kind == Kind::Agent;
assert_eq!(
p.contains(&format!("swarm/{segment}/")),
expected,
"kind {kind:?} in an agent's document: {p}"
);
}
assert!(!p.contains("argus"), "no other principal is named");
assert_eq!(
p.matches("path \"").count(),
2,
"two stanzas, or the document grants something unaccounted for: {p}"
);
}
#[test]
fn an_agents_document_is_exactly_its_own_two_stanzas() {
// Pinned byte for byte: an added stanza (a hive's queue credential, a
// second agent) is exactly what a presence check misses.
assert_eq!(render_agent("atlas").expect("legal"), ATLAS_DOCUMENT);
// The control: the pin discriminates between agents.
assert!(
!render_agent("other")
.expect("legal")
.contains("agents/atlas/")
);
}
#[test]
fn an_agents_grant_writes_nothing() {
// An agent that could write its own credentials could hand itself an
// identity it was never issued — and `create`/`update` on that path is
// exactly what the controller holds, so the wall is the capability.
let p = render_agent("atlas").expect("legal");
for capability in ["create", "update", "delete", "sudo", "patch"] {
assert!(
!p.contains(capability),
"an agent's document must not grant {capability}: {p}"
);
}
}
#[test]
fn an_agent_reads_values_and_lists_names_and_nothing_crosswise() {
// `read` on `data/` is the values; `list` on `metadata/` is the key
// names. `read` on `metadata/` (version history, deletion times) is not
// granted, nor is `list` anywhere but `metadata/`.
let p = render_agent("atlas").expect("legal");
let stanzas: Vec<&str> = p.split_inclusive("}\n").collect();
assert_eq!(stanzas.len(), 2, "{p}");
for stanza in stanzas {
let on_data = stanza.starts_with("path \"secret/data/");
let on_metadata = stanza.starts_with("path \"secret/metadata/");
assert!(on_data != on_metadata, "{stanza}");
assert_eq!(
stanza.contains("capabilities = [\"read\"]"),
on_data,
"{stanza}"
);
assert_eq!(
stanza.contains("capabilities = [\"list\"]"),
on_metadata,
"{stanza}"
);
}
}
#[test]
fn one_agents_document_does_not_reach_another_agents_path() {
let a = render_agent("atlas").expect("legal");
assert!(a.contains("swarm/agents/atlas/*"));
assert!(!a.contains("argus"));
assert_eq!(a, render_agent("atlas").expect("legal"));
}
#[test]
fn an_agent_name_that_could_close_the_stanza_is_refused() {
// Same live injection surface as the hive renderer's: the name reaches
// the document text, not just an identifier.
assert!(render_agent("atlas/*\" { capabilities = [\"root\"] }").is_err());
assert!(render_agent("").is_err());
assert!(agent_object_name("atlas/*\" { capabilities = [\"root\"] }").is_err());
// The control: if everything were refused the arms above would pass for
// the wrong reason.
assert!(render_agent("a-b_C9").is_ok());
assert!(agent_object_name("a-b_C9").is_ok());
}
#[test]
fn an_agents_object_name_sits_where_the_controller_can_write_it() {
// `hive-*` is the whole of what `controllerPolicyText` lets the
// controller create under `sys/policies/acl/` and `auth/cert/certs/`,
// and the controller is the only principal that learns an agent exists.
let n = agent_object_name("atlas").expect("legal");
assert_eq!(n, "hive-agent-atlas");
assert!(n.starts_with(HIVE_PREFIX));
assert!(n.starts_with(AGENT_PREFIX));
}
#[test]
fn an_agents_object_name_cannot_be_spelled_by_a_legal_hive_name() {
// The collision argument, as an assertion rather than as prose: the
// only hive name that renders an agent's object name is one carrying
// the substring `agent`, which `nix/reserved-hive-fragments.nix`
// forbids — so the guard this leans on is named in a test that fails if
// the prefix is ever changed to something that guard does not cover.
let agent = agent_object_name("atlas").expect("legal");
let colliding_hive = agent
.strip_prefix(HIVE_PREFIX)
.expect("an agent's name is inside the hive namespace");
assert_eq!(colliding_hive, "agent-atlas");
assert_eq!(
hive_object_name(colliding_hive).expect("legal as a path segment"),
agent,
"this is the hive name a collision would need"
);
assert!(
colliding_hive.contains("agent"),
"and it is unrepresentable only because a hive name may not contain `agent`"
);
}
#[test]
fn an_agents_document_is_not_the_hives() {
// The pin the two renderers need against each other: a later edit that
// made `render_agent` delegate to `render`, or vice versa, would hand
// an agent the hive's `agents/*` stanza.
let agent = render_agent("atlas").expect("legal");
let hive = render("atlas").expect("legal");
assert_ne!(agent, hive);
assert!(!agent.contains("swarm/services/"));
assert!(!agent.contains("swarm/hives/"));
// And the hive's is untouched by this module's growth: still the two
// stanzas `the_document_grants_every_agent_and_this_hive_alone` pins
// byte for byte, neither narrowed to make the agent's look consistent
// with it.
assert!(hive.contains("path \"secret/data/swarm/agents/*\""));
}
#[test]
fn only_agent_roles_come_back_and_without_their_prefix() {
let roles: Vec<String> = [
"hive-agent-atlas",
"hive-pr1ma",
"swarm-controller",
"hive-agent-argus",
]
.map(str::to_owned)
.to_vec();
assert_eq!(agents_from_role_names(&roles), ["atlas", "argus"]);
}
#[test]
fn a_role_agent_object_name_could_not_have_produced_is_dropped() {
// An empty suffix and one carrying a dot are both names the prefix
// matches and no agent can have.
let roles: Vec<String> = ["hive-agent-", "hive-agent-a.b", "hive-agent-ok"]
.map(str::to_owned)
.to_vec();
assert_eq!(agents_from_role_names(&roles), ["ok"]);
}
#[test]
fn the_listing_inverts_agent_object_name() {
let name = agent_object_name("atlas").expect("legal");
assert_eq!(agents_from_role_names(&[name]), ["atlas"]);
}
}