From 139007e656e0c08447328910c250707e99af44c3 Mon Sep 17 00:00:00 2001 From: atlas Date: Mon, 26 Oct 2026 23:02:08 +0100 Subject: [PATCH] stop writing and attaching per-agent and per-hive bao policies --- docs/swarm/credentials.md | 41 ++-- hive-matrix-mcp/src/credential.rs | 2 +- nix/host-modules/otel.nix | 2 +- nix/host-modules/swarm-bao.nix | 6 +- swarm-controller/src/agent_entity.rs | 15 +- swarm-controller/src/agent_identity.rs | 76 +++---- swarm-controller/src/main.rs | 4 +- swarm-controller/src/read_policy.rs | 179 ++++----------- swarm-secret-client/src/acp.rs | 2 +- swarm-secret-client/src/claude_login.rs | 6 +- swarm-secret-client/src/client.rs | 9 +- swarm-secret-client/src/forge.rs | 25 +-- swarm-secret-client/src/github.rs | 6 +- swarm-secret-client/src/matrix.rs | 6 +- swarm-secret-client/src/mtls.rs | 4 +- swarm-secret-client/src/policy.rs | 285 +++++++++--------------- swarm-secret-client/src/queue.rs | 12 +- 17 files changed, 257 insertions(+), 423 deletions(-) diff --git a/docs/swarm/credentials.md b/docs/swarm/credentials.md index 3f269fa6..25157091 100644 --- a/docs/swarm/credentials.md +++ b/docs/swarm/credentials.md @@ -369,35 +369,38 @@ that mount's one role for a `hive-agent-` client certificate at agent creation. Host roles never pin that CA and agent roles pin only it, so an agent's certificate opens that agent's own `swarm/agents//*` and nothing else: `read` on the values there and `list` on their names. -`swarm-controller` writes that policy at agent creation and rewrites every -agent's at its own start, so a change to it reaches existing agents with the -next controller restart. -The agent's cert-auth role attaches a second policy, `hive-agent`, shared by -all agents. It states the same rule with `{{identity.entity.name}}` where the -per-agent policy has the agent's name, so the rule is one document. For that -the controller names each agent's identity entity after the agent: at agent -creation, and for every agent at its own start, it reads the cert mount's +All agents share one policy, `hive-agent`, which the agent's cert-auth role +attaches and no other. It states the rule with `{{identity.entity.name}}` +where an agent's name would go, so the rule is one document and a change to it +is one write. `swarm-controller` writes it at agent creation and at its own +start. + +For that the controller names each agent's identity entity after the agent: at +agent creation, and for every agent at its own start, it reads the cert mount's accessor and creates the entity `` with the alias `hive-agent-`, or renames the entity a first login created under a generated name. When the alias -sits on any other entity, the controller logs an error and changes nothing. It needs -the `identity/` and `sys/auth/cert` grants in `swarm-bao.nix`'s -`controllerPolicyText` for that. Without them it logs a warning and the agent -keeps working through its own policy, which stays attached until every agent -has its entity. An agent's role gains `hive-agent` at its next certificate -mint. +sits on any other entity, the controller logs an error and changes nothing, and +that agent reads nothing until an operator resolves it. It needs the `identity/` +and `sys/auth/cert` grants in `swarm-bao.nix`'s `controllerPolicyText` for that. -A hive's cert-auth role `hive-` likewise attaches a second policy, -`hive-hives`, shared by all hives. A hive's certificate carries its bare name as +A cert-auth role that predates the shared policy may still name a policy +`hive-agent-`. The controller no longer writes or attaches these. The +objects stay in the store and keep granting what they did, to a token that +already carries them and to a role not yet rewritten. A live agent's role is +rewritten at its next certificate renewal. + +A hive's cert-auth role `hive-` attaches one policy, `hive-hives`, shared +by all hives. A hive's certificate carries its bare name as the common name, which is the login's alias name, so hives need no entity: the policy states the hive's own path as `swarm/hives/{{identity.entity.aliases..name}}/*`, where `` is the cert mount's accessor. The controller reads the accessor at its own start, -writes the policy, and attaches it when it rewrites the roles. No hive name can +writes the policy, and rewrites the roles to attach it. No hive name can spell `hive-hives`: it would be a hive called `hives`, and `reserved-hive-fragments.nix` refuses the substring `hive`. When the controller -can't read the accessor or write the policy it logs a warning and the role -attaches the hive's own policy alone, which stays attached. +can't read the accessor or write the policy it logs a warning and leaves the +roles untouched, so a role that names a `hive-` policy keeps it. ### Per-principal identities diff --git a/hive-matrix-mcp/src/credential.rs b/hive-matrix-mcp/src/credential.rs index cc7b2ce2..c63fb7d6 100644 --- a/hive-matrix-mcp/src/credential.rs +++ b/hive-matrix-mcp/src/credential.rs @@ -17,7 +17,7 @@ //! //! Which accounts exist is read the same way: [`linked_accounts`] lists this //! agent's `matrix/` directory, which its policy grants `list` on -//! (`swarm_secret_client::policy::render_agent`). +//! (`swarm_secret_client::policy::render_shared_agent`). //! //! The file arm is a `tokenFile` an operator declared in `matrixAccounts`, or //! the `main` token a hive minted before the swarm did. diff --git a/nix/host-modules/otel.nix b/nix/host-modules/otel.nix index 6d57db0f..62081e81 100644 --- a/nix/host-modules/otel.nix +++ b/nix/host-modules/otel.nix @@ -38,7 +38,7 @@ let # The hive's own store identity. The collector authenticates to the swarm # as the hive, so it reads its secret as the hive: the hive leaf's policy - # (`swarm-secret-client`'s `policy::render`) grants `swarm/hives//*`. + # (`swarm-secret-client`'s `policy::render_shared_hives`) grants `swarm/hives//*`. runsHive = config.services.hyperhive.otel.collector.enable; hiveLeaf = diff --git a/nix/host-modules/swarm-bao.nix b/nix/host-modules/swarm-bao.nix index 93a48bcb..270e78cf 100644 --- a/nix/host-modules/swarm-bao.nix +++ b/nix/host-modules/swarm-bao.nix @@ -872,8 +872,8 @@ let agentPkiRoleName = baoDeploy.agentPkiRoleName; # Every common name the agent role issues for: `swarm_secret_client`'s - # `policy::AGENT_PREFIX`, which names each agent's cert-auth role and - # policy too. Outside it the role refuses, so the controller cannot obtain + # `policy::AGENT_PREFIX`, which names each agent's cert-auth role too. + # Outside it the role refuses, so the controller cannot obtain # a certificate for any other name. agentCnGlob = "hive-agent-*"; @@ -938,7 +938,7 @@ let # # 🩸 Each of these reads exactly ONE path in the store, and does it holding # its own leaf rather than `deploy.bao.clientCertFile` — the hive's own leaf, - # whose policy (`swarm-secret-client`'s `policy::render`) grants read on + # whose policy (`swarm-secret-client`'s `policy::render_shared_hives`) grants read on # `swarm/agents/*` AND `swarm/hives//*`, and nothing under # `swarm/services/`. # Principals presenting one certificate are ONE principal to bao, so sharing diff --git a/swarm-controller/src/agent_entity.rs b/swarm-controller/src/agent_entity.rs index 0bbc0d2d..a6f0b03b 100644 --- a/swarm-controller/src/agent_entity.rs +++ b/swarm-controller/src/agent_entity.rs @@ -13,11 +13,12 @@ //! is ever moved. Any other arrangement is a [`Plan::Conflict`]: nothing is //! written and an operator decides. //! -//! Everything else here is best effort. An agent's own policy still grants it -//! its paths, so a store that refuses these calls costs the shared policy and -//! nothing else: the failure is logged at `warn` and the caller goes on. The -//! grants are the `identity/` stanzas of `swarm-bao.nix`'s -//! `controllerPolicyText`, and a controller can start before they deploy. +//! Everything else here is best effort: a failure is logged at `warn` and the +//! caller goes on. An agent whose entity is not named after it reads nothing, +//! because the shared policy is the only one its role attaches; at mint the +//! read-back fails, and on a start pass its next reads are refused. +//! The grants are the `identity/` stanzas of `swarm-bao.nix`'s +//! `controllerPolicyText`. use anyhow::{Context, Result, bail}; use swarm_secret_client::{ @@ -97,7 +98,7 @@ pub async fn ensure_agents(store: &SecretStore, agents: &[String]) { Err(e) => { tracing::warn!( error = %e, - "agent entities are not managed: the cert auth mount's accessor could not be read; agents keep their own policy" + "agent entities are not managed: the cert auth mount's accessor could not be read; an agent whose entity is not named after it cannot read its secrets" ); return; } @@ -173,7 +174,7 @@ async fn ensure_one(store: &SecretStore, accessor: &str, agent: &str) -> Result< agent, alias, entity_name, - "the agent's alias is on an entity that is not the agent's; nothing was changed and the agent keeps its own policy" + "the agent's alias is on an entity that is not the agent's; nothing was changed and the agent cannot read its secrets until an operator resolves it" ); } } diff --git a/swarm-controller/src/agent_identity.rs b/swarm-controller/src/agent_identity.rs index aead0378..f51a0b6b 100644 --- a/swarm-controller/src/agent_identity.rs +++ b/swarm-controller/src/agent_identity.rs @@ -17,10 +17,10 @@ //! not derived from the first, so either renews without reference to the other. //! //! Four separate strings have to agree before an agent can authenticate: the -//! policy's name, the cert-auth role's name, the certificate's common name, -//! and the authority the role pins. That is the kind of agreement that holds -//! in review and fails in production — a mismatch is a 403 naming none of the -//! four — so [`mint_and_verify`] does not finish on a write. See +//! identity entity's name, the cert-auth role's name, the certificate's common +//! name, and the authority the role pins. That is the kind of agreement that +//! holds in review and fails in production — a mismatch is a 403 naming none +//! of the four — so [`mint_and_verify`] does not finish on a write. See //! [`read_back_as_agent`]. //! //! The authority is the store's own agent CA, generated inside its agent PKI @@ -62,15 +62,12 @@ fn agent_pki(get: impl Fn(&str) -> Option) -> Result<(String, String)> { /// What the cert-auth role for `agent` is written from. struct RoleInputs<'a> { - /// Role name, policy name and the common name the role matches: one string. + /// Role name and the common name the role matches: one string. name: String, /// The authority the role pins: the one that signed this very leaf. ca: &'a str, - /// The policy document the role attaches. - policy: String, - /// The policies the role attaches by name: the agent's own, and the one every - /// agent shares. - attached: [String; 2], + /// The policies the role attaches by name: the one every agent shares. + attached: Vec, } fn role_inputs<'a>(agent: &str, credential: &'a mtls::Credential) -> Result> { @@ -78,8 +75,7 @@ fn role_inputs<'a>(agent: &str, credential: &'a mtls::Credential) -> Result Result { /// Give `agent` an identity at the store, and prove it works. /// -/// Five store calls' worth of agreement, then the login that checks it: +/// Six store calls' worth of agreement, then the login that checks it: /// /// 1. have the agent PKI role issue a leaf whose common name is /// [`policy::agent_object_name`]; the store generates its key; @@ -124,14 +120,13 @@ pub(crate) fn generate_queue_secret() -> Result { /// 3. publish a queue secret at [`queue::agent_queue_path`] — the agent's own /// identity at the swarm queue, minted here so that the credential an agent /// presents names *it* rather than its hive; -/// 4. write the ACL document [`policy::render_agent`] renders — read and list on -/// this one agent's paths — and the shared [`policy::render_shared_agent`]; -/// 5. write the cert-auth role that ties the three together, attaching both -/// policies and pinning the CA the store named as this leaf's issuer. +/// 4. write the shared ACL document [`policy::render_shared_agent`] renders; +/// 5. write the cert-auth role that ties the three together, attaching that +/// policy and pinning the CA the store named as this leaf's issuer; +/// 6. name the agent's identity entity after the agent ([`crate::agent_entity`]), +/// which the policy reads. /// -/// Policy before role, for the reason `read_policy::provision` gives: the role -/// names the policy, so the other order leaves a window in which it points at -/// nothing. +/// Policy before role, for the reason `read_policy::provision` gives. /// /// ⚠️ **Step 3 is idempotent and steps 1–2 are not.** Re-running issues a /// fresh leaf, picked up on the agent's next boot, but leaves an existing @@ -140,9 +135,10 @@ pub(crate) fn generate_queue_secret() -> Result { /// replacing one by age is [`crate::agent_renewal`]'s. /// /// # Errors -/// Anything that stops one of those five steps, with the step named. A -/// failure here fails the job node and nothing else — the agent is still -/// created, without a store identity. +/// Anything that stops steps 1–5, with the step named, or the read-back login, +/// which is where a step 6 that did not take shows up. A failure here fails the +/// job node and nothing else — the agent is still created, without a store +/// identity. pub async fn mint_and_verify(agent: &str) -> Result<()> { let (mount, pki_role) = agent_pki(|k| std::env::var(k).ok())?; let name = policy::agent_object_name(agent)?; @@ -201,10 +197,6 @@ pub async fn mint_and_verify(agent: &str) -> Result<()> { let queue_credential = wanted; let inputs = role_inputs(agent, &credential)?; - store - .write_policy(&inputs.name, &inputs.policy) - .await - .with_context(|| format!("writing the read policy {}", inputs.name))?; store .write_policy(policy::SHARED_AGENT_POLICY, &policy::render_shared_agent()) .await @@ -220,7 +212,11 @@ pub async fn mint_and_verify(agent: &str) -> Result<()> { &inputs.name, inputs.ca, &inputs.name, - &inputs.attached.each_ref().map(String::as_str), + &inputs + .attached + .iter() + .map(String::as_str) + .collect::>(), ) .await .with_context(|| format!("writing the cert-auth role {}", inputs.name))?; @@ -297,7 +293,7 @@ pub async fn revoke_queue_credential(agent: &str) -> Result<()> { /// /// Both paths, not just the certificate's, for the reason this function /// exists at all: the queue credential is readable only because it sits inside -/// the stanza [`policy::render_agent`] already grants, and a policy that +/// the stanza [`policy::render_shared_agent`] grants, and a policy that /// drifted from that path would otherwise fail at the agent's first connection /// — far from here, as a denial naming neither the policy nor the path. /// @@ -450,9 +446,9 @@ mod tests { } /// Three of the four strings that must agree, checked where this process - /// sets them: role, policy and matched CN are one name; the pinned CA is - /// the issuer the store reported for this leaf; the policy is the agent's - /// own single stanza. + /// sets them: role and matched CN are one name; the pinned CA is the issuer + /// the store reported for this leaf; the role attaches the shared policy + /// and no policy of the agent's own. #[test] fn the_role_pins_the_leafs_own_issuer_under_the_agents_name() { let credential = mtls::Credential { @@ -468,17 +464,21 @@ mod tests { "the CN the leaf is issued for" ); assert_eq!(inputs.ca, "AGENT-CA"); + // Only the shared policy: a policy named for the agent is not attached. assert_eq!( inputs.attached, - ["hive-agent-atlas", policy::SHARED_AGENT_POLICY], - "the role attaches the agent's own policy and the shared one" + [policy::SHARED_AGENT_POLICY], + "the role attaches the shared policy and nothing else" + ); + assert!( + !inputs.attached.contains(&inputs.name), + "the role must not attach a policy named for the agent" ); - assert_eq!(inputs.policy, policy::render_agent("atlas").expect("legal")); - assert!(!inputs.policy.contains("swarm/hives/"), "{}", inputs.policy); - // The control: another agent's inputs differ in both name and grant. + // The control: another agent's inputs differ in name, and attach the + // same shared policy, so the check above can see a policy that is there. let other = role_inputs("argus", &credential).expect("legal"); assert_ne!(other.name, inputs.name); - assert!(!other.policy.contains("agents/atlas/"), "{}", other.policy); + assert_eq!(other.attached, inputs.attached); } } diff --git a/swarm-controller/src/main.rs b/swarm-controller/src/main.rs index ad714199..49817605 100644 --- a/swarm-controller/src/main.rs +++ b/swarm-controller/src/main.rs @@ -217,8 +217,8 @@ async fn main() -> Result<()> { // abandoned, since the two of them boot together. let hive_names: Vec = hives.iter().map(|h| h.name.clone()).collect(); read_policy::ensure_hive_access(hive_names).await; - // Agents keep the policy they were minted with until this rewrites it, so - // what `policy::render_agent` grants today reaches them only from here. + // The one policy every agent's role attaches, and the entity each agent's + // login is read under, are checked here at every start. read_policy::ensure_agent_policies().await; let state = AppState { diff --git a/swarm-controller/src/read_policy.rs b/swarm-controller/src/read_policy.rs index dea5eff1..80a0849a 100644 --- a/swarm-controller/src/read_policy.rs +++ b/swarm-controller/src/read_policy.rs @@ -14,18 +14,16 @@ //! take down a daemon that serves everything else, and a store that answers is //! answering the same way next time, so the next start is the retry for those. //! -//! The document names the hive it is written for: one stanza is the same -//! everywhere, the other is scoped to the reader's own name -//! ([`swarm_secret_client::policy`] explains the asymmetry). The same rule is -//! also written once for all hives, [`policy::SHARED_HIVE_POLICY`], with the -//! reader's own name taken from its login's alias on the cert mount instead of -//! written into the text; a hive's role attaches both. The hive list is loaded -//! once because a config change means a redeploy. +//! The rule is written once for all hives, [`policy::SHARED_HIVE_POLICY`]: one +//! stanza is the same everywhere, the other is scoped to the reader's own name +//! ([`swarm_secret_client::policy`] explains the asymmetry), taken from its +//! login's alias on the cert mount instead of written into the text. A hive's +//! role attaches that policy and no other. The hive list is loaded once because +//! a config change means a redeploy. //! -//! Every agent's policy is rewritten by the same kind of pass -//! ([`ensure_agent_policies`]), so a change to `policy::render_agent` reaches -//! agents minted before it. Its roster is the store's `hive-agent-*` cert-auth -//! roles. +//! The shared agent policy is written by the same kind of pass +//! ([`ensure_agent_policies`]), together with each agent's identity entity. +//! Its roster is the store's `hive-agent-*` cert-auth roles. use std::{future::Future, time::Duration}; @@ -128,10 +126,12 @@ where None } -/// One pass: log in once, write every hive's policy, then every hive's role. +/// One pass: log in once, write the shared hive policy, then every hive's role. /// /// Policy first, because the role names the policy — the other order leaves a -/// window in which the role points at nothing. +/// window in which the role points at nothing. Without the policy the roles are +/// left as they are: a role written to name a policy that does not exist logs +/// its hive in able to read nothing. /// /// # Errors /// [`Unreachable`] for the states a later attempt can resolve: a store that is @@ -154,12 +154,9 @@ async fn provision(hives: &[String]) -> Result<(), Unreachable> { } Err(e) => return Err(Unreachable(e.to_string())), }; - for hive in hives { - if let Err(e) = write_policy_for(&store, hive).await { - tracing::warn!(hive, error = %format!("{e:#}"), "writing this hive's read policy failed"); - } + if !write_shared_policy(&store).await { + return Ok(()); } - let shared = write_shared_policy(&store).await; let Some(path) = std::env::var_os(ENV_HIVE_CLIENT_CA) else { tracing::info!("no hive certificate authority configured; hive roles are not managed here"); @@ -171,38 +168,25 @@ async fn provision(hives: &[String]) -> Result<(), Unreachable> { let ca = std::fs::read_to_string(&path) .map_err(|e| Unreachable(format!("reading the hive authority {path}: {e}")))?; for hive in hives { - if let Err(e) = write_role(&store, hive, &ca, shared).await { + if let Err(e) = write_role(&store, hive, &ca).await { tracing::warn!(hive, error = %format!("{e:#}"), "creating this hive's store role failed"); } } Ok(()) } -/// One hive's policy: its own name, its own document. -async fn write_policy_for(store: &SecretStore, hive: &str) -> Result<()> { - let name = policy::hive_object_name(hive)?; - store - .write_policy(&name, &policy::render(hive)?) - .await - .with_context(|| format!("writing the read policy {name}"))?; - tracing::info!(hive, policy = %name, "hive read policy in place"); - Ok(()) -} - /// Write [`policy::SHARED_HIVE_POLICY`], rendered with the cert mount's -/// accessor. Answers whether it is in place: a hive's role attaches it only -/// then. +/// accessor. Answers whether it is in place: hive roles are written only then. /// -/// Best effort. A hive's own policy grants it the same paths, so a store that -/// refuses the accessor read (the controller's policy may not carry it yet) or -/// the write costs the shared policy and nothing else. +/// A store that refuses the accessor read (the controller's policy may not +/// carry it yet) or the write leaves the roles already in the store as they are. async fn write_shared_policy(store: &SecretStore) -> bool { let accessor = match store.auth_mount_accessor(DEFAULT_CERT_MOUNT).await { Ok(accessor) => accessor, Err(e) => { tracing::warn!( error = %e, - "the shared hive policy is not managed: the cert auth mount's accessor could not be read; hives keep their own policy" + "the shared hive policy is not managed: the cert auth mount's accessor could not be read; hive roles are left as they are" ); return false; } @@ -210,7 +194,7 @@ async fn write_shared_policy(store: &SecretStore) -> bool { let document = match policy::render_shared_hives(&accessor) { Ok(document) => document, Err(e) => { - tracing::warn!(error = %e, "the shared hive policy is not managed: the cert auth mount's accessor is not usable in a policy; hives keep their own policy"); + tracing::warn!(error = %e, "the shared hive policy is not managed: the cert auth mount's accessor is not usable in a policy; hive roles are left as they are"); return false; } }; @@ -229,28 +213,23 @@ async fn write_shared_policy(store: &SecretStore) -> bool { tracing::warn!( policy = policy::SHARED_HIVE_POLICY, error = %e, - "writing the shared hive policy failed; hives keep their own policy" + "writing the shared hive policy failed; hive roles are left as they are" ); false } } } -/// The policies `hive`'s role attaches: its own, and the shared one when that -/// is in place. -fn attached_policies(hive: &str, shared: bool) -> Result, Error> { - let mut attached = vec![policy::hive_object_name(hive)?]; - if shared { - attached.push(policy::SHARED_HIVE_POLICY.to_owned()); - } - Ok(attached) +/// The policies a hive's role attaches: the shared one, and no policy named for +/// the hive. +fn attached_policies() -> Vec { + vec![policy::SHARED_HIVE_POLICY.to_owned()] } -/// The role for one hive: its own certificate's subject, its own policy, and -/// the shared one when `shared`. -async fn write_role(store: &SecretStore, hive: &str, ca: &str, shared: bool) -> Result<()> { +/// The role for one hive: its own certificate's subject and the shared policy. +async fn write_role(store: &SecretStore, hive: &str, ca: &str) -> Result<()> { let name = policy::hive_object_name(hive)?; - let attached = attached_policies(hive, shared)?; + let attached = attached_policies(); store // The subject is the hive's name: `glue-bao-tls.nix` mints a hive's // client leaf with its name as the CN, and cert auth matches on that. @@ -267,8 +246,8 @@ async fn write_role(store: &SecretStore, hive: &str, ca: &str, shared: bool) -> Ok(()) } -/// Rewrite every agent's policy from [`policy::render_agent`], with the retry -/// [`ensure_hive_access`] has and for the same reason. +/// Write the shared agent policy and give every agent its identity entity, with +/// the retry [`ensure_hive_access`] has and for the same reason. /// /// Returns once the first pass is done. When that pass cannot reach the store, /// it repeats in the background for up to [`RETRY_WINDOW`]. @@ -294,7 +273,7 @@ pub async fn ensure_agent_policies() { tracing::warn!( attempts = RETRY_ATTEMPTS, window = ?RETRY_WINDOW, - "giving up on the agent policy rewrite; agents keep the policy they were minted with until this daemon is restarted" + "giving up on the agent policy rewrite; the shared agent policy and the agents' identity entities are not checked until this daemon is restarted" ); } }); @@ -302,9 +281,9 @@ pub async fn ensure_agent_policies() { } /// One pass: log in once, list the agents, write the shared agent policy and -/// each one's own, and give each its identity entity. +/// give each agent its identity entity. /// -/// Policies and entities only. An agent's cert-auth role and certificate are +/// The policy and entities only. An agent's cert-auth role and certificate are /// `agent_identity::mint_and_verify`'s, and that reissues the certificate, so /// it is not what a pass on every start may call. /// @@ -343,46 +322,17 @@ async fn rewrite_agent_policies() -> Result<(), Unreachable> { "writing the shared agent policy failed" ), } - let policies = agent_policies(&roles); - let mut written = 0_usize; - for (name, document) in &policies { - match store.write_policy(name, document).await { - Ok(()) => written += 1, - Err(e) => { - tracing::warn!(policy = %name, error = %e, "rewriting this agent's policy failed"); - } - } - } - tracing::info!(written, agents = policies.len(), "agent policies rewritten"); crate::agent_entity::ensure_agents(&store, &policy::agents_from_role_names(&roles)).await; Ok(()) } -/// The policy to write for each agent among `roles`, a listing of cert-auth -/// roles: its name, which is the role's own, and its document. -/// -/// A role that is not an agent's — a hive's `hive-`, a host's — gets -/// nothing: the same `write_policy` call with an agent's document would -/// narrow that principal to one agent's subtree. -fn agent_policies(roles: &[String]) -> Vec<(String, String)> { - policy::agents_from_role_names(roles) - .iter() - .filter_map(|agent| { - Some(( - policy::agent_object_name(agent).ok()?, - policy::render_agent(agent).ok()?, - )) - }) - .collect() -} - #[cfg(test)] mod tests { use std::{cell::Cell, time::Duration}; use super::{ - RETRY_ATTEMPTS, RETRY_INTERVAL, RETRY_WINDOW, Unreachable, agent_policies, - attached_policies, policy, retry_until_ok, + RETRY_ATTEMPTS, RETRY_INTERVAL, RETRY_WINDOW, Unreachable, attached_policies, policy, + retry_until_ok, }; #[test] @@ -424,54 +374,15 @@ mod tests { } #[test] - fn the_pass_writes_agents_policies_under_their_role_names_and_no_one_elses() { - let roles: Vec = [ - "hive-agent-atlas", - "hive-pr1ma", - "swarm-controller", - "hive-agent-argus", - ] - .map(str::to_owned) - .to_vec(); - let written = agent_policies(&roles); - let names: Vec<&str> = written.iter().map(|(name, _)| name.as_str()).collect(); - // The role names an agent's policy by its own name, so a policy written - // under any other name is one the role does not attach. - assert_eq!(names, ["hive-agent-atlas", "hive-agent-argus"]); - assert_eq!( - written[0].1, - policy::render_agent("atlas").expect("legal"), - "the prefix is stripped before rendering, or the document names `agent-atlas`" + fn a_hives_role_attaches_the_shared_policy_and_none_named_for_the_hive() { + let attached = attached_policies(); + assert_eq!(attached, [policy::SHARED_HIVE_POLICY]); + let own = policy::hive_object_name("pr1ma").expect("legal"); + assert!( + !attached.contains(&own), + "the role must not attach a policy named for the hive" ); - assert_eq!(written[1].1, policy::render_agent("argus").expect("legal")); - } - - #[test] - fn a_hives_role_attaches_its_own_policy_and_the_shared_one() { - assert_eq!( - attached_policies("pr1ma", true).expect("legal"), - ["hive-pr1ma", policy::SHARED_HIVE_POLICY] - ); - } - - #[test] - fn without_the_shared_policy_the_role_keeps_the_hives_own() { - assert_eq!( - attached_policies("pr1ma", false).expect("legal"), - ["hive-pr1ma"] - ); - } - - #[test] - fn the_shared_text_serves_every_hive_what_its_own_policy_does() { - let shared = policy::render_shared_hives("auth_cert_0a1b2c3d").expect("legal accessor"); - let template = "{{identity.entity.aliases.auth_cert_0a1b2c3d.name}}"; - for hive in ["pr1ma", "beta", "gamma-2", "delta_3"] { - assert_eq!( - shared.replace(template, hive), - policy::render(hive).expect("legal"), - "{hive}" - ); - } + // The control: the same lookup finds a policy that is attached. + assert!(attached.contains(&policy::SHARED_HIVE_POLICY.to_owned())); } } diff --git a/swarm-secret-client/src/acp.rs b/swarm-secret-client/src/acp.rs index 065325bd..49c23d36 100644 --- a/swarm-secret-client/src/acp.rs +++ b/swarm-secret-client/src/acp.rs @@ -14,7 +14,7 @@ use crate::{ /// The path holding `agent`'s ACP provider API key. /// /// A flat leaf under the agent's prefix, like [`crate::forge::agent_token_path`], -/// so the agent's own read stanza ([`crate::policy::render_agent`]) already +/// so the agent's own read stanza ([`crate::policy::render_shared_agent`]) already /// covers it. /// /// # Errors diff --git a/swarm-secret-client/src/claude_login.rs b/swarm-secret-client/src/claude_login.rs index 782822fa..3cfcda4b 100644 --- a/swarm-secret-client/src/claude_login.rs +++ b/swarm-secret-client/src/claude_login.rs @@ -18,7 +18,7 @@ use crate::{ /// /// A flat leaf under the agent's prefix, like /// [`crate::github::account_path`], so the agent's own read stanza -/// ([`crate::policy::render_agent`]) already covers it. +/// ([`crate::policy::render_shared_agent`]) already covers it. /// /// # Errors /// [`Error::PathSegment`] when `agent` contains anything but `[A-Za-z0-9_-]`, @@ -88,12 +88,12 @@ mod tests { #[test] fn the_agents_own_read_grant_covers_the_path() { let path = credentials_path("atlas").expect("legal"); - let own = crate::policy::render_agent("atlas").expect("legal"); + let own = crate::policy::effective_agent("atlas"); assert!( covered_by(&own, &path), "no stanza in\n{own}\ncovers {path}" ); - let other = crate::policy::render_agent("argus").expect("legal"); + let other = crate::policy::effective_agent("argus"); assert!(!covered_by(&other, &path), "argus's policy reaches {path}"); } diff --git a/swarm-secret-client/src/client.rs b/swarm-secret-client/src/client.rs index 829b4b56..5fb4592d 100644 --- a/swarm-secret-client/src/client.rs +++ b/swarm-secret-client/src/client.rs @@ -780,7 +780,7 @@ mod tests { let request = WriteAclPolicy { name: "hive-pr1ma".to_owned(), - policy: crate::policy::render("pr1ma").expect("a plain name is legal"), + policy: crate::policy::effective_hive("pr1ma"), }; assert_eq!(request.path(), "sys/policies/acl/hive-pr1ma"); } @@ -791,7 +791,7 @@ mod tests { let request = WriteAclPolicy { name: "hive-pr1ma".to_owned(), - policy: crate::policy::render("pr1ma").expect("a plain name is legal"), + policy: crate::policy::effective_hive("pr1ma"), }; let body = request .body() @@ -799,10 +799,7 @@ mod tests { .expect("a policy write sends one"); let sent: serde_json::Value = serde_json::from_slice(&body).expect("the body is the JSON the store parses"); - assert_eq!( - sent["policy"], - crate::policy::render("pr1ma").expect("legal") - ); + assert_eq!(sent["policy"], crate::policy::effective_hive("pr1ma")); assert!( sent.get("name").is_none(), "`name` addresses the policy in the path; sending it too would make \ diff --git a/swarm-secret-client/src/forge.rs b/swarm-secret-client/src/forge.rs index f7656f43..438a9333 100644 --- a/swarm-secret-client/src/forge.rs +++ b/swarm-secret-client/src/forge.rs @@ -28,7 +28,7 @@ use crate::{ /// /// A flat leaf under the agent's prefix, like its controller-minted siblings /// [`crate::queue::agent_queue_path`] and [`crate::mtls::identity_path`], so -/// the agent's own read stanza ([`crate::policy::render_agent`]) +/// the agent's own read stanza ([`crate::policy::render_shared_agent`]) /// already covers it. /// /// # Errors @@ -190,7 +190,7 @@ mod tests { #[test] fn no_hive_policy_reaches_the_controller_token() { - let policy = crate::policy::render("alpha").expect("legal"); + let policy = crate::policy::effective_hive("alpha"); let covers = |path: &str| { policy.lines().any(|line| { line.strip_prefix("path \"") @@ -232,7 +232,7 @@ mod tests { #[test] fn the_agents_own_grants_list_its_accounts_and_read_each() { - let policy = crate::policy::render_agent("atlas").expect("legal"); + let policy = crate::policy::effective_agent("atlas"); let covers = |path: &str| { policy.lines().any(|line| { line.strip_prefix("path \"") @@ -309,7 +309,7 @@ mod tests { // policy is rendered elsewhere. If the path ever moved out from under // it, the agent's fetch would 403 at boot, naming neither. let path = agent_token_path("atlas").expect("legal"); - let policy = crate::policy::render_agent("atlas").expect("legal"); + let policy = crate::policy::effective_agent("atlas"); let covered = policy.lines().any(|line| { line.strip_prefix("path \"") .and_then(|rest| rest.split_once("\" {")) @@ -324,7 +324,7 @@ mod tests { // Control for the test above: the prefix match must actually be // discriminating, or it proves nothing. let path = agent_token_path("atlas").expect("legal"); - let policy = crate::policy::render_agent("argus").expect("legal"); + let policy = crate::policy::effective_agent("argus"); let covered = policy.lines().any(|line| { line.strip_prefix("path \"") .and_then(|rest| rest.split_once("\" {")) @@ -357,15 +357,12 @@ mod tests { #[test] fn a_hive_reaches_its_own_forge_token_and_no_other_hives() { let covered_by = |hive: &str, path: &str| { - crate::policy::render(hive) - .expect("legal") - .lines() - .any(|line| { - line.strip_prefix("path \"") - .and_then(|rest| rest.split_once("\" {")) - .and_then(|(p, _)| p.strip_suffix('*')) - .is_some_and(|prefix| format!("secret/data/{path}").starts_with(prefix)) - }) + crate::policy::effective_hive(hive).lines().any(|line| { + line.strip_prefix("path \"") + .and_then(|rest| rest.split_once("\" {")) + .and_then(|(p, _)| p.strip_suffix('*')) + .is_some_and(|prefix| format!("secret/data/{path}").starts_with(prefix)) + }) }; let alpha = hive_token_path("alpha").expect("legal"); assert!(covered_by("alpha", &alpha), "alpha cannot read {alpha}"); diff --git a/swarm-secret-client/src/github.rs b/swarm-secret-client/src/github.rs index 0bd92016..885c4998 100644 --- a/swarm-secret-client/src/github.rs +++ b/swarm-secret-client/src/github.rs @@ -18,7 +18,7 @@ use crate::{ /// /// A flat leaf under the agent's prefix, like /// [`crate::forge::agent_token_path`], so the agent's own read stanza -/// ([`crate::policy::render_agent`]) already covers it. +/// ([`crate::policy::render_shared_agent`]) already covers it. /// /// # Errors /// [`Error::PathSegment`] when `agent` contains anything but `[A-Za-z0-9_-]`, @@ -84,13 +84,13 @@ mod tests { #[test] fn the_agents_own_read_grant_covers_the_path() { let path = account_path("atlas").expect("legal"); - let own = crate::policy::render_agent("atlas").expect("legal"); + let own = crate::policy::effective_agent("atlas"); assert!( covered_by(&own, &path), "no stanza in\n{own}\ncovers {path}" ); // The control: another agent's grant does not reach it. - let other = crate::policy::render_agent("argus").expect("legal"); + let other = crate::policy::effective_agent("argus"); assert!(!covered_by(&other, &path), "argus's policy reaches {path}"); } diff --git a/swarm-secret-client/src/matrix.rs b/swarm-secret-client/src/matrix.rs index 5eebcaba..e02de8dc 100644 --- a/swarm-secret-client/src/matrix.rs +++ b/swarm-secret-client/src/matrix.rs @@ -118,7 +118,7 @@ pub(crate) const CONTROLLER: &str = "swarm-controller"; /// /// The matrix container mints it and publishes it here; `swarm-controller` /// reads it to create agents' accounts. Under [`Kind::Controller`] because -/// [`crate::policy::render`] grants no hive that kind: every hive reads +/// [`crate::policy::render_shared_hives`] grants no hive that kind: every hive reads /// `agents/*` and its own `hives//*`, and nothing else. /// /// # Errors @@ -186,7 +186,7 @@ mod tests { fn the_agent_reads_its_own_recovery_passphrase_and_no_other() { // The daemon reads this under the agent's own policy, so the rendered // document is what has to cover it. - let doc = crate::policy::render_agent("atlas").expect("a plain name is legal"); + let doc = crate::policy::effective_agent("atlas"); let granted: Vec<&str> = doc .lines() .filter_map(|l| l.strip_prefix("path \"secret/data/")) @@ -272,7 +272,7 @@ mod tests { // be under any stanza a hive's policy renders. Checked against the // rendered document rather than against the prefix, because the // document is what the store enforces. - let doc = crate::policy::render("pr1ma").expect("a plain name is legal"); + let doc = crate::policy::effective_hive("pr1ma"); // Every stanza is `path "secret/data/*" { … }`. let granted: Vec<&str> = doc .lines() diff --git a/swarm-secret-client/src/mtls.rs b/swarm-secret-client/src/mtls.rs index 062692ac..f6013582 100644 --- a/swarm-secret-client/src/mtls.rs +++ b/swarm-secret-client/src/mtls.rs @@ -106,12 +106,12 @@ mod tests { } /// The path sits under the same prefix an agent's policy grants - /// (`policy::render_agent`'s stanza is `swarm/agents//*`), which is + /// (`policy::render_shared_agent`'s stanza is `swarm/agents//*`), which is /// what makes an agent able to read its own identity back. #[test] fn an_agents_identity_is_inside_its_own_policy_stanza() { let path = identity_path("atlas").expect("legal"); - let document = crate::policy::render_agent("atlas").expect("legal"); + let document = crate::policy::effective_agent("atlas"); let prefix = format!("{}/data/swarm/agents/atlas/", crate::path::MOUNT); assert!( path.starts_with("swarm/agents/atlas/"), diff --git a/swarm-secret-client/src/policy.rs b/swarm-secret-client/src/policy.rs index d3a99688..232e5b49 100644 --- a/swarm-secret-client/src/policy.rs +++ b/swarm-secret-client/src/policy.rs @@ -5,8 +5,9 @@ //! 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 hive document ([`render_shared_hives`]) 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 @@ -33,15 +34,14 @@ use crate::{ path::{Kind, MOUNT, ROOT, checked_segment}, }; -/// Namespace for a hive's own policy and cert-auth role. +/// Namespace for a hive's cert-auth role and the policies the controller writes. /// /// 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. +/// The cert-auth role name for `hive`. /// /// # Errors /// [`Error::PathSegment`] when `hive` holds anything but `[A-Za-z0-9_-]`. @@ -50,7 +50,7 @@ pub fn hive_object_name(hive: &str) -> Result { Ok(format!("{HIVE_PREFIX}{hive}")) } -/// Namespace for an agent's own policy and cert-auth role. +/// Namespace for an agent's cert-auth role and certificate common name. /// /// Deliberately *inside* [`HIVE_PREFIX`] rather than beside it. /// `sys/policies/acl/hive-*` and `auth/cert/certs/hive-*` are the whole of what @@ -59,7 +59,7 @@ pub fn hive_object_name(hive: &str) -> Result { /// 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. +/// the shared policies grant read on. /// /// It still cannot collide with a hive's. [`hive_object_name`] renders /// `hive-`, so a collision needs a hive named `agent-` — and @@ -75,9 +75,9 @@ pub fn hive_object_name(hive: &str) -> Result { /// 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. +/// The cert-auth role name for `agent`, and the common name of the certificate +/// that authenticates it. One string, two objects: the role matches the subject +/// by spelling both the same. /// /// Injective in `agent` — the prefix is fixed and [`checked_segment`] has /// already refused anything that could re-punctuate the suffix. Agent names @@ -124,39 +124,43 @@ 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 one policy every hive's cert-auth role attaches. /// -/// 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. +/// Inside [`HIVE_PREFIX`] so that the controller may write it. It is the object +/// name of a hive called `hives`, and `nix/reserved-hive-fragments.nix` forbids +/// the substring `hive` in every hive name, so no hive spells it. +pub const SHARED_HIVE_POLICY: &str = "hive-hives"; + +/// Render the policy [`SHARED_HIVE_POLICY`] holds: read on every agent's +/// credentials and on this hive's own, with the hive's name taken from the alias +/// of its certificate login on the cert auth mount `mount_accessor`. +/// +/// A hive's certificate carries its bare name as the common name, and that is +/// the alias name, so the own-path stanza reads `swarm/hives//` for +/// whichever hive presents the token. A login whose alias is not a hive's name +/// reads a path where nothing is stored. The `agents/` and `hives/` segments +/// are fixed text, never taken from the identity. +/// +/// 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 only that one: a hive holds the queue +/// credential its own agents authenticate with, so it reads the one principal +/// named after itself, which is why the path takes the name from the alias +/// instead of widening to the whole kind. /// /// 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 { - checked_segment("hive", hive)?; - Ok(hive_document(hive)) -} - -/// The text of [`render`] with `own` as the hive segment: a checked hive name, -/// or the template [`render_shared_hives`] puts there. -fn hive_document(own: &str) -> String { +/// [`Error::PathSegment`] when `mount_accessor` holds anything but +/// `[A-Za-z0-9_-]`: it is interpolated into the policy text, where it could +/// close the stanza. +pub fn render_shared_hives(mount_accessor: &str) -> Result { + checked_segment("mount accessor", mount_accessor)?; + let own = format!("{{{{identity.entity.aliases.{mount_accessor}.name}}}}"); let agents = read_stanza(&format!( "{MOUNT}/data/{ROOT}/{}/*", <&str>::from(Kind::Agent) @@ -165,76 +169,10 @@ fn hive_document(own: &str) -> String { "{MOUNT}/data/{ROOT}/{}/{own}/*", <&str>::from(Kind::Hive) )); - format!("{agents}{own}") + Ok(format!("{agents}{own}")) } -/// The one policy every hive's cert-auth role attaches next to its own. -/// -/// Inside [`HIVE_PREFIX`] so that the controller may write it. It is the object -/// name of a hive called `hives`, and `nix/reserved-hive-fragments.nix` forbids -/// the substring `hive` in every hive name, so no hive spells it. -pub const SHARED_HIVE_POLICY: &str = "hive-hives"; - -/// Render the policy [`SHARED_HIVE_POLICY`] holds: the text of [`render`], with -/// the hive's name taken from the alias of its certificate login on the cert -/// auth mount `mount_accessor`. -/// -/// A hive's certificate carries its bare name as the common name, and that is -/// the alias name, so the own-path stanza reads `swarm/hives//` for -/// whichever hive presents the token. A login whose alias is not a hive's name -/// reads a path where nothing is stored. The `agents/` and `hives/` segments -/// are fixed text, never taken from the identity. -/// -/// # Errors -/// [`Error::PathSegment`] when `mount_accessor` holds anything but -/// `[A-Za-z0-9_-]`: it is interpolated into the policy text, where it could -/// close the stanza. -pub fn render_shared_hives(mount_accessor: &str) -> Result { - checked_segment("mount accessor", mount_accessor)?; - Ok(hive_document(&format!( - "{{{{identity.entity.aliases.{mount_accessor}.name}}}}" - ))) -} - -/// 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//…`, 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 { - 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}")) -} - -/// The one policy every agent's cert-auth role attaches next to its own. +/// The one policy every agent's cert-auth role attaches. /// /// Inside [`HIVE_PREFIX`] so that the controller may write it, and without an /// agent's suffix so that it is never an agent's object name: @@ -248,14 +186,21 @@ pub const SHARED_AGENT_POLICY: &str = "hive-agent"; /// agent's name. const ENTITY_NAME_TEMPLATE: &str = "{{identity.entity.name}}"; -/// Render the policy [`SHARED_AGENT_POLICY`] holds: the stanzas of -/// [`render_agent`], with the agent's name taken from the identity entity of -/// whoever presents the token instead of written into the text. +/// Render the policy [`SHARED_AGENT_POLICY`] holds: read on one agent's +/// credentials and list on their names (how `hive-matrix-daemon` finds its +/// linked accounts), with the agent's name taken from the identity entity of +/// whoever presents the token. /// /// The text is the same for every agent, so a change to the rule is one write. /// An entity whose name is not an agent's grants nothing: the controller names /// each agent's entity after the agent, and a login that no entity of that /// name owns reads `swarm/agents//`, where nothing is stored. +/// +/// Nothing here writes: an agent that could write its own credentials could +/// hand itself an identity it was never issued. An agent has no business with +/// another agent's credentials, a hive's, a service's, or the controller's, so +/// none of [`render_shared_hives`]'s breadth applies. A hive still reads this +/// agent's secrets, and closing that is its own decision. #[must_use] pub fn render_shared_agent() -> String { let kind = <&str>::from(Kind::Agent); @@ -268,23 +213,36 @@ pub fn render_shared_agent() -> String { format!("{read}{list}") } +/// What an agent's token grants under [`SHARED_AGENT_POLICY`]: the document +/// with the store's template filled in for `agent`. The oracle tests use to ask +/// whether the policy covers a path. +#[cfg(test)] +pub(crate) fn effective_agent(agent: &str) -> String { + checked_segment("agent", agent).expect("a legal agent name"); + render_shared_agent().replace(ENTITY_NAME_TEMPLATE, agent) +} + +/// What a hive's token grants under [`SHARED_HIVE_POLICY`]: the document with +/// the store's template filled in for `hive`. +#[cfg(test)] +pub(crate) fn effective_hive(hive: &str) -> String { + checked_segment("hive", hive).expect("a legal hive name"); + render_shared_hives("auth_cert_test") + .expect("a legal accessor") + .replace("{{identity.entity.aliases.auth_cert_test.name}}", hive) +} + #[cfg(test)] mod tests { use super::*; #[test] - fn the_shared_document_is_the_per_agent_one_with_the_entity_name_for_the_agent() { - let shared = render_shared_agent(); + fn the_shared_agent_document_reads_the_entity_name_where_the_agent_goes() { assert_eq!( - shared, + render_shared_agent(), "path \"secret/data/swarm/agents/{{identity.entity.name}}/*\" {\n capabilities = [\"read\"]\n}\n\ path \"secret/metadata/swarm/agents/{{identity.entity.name}}/*\" {\n capabilities = [\"list\"]\n}\n" ); - // Same shape as the per-agent document, so the rule is one rule. - assert_eq!( - shared.replace("{{identity.entity.name}}", "atlas"), - render_agent("atlas").expect("legal") - ); } #[test] @@ -314,28 +272,26 @@ mod tests { #[test] fn the_document_grants_every_agent_and_this_hive_alone() { assert_eq!( - render("pr1ma").expect("a plain name is legal"), + effective_hive("pr1ma"), "path \"secret/data/swarm/agents/*\" {\n capabilities = [\"read\"]\n}\n\ path \"secret/data/swarm/hives/pr1ma/*\" {\n capabilities = [\"read\"]\n}\n" ); } #[test] - fn the_shared_hive_document_is_each_hives_own_with_the_alias_name_for_the_hive() { + fn the_shared_hive_document_takes_the_hive_from_the_alias_name() { let shared = render_shared_hives("auth_cert_1a2b3c4d").expect("legal accessor"); assert_eq!( shared, "path \"secret/data/swarm/agents/*\" {\n capabilities = [\"read\"]\n}\n\ path \"secret/data/swarm/hives/{{identity.entity.aliases.auth_cert_1a2b3c4d.name}}/*\" {\n capabilities = [\"read\"]\n}\n" ); - // Same text as every hive's own document once the template is filled - // in, for hives that differ in length, case and punctuation. + // The template fills in for hives that differ in length, case and + // punctuation, and each then reads exactly its own path. for hive in ["pr1ma", "a", "Mixed_Case-1", "x".repeat(63).as_str()] { - assert_eq!( - shared.replace("{{identity.entity.aliases.auth_cert_1a2b3c4d.name}}", hive), - render(hive).expect("legal"), - "{hive}" - ); + let own = effective_hive(hive); + assert!(own.contains(&format!("swarm/hives/{hive}/*")), "{own}"); + assert!(!own.contains("{{"), "{own}"); } } @@ -387,7 +343,7 @@ mod tests { // 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"); + let p = effective_hive("pr1ma"); assert!(!p.contains("swarm/services"), "{p}"); let service = "swarm/services/swarm-grafana/oidc/client"; assert!( @@ -408,7 +364,7 @@ mod tests { /// 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 alpha = effective_hive("alpha"); let own = crate::matrix::sender_token_path("alpha").expect("legal"); let other = crate::matrix::sender_token_path("beta").expect("legal"); @@ -456,7 +412,7 @@ mod tests { 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"); + let p = effective_hive("pr1ma"); assert!(!p.contains("create")); assert!(!p.contains("update")); assert!(!p.contains("delete")); @@ -465,32 +421,16 @@ mod tests { #[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"); + let a = effective_hive("alpha"); 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()); + // The name reaches a role name and a certificate subject, so a name that + // could re-punctuate either is refused. assert!( hive_object_name("atlas/*\" { capabilities = [\"root\"] }").is_err(), "the object NAME is a place a name can do damage too" @@ -502,7 +442,6 @@ mod tests { // 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] @@ -515,16 +454,13 @@ mod tests { assert!(n.starts_with(HIVE_PREFIX)); } - /// What `render_agent("atlas")` must render, byte for byte. + /// What `effective_agent("atlas")` must be, 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 - ); + assert_eq!(effective_agent("atlas"), ATLAS_DOCUMENT); } #[test] @@ -532,7 +468,7 @@ mod tests { // `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"); + let p = effective_agent("atlas"); assert!( !p.contains("swarm/agents/*"), "the agent stanza must not widen to the kind: {p}" @@ -546,7 +482,7 @@ mod tests { // 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"); + let p = effective_agent("atlas"); for kind in Kind::ALL { let segment = <&str>::from(kind); let expected = kind == Kind::Agent; @@ -568,13 +504,9 @@ mod tests { 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); + assert_eq!(effective_agent("atlas"), ATLAS_DOCUMENT); // The control: the pin discriminates between agents. - assert!( - !render_agent("other") - .expect("legal") - .contains("agents/atlas/") - ); + assert!(!effective_agent("other").contains("agents/atlas/")); } #[test] @@ -582,7 +514,7 @@ mod tests { // 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"); + let p = effective_agent("atlas"); for capability in ["create", "update", "delete", "sudo", "patch"] { assert!( !p.contains(capability), @@ -596,7 +528,7 @@ mod tests { // `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 p = effective_agent("atlas"); let stanzas: Vec<&str> = p.split_inclusive("}\n").collect(); assert_eq!(stanzas.len(), 2, "{p}"); for stanza in stanzas { @@ -618,22 +550,19 @@ mod tests { #[test] fn one_agents_document_does_not_reach_another_agents_path() { - let a = render_agent("atlas").expect("legal"); + let a = effective_agent("atlas"); 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()); + // The name reaches a role name and a certificate subject, so a name that + // could re-punctuate either is refused. assert!(agent_object_name("atlas/*\" { capabilities = [\"root\"] }").is_err()); + assert!(agent_object_name("").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()); } @@ -673,18 +602,14 @@ mod tests { #[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"); + // A later edit that made one shared document delegate to the other + // would hand an agent the hive's `agents/*` stanza. + let agent = effective_agent("atlas"); + let hive = effective_hive("atlas"); 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. + // The control: the hive's document does carry that stanza. assert!(hive.contains("path \"secret/data/swarm/agents/*\"")); } diff --git a/swarm-secret-client/src/queue.rs b/swarm-secret-client/src/queue.rs index a10ed244..0c32251d 100644 --- a/swarm-secret-client/src/queue.rs +++ b/swarm-secret-client/src/queue.rs @@ -27,8 +27,8 @@ use crate::{ /// Under the agent's own principal prefix, and that is the whole reason for /// this spelling rather than a new top-level one: every agent's ACL document /// already grants read on `swarm/agents//*` -/// ([`crate::policy::render_agent`]), so a credential here needs no new grant, -/// no policy re-render, and no rewrite of any existing agent's document. +/// ([`crate::policy::render_shared_agent`]), so a credential here needs no new grant +/// and no policy rewrite. /// /// # Errors /// [`Error::PathSegment`] when `agent` contains anything but `[A-Za-z0-9_-]`, @@ -68,7 +68,7 @@ pub struct ControllerCredential { /// identity hive-c0re publishes the hive's status to the swarm queue with. /// /// Under the hive's own prefix, which the hive's policy reads -/// ([`crate::policy::render`]). +/// ([`crate::policy::render_shared_hives`]). /// /// The nix half is `swarm-secret-publisher.nix`, which writes it. /// @@ -129,7 +129,7 @@ mod tests { #[test] fn no_hive_policy_reaches_the_controller_client() { - let doc = crate::policy::render("alpha").expect("a plain name is legal"); + let doc = crate::policy::effective_hive("alpha"); // Every stanza is `path "secret/data/*" { … }`. let granted: Vec<&str> = doc .lines() @@ -157,7 +157,7 @@ mod tests { #[test] fn a_hives_own_policy_reads_its_client_and_no_other_hives() { - let doc = crate::policy::render("alpha").expect("a plain name is legal"); + let doc = crate::policy::effective_hive("alpha"); let granted: Vec<&str> = doc .lines() .filter_map(|l| l.strip_prefix("path \"secret/data/")) @@ -216,7 +216,7 @@ mod tests { /// it belongs to. #[test] fn the_agents_existing_policy_already_covers_its_queue_path() { - let document = crate::policy::render_agent("atlas").expect("legal"); + let document = crate::policy::effective_agent("atlas"); let path = agent_queue_path("atlas").expect("legal"); let stanza = path .rsplit_once('/')