diff --git a/swarm-nats-auth/src/policy.rs b/swarm-nats-auth/src/policy.rs index 68829ff9..bf58f70d 100644 --- a/swarm-nats-auth/src/policy.rs +++ b/swarm-nats-auth/src/policy.rs @@ -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..` alone does **not** let a client write that key. The -//! client looks the bucket up first, so `$JS.API.STREAM.INFO.KV_` is -//! part of the minimum. -//! - `$JS.API.>` is not "the `JetStream` permission". It also covers -//! `$JS.API.STREAM.DELETE.KV_`, 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..` 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 { let mut subjects = Self::jetstream_minimum().to_vec(); subjects.extend([