| Filename | Latest commit message | Latest commit date |
|---|---|---|
Every admitted client got the same unrestricted grant, so any hive could write any other hive's status key. The responder now derives a permission set from the caller's identity and mints it into the user JWT. A hive may publish to its own KV key and the two JetStream subjects needed to reach it; the controller may list and fetch every key and write none; anything else is denied outright. Deny is the default because every other shape fails open, and silently: a client that matched no rule and kept the old grant would make the policy advisory. The subject sets are measured rather than reasoned about, and two of them are counter-intuitive. `$KV.<bucket>.<key>` alone does not let a client write that key, because the client resolves the bucket first. And `$JS.API.>` is not "the JetStream permission": it also covers `$JS.API.STREAM.DELETE`, with which a hive correctly refused on a neighbour's key can delete the whole bucket and every hive's data with it. Granting it would have made per-key scoping decorative, so the subjects are named individually and a test asserts the wildcard does not come back as a convenience. Minimality is by removal: each subject was dropped in turn to confirm the client breaks without it. That is not pedantry — an additive search had called a set minimal while two of its five subjects were never needed, which ships an unnecessary grant with a measurement attached making it look earned. Both grants include `STREAM.CREATE` on the one named stream, because `status::open_or_create` is called by both ends: either may arrive first on a fresh swarm, and without it a new swarm never gets a bucket at all. `CREATE` is not `UPDATE`, so a second arrival cannot reshape the bucket the first one made. `status::BUCKET` moves out from behind the `kv` feature so this responder can share it. The name is a `&str` with no dependencies and only `open_or_create` needs JetStream; gating the name forced a third consumer to choose between a stack it does not use and a copied literal, and the copied literal is exactly the disagreement that module exists to prevent. Only publish is scoped. Subscription permissions are unrestricted and unmeasured, and the module docs say so rather than implying a property nothing established. |
||
| .. | ||
| src | ||
| Cargo.toml | ||
| README.md | ||
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.
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
argvis readable via/proc/<pid>/cmdline, which is0444. Paths are not secrets, so passing them as flags is fine. - 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.
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.