swarm-nats-auth: limit agent subscribe to its own inbox and input
An agent's queue credential could subscribe to every subject in the swarm account, so any agent could read every other agent's terminal, turn state, operator input and request replies. Agents are the least trusted clients on the queue (mara's ruling on #4901), so their grants now carry a subscribe allow-list: - an agent's own credential (the harness and its subagent daemon): `_INBOX.agent.<agent>.>` and `$SWARM.input.<agent>` - the hive's shared agent client: `_INBOX.client.<client-id>.>` Hive and swarm-controller grants are unchanged (no `sub` claim, so still every subject). The clients connect with the matching inbox prefix, so the replies they rely on still arrive: the icon write's JetStream ack and the schedule DIRECT.GET. `connect_with_token` takes the agent name and sets the prefix itself; the hive-shared fallback uses the new `connect_with_inbox_prefix`. The harness no longer subscribes to its input subject when it fell back to the hive's shared client: that identity names no agent, so it cannot be granted one agent's input without all of them. It logs that and skips, the same as the schedule reader on that path. Refs #4901
This commit is contained in:
parent
bc11b85e07
commit
52037133e2
8 changed files with 236 additions and 33 deletions
|
|
@ -17,19 +17,20 @@
|
|||
//! 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`.
|
||||
/// The subjects an admitted client may publish and subscribe to.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct Permissions {
|
||||
/// Subjects allowed for publish. Never empty: an empty allow-list is a
|
||||
/// grant that can do nothing, which is a denial wearing a grant's shape.
|
||||
pub publish: Vec<String>,
|
||||
/// Subjects allowed for subscribe, or `None` for every subject in the
|
||||
/// account.
|
||||
///
|
||||
/// `Some` only for agents, the least trusted clients: their own reply
|
||||
/// inbox, plus their own input subject for an agent's own credential.
|
||||
/// Hives and readers get `None`. Never `Some(vec![])`: NATS reads an empty
|
||||
/// allow-list as no restriction at all.
|
||||
pub subscribe: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Which client ids get which permissions.
|
||||
|
|
@ -168,7 +169,13 @@ impl Policy {
|
|||
.iter()
|
||||
.map(|s| s.replace(AGENT_PLACEHOLDER, agent))
|
||||
.collect();
|
||||
(!publish.is_empty()).then_some(Permissions { publish })
|
||||
(!publish.is_empty()).then(|| Permissions {
|
||||
publish,
|
||||
subscribe: Some(vec![
|
||||
format!("{}.>", swarm_queue_client::agent_inbox_prefix(agent)),
|
||||
swarm_queue_client::agent_input_subject(agent),
|
||||
]),
|
||||
})
|
||||
}
|
||||
|
||||
/// The permissions for `client_id`, or `None` when no rule matches.
|
||||
|
|
@ -191,18 +198,30 @@ impl Policy {
|
|||
// for agents, expanded for that hive, and **nothing when it configured
|
||||
// none**: an empty publish list would be a denial wearing a grant's
|
||||
// shape, so this returns `None` and the client is refused outright.
|
||||
//
|
||||
// Subscribe is its own reply inbox alone. The identity names no agent,
|
||||
// so it cannot be granted one agent's input subject without all of
|
||||
// them.
|
||||
if let Some(hive) = self.agent_hive(client_id) {
|
||||
let publish = self.agent_subjects(hive);
|
||||
return (!publish.is_empty()).then_some(Permissions { publish });
|
||||
return (!publish.is_empty()).then(|| Permissions {
|
||||
publish,
|
||||
subscribe: Some(vec![format!(
|
||||
"{}.>",
|
||||
swarm_queue_client::client_inbox_prefix(client_id)
|
||||
)]),
|
||||
});
|
||||
}
|
||||
if let Some(hive) = self.hive_name(client_id) {
|
||||
return Some(Permissions {
|
||||
publish: self.hive_subjects(hive),
|
||||
subscribe: None,
|
||||
});
|
||||
}
|
||||
if self.readers.iter().any(|r| r == client_id) {
|
||||
return Some(Permissions {
|
||||
publish: self.reader_subjects(),
|
||||
subscribe: None,
|
||||
});
|
||||
}
|
||||
None
|
||||
|
|
@ -921,6 +940,103 @@ mod tests {
|
|||
assert_eq!(policy().permissions(""), None);
|
||||
}
|
||||
|
||||
/// Whether the grant `grant` covers the concrete subject `subject`, by
|
||||
/// NATS' token rules: `*` is one token, `>` one or more.
|
||||
fn covers(grant: &str, subject: &str) -> bool {
|
||||
let g: Vec<&str> = grant.split('.').collect();
|
||||
let s: Vec<&str> = subject.split('.').collect();
|
||||
for (i, t) in g.iter().enumerate() {
|
||||
match *t {
|
||||
">" => return s.len() > i,
|
||||
"*" if i < s.len() => {}
|
||||
t if s.get(i) == Some(&t) => {}
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
g.len() == s.len()
|
||||
}
|
||||
|
||||
fn may_subscribe(p: &Permissions, subject: &str) -> bool {
|
||||
let grants = p
|
||||
.subscribe
|
||||
.as_ref()
|
||||
.expect("an agent's subscribe is an allow-list, never unrestricted");
|
||||
assert!(!grants.is_empty(), "an empty allow-list is unrestricted");
|
||||
grants.iter().any(|g| covers(g, subject))
|
||||
}
|
||||
|
||||
/// The reply inbox each agent client connects with, as a subject a
|
||||
/// request under it would receive on.
|
||||
fn reply(prefix: &str) -> String {
|
||||
format!("{prefix}.nuid123.token456")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_agent_token_may_subscribe_to_its_own_inbox_and_input_only() {
|
||||
use swarm_queue_client::{agent_inbox_prefix, agent_input_subject, client_inbox_prefix};
|
||||
|
||||
let p = policy_with_agent_subject()
|
||||
.with_agent_token_subjects(vec!["$SWARM.term.{agent}".to_owned()])
|
||||
.expect("a per-agent template is valid");
|
||||
let atlas = p.agent_token_permissions("atlas").expect("configured");
|
||||
assert!(may_subscribe(&atlas, &reply(&agent_inbox_prefix("atlas"))));
|
||||
assert!(may_subscribe(&atlas, &agent_input_subject("atlas")));
|
||||
for refused in [
|
||||
">".to_owned(),
|
||||
"$SWARM.term.>".to_owned(),
|
||||
"$SWARM.term.argus".to_owned(),
|
||||
"$SWARM.term.atlas".to_owned(),
|
||||
"$SWARM.input.*".to_owned(),
|
||||
agent_input_subject("argus"),
|
||||
reply(&agent_inbox_prefix("argus")),
|
||||
reply(&client_inbox_prefix("hive-alpha-agent")),
|
||||
"_INBOX.nuid123.token456".to_owned(),
|
||||
] {
|
||||
assert!(
|
||||
!may_subscribe(&atlas, &refused),
|
||||
"atlas may subscribe to {refused}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_hives_shared_agent_client_may_subscribe_to_its_own_inbox_only() {
|
||||
use swarm_queue_client::{agent_inbox_prefix, agent_input_subject, client_inbox_prefix};
|
||||
|
||||
let p = policy_with_agent_subject();
|
||||
let alpha = p.permissions("hive-alpha-agent").expect("alpha's agents");
|
||||
assert!(may_subscribe(
|
||||
&alpha,
|
||||
&reply(&client_inbox_prefix("hive-alpha-agent"))
|
||||
));
|
||||
for refused in [
|
||||
">".to_owned(),
|
||||
"$SWARM.term.>".to_owned(),
|
||||
"$SWARM.term.alpha.atlas".to_owned(),
|
||||
agent_input_subject("atlas"),
|
||||
reply(&client_inbox_prefix("hive-beta-agent")),
|
||||
reply(&agent_inbox_prefix("atlas")),
|
||||
"_INBOX.nuid123.token456".to_owned(),
|
||||
] {
|
||||
assert!(
|
||||
!may_subscribe(&alpha, &refused),
|
||||
"alpha's agents may subscribe to {refused}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hives_and_readers_keep_an_unrestricted_subscribe() {
|
||||
let p = policy();
|
||||
assert_eq!(p.permissions("hive-alpha").expect("a hive").subscribe, None);
|
||||
assert_eq!(
|
||||
p.permissions("swarm-controller")
|
||||
.expect("the reader")
|
||||
.subscribe,
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_prefix_alone_names_no_hive() {
|
||||
// `hive-` would otherwise produce `$KV.hive-status.`, a subject nobody
|
||||
|
|
@ -1313,19 +1429,6 @@ mod tests {
|
|||
assert_eq!(argus.publish, vec![direct_get_subject("argus")]);
|
||||
assert!(!atlas.publish.contains(&direct_get_subject("argus")));
|
||||
|
||||
let covers = |grant: &str, subject: &str| {
|
||||
let g: Vec<&str> = grant.split('.').collect();
|
||||
let s: Vec<&str> = subject.split('.').collect();
|
||||
for (i, t) in g.iter().enumerate() {
|
||||
match *t {
|
||||
">" => return s.len() > i,
|
||||
"*" if i < s.len() => {}
|
||||
t if s.get(i) == Some(&t) => {}
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
g.len() == s.len()
|
||||
};
|
||||
for refused in [
|
||||
direct_get_subject("argus"),
|
||||
format!("$JS.API.DIRECT.GET.{STREAM}"),
|
||||
|
|
|
|||
|
|
@ -157,6 +157,9 @@ struct UserNats {
|
|||
/// field the server rejected while the responder said `granted=true`.
|
||||
#[serde(rename = "pub")]
|
||||
publish: Permission,
|
||||
/// Subjects this client may subscribe to; absent means every subject.
|
||||
#[serde(rename = "sub", skip_serializing_if = "Option::is_none")]
|
||||
subscribe: Option<Permission>,
|
||||
subs: i64,
|
||||
data: i64,
|
||||
payload: i64,
|
||||
|
|
@ -186,6 +189,7 @@ fn user_jwt(
|
|||
account: &str,
|
||||
user_nkey: &str,
|
||||
publish: Vec<String>,
|
||||
subscribe: Option<Vec<String>>,
|
||||
) -> String {
|
||||
sign(
|
||||
Claims {
|
||||
|
|
@ -198,6 +202,7 @@ fn user_jwt(
|
|||
nats: UserNats {
|
||||
kind: "user",
|
||||
publish: Permission { allow: publish },
|
||||
subscribe: subscribe.map(|allow| Permission { allow }),
|
||||
subs: -1,
|
||||
data: -1,
|
||||
payload: -1,
|
||||
|
|
@ -223,7 +228,14 @@ pub fn grant(
|
|||
permissions: &crate::policy::Permissions,
|
||||
) -> String {
|
||||
let now = now_secs();
|
||||
let jwt = user_jwt(now, issuer, account, user_nkey, permissions.publish.clone());
|
||||
let jwt = user_jwt(
|
||||
now,
|
||||
issuer,
|
||||
account,
|
||||
user_nkey,
|
||||
permissions.publish.clone(),
|
||||
permissions.subscribe.clone(),
|
||||
);
|
||||
response(now, issuer, server_id, user_nkey, Ok(jwt))
|
||||
}
|
||||
|
||||
|
|
@ -346,6 +358,7 @@ mod tests {
|
|||
fn perms() -> crate::policy::Permissions {
|
||||
crate::policy::Permissions {
|
||||
publish: vec!["$KV.hive-status.alpha".to_owned()],
|
||||
subscribe: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -369,6 +382,25 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_issued_user_jwt_carries_a_subscribe_allow_list_only_when_given_one() {
|
||||
let account = KeyPair::new_account();
|
||||
let scoped = crate::policy::Permissions {
|
||||
subscribe: Some(vec!["_INBOX.agent.atlas.>".to_owned()]),
|
||||
..perms()
|
||||
};
|
||||
let wrapper = decode_claims(&grant(&account, "APP", "NSERVER", "UCLIENT", &scoped));
|
||||
let user = decode_claims(wrapper["nats"]["jwt"].as_str().expect("a user jwt"));
|
||||
assert_eq!(
|
||||
user["nats"]["sub"]["allow"],
|
||||
serde_json::json!(["_INBOX.agent.atlas.>"])
|
||||
);
|
||||
|
||||
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"]["sub"].is_null(), "{}", user["nats"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_grant_carries_a_user_jwt_and_no_error() {
|
||||
let account = KeyPair::new_account();
|
||||
|
|
|
|||
Loading…
Reference in a new issue