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:
parent
d7a506defa
commit
baab0f393e
2 changed files with 221 additions and 4 deletions
|
|
@ -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`]
|
||||||
|
|
|
||||||
|
|
@ -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/*\""));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue