Watch
0
0
Fork
You've already forked hyperhive
0

swarm-nats-auth: a review classifier hears try-outs and answers by reply only

swarm-queue-client gains review_classifier: the try-out subject, the
request and reply that travel on it, and the deadlines both ends share.
The controller's reader grant may publish the subject; a client named
with --review-classifier-client may subscribe to it and its inbox, and
answers under NATS allow_responses (resp in the user JWT) within the
run deadline. Every other client id is still denied.
This commit is contained in:
atlas 2026-10-12 03:03:52 +02:00 • committed by mara
commit 98c873965b
8 changed files with 354 additions and 13 deletions

View file

@ -160,6 +160,9 @@ struct UserNats {
/// Subjects this client may subscribe to; absent means every subject.
#[serde(rename = "sub", skip_serializing_if = "Option::is_none")]
subscribe: Option<Permission>,
/// The reply allowance; absent means none.
#[serde(rename = "resp", skip_serializing_if = "Option::is_none")]
responses: Option<ResponsePermission>,
subs: i64,
data: i64,
payload: i64,
@ -178,6 +181,25 @@ struct Permission {
allow: Vec<String>,
}
/// NATS' `allow_responses`: the client may publish `max` messages to the
/// reply subject of each request it receives, within `ttl` nanoseconds (Go's
/// `time.Duration` in `jwt/v2`'s `ResponsePermission`).
#[derive(Serialize)]
struct ResponsePermission {
max: i64,
ttl: i64,
}
impl ResponsePermission {
/// One answer per request, within `window`.
fn one_within(window: std::time::Duration) -> Self {
Self {
max: 1,
ttl: i64::try_from(window.as_nanos()).unwrap_or(i64::MAX),
}
}
}
/// Mint the user JWT an admitted client presents.
///
/// `account` is the account **name** from the server's `accounts` block (the
@ -188,8 +210,7 @@ fn user_jwt(
issuer: &KeyPair,
account: &str,
user_nkey: &str,
publish: Vec<String>,
subscribe: Option<Vec<String>>,
permissions: &crate::policy::Permissions,
) -> String {
sign(
Claims {
@ -201,8 +222,14 @@ fn user_jwt(
aud: Some(account.to_owned()),
nats: UserNats {
kind: "user",
publish: Permission { allow: publish },
subscribe: subscribe.map(|allow| Permission { allow }),
publish: Permission {
allow: permissions.publish.clone(),
},
subscribe: permissions
.subscribe
.clone()
.map(|allow| Permission { allow }),
responses: permissions.responses.map(ResponsePermission::one_within),
subs: -1,
data: -1,
payload: -1,
@ -228,14 +255,7 @@ pub fn grant(
permissions: &crate::policy::Permissions,
) -> String {
let now = now_secs();
let jwt = user_jwt(
now,
issuer,
account,
user_nkey,
permissions.publish.clone(),
permissions.subscribe.clone(),
);
let jwt = user_jwt(now, issuer, account, user_nkey, permissions);
response(now, issuer, server_id, user_nkey, Ok(jwt))
}
@ -359,6 +379,7 @@ mod tests {
crate::policy::Permissions {
publish: vec!["$KV.hive-status.alpha".to_owned()],
subscribe: None,
responses: None,
}
}
@ -401,6 +422,27 @@ mod tests {
assert!(user["nats"]["sub"].is_null(), "{}", user["nats"]);
}
#[test]
fn the_issued_user_jwt_carries_a_reply_allowance_only_when_given_one() {
let account = KeyPair::new_account();
let answering = crate::policy::Permissions {
responses: Some(std::time::Duration::from_mins(31)),
..perms()
};
let wrapper = decode_claims(&grant(&account, "APP", "NSERVER", "UCLIENT", &answering));
let user = decode_claims(wrapper["nats"]["jwt"].as_str().expect("a user jwt"));
// The names and units nats-server's `jwt/v2` reads: `max` messages,
// `ttl` as a Go duration in nanoseconds.
assert_eq!(
user["nats"]["resp"],
serde_json::json!({ "max": 1, "ttl": 31 * 60 * 1_000_000_000_i64 })
);
let wrapper = decode_claims(&grant(&account, "APP", "NSERVER", "UCLIENT", &perms()));
let user = decode_claims(wrapper["nats"]["jwt"].as_str().expect("a user jwt"));
assert!(user["nats"]["resp"].is_null(), "{}", user["nats"]);
}
#[test]
fn a_grant_carries_a_user_jwt_and_no_error() {
let account = KeyPair::new_account();