Watch
0
0
Fork
You've already forked hyperhive
0

swarm-secret-client: agents may list their own subtree; controller rewrites agent policies

render_agent gains a second stanza: list on
secret/metadata/swarm/agents/<agent>/*, next to the existing read on
secret/data/swarm/agents/<agent>/*. An agent can now learn which
credentials it holds by listing its own subtree. Metadata read, writes
and every other principal's paths stay refused.

An agent's policy was only written when it was minted, so existing agents
would never get the new stanza. swarm-controller now rewrites every
agent's policy at start (read_policy::ensure_agent_policies), with the
same 30s / 24h retry as ensure_hive_access. The roster is the store's
hive-agent-* cert-auth roles, listed with the controller's existing
`list` on auth/cert/certs; the writes use its existing grant on
sys/policies/acl/hive-*. Only the policy is written: mint_and_verify
also reissues the certificate, so the pass does not call it.

Refs #4348
This commit is contained in:
atlas 2026-10-01 17:41:13 +02:00
commit e04616eb70
5 changed files with 204 additions and 43 deletions

View file

@ -114,12 +114,19 @@ pub fn agents_from_role_names(names: &[String]) -> Vec<String> {
.collect()
}
/// One read stanza. The only shape this module emits, so "read-only" is a
/// property of the renderer rather than of each call site.
/// 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, on this
/// hive's own, and on the swarm services'.
///
@ -166,15 +173,16 @@ pub fn render(hive: &str) -> Result<String, Error> {
Ok(format!("{agents}{own}{services}"))
}
/// Render `agent`'s policy document: read on that one agent's credentials, and
/// on nothing else at all.
/// 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.
///
/// One stanza, and the single interpolated name in it is the whole document: an
/// agent authenticating with its own certificate gets a token that fetches
/// `swarm/agents/<agent>/…` 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.
/// 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
@ -185,23 +193,22 @@ pub fn render(hive: &str) -> Result<String, Error> {
/// no business with a service's OIDC secret, another agent's credentials, a
/// hive'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.
/// Read-only, for the reason the hive's is: an agent that could write its own
/// credentials could hand itself an identity it was never issued.
/// 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.
///
/// ⚠️ Unlike [`render`]'s, this name is not deploy-time at every layer: nothing
/// in `nix/host-modules/` knows which agents exist — hive-c0re creates them at
/// runtime into its own meta flake (`hive_c0re::meta`).
/// ⚠️ 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_-]` — it
/// is interpolated into a policy path, so a name that could close the stanza
/// could grant itself anything.
/// [`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)?;
Ok(read_stanza(&format!(
"{MOUNT}/data/{ROOT}/{}/{agent}/*",
<&str>::from(Kind::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)]
@ -349,26 +356,30 @@ mod tests {
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_one_stanza_naming_that_agent() {
fn an_agents_document_is_two_stanzas_naming_that_agent() {
assert_eq!(
render_agent("atlas").expect("a plain name is legal"),
"path \"secret/data/swarm/agents/atlas/*\" {\n capabilities = [\"read\"]\n}\n"
ATLAS_DOCUMENT
);
}
#[test]
fn an_agents_document_does_not_grant_the_whole_agent_prefix() {
// The arm the whole change exists for. `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.
// `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("swarm/agents/atlas/*"));
assert!(p.contains("secret/data/swarm/agents/atlas/*"));
assert!(p.contains("secret/metadata/swarm/agents/atlas/*"));
}
#[test]
@ -389,19 +400,16 @@ mod tests {
assert!(!p.contains("argus"), "no other principal is named");
assert_eq!(
p.matches("path \"").count(),
1,
"one stanza, or the document grants something unaccounted for: {p}"
2,
"two stanzas, or the document grants something unaccounted for: {p}"
);
}
#[test]
fn an_agents_document_is_exactly_its_own_read_stanza() {
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"),
"path \"secret/data/swarm/agents/atlas/*\" {\n capabilities = [\"read\"]\n}\n"
);
assert_eq!(render_agent("atlas").expect("legal"), ATLAS_DOCUMENT);
// The control: the pin discriminates between agents.
assert!(
!render_agent("other")
@ -411,18 +419,42 @@ mod tests {
}
#[test]
fn an_agents_grant_is_read_only() {
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", "list", "sudo", "patch"] {
for capability in ["create", "update", "delete", "sudo", "patch"] {
assert!(
!p.contains(capability),
"an agent's document must not grant {capability}: {p}"
);
}
assert!(p.contains("capabilities = [\"read\"]"));
}
#[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]