docs(#3297): move each subject set's rationale next to the list it constrains

The module doc had grown a 37-line preamble carrying three separate
arguments, which the comment-block lint refuses. Splitting it is the
better fix than raising the limit: the reader who is about to widen a
subject list meets the reason not to at the list, not seven screens up.

Also corrects one claim I had no measurement for. The CREATE note said
creating an existing stream with a different config "is an error rather
than a rewrite" — asserted, not observed. What is observed is narrower
and enough: a hive holding this grant leaves the stream config untouched
and never publishes $JS.API.STREAM.UPDATE at all.
This commit is contained in:
atlas 2026-08-17 01:31:56 +02:00
commit 2e9ce53a32

View file

@ -12,31 +12,19 @@
//! the failure would be silent. Denying costs a loud refusal the first time a
//! new consumer appears, which is a config line to fix.
//!
//! # Subjects were measured, not reasoned about
//!
//! The defaults below are the *minimal* sets, established by granting a
//! candidate set and then removing each subject in turn to confirm the client
//! breaks without it (`state/attack-3297-js-api-door.sh`,
//! `state/attack-3297-reader-scope.sh` in atlas's notes). Two things that a
//! reader would otherwise reasonably assume, and that are false:
//!
//! - `$KV.<bucket>.<key>` alone does **not** let a client write that key. The
//! client looks the bucket up first, so `$JS.API.STREAM.INFO.KV_<bucket>` is
//! part of the minimum.
//! - `$JS.API.>` is not "the `JetStream` permission". It also covers
//! `$JS.API.STREAM.DELETE.KV_<bucket>`, with which any hive can destroy the
//! whole bucket — every hive's data — while being correctly refused on a
//! neighbour's individual key. Granting it makes per-key scoping decorative.
//!
//! # Only publish is scoped
//!
//! Subscription permissions are left unrestricted. The queue's confidentiality
//! boundary is the account, and a hive reading another hive's *published*
//! status is not the problem this solves — writing it is. Scoping `sub` is a
//! separate change with its own measurement, and claiming it here without one
//! would be the same mistake as `$JS.API.>` above.
//! Each subject set below is *minimal by removal*, and carries its own note
//! saying what breaks without it — the reasoning lives next to the list it
//! constrains rather than in one block here, because that is where a reader
//! about to edit the list will meet it.
/// The subjects an admitted client may publish to.
///
/// **Publish only — subscription is left unrestricted.** The queue's
/// confidentiality boundary is the account, and a hive reading another hive's
/// *published* status is not the problem this solves; writing it is. Scoping
/// `sub` is a separate change with its own measurement, and claiming it here
/// without one would repeat the `$JS.API.>` mistake described on
/// `Policy::hive_subjects`.
#[derive(Debug, PartialEq, Eq)]
pub struct Permissions {
/// Subjects allowed for publish. Never empty: an empty allow-list is a
@ -181,14 +169,32 @@ impl Policy {
/// connects first therefore has to be able to create it.
///
/// Narrower than it looks: this is `CREATE` on one named stream, not
/// `UPDATE` and not the `$JS.API.>` wildcard. Creating a stream that
/// already exists with a different config is an error rather than a
/// rewrite, so a second arrival cannot reshape the bucket the first one
/// made.
/// `UPDATE` and not the `$JS.API.>` wildcard. Measured rather than
/// assumed — a hive holding this grant and running `stream edit` leaves
/// the stream's config untouched, and no `$JS.API.STREAM.UPDATE` is ever
/// published. Worth stating because the failure it would hide is quiet: a
/// hive able to reshape the shared bucket could set `MaxMsgs: 1` and evict
/// every other hive's status without ever touching `STREAM.DELETE`, and
/// per-key scoping would still look intact.
fn create(&self) -> String {
format!("$JS.API.STREAM.CREATE.{}", self.stream())
}
/// What one hive may publish: the account minimum, the bucket lookup,
/// creation, and its **own** key.
///
/// Two things a reader would reasonably assume, both false and both
/// measured (`state/attack-3297-js-api-door.sh` in atlas's notes):
///
/// - `$KV.<bucket>.<key>` alone does **not** let a client write that key.
/// The client resolves the bucket first, so the `STREAM.INFO` subject is
/// part of the minimum for a plain write.
/// - `$JS.API.>` is not "the `JetStream` permission". It also covers
/// `$JS.API.STREAM.DELETE`, with which a hive correctly refused on a
/// neighbour's individual key can destroy the whole bucket — every
/// hive's data. Granting it would make per-key scoping decorative, which
/// is why these are named one at a time and a test asserts the wildcard
/// never returns as a convenience.
fn hive_subjects(&self, hive: &str) -> Vec<String> {
let mut subjects = Self::jetstream_minimum().to_vec();
subjects.extend([