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
|
serde_json.workspace = true
|
||||||
# The jti digest: base32hex(sha256(claims)) over every JWT this crate signs.
|
# The jti digest: base32hex(sha256(claims)) over every JWT this crate signs.
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
# For `status::BUCKET` alone - the subjects a hive may publish to are derived
|
# For `status::BUCKET` and `notices::STREAM` - the subjects a hive may
|
||||||
# from the bucket name, and the reader, the writer and this responder must
|
# publish to are derived from these names, and every end that touches them
|
||||||
# name the same one. Deliberately WITHOUT the `kv` feature: this crate derives
|
# must agree on the same one. Deliberately WITHOUT the `kv` feature: this
|
||||||
# subject strings, it never opens the bucket.
|
# crate derives subject strings, it never opens the bucket. `notices`
|
||||||
swarm-queue-client.workspace = true
|
# is name-only too (no `jetstream`/`kv` surface), same reason.
|
||||||
|
swarm-queue-client = { workspace = true, features = ["notices"] }
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -180,6 +180,36 @@ impl Policy {
|
||||||
format!("$JS.API.STREAM.CREATE.{}", self.stream())
|
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,
|
/// What one hive may publish: the account minimum, the bucket lookup,
|
||||||
/// creation, and its **own** key.
|
/// creation, and its **own** key.
|
||||||
///
|
///
|
||||||
|
|
@ -202,6 +232,7 @@ impl Policy {
|
||||||
self.create(),
|
self.create(),
|
||||||
format!("$KV.{}.{hive}", self.bucket),
|
format!("$KV.{}.{hive}", self.bucket),
|
||||||
]);
|
]);
|
||||||
|
subjects.extend(Self::notices_subjects());
|
||||||
subjects.extend(
|
subjects.extend(
|
||||||
self.extra_hive_subjects
|
self.extra_hive_subjects
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -449,6 +480,67 @@ mod tests {
|
||||||
assert_eq!(policy().permissions("hive-"), None);
|
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]
|
#[test]
|
||||||
fn extra_subjects_are_scoped_to_the_hive_that_publishes_them() {
|
fn extra_subjects_are_scoped_to_the_hive_that_publishes_them() {
|
||||||
// The extension point: a second stream (lifecycle notices, say) is
|
// 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
|
# to live in one of them, and neither end of that bucket is senior to the
|
||||||
# other.
|
# other.
|
||||||
kv = ["async-nats/kv"]
|
kv = ["async-nats/kv"]
|
||||||
# `jetstream` (streams, publish, consumers) is already in async-nats's
|
# 🩸 `jetstream` is NOT in async-nats's default feature set here — the
|
||||||
# default feature set — `kv` above only adds the KV-specific type
|
# workspace-level dependency turns default features off entirely (see
|
||||||
# surface on top of it. This feature exists purely to keep `notices.rs`
|
# root `Cargo.toml`: `server_2_14`/`nkeys`/`ring` only). `kv` above works
|
||||||
# out of a consumer's compiled surface unless it asks for it, matching
|
# standalone only because async-nats's own `kv` feature pulls `jetstream`
|
||||||
# `kv`'s organizational role rather than gating a real async-nats flag.
|
# in transitively; `notices.rs` uses `async_nats::jetstream` directly and
|
||||||
notices = []
|
# 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]
|
[dependencies]
|
||||||
# Bare (no `kv`/`jetstream`) unless a consumer opts into the `kv` feature
|
# Bare (no `kv`/`jetstream`) unless a consumer opts into the `kv` feature
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue