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

@ -731,32 +731,7 @@ fn render(declaration: &swarm_queue_client::wanted::HiveWanted) -> Vec<AgentDecl
/// auth-callout traffic and give the two paths independent reconnect state,
/// so one could be serving while the other was still down.
fn wanted_writer(status: Option<&Arc<status::StatusReader>>) -> Option<Arc<wanted::WantedWriter>> {
status.map(|s| {
Arc::new(wanted::WantedWriter::new(
s.queue_client(),
read_policy_sink(),
))
})
}
/// The sink that keeps a hive's read grant in step with its declaration, or
/// `None` on a deployment that has no store identity to write one with.
///
/// Decided once here rather than per write: the `BAO_*` environment is a
/// systemd unit's, so a variable that is absent at start is absent for the
/// life of the process. The absent case is logged because it is otherwise
/// indistinguishable from a grant that is being published and ignored.
fn read_policy_sink() -> Option<Arc<dyn read_policy::ReadPolicySink>> {
match swarm_secret_client::client::Settings::from_env() {
Ok(_) => Some(Arc::new(read_policy::StoreSink)),
Err(e) => {
tracing::info!(
reason = %e,
"no secret-store identity: hives get no read grant, and no credential delivery either"
);
None
}
}
status.map(|s| Arc::new(wanted::WantedWriter::new(s.queue_client())))
}
/// The per-agent status reader, sharing the status reader's connection and
@ -1647,8 +1622,15 @@ async fn main() -> Result<()> {
let config_prs = forge_client.clone().map(config_pr::spawn);
let state_forge = keep_forge_for_state(forge_client, webhook_secret.clone());
let hives = load_hives();
// Before serving, because a hive whose policy does not exist cannot read
// anything this daemon writes for it. Same "log and carry on" shape as
// every connect above.
read_policy::ensure_hive_policies(&hives.iter().map(|h| h.name.clone()).collect::<Vec<_>>())
.await;
let state = AppState {
hives: Arc::new(load_hives()),
hives: Arc::new(hives),
links: Arc::new(load_links()),
wanted: wanted_writer(status.as_ref()),
agent_status: agent_status_reader(status.as_ref()),