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
agent's certificate opens that agent's own `swarm/agents/<agent>/*` 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 `<agent>` with the alias `hive-agent-<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-<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-<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 policy states the hive's own path as
`swarm/hives/{{identity.entity.aliases.<accessor>.name}}/*`, where `<accessor>`
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-<hive>` policy keeps it.
### Per-principal identities

View file

@ -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.

View file

@ -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/<hive>/*`.
# (`swarm-secret-client`'s `policy::render_shared_hives`) grants `swarm/hives/<hive>/*`.
runsHive = config.services.hyperhive.otel.collector.enable;
hiveLeaf =

View file

@ -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/<hive>/*`, and nothing under
# `swarm/services/`.
# 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
//! 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"
);
}
}

View file

@ -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<String>) -> 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<String>,
}
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 {
name: name.clone(),
ca: &credential.ca,
policy: policy::render_agent(agent)?,
attached: [name, policy::SHARED_AGENT_POLICY.to_owned()],
attached: vec![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.
///
/// 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<String> {
/// 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<String> {
/// 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::<Vec<_>>(),
)
.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);
}
}

View file

@ -217,8 +217,8 @@ async fn main() -> Result<()> {
// abandoned, since the two of them boot together.
let hive_names: Vec<String> = 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 {

View file

@ -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<Vec<String>, 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<String> {
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-<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<String> = [
"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()));
}
}

View file

@ -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

View file

@ -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}");
}

View file

@ -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 \

View file

@ -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,10 +357,7 @@ 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| {
crate::policy::effective_hive(hive).lines().any(|line| {
line.strip_prefix("path \"")
.and_then(|rest| rest.split_once("\" {"))
.and_then(|(p, _)| p.strip_suffix('*'))

View file

@ -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}");
}

View file

@ -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/<hive>/*`, 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/<prefix>*" { … }`.
let granted: Vec<&str> = doc
.lines()

View file

@ -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/<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.
#[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/"),

View file

@ -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<String, Error> {
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<String, Error> {
/// 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-<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.
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/<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.
///
/// ⚠️ 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<String, Error> {
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<String, Error> {
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/<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.
/// 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/<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]
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/*\""));
}

View file

@ -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/<agent>/*`
/// ([`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/<prefix>*" { … }`.
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('/')