Watch
0
0
Fork
You've already forked hyperhive
0

stop writing and attaching per-agent and per-hive bao policies

This commit is contained in:
atlas 2026-10-26 23:02:08 +01:00
commit 139007e656
17 changed files with 253 additions and 419 deletions

View file

@ -369,35 +369,38 @@ that mount's one role for a `hive-agent-<agent>` client certificate at agent
creation. Host roles never pin that CA and agent roles pin only it, so an 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/<agent>/*` and agent's certificate opens that agent's own `swarm/agents/<agent>/*` and
nothing else: `read` on the values there and `list` on their names. 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 share one policy, `hive-agent`, which the agent's cert-auth role
all agents. It states the same rule with `{{identity.entity.name}}` where the attaches and no other. It states the rule with `{{identity.entity.name}}`
per-agent policy has the agent's name, so the rule is one document. For that where an agent's name would go, so the rule is one document and a change to it
the controller names each agent's identity entity after the agent: at agent is one write. `swarm-controller` writes it at agent creation and at its own
creation, and for every agent at its own start, it reads the cert mount's 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 `<agent>` with the alias `hive-agent-<agent>`, accessor and creates the entity `<agent>` with the alias `hive-agent-<agent>`,
or renames the entity a first login created under a generated name. When the alias 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 sits on any other entity, the controller logs an error and changes nothing, and
the `identity/` and `sys/auth/cert` grants in `swarm-bao.nix`'s that agent reads nothing until an operator resolves it. It needs the `identity/`
`controllerPolicyText` for that. Without them it logs a warning and the agent and `sys/auth/cert` grants in `swarm-bao.nix`'s `controllerPolicyText` for that.
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.
A hive's cert-auth role `hive-<hive>` likewise attaches a second policy, A cert-auth role that predates the shared policy may still name a policy
`hive-hives`, shared by all hives. A hive's certificate carries its bare name as `hive-agent-<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-<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 common name, which is the login's alias name, so hives need no entity:
the policy states the hive's own path as the policy states the hive's own path as
`swarm/hives/{{identity.entity.aliases.<accessor>.name}}/*`, where `<accessor>` `swarm/hives/{{identity.entity.aliases.<accessor>.name}}/*`, where `<accessor>`
is the cert mount's accessor. The controller reads the accessor at its own start, 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 spell `hive-hives`: it would be a hive called `hives`, and
`reserved-hive-fragments.nix` refuses the substring `hive`. When the controller `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 can't read the accessor or write the policy it logs a warning and leaves the
attaches the hive's own policy alone, which stays attached. roles untouched, so a role that names a `hive-<hive>` policy keeps it.
### Per-principal identities ### Per-principal identities

View file

@ -17,7 +17,7 @@
//! //!
//! Which accounts exist is read the same way: [`linked_accounts`] lists this //! Which accounts exist is read the same way: [`linked_accounts`] lists this
//! agent's `matrix/` directory, which its policy grants `list` on //! 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 file arm is a `tokenFile` an operator declared in `matrixAccounts`, or
//! the `main` token a hive minted before the swarm did. //! the `main` token a hive minted before the swarm did.

View file

@ -38,7 +38,7 @@ let
# The hive's own store identity. The collector authenticates to the swarm # 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 # 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/<hive>/*`. # (`swarm-secret-client`'s `policy::render_shared_hives`) grants `swarm/hives/<hive>/*`.
runsHive = config.services.hyperhive.otel.collector.enable; runsHive = config.services.hyperhive.otel.collector.enable;
hiveLeaf = hiveLeaf =

View file

@ -872,8 +872,8 @@ let
agentPkiRoleName = baoDeploy.agentPkiRoleName; agentPkiRoleName = baoDeploy.agentPkiRoleName;
# Every common name the agent role issues for: `swarm_secret_client`'s # 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::AGENT_PREFIX`, which names each agent's cert-auth role too.
# policy too. Outside it the role refuses, so the controller cannot obtain # Outside it the role refuses, so the controller cannot obtain
# a certificate for any other name. # a certificate for any other name.
agentCnGlob = "hive-agent-*"; agentCnGlob = "hive-agent-*";
@ -938,7 +938,7 @@ let
# #
# 🩸 Each of these reads exactly ONE path in the store, and does it holding # 🩸 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, # 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/<hive>/*`, and nothing under # `swarm/agents/*` AND `swarm/hives/<hive>/*`, and nothing under
# `swarm/services/`. # `swarm/services/`.
# Principals presenting one certificate are ONE principal to bao, so sharing # Principals presenting one certificate are ONE principal to bao, so sharing

View file

@ -13,11 +13,12 @@
//! is ever moved. Any other arrangement is a [`Plan::Conflict`]: nothing is //! is ever moved. Any other arrangement is a [`Plan::Conflict`]: nothing is
//! written and an operator decides. //! written and an operator decides.
//! //!
//! Everything else here is best effort. An agent's own policy still grants it //! Everything else here is best effort: a failure is logged at `warn` and the
//! its paths, so a store that refuses these calls costs the shared policy and //! caller goes on. An agent whose entity is not named after it reads nothing,
//! nothing else: the failure is logged at `warn` and the caller goes on. The //! because the shared policy is the only one its role attaches; at mint the
//! grants are the `identity/` stanzas of `swarm-bao.nix`'s //! read-back fails, and on a start pass its next reads are refused.
//! `controllerPolicyText`, and a controller can start before they deploy. //! The grants are the `identity/` stanzas of `swarm-bao.nix`'s
//! `controllerPolicyText`.
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
use swarm_secret_client::{ use swarm_secret_client::{
@ -97,7 +98,7 @@ pub async fn ensure_agents(store: &SecretStore, agents: &[String]) {
Err(e) => { Err(e) => {
tracing::warn!( tracing::warn!(
error = %e, 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; return;
} }
@ -173,7 +174,7 @@ async fn ensure_one(store: &SecretStore, accessor: &str, agent: &str) -> Result<
agent, agent,
alias, alias,
entity_name, 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"
); );
} }
} }

View file

@ -17,10 +17,10 @@
//! not derived from the first, so either renews without reference to the other. //! 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 //! 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, //! identity entity's name, the cert-auth role's name, the certificate's common
//! and the authority the role pins. That is the kind of agreement that holds //! name, and the authority the role pins. That is the kind of agreement that
//! in review and fails in production — a mismatch is a 403 naming none of the //! holds in review and fails in production — a mismatch is a 403 naming none
//! four — so [`mint_and_verify`] does not finish on a write. See //! of the four — so [`mint_and_verify`] does not finish on a write. See
//! [`read_back_as_agent`]. //! [`read_back_as_agent`].
//! //!
//! The authority is the store's own agent CA, generated inside its agent PKI //! 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<String>) -> Result<(String, String)> {
/// What the cert-auth role for `agent` is written from. /// What the cert-auth role for `agent` is written from.
struct RoleInputs<'a> { 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, name: String,
/// The authority the role pins: the one that signed this very leaf. /// The authority the role pins: the one that signed this very leaf.
ca: &'a str, ca: &'a str,
/// The policy document the role attaches. /// The policies the role attaches by name: the one every agent shares.
policy: String, attached: Vec<String>,
/// The policies the role attaches by name: the agent's own, and the one every
/// agent shares.
attached: [String; 2],
} }
fn role_inputs<'a>(agent: &str, credential: &'a mtls::Credential) -> Result<RoleInputs<'a>> { fn role_inputs<'a>(agent: &str, credential: &'a mtls::Credential) -> Result<RoleInputs<'a>> {
@ -78,8 +75,7 @@ fn role_inputs<'a>(agent: &str, credential: &'a mtls::Credential) -> Result<Role
Ok(RoleInputs { Ok(RoleInputs {
name: name.clone(), name: name.clone(),
ca: &credential.ca, ca: &credential.ca,
policy: policy::render_agent(agent)?, attached: vec![policy::SHARED_AGENT_POLICY.to_owned()],
attached: [name, policy::SHARED_AGENT_POLICY.to_owned()],
}) })
} }
@ -115,7 +111,7 @@ pub(crate) fn generate_queue_secret() -> Result<String> {
/// Give `agent` an identity at the store, and prove it works. /// 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 /// 1. have the agent PKI role issue a leaf whose common name is
/// [`policy::agent_object_name`]; the store generates its key; /// [`policy::agent_object_name`]; the store generates its key;
@ -124,14 +120,13 @@ pub(crate) fn generate_queue_secret() -> Result<String> {
/// 3. publish a queue secret at [`queue::agent_queue_path`] — the agent's own /// 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 /// identity at the swarm queue, minted here so that the credential an agent
/// presents names *it* rather than its hive; /// presents names *it* rather than its hive;
/// 4. write the ACL document [`policy::render_agent`] renders — read and list on /// 4. write the shared ACL document [`policy::render_shared_agent`] renders;
/// this one agent's paths — and the shared [`policy::render_shared_agent`]; /// 5. write the cert-auth role that ties the three together, attaching that
/// 5. write the cert-auth role that ties the three together, attaching both /// policy and pinning the CA the store named as this leaf's issuer;
/// policies 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 /// Policy before role, for the reason `read_policy::provision` gives.
/// names the policy, so the other order leaves a window in which it points at
/// nothing.
/// ///
/// ⚠️ **Step 3 is idempotent and steps 1–2 are not.** Re-running issues a /// ⚠️ **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 /// 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<String> {
/// replacing one by age is [`crate::agent_renewal`]'s. /// replacing one by age is [`crate::agent_renewal`]'s.
/// ///
/// # Errors /// # Errors
/// Anything that stops one of those five steps, with the step named. A /// Anything that stops steps 1–5, with the step named, or the read-back login,
/// failure here fails the job node and nothing else — the agent is still /// which is where a step 6 that did not take shows up. A failure here fails the
/// created, without a store identity. /// job node and nothing else — the agent is still created, without a store
/// identity.
pub async fn mint_and_verify(agent: &str) -> Result<()> { pub async fn mint_and_verify(agent: &str) -> Result<()> {
let (mount, pki_role) = agent_pki(|k| std::env::var(k).ok())?; let (mount, pki_role) = agent_pki(|k| std::env::var(k).ok())?;
let name = policy::agent_object_name(agent)?; 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 queue_credential = wanted;
let inputs = role_inputs(agent, &credential)?; let inputs = role_inputs(agent, &credential)?;
store
.write_policy(&inputs.name, &inputs.policy)
.await
.with_context(|| format!("writing the read policy {}", inputs.name))?;
store store
.write_policy(policy::SHARED_AGENT_POLICY, &policy::render_shared_agent()) .write_policy(policy::SHARED_AGENT_POLICY, &policy::render_shared_agent())
.await .await
@ -220,7 +212,11 @@ pub async fn mint_and_verify(agent: &str) -> Result<()> {
&inputs.name, &inputs.name,
inputs.ca, inputs.ca,
&inputs.name, &inputs.name,
&inputs.attached.each_ref().map(String::as_str), &inputs
.attached
.iter()
.map(String::as_str)
.collect::<Vec<_>>(),
) )
.await .await
.with_context(|| format!("writing the cert-auth role {}", inputs.name))?; .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 /// 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 /// 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 /// 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. /// — 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 /// 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 /// sets them: role and matched CN are one name; the pinned CA is the issuer
/// the issuer the store reported for this leaf; the policy is the agent's /// the store reported for this leaf; the role attaches the shared policy
/// own single stanza. /// and no policy of the agent's own.
#[test] #[test]
fn the_role_pins_the_leafs_own_issuer_under_the_agents_name() { fn the_role_pins_the_leafs_own_issuer_under_the_agents_name() {
let credential = mtls::Credential { let credential = mtls::Credential {
@ -468,17 +464,21 @@ mod tests {
"the CN the leaf is issued for" "the CN the leaf is issued for"
); );
assert_eq!(inputs.ca, "AGENT-CA"); assert_eq!(inputs.ca, "AGENT-CA");
// Only the shared policy: a policy named for the agent is not attached.
assert_eq!( assert_eq!(
inputs.attached, inputs.attached,
["hive-agent-atlas", policy::SHARED_AGENT_POLICY], [policy::SHARED_AGENT_POLICY],
"the role attaches the agent's own policy and the shared one" "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"); let other = role_inputs("argus", &credential).expect("legal");
assert_ne!(other.name, inputs.name); assert_ne!(other.name, inputs.name);
assert!(!other.policy.contains("agents/atlas/"), "{}", other.policy); assert_eq!(other.attached, inputs.attached);
} }
} }

View file

@ -217,8 +217,8 @@ async fn main() -> Result<()> {
// abandoned, since the two of them boot together. // abandoned, since the two of them boot together.
let hive_names: Vec<String> = hives.iter().map(|h| h.name.clone()).collect(); let hive_names: Vec<String> = hives.iter().map(|h| h.name.clone()).collect();
read_policy::ensure_hive_access(hive_names).await; read_policy::ensure_hive_access(hive_names).await;
// Agents keep the policy they were minted with until this rewrites it, so // The one policy every agent's role attaches, and the entity each agent's
// what `policy::render_agent` grants today reaches them only from here. // login is read under, are checked here at every start.
read_policy::ensure_agent_policies().await; read_policy::ensure_agent_policies().await;
let state = AppState { let state = AppState {

View file

@ -14,18 +14,16 @@
//! take down a daemon that serves everything else, and a store that answers is //! 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. //! 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 //! The rule is written once for all hives, [`policy::SHARED_HIVE_POLICY`]: one
//! everywhere, the other is scoped to the reader's own name //! 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 //! ([`swarm_secret_client::policy`] explains the asymmetry), taken from its
//! also written once for all hives, [`policy::SHARED_HIVE_POLICY`], with the //! login's alias on the cert mount instead of written into the text. A hive's
//! reader's own name taken from its login's alias on the cert mount instead of //! role attaches that policy and no other. The hive list is loaded once because
//! written into the text; a hive's role attaches both. The hive list is loaded //! a config change means a redeploy.
//! once because a config change means a redeploy.
//! //!
//! Every agent's policy is rewritten by the same kind of pass //! The shared agent policy is written by the same kind of pass
//! ([`ensure_agent_policies`]), so a change to `policy::render_agent` reaches //! ([`ensure_agent_policies`]), together with each agent's identity entity.
//! agents minted before it. Its roster is the store's `hive-agent-*` cert-auth //! Its roster is the store's `hive-agent-*` cert-auth roles.
//! roles.
use std::{future::Future, time::Duration}; use std::{future::Future, time::Duration};
@ -128,10 +126,12 @@ where
None 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 /// 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 /// # Errors
/// [`Unreachable`] for the states a later attempt can resolve: a store that is /// [`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())), Err(e) => return Err(Unreachable(e.to_string())),
}; };
for hive in hives { if !write_shared_policy(&store).await {
if let Err(e) = write_policy_for(&store, hive).await { return Ok(());
tracing::warn!(hive, error = %format!("{e:#}"), "writing this hive's read policy failed");
}
} }
let shared = write_shared_policy(&store).await;
let Some(path) = std::env::var_os(ENV_HIVE_CLIENT_CA) else { 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"); 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) let ca = std::fs::read_to_string(&path)
.map_err(|e| Unreachable(format!("reading the hive authority {path}: {e}")))?; .map_err(|e| Unreachable(format!("reading the hive authority {path}: {e}")))?;
for hive in hives { 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"); tracing::warn!(hive, error = %format!("{e:#}"), "creating this hive's store role failed");
} }
} }
Ok(()) 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 /// 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 /// accessor. Answers whether it is in place: hive roles are written only then.
/// then.
/// ///
/// Best effort. A hive's own policy grants it the same paths, so a store that /// A store that refuses the accessor read (the controller's policy may not
/// refuses the accessor read (the controller's policy may not carry it yet) or /// carry it yet) or the write leaves the roles already in the store as they are.
/// the write costs the shared policy and nothing else.
async fn write_shared_policy(store: &SecretStore) -> bool { async fn write_shared_policy(store: &SecretStore) -> bool {
let accessor = match store.auth_mount_accessor(DEFAULT_CERT_MOUNT).await { let accessor = match store.auth_mount_accessor(DEFAULT_CERT_MOUNT).await {
Ok(accessor) => accessor, Ok(accessor) => accessor,
Err(e) => { Err(e) => {
tracing::warn!( tracing::warn!(
error = %e, 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; return false;
} }
@ -210,7 +194,7 @@ async fn write_shared_policy(store: &SecretStore) -> bool {
let document = match policy::render_shared_hives(&accessor) { let document = match policy::render_shared_hives(&accessor) {
Ok(document) => document, Ok(document) => document,
Err(e) => { 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; return false;
} }
}; };
@ -229,28 +213,23 @@ async fn write_shared_policy(store: &SecretStore) -> bool {
tracing::warn!( tracing::warn!(
policy = policy::SHARED_HIVE_POLICY, policy = policy::SHARED_HIVE_POLICY,
error = %e, 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 false
} }
} }
} }
/// The policies `hive`'s role attaches: its own, and the shared one when that /// The policies a hive's role attaches: the shared one, and no policy named for
/// is in place. /// the hive.
fn attached_policies(hive: &str, shared: bool) -> Result<Vec<String>, Error> { fn attached_policies() -> Vec<String> {
let mut attached = vec![policy::hive_object_name(hive)?]; vec![policy::SHARED_HIVE_POLICY.to_owned()]
if shared {
attached.push(policy::SHARED_HIVE_POLICY.to_owned());
}
Ok(attached)
} }
/// The role for one hive: its own certificate's subject, its own policy, and /// The role for one hive: its own certificate's subject and the shared policy.
/// the shared one when `shared`. async fn write_role(store: &SecretStore, hive: &str, ca: &str) -> Result<()> {
async fn write_role(store: &SecretStore, hive: &str, ca: &str, shared: bool) -> Result<()> {
let name = policy::hive_object_name(hive)?; let name = policy::hive_object_name(hive)?;
let attached = attached_policies(hive, shared)?; let attached = attached_policies();
store store
// The subject is the hive's name: `glue-bao-tls.nix` mints a hive's // 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. // 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(()) Ok(())
} }
/// Rewrite every agent's policy from [`policy::render_agent`], with the retry /// Write the shared agent policy and give every agent its identity entity, with
/// [`ensure_hive_access`] has and for the same reason. /// 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, /// Returns once the first pass is done. When that pass cannot reach the store,
/// it repeats in the background for up to [`RETRY_WINDOW`]. /// it repeats in the background for up to [`RETRY_WINDOW`].
@ -294,7 +273,7 @@ pub async fn ensure_agent_policies() {
tracing::warn!( tracing::warn!(
attempts = RETRY_ATTEMPTS, attempts = RETRY_ATTEMPTS,
window = ?RETRY_WINDOW, 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 /// 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 /// `agent_identity::mint_and_verify`'s, and that reissues the certificate, so
/// it is not what a pass on every start may call. /// 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" "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; crate::agent_entity::ensure_agents(&store, &policy::agents_from_role_names(&roles)).await;
Ok(()) 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-<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)] #[cfg(test)]
mod tests { mod tests {
use std::{cell::Cell, time::Duration}; use std::{cell::Cell, time::Duration};
use super::{ use super::{
RETRY_ATTEMPTS, RETRY_INTERVAL, RETRY_WINDOW, Unreachable, agent_policies, RETRY_ATTEMPTS, RETRY_INTERVAL, RETRY_WINDOW, Unreachable, attached_policies, policy,
attached_policies, policy, retry_until_ok, retry_until_ok,
}; };
#[test] #[test]
@ -424,54 +374,15 @@ mod tests {
} }
#[test] #[test]
fn the_pass_writes_agents_policies_under_their_role_names_and_no_one_elses() { fn a_hives_role_attaches_the_shared_policy_and_none_named_for_the_hive() {
let roles: Vec<String> = [ let attached = attached_policies();
"hive-agent-atlas", assert_eq!(attached, [policy::SHARED_HIVE_POLICY]);
"hive-pr1ma", let own = policy::hive_object_name("pr1ma").expect("legal");
"swarm-controller", assert!(
"hive-agent-argus", !attached.contains(&own),
] "the role must not attach a policy named for the hive"
.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`"
); );
assert_eq!(written[1].1, policy::render_agent("argus").expect("legal")); // The control: the same lookup finds a policy that is attached.
} assert!(attached.contains(&policy::SHARED_HIVE_POLICY.to_owned()));
#[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}"
);
}
} }
} }

View file

@ -14,7 +14,7 @@ use crate::{
/// The path holding `agent`'s ACP provider API key. /// The path holding `agent`'s ACP provider API key.
/// ///
/// A flat leaf under the agent's prefix, like [`crate::forge::agent_token_path`], /// 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. /// covers it.
/// ///
/// # Errors /// # Errors

View file

@ -18,7 +18,7 @@ use crate::{
/// ///
/// A flat leaf under the agent's prefix, like /// A flat leaf under the agent's prefix, like
/// [`crate::github::account_path`], so the agent's own read stanza /// [`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 /// # Errors
/// [`Error::PathSegment`] when `agent` contains anything but `[A-Za-z0-9_-]`, /// [`Error::PathSegment`] when `agent` contains anything but `[A-Za-z0-9_-]`,
@ -88,12 +88,12 @@ mod tests {
#[test] #[test]
fn the_agents_own_read_grant_covers_the_path() { fn the_agents_own_read_grant_covers_the_path() {
let path = credentials_path("atlas").expect("legal"); let path = credentials_path("atlas").expect("legal");
let own = crate::policy::render_agent("atlas").expect("legal"); let own = crate::policy::effective_agent("atlas");
assert!( assert!(
covered_by(&own, &path), covered_by(&own, &path),
"no stanza in\n{own}\ncovers {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}"); assert!(!covered_by(&other, &path), "argus's policy reaches {path}");
} }

View file

@ -780,7 +780,7 @@ mod tests {
let request = WriteAclPolicy { let request = WriteAclPolicy {
name: "hive-pr1ma".to_owned(), 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"); assert_eq!(request.path(), "sys/policies/acl/hive-pr1ma");
} }
@ -791,7 +791,7 @@ mod tests {
let request = WriteAclPolicy { let request = WriteAclPolicy {
name: "hive-pr1ma".to_owned(), 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 let body = request
.body() .body()
@ -799,10 +799,7 @@ mod tests {
.expect("a policy write sends one"); .expect("a policy write sends one");
let sent: serde_json::Value = let sent: serde_json::Value =
serde_json::from_slice(&body).expect("the body is the JSON the store parses"); serde_json::from_slice(&body).expect("the body is the JSON the store parses");
assert_eq!( assert_eq!(sent["policy"], crate::policy::effective_hive("pr1ma"));
sent["policy"],
crate::policy::render("pr1ma").expect("legal")
);
assert!( assert!(
sent.get("name").is_none(), sent.get("name").is_none(),
"`name` addresses the policy in the path; sending it too would make \ "`name` addresses the policy in the path; sending it too would make \

View file

@ -28,7 +28,7 @@ use crate::{
/// ///
/// A flat leaf under the agent's prefix, like its controller-minted siblings /// A flat leaf under the agent's prefix, like its controller-minted siblings
/// [`crate::queue::agent_queue_path`] and [`crate::mtls::identity_path`], so /// [`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. /// already covers it.
/// ///
/// # Errors /// # Errors
@ -190,7 +190,7 @@ mod tests {
#[test] #[test]
fn no_hive_policy_reaches_the_controller_token() { 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| { let covers = |path: &str| {
policy.lines().any(|line| { policy.lines().any(|line| {
line.strip_prefix("path \"") line.strip_prefix("path \"")
@ -232,7 +232,7 @@ mod tests {
#[test] #[test]
fn the_agents_own_grants_list_its_accounts_and_read_each() { 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| { let covers = |path: &str| {
policy.lines().any(|line| { policy.lines().any(|line| {
line.strip_prefix("path \"") line.strip_prefix("path \"")
@ -309,7 +309,7 @@ mod tests {
// policy is rendered elsewhere. If the path ever moved out from under // policy is rendered elsewhere. If the path ever moved out from under
// it, the agent's fetch would 403 at boot, naming neither. // it, the agent's fetch would 403 at boot, naming neither.
let path = agent_token_path("atlas").expect("legal"); 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| { let covered = policy.lines().any(|line| {
line.strip_prefix("path \"") line.strip_prefix("path \"")
.and_then(|rest| rest.split_once("\" {")) .and_then(|rest| rest.split_once("\" {"))
@ -324,7 +324,7 @@ mod tests {
// Control for the test above: the prefix match must actually be // Control for the test above: the prefix match must actually be
// discriminating, or it proves nothing. // discriminating, or it proves nothing.
let path = agent_token_path("atlas").expect("legal"); 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| { let covered = policy.lines().any(|line| {
line.strip_prefix("path \"") line.strip_prefix("path \"")
.and_then(|rest| rest.split_once("\" {")) .and_then(|rest| rest.split_once("\" {"))
@ -357,15 +357,12 @@ mod tests {
#[test] #[test]
fn a_hive_reaches_its_own_forge_token_and_no_other_hives() { fn a_hive_reaches_its_own_forge_token_and_no_other_hives() {
let covered_by = |hive: &str, path: &str| { let covered_by = |hive: &str, path: &str| {
crate::policy::render(hive) crate::policy::effective_hive(hive).lines().any(|line| {
.expect("legal") line.strip_prefix("path \"")
.lines() .and_then(|rest| rest.split_once("\" {"))
.any(|line| { .and_then(|(p, _)| p.strip_suffix('*'))
line.strip_prefix("path \"") .is_some_and(|prefix| format!("secret/data/{path}").starts_with(prefix))
.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"); let alpha = hive_token_path("alpha").expect("legal");
assert!(covered_by("alpha", &alpha), "alpha cannot read {alpha}"); assert!(covered_by("alpha", &alpha), "alpha cannot read {alpha}");

View file

@ -18,7 +18,7 @@ use crate::{
/// ///
/// A flat leaf under the agent's prefix, like /// A flat leaf under the agent's prefix, like
/// [`crate::forge::agent_token_path`], so the agent's own read stanza /// [`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 /// # Errors
/// [`Error::PathSegment`] when `agent` contains anything but `[A-Za-z0-9_-]`, /// [`Error::PathSegment`] when `agent` contains anything but `[A-Za-z0-9_-]`,
@ -84,13 +84,13 @@ mod tests {
#[test] #[test]
fn the_agents_own_read_grant_covers_the_path() { fn the_agents_own_read_grant_covers_the_path() {
let path = account_path("atlas").expect("legal"); let path = account_path("atlas").expect("legal");
let own = crate::policy::render_agent("atlas").expect("legal"); let own = crate::policy::effective_agent("atlas");
assert!( assert!(
covered_by(&own, &path), covered_by(&own, &path),
"no stanza in\n{own}\ncovers {path}" "no stanza in\n{own}\ncovers {path}"
); );
// The control: another agent's grant does not reach it. // 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}"); assert!(!covered_by(&other, &path), "argus's policy reaches {path}");
} }

View file

@ -118,7 +118,7 @@ pub(crate) const CONTROLLER: &str = "swarm-controller";
/// ///
/// The matrix container mints it and publishes it here; `swarm-controller` /// The matrix container mints it and publishes it here; `swarm-controller`
/// reads it to create agents' accounts. Under [`Kind::Controller`] because /// 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/<hive>/*`, and nothing else. /// `agents/*` and its own `hives/<hive>/*`, and nothing else.
/// ///
/// # Errors /// # Errors
@ -186,7 +186,7 @@ mod tests {
fn the_agent_reads_its_own_recovery_passphrase_and_no_other() { fn the_agent_reads_its_own_recovery_passphrase_and_no_other() {
// The daemon reads this under the agent's own policy, so the rendered // The daemon reads this under the agent's own policy, so the rendered
// document is what has to cover it. // 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 let granted: Vec<&str> = doc
.lines() .lines()
.filter_map(|l| l.strip_prefix("path \"secret/data/")) .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 // be under any stanza a hive's policy renders. Checked against the
// rendered document rather than against the prefix, because the // rendered document rather than against the prefix, because the
// document is what the store enforces. // 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/<prefix>*" { … }`. // Every stanza is `path "secret/data/<prefix>*" { … }`.
let granted: Vec<&str> = doc let granted: Vec<&str> = doc
.lines() .lines()

View file

@ -106,12 +106,12 @@ mod tests {
} }
/// The path sits under the same prefix an agent's policy grants /// The path sits under the same prefix an agent's policy grants
/// (`policy::render_agent`'s stanza is `swarm/agents/<agent>/*`), which is /// (`policy::render_shared_agent`'s stanza is `swarm/agents/<entity name>/*`), which is
/// what makes an agent able to read its own identity back. /// what makes an agent able to read its own identity back.
#[test] #[test]
fn an_agents_identity_is_inside_its_own_policy_stanza() { fn an_agents_identity_is_inside_its_own_policy_stanza() {
let path = identity_path("atlas").expect("legal"); 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); let prefix = format!("{}/data/swarm/agents/atlas/", crate::path::MOUNT);
assert!( assert!(
path.starts_with("swarm/agents/atlas/"), path.starts_with("swarm/agents/atlas/"),

View file

@ -5,8 +5,9 @@
//! two have to agree on the same path or a delivery fails with a 403 that names //! two have to agree on the same path or a delivery fails with a 403 that names
//! nothing. //! nothing.
//! //!
//! The document has one stanza per kind a hive reads, and they are not all //! The hive document ([`render_shared_hives`]) has one stanza per kind a hive
//! scoped alike — which is the point rather than an inconsistency: //! 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 //! ⚠️ 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 //! 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}, 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 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 /// the difference between a hive the controller may provision and a policy it
/// must not be able to rewrite — including its own. /// must not be able to rewrite — including its own.
pub const HIVE_PREFIX: &str = "hive-"; pub const HIVE_PREFIX: &str = "hive-";
/// The policy and cert-auth role name for `hive`. One name, both objects: the /// The cert-auth role name for `hive`.
/// role attaches the policy by spelling it identically.
/// ///
/// # Errors /// # Errors
/// [`Error::PathSegment`] when `hive` holds anything but `[A-Za-z0-9_-]`. /// [`Error::PathSegment`] when `hive` holds anything but `[A-Za-z0-9_-]`.
@ -50,7 +50,7 @@ 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. /// Namespace for an agent's cert-auth role and certificate common name.
/// ///
/// Deliberately *inside* [`HIVE_PREFIX`] rather than beside it. /// Deliberately *inside* [`HIVE_PREFIX`] rather than beside it.
/// `sys/policies/acl/hive-*` and `auth/cert/certs/hive-*` are the whole of what /// `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<String, Error> {
/// learns an agent exists — a name outside that prefix is one nothing can /// learns an agent exists — a name outside that prefix is one nothing can
/// write. It costs no authority: the controller already holds `create`/`update` /// write. It costs no authority: the controller already holds `create`/`update`
/// on `secret/data/swarm/agents/*`, so it can already replace every credential /// 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 /// It still cannot collide with a hive's. [`hive_object_name`] renders
/// `hive-<hive>`, so a collision needs a hive named `agent-<agent>` — and /// `hive-<hive>`, so a collision needs a hive named `agent-<agent>` — and
@ -75,9 +75,9 @@ pub fn hive_object_name(hive: &str) -> Result<String, Error> {
/// agent leaf. A CN match alone logs nobody in. /// agent leaf. A CN match alone logs nobody in.
pub const AGENT_PREFIX: &str = "hive-agent-"; pub const AGENT_PREFIX: &str = "hive-agent-";
/// The policy and cert-auth role name for `agent`, and the common name of the /// The cert-auth role name for `agent`, and the common name of the certificate
/// certificate that authenticates it. One string, three objects: the role /// that authenticates it. One string, two objects: the role matches the subject
/// attaches the policy and matches the subject by spelling all three the same. /// by spelling both the same.
/// ///
/// Injective in `agent` — the prefix is fixed and [`checked_segment`] has /// Injective in `agent` — the prefix is fixed and [`checked_segment`] has
/// already refused anything that could re-punctuate the suffix. Agent names /// 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") format!("path \"{path}\" {{\n capabilities = [\"list\"]\n}}\n")
} }
/// Render `hive`'s policy document: read on every agent's credentials and on /// The one policy every hive's cert-auth role attaches.
/// this hive's own.
/// ///
/// The name is the only input, and it is deploy-time — so the document is /// Inside [`HIVE_PREFIX`] so that the controller may write it. It is the object
/// still a deploy-time object rather than derived state with a re-emission to /// name of a hive called `hives`, and `nix/reserved-hive-fragments.nix` forbids
/// get wrong. Nothing about which agents exist changes the text. /// 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/<hive>/` 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. /// 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 /// # Errors
/// [`Error::PathSegment`] when `hive` holds anything but `[A-Za-z0-9_-]` — the /// [`Error::PathSegment`] when `mount_accessor` holds anything but
/// name is interpolated into a policy path, so a name that could close the /// `[A-Za-z0-9_-]`: it is interpolated into the policy text, where it could
/// stanza could grant itself anything. /// close the stanza.
pub fn render(hive: &str) -> Result<String, Error> { pub fn render_shared_hives(mount_accessor: &str) -> Result<String, Error> {
checked_segment("hive", hive)?; checked_segment("mount accessor", mount_accessor)?;
Ok(hive_document(hive)) let own = format!("{{{{identity.entity.aliases.{mount_accessor}.name}}}}");
}
/// 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 {
let agents = read_stanza(&format!( let agents = read_stanza(&format!(
"{MOUNT}/data/{ROOT}/{}/*", "{MOUNT}/data/{ROOT}/{}/*",
<&str>::from(Kind::Agent) <&str>::from(Kind::Agent)
@ -165,76 +169,10 @@ fn hive_document(own: &str) -> String {
"{MOUNT}/data/{ROOT}/{}/{own}/*", "{MOUNT}/data/{ROOT}/{}/{own}/*",
<&str>::from(Kind::Hive) <&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. /// The one policy every agent's cert-auth role attaches.
///
/// 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/<hive>/` 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<String, Error> {
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/<agent>/…`, lists the keys under it, and is refused
/// every other path in the store. That is the point — the reason to give an
/// agent an identity is that it then depends on its hive for one file (the
/// certificate) rather than for every credential it uses.
///
/// ⚠️ **None of [`render`]'s breadth is inherited.** A hive's document grants
/// read on `swarm/agents/*` — *every* agent's credentials, not the ones that
/// 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<String, Error> {
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.
/// ///
/// Inside [`HIVE_PREFIX`] so that the controller may write it, and without an /// 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: /// 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. /// agent's name.
const ENTITY_NAME_TEMPLATE: &str = "{{identity.entity.name}}"; const ENTITY_NAME_TEMPLATE: &str = "{{identity.entity.name}}";
/// Render the policy [`SHARED_AGENT_POLICY`] holds: the stanzas of /// Render the policy [`SHARED_AGENT_POLICY`] holds: read on one agent's
/// [`render_agent`], with the agent's name taken from the identity entity of /// credentials and list on their names (how `hive-matrix-daemon` finds its
/// whoever presents the token instead of written into the text. /// 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. /// 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 /// 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 /// each agent's entity after the agent, and a login that no entity of that
/// name owns reads `swarm/agents/<generated name>/`, where nothing is stored. /// name owns reads `swarm/agents/<generated name>/`, 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] #[must_use]
pub fn render_shared_agent() -> String { pub fn render_shared_agent() -> String {
let kind = <&str>::from(Kind::Agent); let kind = <&str>::from(Kind::Agent);
@ -268,23 +213,36 @@ pub fn render_shared_agent() -> String {
format!("{read}{list}") 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test] #[test]
fn the_shared_document_is_the_per_agent_one_with_the_entity_name_for_the_agent() { fn the_shared_agent_document_reads_the_entity_name_where_the_agent_goes() {
let shared = render_shared_agent();
assert_eq!( assert_eq!(
shared, render_shared_agent(),
"path \"secret/data/swarm/agents/{{identity.entity.name}}/*\" {\n capabilities = [\"read\"]\n}\n\ "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" 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] #[test]
@ -314,28 +272,26 @@ mod tests {
#[test] #[test]
fn the_document_grants_every_agent_and_this_hive_alone() { fn the_document_grants_every_agent_and_this_hive_alone() {
assert_eq!( 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/agents/*\" {\n capabilities = [\"read\"]\n}\n\
path \"secret/data/swarm/hives/pr1ma/*\" {\n capabilities = [\"read\"]\n}\n" path \"secret/data/swarm/hives/pr1ma/*\" {\n capabilities = [\"read\"]\n}\n"
); );
} }
#[test] #[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"); let shared = render_shared_hives("auth_cert_1a2b3c4d").expect("legal accessor");
assert_eq!( assert_eq!(
shared, shared,
"path \"secret/data/swarm/agents/*\" {\n capabilities = [\"read\"]\n}\n\ "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" 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 // The template fills in for hives that differ in length, case and
// in, for hives that differ in length, case and punctuation. // punctuation, and each then reads exactly its own path.
for hive in ["pr1ma", "a", "Mixed_Case-1", "x".repeat(63).as_str()] { for hive in ["pr1ma", "a", "Mixed_Case-1", "x".repeat(63).as_str()] {
assert_eq!( let own = effective_hive(hive);
shared.replace("{{identity.entity.aliases.auth_cert_1a2b3c4d.name}}", hive), assert!(own.contains(&format!("swarm/hives/{hive}/*")), "{own}");
render(hive).expect("legal"), assert!(!own.contains("{{"), "{own}");
"{hive}"
);
} }
} }
@ -387,7 +343,7 @@ mod tests {
// A swarm service's OIDC client secret is read under a certificate that // 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 // is not a hive's. Checked by walking the stanzas, so a wider grant
// that happened to cover the tree (`swarm/*`, say) fails too. // 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}"); assert!(!p.contains("swarm/services"), "{p}");
let service = "swarm/services/swarm-grafana/oidc/client"; let service = "swarm/services/swarm-grafana/oidc/client";
assert!( assert!(
@ -408,7 +364,7 @@ mod tests {
/// means something if the stanza that reaches it is too. /// means something if the stanza that reaches it is too.
#[test] #[test]
fn a_hive_reaches_its_own_matrix_sender_token_and_no_other_hives() { 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 own = crate::matrix::sender_token_path("alpha").expect("legal");
let other = crate::matrix::sender_token_path("beta").expect("legal"); let other = crate::matrix::sender_token_path("beta").expect("legal");
@ -456,7 +412,7 @@ mod tests {
fn the_grant_is_read_only() { fn the_grant_is_read_only() {
// A hive reads credentials; a hive that could write one could hand // A hive reads credentials; a hive that could write one could hand
// itself an agent's identity. // itself an agent's identity.
let p = render("pr1ma").expect("legal"); let p = effective_hive("pr1ma");
assert!(!p.contains("create")); assert!(!p.contains("create"));
assert!(!p.contains("update")); assert!(!p.contains("update"));
assert!(!p.contains("delete")); assert!(!p.contains("delete"));
@ -465,32 +421,16 @@ mod tests {
#[test] #[test]
fn one_hives_document_does_not_reach_another_hives_path() { fn one_hives_document_does_not_reach_another_hives_path() {
// Replaces `every_hive_gets_a_byte_identical_document`: the hive stanza let a = effective_hive("alpha");
// 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");
assert!(a.contains("swarm/hives/alpha/*")); assert!(a.contains("swarm/hives/alpha/*"));
assert!(!a.contains("beta")); assert!(!a.contains("beta"));
assert!(!a.contains("swarm/hives/*"), "the hive stanza stays narrow"); 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] #[test]
fn a_name_that_could_close_the_stanza_is_refused() { fn a_name_that_could_close_the_stanza_is_refused() {
// The name reaches the document now, which it did not before — so the // The name reaches a role name and a certificate subject, so a name that
// injection case is live again in the policy TEXT, not just in the // could re-punctuate either is refused.
// policy's identifier.
assert!(render("alpha/*\" { capabilities = [\"root\"] }").is_err());
assert!( assert!(
hive_object_name("atlas/*\" { capabilities = [\"root\"] }").is_err(), hive_object_name("atlas/*\" { capabilities = [\"root\"] }").is_err(),
"the object NAME is a place a name can do damage too" "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 // The control for the case above: if every name were refused, that
// assertion would pass while proving nothing. // assertion would pass while proving nothing.
assert!(hive_object_name("a-b_C9").is_ok()); assert!(hive_object_name("a-b_C9").is_ok());
assert!(render("a-b_C9").is_ok());
} }
#[test] #[test]
@ -515,16 +454,13 @@ mod tests {
assert!(n.starts_with(HIVE_PREFIX)); 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\ 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"; path \"secret/metadata/swarm/agents/atlas/*\" {\n capabilities = [\"list\"]\n}\n";
#[test] #[test]
fn an_agents_document_is_two_stanzas_naming_that_agent() { fn an_agents_document_is_two_stanzas_naming_that_agent() {
assert_eq!( assert_eq!(effective_agent("atlas"), ATLAS_DOCUMENT);
render_agent("atlas").expect("a plain name is legal"),
ATLAS_DOCUMENT
);
} }
#[test] #[test]
@ -532,7 +468,7 @@ mod tests {
// `render`'s agent stanza is `agents/*` on purpose, and inheriting one // `render`'s agent stanza is `agents/*` on purpose, and inheriting one
// character of that here would give every agent every other agent's // character of that here would give every agent every other agent's
// credentials while the document still read as per-agent. // credentials while the document still read as per-agent.
let p = render_agent("atlas").expect("legal"); let p = effective_agent("atlas");
assert!( assert!(
!p.contains("swarm/agents/*"), !p.contains("swarm/agents/*"),
"the agent stanza must not widen to the kind: {p}" "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 // 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 // paths, so a kind added to `path::Kind` cannot be granted here by a
// renderer nobody re-read. // renderer nobody re-read.
let p = render_agent("atlas").expect("legal"); let p = effective_agent("atlas");
for kind in Kind::ALL { for kind in Kind::ALL {
let segment = <&str>::from(kind); let segment = <&str>::from(kind);
let expected = kind == Kind::Agent; let expected = kind == Kind::Agent;
@ -568,13 +504,9 @@ mod tests {
fn an_agents_document_is_exactly_its_own_two_stanzas() { fn an_agents_document_is_exactly_its_own_two_stanzas() {
// Pinned byte for byte: an added stanza (a hive's queue credential, a // Pinned byte for byte: an added stanza (a hive's queue credential, a
// second agent) is exactly what a presence check misses. // 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. // The control: the pin discriminates between agents.
assert!( assert!(!effective_agent("other").contains("agents/atlas/"));
!render_agent("other")
.expect("legal")
.contains("agents/atlas/")
);
} }
#[test] #[test]
@ -582,7 +514,7 @@ mod tests {
// An agent that could write its own credentials could hand itself an // An agent that could write its own credentials could hand itself an
// identity it was never issued — and `create`/`update` on that path is // identity it was never issued — and `create`/`update` on that path is
// exactly what the controller holds, so the wall is the capability. // 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"] { for capability in ["create", "update", "delete", "sudo", "patch"] {
assert!( assert!(
!p.contains(capability), !p.contains(capability),
@ -596,7 +528,7 @@ mod tests {
// `read` on `data/` is the values; `list` on `metadata/` is the key // `read` on `data/` is the values; `list` on `metadata/` is the key
// names. `read` on `metadata/` (version history, deletion times) is not // names. `read` on `metadata/` (version history, deletion times) is not
// granted, nor is `list` anywhere but `metadata/`. // 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(); let stanzas: Vec<&str> = p.split_inclusive("}\n").collect();
assert_eq!(stanzas.len(), 2, "{p}"); assert_eq!(stanzas.len(), 2, "{p}");
for stanza in stanzas { for stanza in stanzas {
@ -618,22 +550,19 @@ mod tests {
#[test] #[test]
fn one_agents_document_does_not_reach_another_agents_path() { 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("swarm/agents/atlas/*"));
assert!(!a.contains("argus")); assert!(!a.contains("argus"));
assert_eq!(a, render_agent("atlas").expect("legal"));
} }
#[test] #[test]
fn an_agent_name_that_could_close_the_stanza_is_refused() { fn an_agent_name_that_could_close_the_stanza_is_refused() {
// Same live injection surface as the hive renderer's: the name reaches // The name reaches a role name and a certificate subject, so a name that
// the document text, not just an identifier. // could re-punctuate either is refused.
assert!(render_agent("atlas/*\" { capabilities = [\"root\"] }").is_err());
assert!(render_agent("").is_err());
assert!(agent_object_name("atlas/*\" { capabilities = [\"root\"] }").is_err()); 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 control: if everything were refused the arms above would pass for
// the wrong reason. // the wrong reason.
assert!(render_agent("a-b_C9").is_ok());
assert!(agent_object_name("a-b_C9").is_ok()); assert!(agent_object_name("a-b_C9").is_ok());
} }
@ -673,18 +602,14 @@ mod tests {
#[test] #[test]
fn an_agents_document_is_not_the_hives() { fn an_agents_document_is_not_the_hives() {
// The pin the two renderers need against each other: a later edit that // A later edit that made one shared document delegate to the other
// made `render_agent` delegate to `render`, or vice versa, would hand // would hand an agent the hive's `agents/*` stanza.
// an agent the hive's `agents/*` stanza. let agent = effective_agent("atlas");
let agent = render_agent("atlas").expect("legal"); let hive = effective_hive("atlas");
let hive = render("atlas").expect("legal");
assert_ne!(agent, hive); assert_ne!(agent, hive);
assert!(!agent.contains("swarm/services/")); assert!(!agent.contains("swarm/services/"));
assert!(!agent.contains("swarm/hives/")); assert!(!agent.contains("swarm/hives/"));
// And the hive's is untouched by this module's growth: still the two // The control: the hive's document does carry that stanza.
// 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.
assert!(hive.contains("path \"secret/data/swarm/agents/*\"")); assert!(hive.contains("path \"secret/data/swarm/agents/*\""));
} }

View file

@ -27,8 +27,8 @@ use crate::{
/// Under the agent's own principal prefix, and that is the whole reason for /// 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 /// this spelling rather than a new top-level one: every agent's ACL document
/// already grants read on `swarm/agents/<agent>/*` /// already grants read on `swarm/agents/<agent>/*`
/// ([`crate::policy::render_agent`]), so a credential here needs no new grant, /// ([`crate::policy::render_shared_agent`]), so a credential here needs no new grant
/// no policy re-render, and no rewrite of any existing agent's document. /// and no policy rewrite.
/// ///
/// # Errors /// # Errors
/// [`Error::PathSegment`] when `agent` contains anything but `[A-Za-z0-9_-]`, /// [`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. /// 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 /// 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. /// The nix half is `swarm-secret-publisher.nix`, which writes it.
/// ///
@ -129,7 +129,7 @@ mod tests {
#[test] #[test]
fn no_hive_policy_reaches_the_controller_client() { 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/<prefix>*" { … }`. // Every stanza is `path "secret/data/<prefix>*" { … }`.
let granted: Vec<&str> = doc let granted: Vec<&str> = doc
.lines() .lines()
@ -157,7 +157,7 @@ mod tests {
#[test] #[test]
fn a_hives_own_policy_reads_its_client_and_no_other_hives() { 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 let granted: Vec<&str> = doc
.lines() .lines()
.filter_map(|l| l.strip_prefix("path \"secret/data/")) .filter_map(|l| l.strip_prefix("path \"secret/data/"))
@ -216,7 +216,7 @@ mod tests {
/// it belongs to. /// it belongs to.
#[test] #[test]
fn the_agents_existing_policy_already_covers_its_queue_path() { 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 path = agent_queue_path("atlas").expect("legal");
let stanza = path let stanza = path
.rsplit_once('/') .rsplit_once('/')