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:
parent
fb5eb9edc1
commit
98c873965b
8 changed files with 354 additions and 13 deletions
|
|
@ -30,6 +30,10 @@ pub struct Permissions {
|
|||
/// subscribes to plus its own reply inbox. Never `Some(vec![])`: NATS
|
||||
/// reads an empty allow-list as no restriction at all.
|
||||
pub subscribe: Option<Vec<String>>,
|
||||
/// How long the client may answer a request it received, on that
|
||||
/// request's reply subject, or `None` for no such allowance. NATS'
|
||||
/// `allow_responses`: the reply subject need not be in `publish`.
|
||||
pub responses: Option<std::time::Duration>,
|
||||
}
|
||||
|
||||
/// Which client ids get which permissions.
|
||||
|
|
@ -45,6 +49,7 @@ pub struct Policy {
|
|||
readers: Vec<String>,
|
||||
extra_hive_subjects: Vec<String>,
|
||||
agent_token_subjects: Vec<String>,
|
||||
review_classifiers: Vec<String>,
|
||||
}
|
||||
|
||||
/// Placeholder replaced with the hive's own name in `extra_hive_subjects`.
|
||||
|
|
@ -114,6 +119,7 @@ impl Policy {
|
|||
readers,
|
||||
extra_hive_subjects,
|
||||
agent_token_subjects: Vec::new(),
|
||||
review_classifiers: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -135,6 +141,13 @@ impl Policy {
|
|||
Ok(self)
|
||||
}
|
||||
|
||||
/// Set the client ids that answer the review classifier's try-outs.
|
||||
#[must_use]
|
||||
pub fn with_review_classifiers(mut self, clients: Vec<String>) -> Self {
|
||||
self.review_classifiers = clients;
|
||||
self
|
||||
}
|
||||
|
||||
/// The permissions for an agent whose own credential was verified, or
|
||||
/// `None` when none are configured.
|
||||
///
|
||||
|
|
@ -153,6 +166,7 @@ impl Policy {
|
|||
format!("{}.>", swarm_queue_client::agent_inbox_prefix(agent)),
|
||||
swarm_queue_client::agent_input_subject(agent),
|
||||
]),
|
||||
responses: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -182,17 +196,39 @@ impl Policy {
|
|||
return Some(Permissions {
|
||||
publish: self.hive_subjects(hive),
|
||||
subscribe: Some(Self::hive_subscribe(client_id, hive)),
|
||||
responses: None,
|
||||
});
|
||||
}
|
||||
if self.readers.iter().any(|r| r == client_id) {
|
||||
return Some(Permissions {
|
||||
publish: self.reader_subjects(),
|
||||
subscribe: Some(Self::reader_subscribe(client_id)),
|
||||
responses: None,
|
||||
});
|
||||
}
|
||||
if self.review_classifiers.iter().any(|c| c == client_id) {
|
||||
return Some(Self::review_classifier_permissions(client_id));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// What the review classifier may do: hear try-out requests and answer
|
||||
/// each on its reply subject, for as long as a run may take.
|
||||
///
|
||||
/// Its publish list is its own inbox, which reaches no other client:
|
||||
/// NATS reads an empty allow-list as every subject, so the list carries
|
||||
/// one entry even though the answers go out under `responses`.
|
||||
fn review_classifier_permissions(client_id: &str) -> Permissions {
|
||||
Permissions {
|
||||
publish: vec![Self::inbox(client_id)],
|
||||
subscribe: Some(vec![
|
||||
swarm_queue_client::review_classifier::SUBJECT.to_owned(),
|
||||
Self::inbox(client_id),
|
||||
]),
|
||||
responses: Some(swarm_queue_client::review_classifier::RUN_DEADLINE),
|
||||
}
|
||||
}
|
||||
|
||||
/// What a hive may subscribe to: the two events the controller sends it,
|
||||
/// and its own reply inbox.
|
||||
///
|
||||
|
|
@ -512,6 +548,9 @@ impl Policy {
|
|||
// `from` as given, which holds only while this client is the
|
||||
// family's sole publisher: no hive or agent grant may name it.
|
||||
swarm_queue_client::AGENT_INPUT_SUBJECT_WILDCARD.to_owned(),
|
||||
// A review classifier try-out, sent as a request: the reply comes
|
||||
// back on this client's inbox, which it already subscribes to.
|
||||
swarm_queue_client::review_classifier::SUBJECT.to_owned(),
|
||||
// The wanted-state buckets — one per hive, all created and written
|
||||
// by this single client.
|
||||
//
|
||||
|
|
@ -1091,6 +1130,81 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
const CLASSIFIER: &str = "swarm-review-classifier";
|
||||
|
||||
fn with_classifier() -> Policy {
|
||||
policy().with_review_classifiers(vec![CLASSIFIER.to_owned()])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_review_classifier_hears_its_subject_and_inbox_only_and_answers_by_reply() {
|
||||
use swarm_queue_client::review_classifier::{RUN_DEADLINE, SUBJECT};
|
||||
use swarm_queue_client::{agent_input_subject, client_inbox_prefix};
|
||||
|
||||
let p = with_classifier()
|
||||
.permissions(CLASSIFIER)
|
||||
.expect("a configured classifier is admitted");
|
||||
let inbox = client_inbox_prefix(CLASSIFIER);
|
||||
assert!(may_subscribe(&p, SUBJECT));
|
||||
assert!(may_subscribe(&p, &reply(&inbox)));
|
||||
for refused in [
|
||||
">".to_owned(),
|
||||
"$SWARM.>".to_owned(),
|
||||
"$SWARM.review-classifier.other".to_owned(),
|
||||
"$SWARM.term.atlas".to_owned(),
|
||||
agent_input_subject("atlas"),
|
||||
reply(&client_inbox_prefix("swarm-controller")),
|
||||
"_INBOX.nuid123.token456".to_owned(),
|
||||
] {
|
||||
assert!(
|
||||
!may_subscribe(&p, &refused),
|
||||
"the classifier may subscribe to {refused}"
|
||||
);
|
||||
}
|
||||
// It publishes nothing but replies: its own inbox keeps the list
|
||||
// non-empty, and reaches no other client.
|
||||
assert_eq!(p.publish, vec![format!("{inbox}.>")]);
|
||||
assert_eq!(p.responses, Some(RUN_DEADLINE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_client_is_denied_with_a_review_classifier_configured() {
|
||||
let p = with_classifier();
|
||||
assert_eq!(p.permissions("some-other-service"), None);
|
||||
assert_eq!(p.permissions("swarm-review-classifier-2"), None);
|
||||
// The classifier's id is nothing until it is configured.
|
||||
assert_eq!(policy().permissions(CLASSIFIER), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_reader_may_send_a_try_out() {
|
||||
use swarm_queue_client::review_classifier::SUBJECT;
|
||||
|
||||
let p = with_classifier()
|
||||
.with_agent_token_subjects(vec!["$SWARM.term.{agent}".to_owned()])
|
||||
.expect("a per-agent template is valid");
|
||||
let reader = p.permissions("swarm-controller").expect("the reader");
|
||||
assert!(reader.publish.iter().any(|g| covers(g, SUBJECT)));
|
||||
assert_eq!(reader.responses, None);
|
||||
for (who, grant) in [
|
||||
("a hive", p.permissions("hive-alpha").expect("a hive")),
|
||||
(
|
||||
"the classifier",
|
||||
p.permissions(CLASSIFIER).expect("admitted"),
|
||||
),
|
||||
(
|
||||
"an agent",
|
||||
p.agent_token_permissions("atlas").expect("configured"),
|
||||
),
|
||||
] {
|
||||
assert!(
|
||||
!grant.publish.iter().any(|g| covers(g, SUBJECT)),
|
||||
"{who} may send a try-out: {:?}",
|
||||
grant.publish
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_prefix_alone_names_no_hive() {
|
||||
// `hive-` would otherwise produce `$KV.hive-status.`, a subject nobody
|
||||
|
|
|
|||
Loading…
Reference in a new issue