fix(#3297): grant the JetStream subjects every client needs first

A grant carrying every bucket-specific subject and neither of these
cannot create the bucket at all: the client times out on `$JS.API.INFO`
long before it reaches a subject that was granted, and a NATS denial
reaches the client as a hang rather than an error.

Both were named by the server's own log, not reasoned about.
`$JS.API.INFO` is the account-level JetStream info every client requests
on connect; `$JS.API.STREAM.NAMES` is how a client finds the stream
backing a bucket. The latter lets a client enumerate stream names in the
account, which in an account holding one bucket discloses a name both
ends already share.

Every earlier measurement missed them, because each either granted
`$JS.API.>` wholesale or ran against a bucket the setup had already
created while unscoped. A minimum established against an existing bucket
is not the minimum for making one, so the leave-one-out that trimmed the
reader's set could not have found this — every candidate it tried was
tried in a world where the bucket existed.

Found by running the shipping gate against the real binary. No unit test
could have: the failure is a timeout inside a real server's permission
check.
This commit is contained in:
atlas 2026-08-17 01:14:41 +02:00
commit 61a13a63d4

View file

@ -150,6 +150,28 @@ impl Policy {
format!("KV_{}", self.bucket)
}
/// What *any* `JetStream` client must be able to ask before it can do
/// anything at all, bucket-specific or not.
///
/// Both were measured from the server's own refusals, not reasoned about:
/// a grant carrying every bucket-specific subject and neither of these
/// cannot even create the bucket — the client times out on `$JS.API.INFO`
/// long before it reaches a subject that was granted.
///
/// - `$JS.API.INFO` — account-level `JetStream` info, requested on connect.
/// - `$JS.API.STREAM.NAMES` — how a client finds the stream backing a
/// bucket. It lets a client enumerate stream names in the account, which
/// in an account holding one bucket discloses a name both ends already
/// share.
///
/// 🩸 Earlier measurements missed both, because they either granted
/// `$JS.API.>` wholesale or ran against a bucket the *setup* had already
/// created while unscoped. A minimum established against an existing
/// bucket is not the minimum for making one.
fn jetstream_minimum() -> [String; 2] {
["$JS.API.INFO".to_owned(), "$JS.API.STREAM.NAMES".to_owned()]
}
/// Creating the bucket, which **both** ends need.
///
/// `swarm_queue_client::status::open_or_create` is called by the hive that
@ -168,11 +190,12 @@ impl Policy {
}
fn hive_subjects(&self, hive: &str) -> Vec<String> {
let mut subjects = vec![
let mut subjects = Self::jetstream_minimum().to_vec();
subjects.extend([
format!("$JS.API.STREAM.INFO.{}", self.stream()),
self.create(),
format!("$KV.{}.{hive}", self.bucket),
];
]);
subjects.extend(
self.extra_hive_subjects
.iter()
@ -183,7 +206,8 @@ impl Policy {
fn reader_subjects(&self) -> Vec<String> {
let stream = self.stream();
vec![
let mut subjects = Self::jetstream_minimum().to_vec();
subjects.extend([
format!("$JS.API.STREAM.INFO.{stream}"),
self.create(),
// The `.>` form specifically: the bare `$JS.API.DIRECT.GET.<stream>`
@ -194,7 +218,8 @@ impl Policy {
// reader without this can get a key it already knows and discover
// nothing.
format!("$JS.API.CONSUMER.CREATE.{stream}.>"),
]
]);
subjects
}
}
@ -281,6 +306,24 @@ mod tests {
);
}
#[test]
fn every_grant_carries_the_jetstream_minimum() {
// 🩸 Found by the shipping gate, not by any unit test: a grant with
// every bucket-specific subject and neither of these cannot create the
// bucket at all. The client times out on `$JS.API.INFO` before it
// reaches anything that was granted, and a NATS denial looks like a
// hang from the client side — the server log is what named them.
for client in ["hive-alpha", "swarm-controller"] {
let p = policy().permissions(client).expect("admitted");
for required in ["$JS.API.INFO", "$JS.API.STREAM.NAMES"] {
assert!(
p.publish.iter().any(|s| s == required),
"{client} is missing {required}, so it cannot use JetStream at all"
);
}
}
}
#[test]
fn both_ends_may_create_the_bucket_but_not_reshape_it() {
// 🩸 The bug the subject measurements could not see: they ran against