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:
parent
22ce8b815a
commit
d0c2b85d45
3 changed files with 159 additions and 27 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue