diff --git a/swarm-nats-auth/src/policy.rs b/swarm-nats-auth/src/policy.rs index 4f3a76b2..411f0668 100644 --- a/swarm-nats-auth/src/policy.rs +++ b/swarm-nats-auth/src/policy.rs @@ -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 diff --git a/swarm-queue-client/src/events.rs b/swarm-queue-client/src/events.rs new file mode 100644 index 00000000..883b0b94 --- /dev/null +++ b/swarm-queue-client/src/events.rs @@ -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")); + } +} diff --git a/swarm-queue-client/src/lib.rs b/swarm-queue-client/src/lib.rs index 1c03a317..0a0fc7ba 100644 --- a/swarm-queue-client/src/lib.rs +++ b/swarm-queue-client/src/lib.rs @@ -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 {