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.
This commit is contained in:
parent
5539819330
commit
c8a3159297
7 changed files with 436 additions and 21 deletions
|
|
@ -149,6 +149,14 @@ fn now_secs() -> i64 {
|
|||
struct UserNats {
|
||||
#[serde(rename = "type")]
|
||||
kind: &'static str,
|
||||
/// Subjects this client may publish to.
|
||||
///
|
||||
/// The server enforces what is minted here — measured, because a
|
||||
/// non-operator server validates minted claims against its own config
|
||||
/// rather than trusting them, and this responder's own history includes a
|
||||
/// field the server rejected while the responder said `granted=true`.
|
||||
#[serde(rename = "pub")]
|
||||
publish: Permission,
|
||||
subs: i64,
|
||||
data: i64,
|
||||
payload: i64,
|
||||
|
|
@ -156,12 +164,29 @@ struct UserNats {
|
|||
version: i64,
|
||||
}
|
||||
|
||||
/// A NATS permission block.
|
||||
///
|
||||
/// Only `allow` is modelled. NATS also takes `deny`, and a struct that has it
|
||||
/// is a struct someone will use: an allow-list plus a deny-list has two places
|
||||
/// deciding the same question, and the interaction between them is a thing to
|
||||
/// remember rather than to read.
|
||||
#[derive(Serialize)]
|
||||
struct Permission {
|
||||
allow: Vec<String>,
|
||||
}
|
||||
|
||||
/// Mint the user JWT an admitted client presents.
|
||||
///
|
||||
/// `account` is the account **name** from the server's `accounts` block (the
|
||||
/// module's `clientAccount`), not a public key — in config mode the server
|
||||
/// resolves `aud` against its own config rather than against a key.
|
||||
fn user_jwt(now: i64, issuer: &KeyPair, account: &str, user_nkey: &str) -> String {
|
||||
fn user_jwt(
|
||||
now: i64,
|
||||
issuer: &KeyPair,
|
||||
account: &str,
|
||||
user_nkey: &str,
|
||||
publish: Vec<String>,
|
||||
) -> String {
|
||||
sign(
|
||||
Claims {
|
||||
iat: now,
|
||||
|
|
@ -172,6 +197,7 @@ fn user_jwt(now: i64, issuer: &KeyPair, account: &str, user_nkey: &str) -> Strin
|
|||
aud: Some(account.to_owned()),
|
||||
nats: UserNats {
|
||||
kind: "user",
|
||||
publish: Permission { allow: publish },
|
||||
subs: -1,
|
||||
data: -1,
|
||||
payload: -1,
|
||||
|
|
@ -183,10 +209,21 @@ fn user_jwt(now: i64, issuer: &KeyPair, account: &str, user_nkey: &str) -> Strin
|
|||
)
|
||||
}
|
||||
|
||||
/// Grant: mint a user JWT placing the client in `account` and wrap it.
|
||||
pub fn grant(issuer: &KeyPair, account: &str, server_id: &str, user_nkey: &str) -> String {
|
||||
/// Grant: mint a user JWT placing the client in `account`, scoped to
|
||||
/// `permissions`, and wrap it.
|
||||
///
|
||||
/// Taking the permissions by value rather than defaulting them is the point:
|
||||
/// there is no way to call this and get an unscoped grant by omission, so a
|
||||
/// future caller cannot forget the argument that makes the scoping real.
|
||||
pub fn grant(
|
||||
issuer: &KeyPair,
|
||||
account: &str,
|
||||
server_id: &str,
|
||||
user_nkey: &str,
|
||||
permissions: &crate::policy::Permissions,
|
||||
) -> String {
|
||||
let now = now_secs();
|
||||
let jwt = user_jwt(now, issuer, account, user_nkey);
|
||||
let jwt = user_jwt(now, issuer, account, user_nkey, permissions.publish.clone());
|
||||
response(now, issuer, server_id, user_nkey, Ok(jwt))
|
||||
}
|
||||
|
||||
|
|
@ -304,10 +341,38 @@ mod tests {
|
|||
assert_eq!(claims["aud"], "NSERVER");
|
||||
}
|
||||
|
||||
/// The permissions a test grant carries. Any non-empty set will do for
|
||||
/// the wrapper-shape assertions; `policy.rs` owns what the real ones are.
|
||||
fn perms() -> crate::policy::Permissions {
|
||||
crate::policy::Permissions {
|
||||
publish: vec!["$KV.hive-status.alpha".to_owned()],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_issued_user_jwt_carries_the_permissions_it_was_given() {
|
||||
// The scoping is only real if it survives into the minted token. A
|
||||
// policy that computes the right subjects and a grant that drops them
|
||||
// look identical from every test that stops at the policy.
|
||||
let account = KeyPair::new_account();
|
||||
let wrapper = decode_claims(&grant(&account, "APP", "NSERVER", "UCLIENT", &perms()));
|
||||
let user = decode_claims(wrapper["nats"]["jwt"].as_str().expect("a user jwt"));
|
||||
assert_eq!(user["nats"]["pub"]["allow"][0], "$KV.hive-status.alpha");
|
||||
// An empty or absent allow-list is NATS' "everything": the one shape
|
||||
// that turns this whole change into a no-op while every other
|
||||
// assertion still passes.
|
||||
assert!(
|
||||
user["nats"]["pub"]["allow"]
|
||||
.as_array()
|
||||
.is_some_and(|a| !a.is_empty()),
|
||||
"an empty pub.allow is an unscoped grant"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_grant_carries_a_user_jwt_and_no_error() {
|
||||
let account = KeyPair::new_account();
|
||||
let claims = decode_claims(&grant(&account, "APP", "NSERVER", "UCLIENT"));
|
||||
let claims = decode_claims(&grant(&account, "APP", "NSERVER", "UCLIENT", &perms()));
|
||||
assert!(claims["nats"]["error"].is_null());
|
||||
assert!(
|
||||
claims["nats"]["jwt"]
|
||||
|
|
@ -331,7 +396,7 @@ mod tests {
|
|||
#[test]
|
||||
fn the_issued_user_jwt_names_its_account_by_aud_and_sets_no_issuer_account() {
|
||||
let account = KeyPair::new_account();
|
||||
let wrapper = decode_claims(&grant(&account, "APP", "NSERVER", "UCLIENT"));
|
||||
let wrapper = decode_claims(&grant(&account, "APP", "NSERVER", "UCLIENT", &perms()));
|
||||
let user = decode_claims(wrapper["nats"]["jwt"].as_str().expect("a user jwt"));
|
||||
|
||||
// In server-config mode the account is named by `aud`...
|
||||
|
|
|
|||
Loading…
Reference in a new issue