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

View file

@ -0,0 +1,100 @@
//! Swarm event subjects: the names the controller publishes on and hives
//! subscribe to.
//!
//! Same reason [`crate::status`] exists rather than a `const` on each side —
//! **the ends must agree, and a literal repeated across crates is an agreement
//! nothing checks.** Here there are three of them: the swarm controller
//! publishes, a hive subscribes, and the auth-callout responder derives the
//! subject the controller is *permitted* to publish to. A copied literal in the
//! third would produce the worst failure of the set: a grant that looks right,
//! a publish that is refused, and — because a NATS denial reaches the client as
//! a timeout rather than an error — no message saying so.
//!
//! Unconditional, with no feature gate and no NATS types, for the same reason
//! the bucket *name* in [`crate::status`] is not gated: the responder names
//! this subject without ever publishing to it, and speaks neither `jetstream`
//! nor `kv`. A gate here would make that consumer choose between a stack it
//! does not use and a copied literal.
//!
//! # Why a per-hive subject rather than one shared one
//!
//! The callout policy refuses any extra hive subject with no `{hive}` in it,
//! because such a template expands to the same subject for every hive and so
//! grants each of them the others'. A per-hive event subject satisfies that by
//! construction. It is also what makes the event addressable: the controller
//! decides *which* hives need to know, rather than every hive filtering a
//! shared firehose.
/// The root of the swarm event namespace.
///
/// `$SWARM` rather than a bare name: the `$` prefix is NATS' convention for
/// system-ish subjects and keeps these clear of anything an application might
/// choose for itself.
pub const SUBJECT_ROOT: &str = "$SWARM.events";
/// The leaf naming the *knowledge repository changed* event.
///
/// Semantic, not transport-shaped: it says what happened, not that a forge
/// webhook arrived. The controller interprets a delivery and decides this is
/// what it means; a hive that receives it does not need to know a forge exists.
const KNOWLEDGE_LEAF: &str = "knowledge";
/// The wildcard standing for "any hive", for a grant that must cover all of
/// them.
///
/// Exported so the one caller that needs it — the callout responder, building
/// the controller's publish grant — can pass it to [`knowledge`] instead of
/// assembling a wildcard subject itself. That is the whole point: the grant and
/// the published subject come out of **the same function**, so they cannot
/// drift into disagreement the way two literals would.
pub const ANY_HIVE: &str = "*";
/// The subject carrying *the knowledge repository changed* for `hive`.
///
/// Pass [`ANY_HIVE`] to get the wildcard form used by a grant.
#[must_use]
pub fn knowledge(hive: &str) -> String {
format!("{SUBJECT_ROOT}.{hive}.{KNOWLEDGE_LEAF}")
}
#[cfg(test)]
mod tests {
use super::{ANY_HIVE, SUBJECT_ROOT, knowledge};
/// The concrete and wildcard forms must differ in exactly the hive token.
///
/// Written as a structural comparison rather than by asserting two
/// literals, because two literals is the failure this module exists to
/// prevent: a test that spells the expected subject out by hand passes
/// happily when both it and the code are wrong in the same way.
#[test]
fn the_grant_form_and_the_published_form_differ_only_in_the_hive() {
let concrete = knowledge("alpha");
let wildcard = knowledge(ANY_HIVE);
assert_eq!(
concrete.replacen("alpha", ANY_HIVE, 1),
wildcard,
"substituting the hive token must turn one form into the other"
);
}
/// A NATS wildcard matches one token, so the hive must occupy exactly one.
/// A hive name with a dot in it would silently widen the grant.
#[test]
fn the_hive_occupies_exactly_one_subject_token() {
let root_tokens = SUBJECT_ROOT.split('.').count();
assert_eq!(
knowledge("alpha").split('.').count(),
root_tokens + 2,
"root + hive + leaf; anything else means the hive is not one token"
);
}
/// The event is addressed per hive — one hive's subject must never be
/// another's. Cheap, and it is the property the callout policy relies on.
#[test]
fn two_hives_get_different_subjects() {
assert_ne!(knowledge("alpha"), knowledge("beta"));
}
}

View file

@ -156,6 +156,15 @@ pub fn chain(error: &dyn std::error::Error) -> String {
/// which is the disagreement this module exists to prevent.
pub mod status;
/// Swarm event subjects — the names the controller publishes on and hives
/// subscribe to.
///
/// Unconditional and NATS-type-free for the same reason the bucket name above
/// is: three crates must agree on these strings, and the one that agrees
/// hardest — the auth-callout responder, which decides whether a publish is
/// even permitted — speaks neither `jetstream` nor `kv`.
pub mod events;
/// Only the fields this needs; authelia returns several.
#[derive(serde::Deserialize)]
struct TokenResponse {