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

@ -214,12 +214,8 @@ pub const KNOWLEDGE_SUBJECT: &str = "$SWARM.knowledge";
/// of its agents. Lives here for the same three-crate reason as
/// [`KNOWLEDGE_SUBJECT`], but unlike it this is a **family, one subject per
/// hive** — a hive subscribes to its own and is never woken by a deploy meant
/// for someone else.
///
/// ⚠️ **That split is about noise, not confidentiality.** The auth-callout
/// responder scopes *publish* only and leaves `sub` unrestricted, so a hive
/// that wanted another's messages could still subscribe to them. What the
/// family buys is that it does not receive them by default.
/// for someone else. The queue grants each hive subscribe on its own subject
/// only, so it cannot read another hive's deploys either.
#[must_use]
pub fn deploy_subject(hive: &str) -> String {
format!("{DEPLOY_SUBJECT_PREFIX}.{hive}")
@ -289,6 +285,19 @@ pub fn agent_inbox_prefix(agent: &str) -> String {
format!("_INBOX.agent.{agent}")
}
/// The reply-inbox prefix [`connect`] uses for the client `client_id`: a hive
/// or the swarm controller.
///
/// The queue grants that client subscribe on this prefix's `.>`, so a request
/// made under the library's default `_INBOX.<nuid>` is refused its reply and
/// times out, as is a KV watch or key listing, whose consumer delivers there
/// too. The `client` token keeps it disjoint from [`agent_inbox_prefix`] and
/// from the default.
#[must_use]
pub fn client_inbox_prefix(client_id: &str) -> String {
format!("_INBOX.client.{client_id}")
}
/// What an [`agent_input_subject`] message carries.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct AgentInput {
@ -755,7 +764,8 @@ pub fn install_crypto_provider() {
}
/// Connect to the swarm queue, presenting a token on each connection attempt
/// and minting a fresh one only when the cached one is near expiry.
/// and minting a fresh one only when the cached one is near expiry. Reply
/// inboxes are under [`client_inbox_prefix`] of `cfg.client_id`.
///
/// The rejected alternative — mint once, pass a static `auth_token`, own the
/// reconnect loop — fails in the way this subsystem exists to prevent: the
@ -773,6 +783,7 @@ pub async fn connect(cfg: QueueConfig) -> Result<async_nats::Client, Error> {
let http = build_http_client(&cfg)?;
let url = cfg.url.clone();
let ca_file = cfg.ca_file.clone();
let inbox_prefix = client_inbox_prefix(&cfg.client_id);
// Shared across every invocation of the callback below, which is the
// whole point: the callback fires per connection ATTEMPT, so without
@ -837,7 +848,8 @@ pub async fn connect(cfg: QueueConfig) -> Result<async_nats::Client, Error> {
// near expiry. (Before the cache, "runs the callback" meant "mints",
// and this retry loop was a token-request loop. See
// `MAX_RECONNECT_DELAY`.)
.retry_on_initial_connect();
.retry_on_initial_connect()
.custom_inbox_prefix(inbox_prefix);
// The queue's leaf chains to the swarm's own PKI root, which a host
// process's platform store does not hold: the same anchor the token