swarm-nats-auth: grant every hive the shared hive-notices stream subjects

This commit is contained in:
damocles 2026-08-24 12:38:21 +02:00 committed by mara
commit 81adf2b6b4
3 changed files with 111 additions and 11 deletions

View file

@ -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

View file

@ -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.<bucket>.<hive>`, 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.<hive>` 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.<hive>` 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.<bucket>.<hive>` 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

View file

@ -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