swarm-nats-auth: grant every hive the shared hive-notices stream subjects
This commit is contained in:
parent
044e4020fa
commit
81adf2b6b4
3 changed files with 111 additions and 11 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue