Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-queue-client/src/status.rs
atlas c8a3159297 feat(#3297): scope a hive's queue grant to its own subjects
Every admitted client got the same unrestricted grant, so any hive could
write any other hive's status key. The responder now derives a
permission set from the caller's identity and mints it into the user
JWT.

A hive may publish to its own KV key and the two JetStream subjects
needed to reach it; the controller may list and fetch every key and
write none; anything else is denied outright. Deny is the default
because every other shape fails open, and silently: a client that
matched no rule and kept the old grant would make the policy advisory.

The subject sets are measured rather than reasoned about, and two of
them are counter-intuitive. `$KV.<bucket>.<key>` alone does not let a
client write that key, because the client resolves the bucket first. And
`$JS.API.>` is not "the JetStream permission": it also covers
`$JS.API.STREAM.DELETE`, with which a hive correctly refused on a
neighbour's key can delete the whole bucket and every hive's data with
it. Granting it would have made per-key scoping decorative, so the
subjects are named individually and a test asserts the wildcard does not
come back as a convenience.

Minimality is by removal: each subject was dropped in turn to confirm
the client breaks without it. That is not pedantry — an additive search
had called a set minimal while two of its five subjects were never
needed, which ships an unnecessary grant with a measurement attached
making it look earned.

Both grants include `STREAM.CREATE` on the one named stream, because
`status::open_or_create` is called by both ends: either may arrive first
on a fresh swarm, and without it a new swarm never gets a bucket at all.
`CREATE` is not `UPDATE`, so a second arrival cannot reshape the bucket
the first one made.

`status::BUCKET` moves out from behind the `kv` feature so this
responder can share it. The name is a `&str` with no dependencies and
only `open_or_create` needs JetStream; gating the name forced a third
consumer to choose between a stack it does not use and a copied literal,
and the copied literal is exactly the disagreement that module exists to
prevent.

Only publish is scoped. Subscription permissions are unrestricted and
unmeasured, and the module docs say so rather than implying a property
nothing established.
2026-08-17 17:34:27 +02:00

73 lines
3.2 KiB
Rust

//! The hive-status KV bucket: its name, and the shape it is created with.
//!
//! Two processes touch this bucket from opposite ends — a hive writes its
//! own key, the swarm controller reads every key — and they live in
//! different crates. That is the whole reason this module exists rather
//! than a `const` on each side: **the two ends must agree, and a literal
//! repeated across crates is an agreement nothing checks.**
//!
//! The name is the obvious half. The sharper half is the *config*: both
//! ends open the bucket with [`crate::status::open_or_create`], because either may
//! arrive first on a fresh swarm and neither can assume the other has
//! run. If the two ends passed different `Config`s, whichever created it
//! would win and the other's `get_key_value` would succeed against a
//! bucket it did not ask for — no error, no log, just a retention policy
//! nobody chose. Sharing the constructor makes the race have one outcome
//! instead of two.
//!
//! The bucket *name* is unconditional; only [`open_or_create`] is behind the
//! `kv` feature. A third end names the bucket without ever opening it — the
//! auth-callout responder, which derives the subjects a hive may publish to
//! from it — and it speaks neither `jetstream` nor `kv`. Gating the name too
//! would have made that consumer choose between a JetStream stack it does not
//! use and a copied literal, and a copied literal is precisely the agreement
//! nothing checks.
#[cfg(feature = "kv")]
use crate::Error;
/// The KV bucket hives publish their status snapshots into, one key per
/// hive keyed by `hiveName`.
///
/// A constant and not an option: reader and writer must name the same
/// bucket, and an option is a way for two deployments to disagree about
/// which one that is. Nothing about a bucket name is site-specific.
pub const BUCKET: &str = "hive-status";
/// Open the status bucket, creating it if nothing has yet.
///
/// `history: 1` is the shape: every consumer reads *the last thing each
/// hive said*, and retaining more would be storage bought for a query
/// nobody makes.
///
/// Creating rather than requiring a provisioning step is deliberate — the
/// controller and the hives come up in no particular order, and a bucket
/// that must pre-exist turns "the swarm was deployed in the wrong order"
/// into a permanent, silent absence of data.
#[cfg(feature = "kv")]
pub async fn open_or_create(
client: &async_nats::Client,
) -> Result<async_nats::jetstream::kv::Store, Error> {
let js = async_nats::jetstream::new(client.clone());
match js.get_key_value(BUCKET).await {
Ok(store) => Ok(store),
Err(e) => {
tracing::info!(
bucket = BUCKET,
reason = %e,
"status bucket not available, creating it"
);
js.create_key_value(async_nats::jetstream::kv::Config {
bucket: BUCKET.to_owned(),
description: "Last status snapshot offered by each hive".to_owned(),
history: 1,
..Default::default()
})
.await
.map_err(|source| Error::CreateBucket {
bucket: BUCKET,
source,
})
}
}
}