Commit graph hyperhive/swarm-nats-auth
Author SHA1 Message Date
atlas
2e9ce53a32 docs(#3297): move each subject set's rationale next to the list it constrains
The module doc had grown a 37-line preamble carrying three separate
arguments, which the comment-block lint refuses. Splitting it is the
better fix than raising the limit: the reader who is about to widen a
subject list meets the reason not to at the list, not seven screens up.

Also corrects one claim I had no measurement for. The CREATE note said
creating an existing stream with a different config "is an error rather
than a rewrite" — asserted, not observed. What is observed is narrower
and enough: a hive holding this grant leaves the stream config untouched
and never publishes $JS.API.STREAM.UPDATE at all.
2026-08-17 17:34:27 +02:00
atlas
61a13a63d4 fix(#3297): grant the JetStream subjects every client needs first
A grant carrying every bucket-specific subject and neither of these
cannot create the bucket at all: the client times out on `$JS.API.INFO`
long before it reaches a subject that was granted, and a NATS denial
reaches the client as a hang rather than an error.

Both were named by the server's own log, not reasoned about.
`$JS.API.INFO` is the account-level JetStream info every client requests
on connect; `$JS.API.STREAM.NAMES` is how a client finds the stream
backing a bucket. The latter lets a client enumerate stream names in the
account, which in an account holding one bucket discloses a name both
ends already share.

Every earlier measurement missed them, because each either granted
`$JS.API.>` wholesale or ran against a bucket the setup had already
created while unscoped. A minimum established against an existing bucket
is not the minimum for making one, so the leave-one-out that trimmed the
reader's set could not have found this — every candidate it tried was
tried in a world where the bucket existed.

Found by running the shipping gate against the real binary. No unit test
could have: the failure is a timeout inside a real server's permission
check.
2026-08-17 17:34:27 +02:00
atlas
7b5f383b05 fix(#3297): reject a publish template that names no hive
`--hive-publish-subject` exists to put a second stream inside one hive's
namespace. A template with no `{hive}` in it expands to the same subject
for every hive, so the option whose only purpose is scoping becomes the
way to remove it — silently, and only in the deployment that set it.

`Policy::new` returns a `Result` rather than checking at the call site:
that makes an unscoped policy unconstructible instead of merely
unlikely, the same reason `grant` takes its permissions by value. The
error names the offending template and says what goes wrong with it,
because an operator meets it at boot with no other context.

Also documents what the prefix match does not do. A client id is a hive
here because it starts with the configured prefix, not because it
appears in the roster — the responder runs in a container and cannot see
`swarm.hives`. Passing the roster in would close that and would also be
a second place deciding who may connect as what, which `introspect`'s
docs argue against for the same reason admission lives in one place.

The two intra-doc links to `open_or_create` become plain backticks.
Un-gating the `status` module means its module doc now renders in builds
without the `kv` feature, where the item it linked does not exist.
2026-08-17 17:34:27 +02:00
atlas
c8a3159297 feat(#3297): scope a hive's queue grant to its own subjects
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.
2026-08-17 17:34:27 +02:00
atlas
f287ff1ee8 feat(#3297): read the caller's identity out of introspection
The auth-callout responder learned whether a token was valid, never whose
it was, so every admitted client got the same unscoped grant. Scoping a
grant to one hive's subjects needs an identity to scope it to.

`is_active` becomes `identify_caller` and returns the identity rather
than a boolean: `Ok(Some(client_id))` admits as that client, `Ok(None)`
denies. `active: true` with no `client_id` is a denial, and returning an
identity is what makes that unrepresentable instead of remembered --
there is no admitted-but-unscoped value to construct, so there is no
branch a later edit can forget to handle.

The module deliberately ignores `sub`/`scope`/`exp` on the grounds that
modelling a field implies checking it. That still holds: `active` remains
the whole admission decision, made in one place. `client_id` answers a
different question -- as whom -- and is used downstream of an admission
that has already happened, not as a second gate. The module docs say so,
next to the paragraph that would otherwise argue for deleting the field.

An inactive token has no identity even when the body names one, and an
empty `client_id` counts as absent: it would become a blank component in
a subject the grant is scoped to, which is not a narrower permission but
a different one. The two credential parameters are renamed `own_*` --
this responder's introspection credential is not the caller's identity,
and the function now takes one and returns the other.

The grant itself is still unscoped; narrowing it is the next slice.
2026-08-16 23:48:32 +02:00
atlas
32643dae36 fix(swarm): the doc link pointed at a cfg(test) item
rustdoc builds without the test cfg, so `tests::hand_built_matches_the_reference`
resolves to nothing and broken_intra_doc_links denies it. Plain backticks
rather than making the item visible - a lint is not a reason to change an
item's visibility.

Earned, not lost: this is the first completed rustdoc run on a brand-new
crate, and the link was wrong from the first commit.
2026-08-15 09:34:33 +02:00
atlas
9b35be2a24 chore(swarm): move swarm-nats-auth's deps to the workspace
Per mara on the PR: all deps go into workspace level so versions stay
consistent. async-nats, data-encoding, nkeys and nats-jwt are new
[workspace.dependencies] entries; sha2 and futures-util now come from
there.

The crate had asked for sha2 0.10 while the workspace standard is 0.11,
and for the futures facade where the workspace carries futures-util. Both
resolved without adding a Cargo.lock entry - each was already in the tree
via something else - so the drift was invisible in the lock and would only
have surfaced as two versions of a hashing crate in one binary.
2026-08-15 09:34:33 +02:00
atlas
5be2918559 docs(swarm): add the swarm-nats-auth package readme
Every other crate has one and Cargo.toml already named it. Follows the
sibling shape: what it is, why it is a crate rather than more config, the
invariant that is easy to break (issuer_account must be absent, and
Token::new_user reintroduces it), and what the tests do NOT cover - the
introspection endpoint under test is a stub, so nothing here says anything
about the real authelia integration.
2026-08-15 09:34:33 +02:00
atlas
b22f0ecaa1 docs(swarm): trim the respond module doc under the 30-line block lint
The discovery narrative belongs in the PR body, which carries it. What
stays at the line is what the code cannot say: that issuer_account must be
absent and why reaching for Token::new_user reintroduces the bug, the
BASE32HEX-vs-BASE32 distinction, and that nats-jwt is a test oracle rather
than a runtime dependency.
2026-08-15 09:34:33 +02:00
atlas
188e27478a fix(swarm): name the account by aud, never issuer_account
The responder answered granted=true and the server still refused every
client:

  Error non operator mode account "AUTH": attempted to use issuer_account

nats_jwt::Token::new_user always sets issuer_account, which is an
operator-mode field naming the account when a signing key rather than the
account identity key signed the token. In server-config mode - what this
module renders - its mere presence is fatal, and the account is named by
the claims' aud instead.

nats-jwt can express neither aud nor the omission, so the user JWT is now
hand-built by the same signer as the response wrapper, and nats-jwt moves
to dev-dependencies as the encoder's test oracle.

Every unit test passed throughout: they assert fields that must be
present, and the defect was a field that must be absent.
2026-08-15 09:34:33 +02:00
atlas
3a75c54bcb feat(swarm): the auth-callout responder (#3112 slice 2)
Slice 1 shipped the NATS container with an auth_callout block and no
responder, which is the fail-closed state: the server answers
auth_required and admits nobody. This crate is what lets it say yes.

Connects as the callout-exempt user by nkey (never by name - the server
refuses to start if that entry carries a username), subscribes to
$SYS.REQ.USER.AUTH, validates the presented bearer token against
authelia's introspection endpoint, and replies with a signed NATS user
JWT. A denial is a signed response carrying an error, never silence: a
server that hears nothing cannot tell a refusing responder from a dead
one, so staying quiet would turn every rejection into a timeout and hide
an outage inside what looks like ordinary denials.

Everything that is not an explicit active:true denies - network error,
timeout, non-2xx, unparseable body, no token at all. Those are exactly
the conditions under which an attacker would most like this to fall
open. The introspection budget is held under the server's own 2s
auth_callout timeout by a test, since the two numbers live in different
languages in different files.

nats-jwt mints the user JWT. It cannot mint the authorization_response
wrapper - its claim enum is closed and its claims carry no aud, which
the response needs so a reply cannot be replayed at another server in
the cluster - so that half is hand-written, and a test builds a user
token both ways and requires the bytes to match. That is the only
honest basis for trusting the hand-written path on the shape the crate
does not model.

async-nats is taken with default-features off: the default set carries
jetstream, kv, object-store, websockets and service, none of which a
callout responder speaks.
2026-08-15 09:34:33 +02:00