Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-nats-auth
Repository files (latest commit first)
Filename Latest commit message Latest commit date
atlas fccf2ab892 hive-c0re: read each agent's swarm caps from agent-limits
Every 30 seconds `workers::swarm_limits` reads the `agent-limits` key of
each agent this hive has and stores the result in
swarm-resource-limits.json, where the drop-in, the heap ceiling and the
dashboard read it. A cap therefore follows its agent to whichever hive
runs it.

Each value goes through the hive's own validate_cpu_quota /
validate_memory_max before it is stored, since hive-priv renders it
verbatim into the container's drop-in, and one bad value refuses the whole
set (argus on #4268, carried over from the wanted-state consumer this
replaces). A failed read, or a bucket that does not answer, leaves the
stored caps as they are; three failures in a row raise a banner.

swarm-nats-auth grants every hive `$JS.API.DIRECT.GET.KV_agent-limits.>`:
read-only, and every agent's key, not just the hive's own. Scoping it to
the hive's agents would need a roster in the auth responder, which
policy.rs argues against. mara chose this on #4268 (c95293); narrowing it
is a possible low-priority follow-up, not planned. A test pins that the
grant reads every key, writes none, and reaches no other bucket.

Contributes to #3865.
2026-10-12 20:22:21 +02:00
..
src hive-c0re: read each agent's swarm caps from agent-limits 2026-10-12 20:22:21 +02:00
Cargo.toml swarm-nats-auth: bound the OIDC secret re-read, and test the 401 that starts it 2026-10-06 15:25:29 +02:00
README.md swarm-nats-auth: read its own OIDC client secret from the store 2026-10-06 14:54:19 +02:00

swarm-nats-auth

The auth-callout responder for the swarm's NATS queue — the half that lets the server say yes.

nix/host-modules/swarm-nats.nix configures nats-server with an auth_callout block. That block with no responder is the fail-closed state, and it is the measured one rather than the obvious one: on the pinned nats-server, both authorization { } and authorization { users: [] } answer PONG to an anonymous client, while an auth_callout block sets auth_required and refuses every client no responder has approved. So the module ships the final config from the start and this crate only adds the ability to approve. No interim hole is ever opened.

Why a crate and not more config

The reply is a signed NATS user JWT. Signing needs the account nkey seed and the JWT framing, which is program work — which is why the container and its config landed first and this arrived separately, rather than the pair being one change.

The invariant that is easy to break

⛔ issuer_account must be absent from the issued user token. It is an operator-mode field. The module renders server-config mode (accounts { AUTH, APP }, no operator), where its mere presence makes the server refuse the client — Error non operator mode account "AUTH": attempted to use issuer_account — while the responder cheerfully reports granted=true. The account is named by the claims' aud instead.

nats_jwt::Token::new_user always sets it, so reaching for that constructor reintroduces the bug. nats-jwt is a dev-dependency: it cannot express aud on either token, and its role here is as the encoder's test oracle, not part of the path that runs.

The sealed exchange

The server seals each auth request to the callout xkey and names its own public xkey in the Nats-Server-Xkey header; the responder opens the request with the xkey's seed and seals its reply to that header's key. The responder logs a warning for a request with no header, or one that doesn't open, and sends no reply: the server it serves always seals, so no server waits on one.

The seed is in the swarm secret store, and so are the seeds of the issuer key the responder signs with and the user key it connects as. The responder reads all three before it connects to the queue: a responder missing one doesn't start. The ensure-callout-keys verb creates each pair when the store holds none, taking an issuer or user seed from the import file it is given when that file exists, and removes the file once the store holds a seed. It runs from its own unit before the server, because the server needs the public halves to start and the responder needs the server. docs/swarm/secrets.md has the paths and the boot order.

Rules the code follows

  • A denial is a signed response carrying an error, never silence. The server cannot tell an absent responder from a refusing one, so staying quiet turns every rejection into a timeout and hides an outage inside what looks like ordinary denials.
  • Anything that is not an explicit {"active": true} denies — including an introspection call that could not be made. The failure modes of an HTTP call are exactly the conditions under which an attacker would most like this to fall open.
  • Every credential is a path, never a value. A value in nix config lands in the world-readable store; a value in argv is readable via /proc/<pid>/cmdline, which is 0444. Paths are not secrets, so passing them as flags is fine. The secrets themselves, the callout seeds and the responder's own OIDC client secret, are read from the secret store and held in memory.
  • The introspection timeout is pinned below the server's authorization. timeout, with a test asserting the relation — a responder that answers after the server gave up is indistinguishable from one that never answered. It is one request's whole budget: when the IdP refuses the responder's own secret, the re-read from the store and the one retry fit inside it, or the request is denied and the re-read lands for the next one.

What the tests do and do not cover

Unit tests cover the JWT framing, including byte-equality against nats-jwt on the one shape that crate models. They are not sufficient on their own: this crate's shape is decided by a server that parses what it emits, so the change was also driven against a real nats-server — every refusal repeated with the responder live, because a responder that says yes to everyone passes "a client can connect" perfectly. That harness lives outside this repo; the PR that added this crate links it.

⚠️ Its introspection endpoint is a stub. Those runs prove this crate's own behaviour and nothing about the real authelia integration, which needs a deployment.