From baab0f393ec60e45a4fd31e3debe20ee886d3c53 Mon Sep 17 00:00:00 2001 From: atlas Date: Sun, 13 Sep 2026 23:27:00 +0200 Subject: [PATCH] swarm-secret-client: a read policy scoped to one agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The identity half of giving an agent container its own way into the store. The operator's ruling: minting an identity the agent itself can authenticate with takes the delivery from four pieces (mint -> store -> pass -> use) to three (mint -> store -> use), and makes the agent that much less dependent on the hive it runs on — but only if the grant behind that identity is scoped to that agent's secrets, read-only. Otherwise it is a second way to be everyone. So `render_agent` is a SIBLING of `render`, not a parameter added to it. A hive's document is wide in two of its kinds on purpose (the module header says why each of those is a decision), and none of that breadth transfers: an agent's path names the agent, so scoping to it costs nothing and drifts nowhere, and an agent has no business with a service's OIDC client secret, another agent's credentials, a hive's, or the controller's. The tests say that as an exhaustive check over `path::Kind` rather than as a list of paths, so a kind added later cannot be granted here by a renderer nobody re-read. `render`'s own text is untouched, and its byte-for-byte assertion is left exactly as it stands: a hive can still read every agent's secrets, and closing that is the separate decision its doc comment already prices. The object name is `hive-agent-`, one string for the policy, the cert-auth role and the certificate subject. Inside `hive-` rather than beside it because `sys/policies/acl/hive-*` and `auth/cert/certs/hive-*` are the whole of what the controller may create, and the controller is the only principal that learns an agent exists — at no cost in authority, since it already holds create/update on `secret/data/swarm/agents/*`. It still cannot collide with `hive-`: that needs a hive named `agent-`, and a hive name may not contain `agent` (nix/reserved-hive-fragments.nix, the reservation the queue's `hive--agent` client ids already lean on). A test spells that collision out and fails if the prefix moves somewhere that guard does not cover. Rendering only. Nothing mints a leaf, writes a role or delivers a certificate yet, and nothing calls either new function — because nothing at the module layer knows which agents exist: hive-c0re creates them at runtime and renders them into its own meta flake, which is why even the gateway's per-agent vhosts are a generated conf rather than nix. Issuance therefore has to be runtime, and that design is the operator's to approve before it is built. Refs #4386 --- swarm-secret-client/src/lib.rs | 8 +- swarm-secret-client/src/policy.rs | 217 +++++++++++++++++++++++++++++- 2 files changed, 221 insertions(+), 4 deletions(-) diff --git a/swarm-secret-client/src/lib.rs b/swarm-secret-client/src/lib.rs index 43815132..0987e34a 100644 --- a/swarm-secret-client/src/lib.rs +++ b/swarm-secret-client/src/lib.rs @@ -9,9 +9,11 @@ //! must say identically, so it is said once here. //! //! [`policy`] is the same kind of agreement seen from the other side: which of -//! those paths a hive's own token may read. It belongs here rather than in the -//! controller because the grant and the path are one statement — spelled -//! differently they produce a 403 that names neither. +//! those paths a given principal's own token may read — a hive's, and an +//! agent's, which are two documents because they are two shapes of grant rather +//! than one with a name in it. It belongs here rather than in the controller +//! because the grant and the path are one statement — spelled differently they +//! produce a 403 that names neither. //! //! [`client`] is deliberately ignorant of all of it: it moves whatever type a //! caller names, so a second kind of secret is a new module beside [`matrix`] diff --git a/swarm-secret-client/src/policy.rs b/swarm-secret-client/src/policy.rs index 2977a906..4144bcc9 100644 --- a/swarm-secret-client/src/policy.rs +++ b/swarm-secret-client/src/policy.rs @@ -1,4 +1,4 @@ -//! The read agreement: which credentials a hive's own token may fetch. +//! 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 @@ -51,6 +51,51 @@ pub fn hive_object_name(hive: &str) -> Result { 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-`, so a collision needs a hive named `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--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. +/// +/// ⚠️ The controller's and publisher's subjects are *not* covered by that: their +/// policy names are literals outside `hive-`, but their **common names** are +/// operator-set options (`deploy.bao.controllerCommonName`, +/// `secretPublisherCommonName`) that nothing here can see, and an operator may +/// spell one `hive-agent-atlas`. Whichever change first mints an agent leaf +/// owes the assertion that neither starts with this prefix — `swarm.nix`'s +/// `certAuthCns` is where the mirror-image check for hive names lives. +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 — and agent +/// names are one swarm-wide namespace (an agent's path is +/// `swarm/agents/`, with no hive segment to disambiguate two that +/// matched), so two agents cannot land on one name. +/// +/// # Errors +/// [`Error::PathSegment`] when `agent` holds anything but `[A-Za-z0-9_-]`. +pub fn agent_object_name(agent: &str) -> Result { + checked_segment("agent", agent)?; + Ok(format!("{AGENT_PREFIX}{agent}")) +} + /// One read stanza. The only shape this module emits, so "read-only" is a /// property of the renderer rather than of each call site. fn read_stanza(path: &str) -> String { @@ -103,6 +148,44 @@ pub fn render(hive: &str) -> Result { Ok(format!("{agents}{own}{services}")) } +/// Render `agent`'s policy document: read on that one agent's credentials, and +/// on 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//…` 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 — and on `swarm/services/*` the same way; the module header says +/// why each is a decision rather than an oversight. Neither reason transfers: +/// 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 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. +/// +/// ⚠️ 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`). +/// +/// # 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. +pub fn render_agent(agent: &str) -> Result { + checked_segment("agent", agent)?; + Ok(read_stanza(&format!( + "{MOUNT}/data/{ROOT}/{}/{agent}/*", + <&str>::from(Kind::Agent) + ))) +} + #[cfg(test)] mod tests { use super::*; @@ -193,4 +276,136 @@ mod tests { assert_eq!(n, "hive-pr1ma"); assert!(n.starts_with(HIVE_PREFIX)); } + + #[test] + fn an_agents_document_is_one_stanza_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" + ); + } + + #[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. + 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/*")); + } + + #[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(), + 1, + "one stanza, or the document grants something unaccounted for: {p}" + ); + } + + #[test] + fn an_agents_grant_is_read_only() { + // 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"] { + assert!( + !p.contains(capability), + "an agent's document must not grant {capability}: {p}" + ); + } + assert!(p.contains("capabilities = [\"read\"]")); + } + + #[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 two wide stanzas. + 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 three + // stanzas `the_document_grants_two_whole_prefixes_and_this_hive_alone` + // pins byte for byte, none of them narrowed to make the agent's look + // consistent with it. + assert!(hive.contains("path \"secret/data/swarm/agents/*\"")); + } }