Watch
0
0
Fork
You've already forked hyperhive
0

swarm-nats-auth: limit hive and swarm-controller subscribe to the subjects they use

A hive's queue credential and the swarm controller's could subscribe to
every subject in the swarm account, so a hive could read every other
hive's deploy requests and every agent's terminal, state, input and
request replies.

Their grants now carry a subscribe allow-list:

- a hive `hive-<h>`: `$SWARM.knowledge`, `$SWARM.deploy.<h>` and its
  reply inbox `_INBOX.client.hive-<h>.>`
- the reader (swarm-controller): `$SWARM.term.*`, `$SWARM.term.*.sub.*`,
  `$SWARM.agent-state.*` and its reply inbox
  `_INBOX.client.<client-id>.>`

`swarm_queue_client::connect`, which hive-c0re and swarm-controller both
use, now connects with `_INBOX.client.<client-id>` as its inbox prefix,
the same shape agents got for their own credential. In async-nats 0.50
every request reply and every ordered consumer (the hive's wanted-state
watch, the controller's KV key listings) delivers under that prefix, so
the one inbox grant covers them all.

A hive or controller still on a build without the prefix has every
JetStream reply refused once the responder runs this policy, so the
clients need to be rebuilt before, or with, swarm-nats-auth.

Closes #4945
This commit is contained in:
atlas 2026-10-07 16:05:36 +02:00
commit d0c2b85d45
3 changed files with 159 additions and 27 deletions

View file

@ -26,10 +26,9 @@ pub struct Permissions {
/// Subjects allowed for subscribe, or `None` for every subject in the
/// account.
///
/// `Some` only for agents, the least trusted clients: their own reply
/// inbox and their own input subject.
/// Hives and readers get `None`. Never `Some(vec![])`: NATS reads an empty
/// allow-list as no restriction at all.
/// Every grant [`Policy`] makes is `Some`: the subjects that client
/// 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>>,
}
@ -182,18 +181,55 @@ impl Policy {
if let Some(hive) = self.hive_name(client_id) {
return Some(Permissions {
publish: self.hive_subjects(hive),
subscribe: None,
subscribe: Some(Self::hive_subscribe(client_id, hive)),
});
}
if self.readers.iter().any(|r| r == client_id) {
return Some(Permissions {
publish: self.reader_subjects(),
subscribe: None,
subscribe: Some(Self::reader_subscribe(client_id)),
});
}
None
}
/// What a hive may subscribe to: the two events the controller sends it,
/// and its own reply inbox.
///
/// The inbox carries every `JetStream` reply and the wanted-bucket watch,
/// whose consumer delivers to a fresh inbox subject. The client sets the
/// matching prefix in `swarm_queue_client::connect`. A refused subscribe
/// is reported on the connection, not to the caller, so a subject missing
/// here shows up as a hive that silently stops hearing it.
fn hive_subscribe(client_id: &str, hive: &str) -> Vec<String> {
vec![
swarm_queue_client::KNOWLEDGE_SUBJECT.to_owned(),
swarm_queue_client::deploy_subject(hive),
Self::inbox(client_id),
]
}
/// What a reader may subscribe to: every agent's terminal, its
/// subagents' terminals and its turn state, which the controller relays
/// to swarm-ui, and its own reply inbox.
///
/// The three families are the publishers' (`hive-agent`'s `swarm_term`
/// and `swarm_agent_state`, `swarm_queue_client::subagent_term`), which no
/// shared constant names; a rename there must change these too. Operator
/// input, deploys and knowledge are absent: the reader publishes those.
fn reader_subscribe(client_id: &str) -> Vec<String> {
vec![
"$SWARM.term.*".to_owned(),
"$SWARM.term.*.sub.*".to_owned(),
"$SWARM.agent-state.*".to_owned(),
Self::inbox(client_id),
]
}
fn inbox(client_id: &str) -> String {
format!("{}.>", swarm_queue_client::client_inbox_prefix(client_id))
}
/// The hive a client id names, when it names one.
///
/// The prefix is configuration, not a pattern this module guesses at: the
@ -901,12 +937,12 @@ mod tests {
let grants = p
.subscribe
.as_ref()
.expect("an agent's subscribe is an allow-list, never unrestricted");
.expect("a grant'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
/// The reply inbox each client connects with, as a subject a
/// request under it would receive on.
fn reply(prefix: &str) -> String {
format!("{prefix}.nuid123.token456")
@ -939,16 +975,91 @@ mod tests {
}
}
/// The subscribe the reply multiplexer makes under `prefix`: a wildcard,
/// so the grant has to cover it as a subset, not just one reply.
fn multiplexer(prefix: &str) -> String {
format!("{prefix}.nuid123.*")
}
#[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
);
fn a_hive_may_subscribe_to_its_own_events_and_inbox_only() {
use swarm_queue_client::{
KNOWLEDGE_SUBJECT, agent_inbox_prefix, agent_input_subject, client_inbox_prefix,
deploy_subject,
};
let alpha = policy().permissions("hive-alpha").expect("a hive");
let inbox = client_inbox_prefix("hive-alpha");
for allowed in [
KNOWLEDGE_SUBJECT.to_owned(),
deploy_subject("alpha"),
reply(&inbox),
multiplexer(&inbox),
] {
assert!(
may_subscribe(&alpha, &allowed),
"alpha may not subscribe to {allowed}"
);
}
for refused in [
">".to_owned(),
"$SWARM.>".to_owned(),
deploy_subject("beta"),
swarm_queue_client::DEPLOY_SUBJECT_WILDCARD.to_owned(),
"$SWARM.term.>".to_owned(),
"$SWARM.term.atlas".to_owned(),
"$SWARM.agent-state.atlas".to_owned(),
agent_input_subject("atlas"),
reply(&agent_inbox_prefix("atlas")),
reply(&client_inbox_prefix("hive-beta")),
reply(&client_inbox_prefix("swarm-controller")),
"_INBOX.nuid123.token456".to_owned(),
] {
assert!(
!may_subscribe(&alpha, &refused),
"alpha may subscribe to {refused}"
);
}
}
#[test]
fn the_reader_may_subscribe_to_agent_streams_and_its_inbox_only() {
use swarm_queue_client::{
KNOWLEDGE_SUBJECT, agent_inbox_prefix, agent_input_subject, client_inbox_prefix,
deploy_subject,
};
let reader = policy()
.permissions("swarm-controller")
.expect("the reader");
let inbox = client_inbox_prefix("swarm-controller");
for allowed in [
"$SWARM.term.atlas".to_owned(),
swarm_queue_client::subagent_term::subject("atlas", "scout"),
"$SWARM.agent-state.atlas".to_owned(),
reply(&inbox),
multiplexer(&inbox),
] {
assert!(
may_subscribe(&reader, &allowed),
"the reader may not subscribe to {allowed}"
);
}
for refused in [
">".to_owned(),
"$SWARM.>".to_owned(),
agent_input_subject("atlas"),
deploy_subject("alpha"),
KNOWLEDGE_SUBJECT.to_owned(),
reply(&agent_inbox_prefix("atlas")),
reply(&client_inbox_prefix("hive-alpha")),
"_INBOX.nuid123.token456".to_owned(),
] {
assert!(
!may_subscribe(&reader, &refused),
"the reader may subscribe to {refused}"
);
}
}
#[test]