swarm: let every hive read every agent's credential, and say so

A hive reads its agents' credentials with its own certificate, and nothing
said which paths that certificate may read, so the read half of a delivery
answered 403.

The grant is wide on purpose. An agent's path does not name the hive
hosting it -- agents move -- so a per-hive grant has to be an enumeration
the controller re-emits whenever the roster changes, and an enumeration
that can drift or land out of order advertises a boundary it does not
hold. A wide grant that says what it is beats a narrow one that only looks
narrow. mara's call, on the PR: rather a too-lax scope than one that
pretends to be strict.

What that buys, beyond honesty: the document is identical for every hive
and depends on nothing, so it is written once at startup beside the rest of
a hive's provisioning instead of on every declaration. No derived state, no
re-emission, and the ordering hazard that came with one stops existing.

What still holds is read-only. A hive cannot write an agent's credential,
so it cannot hand itself an agent's identity, and the grant reaches nothing
in the store outside the agent-credential prefix.

The fact is documented where someone meets the boundary rather than only in
this message, and the two ways to narrow it later -- scope per hive, or
give agents their own store identity -- are tracked.
This commit is contained in:
atlas 2026-09-09 17:41:31 +02:00 committed by mara
commit 3752482524
5 changed files with 119 additions and 306 deletions

View file

@ -1,47 +1,48 @@
//! Publishing the grant that lets a hive read its own agents' credentials.
//! Writing the policy a hive's own certificate logs in with.
//!
//! The controller writes a credential; the hive fetches it with its own
//! certificate. Nothing said which paths that certificate may read, so the
//! read half of every delivery answers 403.
//!
//! The grant is derived state: it is re-rendered from the swarm's declaration
//! whenever that declaration changes ([`crate::wanted`]). A missed re-emission
//! is that same untraceable 403, which is why the sink is a trait — "did it
//! emit, and with what" is then a test rather than care.
//! A startup pass, not a hook: the document is the same for every hive and
//! does not depend on which agents exist ([`swarm_secret_client::policy`]
//! explains why it is that wide), so there is nothing to keep in step with
//! anything. Per-hive failures are reported and skipped — one unwritable
//! policy should not take down a daemon that serves everything else, and the
//! next start retries it.
use anyhow::{Context, Result};
use async_trait::async_trait;
use swarm_secret_client::policy;
use swarm_secret_client::{SecretStore, policy};
/// Where a rendered read grant goes.
#[async_trait]
pub trait ReadPolicySink: Send + Sync {
/// Grant `hive` read on exactly `agents`, replacing whatever it had.
///
/// # Errors
/// Whatever the implementation cannot do — for the store-backed one, a
/// name the controller may not write or a store it cannot reach.
async fn publish(&self, hive: &str, agents: &[&str]) -> Result<()>;
}
/// The real sink: renders the policy and writes it into the secret store.
/// Give every hive in `hives` the read policy its certificate will carry.
///
/// Connects per call, like `matrix_account`'s writer does: the controller
/// declares an agent's state rarely, and a handle held across a token's
/// lifetime is a renewal problem in exchange for nothing.
pub struct StoreSink;
#[async_trait]
impl ReadPolicySink for StoreSink {
async fn publish(&self, hive: &str, agents: &[&str]) -> Result<()> {
let name = policy::hive_object_name(hive)?;
let document = policy::render(hive, agents)?;
crate::store::connect()
.await
.context("connecting to the swarm secret store")?
.write_policy(&name, &document)
.await
.with_context(|| format!("writing the read policy {name}"))?;
Ok(())
/// Does nothing when this deployment has no store identity, which is the
/// shape a controller without a secret store runs in.
pub async fn ensure_hive_policies(hives: &[String]) {
if hives.is_empty() {
return;
}
let store = match crate::store::connect().await {
Ok(store) => store,
Err(e) => {
tracing::info!(reason = %e, "no secret store reachable; hive read policies are not managed here");
return;
}
};
for hive in hives {
if let Err(e) = write_one(&store, hive).await {
tracing::warn!(hive, error = %format!("{e:#}"), "writing this hive's read policy failed");
}
}
}
/// One hive's policy: its own name, the shared document.
async fn write_one(store: &SecretStore, hive: &str) -> Result<()> {
let name = policy::hive_object_name(hive)?;
store
.write_policy(&name, &policy::render())
.await
.with_context(|| format!("writing the read policy {name}"))?;
tracing::info!(hive, policy = %name, "hive read policy in place");
Ok(())
}