swarm-controller: keep a hive's read grant in step with its declaration

The controller writes an agent's credential; the hive fetches it back with
its own certificate. Nothing said which paths that certificate may read, so
the read half of a delivery answers 403 with no way to tell why.

The grant is derived from the declaration, so it is re-rendered at the one
place the declaration changes -- WantedWriter::set -- rather than at its
caller, which would work today and break on the second caller.

Emitted before the KV write: a grant that lands late is a 403 on an agent's
first fetch, while one that shrinks early only affects an agent already
being torn down. A failed write then leaves a superset the next declaration
re-renders.

Destroyed agents are filtered out. The declared set is a hive's whole
history -- a destroyed entry stays so that redeclaring it Up is refused as
the terminal transition it is -- so granting every declared agent would
leave a torn-down agent's credentials readable forever.

The sink is a trait because a missed emission is that same untraceable 403:
the double pins which agents were published, and the no-sink and refusing
arms pin the two deployments that are not a happy path. Not covered: the
call site inside set(), which needs a live queue.

The cert role moves to its own module on the way past. It is the
controller's identity at the store, not something the matrix route owns,
and the policy writer needs the same login.
This commit is contained in:
atlas 2026-09-09 16:21:04 +02:00 committed by mara
commit e638db262e
5 changed files with 268 additions and 17 deletions

View file

@ -0,0 +1,47 @@
//! Publishing the grant that lets a hive read its own agents' credentials.
//!
//! 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.
use anyhow::{Context, Result};
use async_trait::async_trait;
use swarm_secret_client::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.
///
/// 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(())
}
}