From 81adf2b6b4dbe2a86c867682f041a72af6759dcb Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 24 Aug 2026 12:38:21 +0200 Subject: [PATCH] swarm-nats-auth: grant every hive the shared hive-notices stream subjects --- swarm-nats-auth/Cargo.toml | 11 +++-- swarm-nats-auth/src/policy.rs | 92 +++++++++++++++++++++++++++++++++++ swarm-queue-client/Cargo.toml | 19 +++++--- 3 files changed, 111 insertions(+), 11 deletions(-) diff --git a/swarm-nats-auth/Cargo.toml b/swarm-nats-auth/Cargo.toml index 60b63ba5..20a52c4f 100644 --- a/swarm-nats-auth/Cargo.toml +++ b/swarm-nats-auth/Cargo.toml @@ -22,11 +22,12 @@ serde.workspace = true serde_json.workspace = true # The jti digest: base32hex(sha256(claims)) over every JWT this crate signs. sha2.workspace = true -# For `status::BUCKET` alone - the subjects a hive may publish to are derived -# from the bucket name, and the reader, the writer and this responder must -# name the same one. Deliberately WITHOUT the `kv` feature: this crate derives -# subject strings, it never opens the bucket. -swarm-queue-client.workspace = true +# For `status::BUCKET` and `notices::STREAM` - the subjects a hive may +# publish to are derived from these names, and every end that touches them +# must agree on the same one. Deliberately WITHOUT the `kv` feature: this +# crate derives subject strings, it never opens the bucket. `notices` +# is name-only too (no `jetstream`/`kv` surface), same reason. +swarm-queue-client = { workspace = true, features = ["notices"] } tokio.workspace = true tracing.workspace = true tracing-subscriber.workspace = true diff --git a/swarm-nats-auth/src/policy.rs b/swarm-nats-auth/src/policy.rs index 1eb3e083..8c1fea29 100644 --- a/swarm-nats-auth/src/policy.rs +++ b/swarm-nats-auth/src/policy.rs @@ -180,6 +180,36 @@ impl Policy { format!("$JS.API.STREAM.CREATE.{}", self.stream()) } + /// What every hive needs to open the shared lifecycle-notices stream: + /// look it up, and create it if this hive is the first to arrive. + /// + /// **Not per-hive templated, unlike `extra_hive_subjects`.** The KV + /// bucket case above namespaces a shared resource *within* itself + /// (`$KV..`, one key per hive); the notices stream has + /// no such per-hive split at the `STREAM.INFO`/`STREAM.CREATE` layer + /// — the stream itself, not a slice of it, is what every hive's + /// `open_or_create` needs to reach before it can publish to its own + /// `hive-notices.` subject (which `extra_hive_subjects` already + /// covers, since that one *is* per-hive). Granting these two subjects + /// to every hive is therefore correct, not a widening: it is + /// `CREATE`/`INFO` on one named stream, the same shape already + /// measured safe for the hive-status bucket in [`Self::create`] — + /// create-if-absent, never `STREAM.UPDATE`, so no hive can reshape a + /// stream another hive (or the swarm-controller, once it reads from + /// this stream) already created. + fn notices_subjects() -> [String; 2] { + // Unlike `Self::stream()` above, no `KV_` prefix: the notices + // stream is a plain `JetStream` stream, not a KV bucket, so its + // NATS stream name *is* `swarm_queue_client::notices::STREAM` + // verbatim — the `KV_` prefix is `create_key_value`'s own + // convention, not something every stream carries. + let stream = swarm_queue_client::notices::STREAM; + [ + format!("$JS.API.STREAM.INFO.{stream}"), + format!("$JS.API.STREAM.CREATE.{stream}"), + ] + } + /// What one hive may publish: the account minimum, the bucket lookup, /// creation, and its **own** key. /// @@ -202,6 +232,7 @@ impl Policy { self.create(), format!("$KV.{}.{hive}", self.bucket), ]); + subjects.extend(Self::notices_subjects()); subjects.extend( self.extra_hive_subjects .iter() @@ -449,6 +480,67 @@ mod tests { assert_eq!(policy().permissions("hive-"), None); } + #[test] + fn a_hive_may_open_the_shared_notices_stream() { + // 🩸 The actual defect this closes: `open_or_create`'s `get_stream` + // call needs these two before a hive ever reaches its own + // `hive-notices.` publish subject, and neither has a + // `{hive}` to go through `extra_hive_subjects`. + let p = policy() + .permissions("hive-alpha") + .expect("a hive is admitted"); + assert!( + p.publish + .contains(&"$JS.API.STREAM.INFO.hive-notices".to_owned()) + ); + assert!( + p.publish + .contains(&"$JS.API.STREAM.CREATE.hive-notices".to_owned()) + ); + } + + #[test] + fn the_notices_stream_grant_is_identical_across_hives() { + // Unlike `$KV..` or an `extra_hive_subjects` + // template, these two subjects name the stream itself, not a + // per-hive slice of it — every hive gets the exact same two + // strings, and that is the correct shape, not an oversight. + let alpha = policy() + .permissions("hive-alpha") + .expect("a hive is admitted"); + let beta = policy() + .permissions("hive-beta") + .expect("a hive is admitted"); + for subject in [ + "$JS.API.STREAM.INFO.hive-notices", + "$JS.API.STREAM.CREATE.hive-notices", + ] { + assert!(alpha.publish.contains(&subject.to_owned())); + assert!(beta.publish.contains(&subject.to_owned())); + } + } + + #[test] + fn the_notices_grant_never_includes_stream_update_or_delete() { + // Same invariant `a_hive_grant_never_includes_the_jetstream_wildcard` + // holds for the status bucket, restated for the stream this fold + // grants CREATE/INFO on: create-if-absent must not become + // reshape-or-destroy. + let p = policy() + .permissions("hive-alpha") + .expect("a hive is admitted"); + assert!( + !p.publish + .iter() + .any(|s| s.contains("hive-notices") && s.contains("STREAM.UPDATE")) + ); + assert!( + !p.publish + .iter() + .any(|s| s.contains("hive-notices") && s.contains("STREAM.DELETE")) + ); + } + #[test] fn extra_subjects_are_scoped_to_the_hive_that_publishes_them() { // The extension point: a second stream (lifecycle notices, say) is diff --git a/swarm-queue-client/Cargo.toml b/swarm-queue-client/Cargo.toml index dc3aa56e..b0b63588 100644 --- a/swarm-queue-client/Cargo.toml +++ b/swarm-queue-client/Cargo.toml @@ -17,12 +17,19 @@ edition.workspace = true # to live in one of them, and neither end of that bucket is senior to the # other. kv = ["async-nats/kv"] -# `jetstream` (streams, publish, consumers) is already in async-nats's -# default feature set — `kv` above only adds the KV-specific type -# surface on top of it. This feature exists purely to keep `notices.rs` -# out of a consumer's compiled surface unless it asks for it, matching -# `kv`'s organizational role rather than gating a real async-nats flag. -notices = [] +# 🩸 `jetstream` is NOT in async-nats's default feature set here — the +# workspace-level dependency turns default features off entirely (see +# root `Cargo.toml`: `server_2_14`/`nkeys`/`ring` only). `kv` above works +# standalone only because async-nats's own `kv` feature pulls `jetstream` +# in transitively; `notices.rs` uses `async_nats::jetstream` directly and +# needs the same request explicitly, or it only compiles by accident when +# something else in the same build happens to also enable `kv` (which is +# exactly how this went unnoticed: `cargo test` at the workspace level +# unifies features across every crate being built, so `hive-c0re`'s own +# `kv` request silently carried `notices.rs` until a single-crate +# `cargo check -p swarm-nats-auth` — no `kv` anywhere in that build — +# surfaced it as `cannot find jetstream in async_nats`). +notices = ["async-nats/jetstream"] [dependencies] # Bare (no `kv`/`jetstream`) unless a consumer opts into the `kv` feature