feat(#3255): name the swarm event subjects, and let the controller publish them

The controller could not emit an event at all: a reader's grant is
`reader_subjects()`, which is `$JS.API.*` only, so a publish to any event
subject would be refused — and a NATS refusal reaches the client as a
timeout, so the visible symptom would have been a hive that never hears
about a change, with nothing in any log naming a permission.

Adds `swarm_queue_client::events`, following `status::BUCKET`: three
crates must agree on these strings (the controller publishes, a hive
subscribes, the callout responder decides whether the publish is
permitted), and a literal repeated across crates is an agreement nothing
checks. The responder speaks neither jetstream nor kv, so the module is
unconditional and carries no NATS types, exactly as the bucket name is.

The grant takes the wildcard form from the same function the publisher
calls, so the two cannot drift; a separate wildcard constant would have
re-created the disagreement this module exists to prevent.

Tests pin that a reader gets the subject and that a hive does NOT — a
hive able to publish here could tell a neighbour the knowledge repo
changed when it had not, which is an unauthenticated write into someone
else's control path. That one asserts on the subject root rather than a
rendered subject, so a future event leaf fails it too instead of passing
because the test only knew about `knowledge`.

Both assertions mutation-tested: removing the grant fails the reader
test, granting a hive the subject fails the denial test, each on its own
assertion line, and the unmutated tree is green.
This commit is contained in:
atlas 2026-08-19 18:27:34 +02:00 committed by mara
commit 8baf1899d8
3 changed files with 156 additions and 0 deletions

View file

@ -234,6 +234,18 @@ impl Policy {
// stays for a named/durable consumer.
format!("$JS.API.CONSUMER.CREATE.{stream}"),
format!("$JS.API.CONSUMER.CREATE.{stream}.>"),
// Swarm events. The controller is the only publisher, and it
// publishes to *every* hive's subject, so the grant takes the
// wildcard form — from the same function the publisher calls, so a
// rename cannot leave the grant naming a subject nobody uses.
//
// This is the reader's only non-JetStream subject, and without it
// the controller cannot emit an event at all. Worth stating because
// the symptom is unhelpful: a refused publish reaches the client as
// a **timeout**, so the visible failure is a hive that never hears
// about a change, with nothing in the controller's log to say a
// permission was the reason.
swarm_queue_client::events::knowledge(swarm_queue_client::events::ANY_HIVE),
]);
subjects
}
@ -295,6 +307,41 @@ mod tests {
assert!(!p.publish.iter().any(|s| s.contains("beta")));
}
#[test]
fn a_reader_may_publish_swarm_events_for_every_hive() {
let p = policy().permissions("swarm-controller").expect("a reader");
assert!(
p.publish.contains(&swarm_queue_client::events::knowledge(
swarm_queue_client::events::ANY_HIVE
)),
"the controller is the only event publisher; without this its \
publish is refused, and a refusal arrives as a timeout"
);
}
#[test]
fn a_hive_may_not_publish_a_swarm_event_to_anyone_including_itself() {
// The controller *interprets* what a delivery means; a hive receives
// that verdict. A hive that could publish on this subject could tell a
// neighbour — or itself — that the knowledge repo changed when it did
// not, which is an unauthenticated write into someone else's control
// path wearing an event's shape.
//
// Asserted on the subject ROOT rather than on one rendered subject: a
// future event leaf added to this namespace must fail this test too,
// rather than passing because the test only knew about `knowledge`.
let p = policy()
.permissions("hive-alpha")
.expect("a hive is admitted");
assert!(
!p.publish
.iter()
.any(|s| s.starts_with(swarm_queue_client::events::SUBJECT_ROOT)),
"a hive must not publish into the swarm event namespace: {:?}",
p.publish
);
}
#[test]
fn a_hive_grant_never_includes_the_jetstream_wildcard() {
// `$JS.API.>` also covers `$JS.API.STREAM.DELETE.KV_hive-status`, with