swarm-secret-client: a read policy scoped to one agent

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-<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-<hive>`: that needs a hive named `agent-<agent>`, and a hive name may not
contain `agent` (nix/reserved-hive-fragments.nix, the reservation the queue's
`hive-<name>-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
This commit is contained in:
atlas 2026-09-13 23:27:00 +02:00 committed by mara
commit baab0f393e
2 changed files with 221 additions and 4 deletions

View file

@ -9,9 +9,11 @@
//! must say identically, so it is said once here. //! must say identically, so it is said once here.
//! //!
//! [`policy`] is the same kind of agreement seen from the other side: which of //! [`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 //! those paths a given principal's own token may read — a hive's, and an
//! controller because the grant and the path are one statement — spelled //! agent's, which are two documents because they are two shapes of grant rather
//! differently they produce a 403 that names neither. //! 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 //! [`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`] //! caller names, so a second kind of secret is a new module beside [`matrix`]

View file

@ -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 //! 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 //! 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<String, Error> {
Ok(format!("{HIVE_PREFIX}{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.
///
/// ⚠️ 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/<agent>`, 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<String, Error> {
checked_segment("agent", agent)?;
Ok(format!("{AGENT_PREFIX}{agent}"))
}
/// One read stanza. The only shape this module emits, so "read-only" is a /// One read stanza. The only shape this module emits, so "read-only" is a
/// property of the renderer rather than of each call site. /// property of the renderer rather than of each call site.
fn read_stanza(path: &str) -> String { fn read_stanza(path: &str) -> String {
@ -103,6 +148,44 @@ pub fn render(hive: &str) -> Result<String, Error> {
Ok(format!("{agents}{own}{services}")) 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/<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.
///
/// ⚠️ **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<String, Error> {
checked_segment("agent", agent)?;
Ok(read_stanza(&format!(
"{MOUNT}/data/{ROOT}/{}/{agent}/*",
<&str>::from(Kind::Agent)
)))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -193,4 +276,136 @@ mod tests {
assert_eq!(n, "hive-pr1ma"); assert_eq!(n, "hive-pr1ma");
assert!(n.starts_with(HIVE_PREFIX)); 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/*\""));
}
} }