Review found that nothing stopped `bearerAuthz = true` on an interactive
client. `renderClient` reads the flag only in the machine branch, so the
scope is never emitted: the client authenticates, is authorised for
nothing, and the build is green. `kind` defaults to `interactive`, so it
is reached by forgetting a field rather than by writing a wrong one —
this module's own failure mode one level up.
The other half of the review asked to relax the method assertion to
accept `null`, on the strength of the option's doc calling `null`
authelia's default. Measured instead: under `authelia.bearer.authz`
authelia refuses the omission outright, so the assertion was right and
the DOC was wrong. The doc now carries the exception, and the assertion
message says null is refused rather than leaving a reader to infer it.
The forge's `/metrics` is published behind the gateway and denied to
everyone, waiting on a client to allow. This is that client.
An audience is a URL: authelia validates a bearer token against the
address being requested, and a client may only request an audience it is
registered for, so registration is the authorisation. The URLs are owned
by the services that publish them while audiences attach to one client,
so services contribute to a list and this module builds the single entry
— the `gateway.localNames` split, forced here by client definitions
concatenating rather than merging into a shared entry.
The access-control rule asks the client list whether the collector is
registered rather than re-deriving the conditions that register it. The
two drifting is not a build failure: authelia refuses a rule naming an
unknown client in its startup validator, so SSO fails to restart.
A scraper reaches a service published behind the gateway by presenting an
access token to authelia's authz endpoint, which requires the client to
carry authelia.bearer.authz. Nothing could express that: scopes are
derived from kind, and a machine client rendered an empty list.
A named capability rather than a free-form scopes list, for the reason
the derivation exists — authelia refuses some scope/grant combinations
outright, openid with client_credentials among them, and a list makes
those expressible again.
The two assertions carry their weight: authelia checks the same
obligations, but in its preStart validator, so a violation builds and
deploys cleanly and then fails to restart with swarm SSO attached to it.
There are two otel options one word apart: swarm.otel is the swarm's
collector, hyperhive.otel is the per-hive tier that ships upstream and
never reads scrapeTargets. The gate went through a let binding declared
800 lines from its use, so which one it referred to was not visible where
it mattered — mara had to ask.
Gating the wrong one is not a build error. It is a target that is either
always declared or never declared, and both look like working config.
The comment claimed the option's rule keeps scraper and target on one host
by construction. It does not. Both services default from
enableRequiredServices via mkDefault, which is an invitation to override
rather than a guarantee, so co-location is a property of the auto-deployed
topology and not of the module.
Gating the target on the collector's own enable makes the loopback address
honest: a host running authelia without a collector no longer declares a
target nothing can read. That absence was the part worth fixing, because it
is silent — no error, no metrics, nothing in a log to notice.
This does not make authelia scrapeable from another host. That needs the
endpoint published under a name with a certificate and an audience, which
is separate work; the option's docs now say so where someone splitting the
two would read it.
The endpoint was off, so nothing reported on the swarm's own SSO. Enabling
it alone would have added no data — the scraper that reads it only landed
with the swarm-tier prometheus receiver.
Loopback only, like the main listener and for a stronger reason: this
endpoint authenticates nothing and reports request volumes and outcomes for
every login on the swarm.
metricsPort is an option rather than a literal because every swarm container
shares the host netns, so two services picking the same port do not conflict
at build time — one loses at runtime with nothing in any log. 9959 is
upstream's default and is unclaimed across nix/.
Two review findings, folded together.
mara: a swarm integrated auto deployed forge always has metrics, so the
toggle is gone. The endpoint follows behindGateway instead, which is the
swarm-integrated shape and the condition the protected location lives
under. Serving it without that location would put it on a listener
openFirewall can expose with nothing in front.
argus: /metrics matched no access_control rule, so default_policy
one_factor governed it. That is any authenticated subject, which today
means any operator and tomorrow any agent. My audience argument covered
the Bearer path only; the same endpoint also accepts CookieSession, and a
cookie carries no audience at all, so the audience was never what stood
between a browser session and this data.
The rule is deny rather than a client-scoped allow because the collector's
client does not exist yet. Authelia refuses a subject naming an
unregistered client, and does so in a preStart validator rather than at
build time, so naming one early yields a green nixos-rebuild and dead
swarm SSO on the next restart. Denying until the client is registered
makes publishing the endpoint safe on its own; registering it is a
one-line change from deny to that allow.
Rule order is load-bearing: authelia takes the first match.
The gateway authenticates scrapers so services do not each grow a static
bearer of their own, but authelia's auth_request endpoint ran its default
strategies, which are cookie-only. A scraper's OAuth2 access token was
refused no matter how it was minted.
CookieSession is listed explicitly because authn_strategies replaces the
defaults rather than extending them. Omitting it evaluates, renders and
starts, and silently ends every operator session on the swarm UI, which
uses this same endpoint.
Unconditional rather than keyed to whichever service is scraped today:
this makes a scheme available, not an authorisation. Authelia refuses a
token carrying no audience for the requested URL and only issues a client
audiences it is registered for, so nothing passes until a client is
registered against a specific URL.
A swarm service that has to tell hives apart needs the token itself to
say which hive presented it. Two client-registration fields were missing
for that, and both are properties of the client rather than of any one
consumer:
- `audience` registers which `aud` values a client may request. It is a
boundary, not a label: asking for one that is not registered is
refused with `invalid_target`, so a hive cannot mint a token for
another hive's slot.
- `accessTokenSignedResponseAlg` switches the access token from
authelia's default opaque handle to an RFC 9068 JWT, which is what
lets a resource server verify a token against `/jwks.json` instead of
asking authelia about every request.
Each hive's audience is its own client id rather than a new per-hive
string. The identity is the directory's, not the consumer's, and the id
is already published as `hiveClientPrefix` precisely so a second
consumer does not carry its own copy of the naming rule.
Both fields default to authelia's own behaviour, so no existing client
changes.
The bridge no longer has a private canonical store, so the env var
naming one is gone rather than repointed. What is left is the single
users file it now reads and writes directly.
That line is what made `swarm agent create` fail on this hive: the
bridge found no store at its own JSON path, saw users already in
`users.yml`, and refused to overwrite a file it had not written --
correctly, given two things claimed to be canonical for one file.
A loopback literal encodes 'authelia is in my netns' at the call site,
and authelia's OIDC endpoints are https-only in effect: reached directly
they answer 400, because the forwarded headers nginx injects for every
other consumer are what let it determine its own issuer. Going by name
deletes the need for those headers rather than reproducing them, and
converges on the URL swarm-nats-auth already uses.
Depends on the CA trust added for the same container in #3407 -- without
it this swaps a 400 for an UnknownIssuer.
The null-url assertion joins the module's existing list: interpolating a
null would surface as a nix coercion error several files from its cause.
Same class-B defect as the queue responder: swarm-authelia-bridge
introspects authelia by name over https and the container trusted no swarm
CA, so the call could only ever fail UnknownIssuer.
This is also the prerequisite for the by-domain introspection change on
#3391 -- flipping that URL without the trust half would swap one failure
for another.
Keeps why the setting exists (the bridge writes this file and cannot
restart authelia) and drops the incident narrative and the answered
objections -- that history belongs in the PR discussion, not in three
comment blocks a future reader has to scroll past.
Authelia reads authentication_backend.file.path once at startup. Without
watch, an identity the bridge creates is real on disk and invisible to the
running authelia until something unrelated bounces the unit.
swarm-authelia-bridge cannot restart authelia -- running unprivileged
inside the container is the whole reason it may write that file, so
restarting the unit is exactly the privilege it was designed not to hold.
swarmctl does restart it, but that is a systemctl -M shellout that can
fail, and when it did the symptom was a login refused for a user whose
record was already correct on disk. This makes correctness stop depending
on that restart succeeding.
The auth-callout responder decides what an admitted client may publish
from two strings: the prefix marking a hive client, and the client id
allowed to read every hive's key. Both were literals in three places --
swarm-authelia.nix mints "hive-${name}", swarm-controller.nix defines
"swarm-controller", and the responder carried its own copies as clap
defaults because swarm-nats.nix passed neither.
Each producer now publishes its value as a readOnly option and the
responder's ExecStart reads them, so the agreement is one evaluation
rather than three strings that happen to be equal. Same pattern the
module already uses for `--account`, and the same argument
swarm-authelia.nix gives for publishing `machine` and `unit`.
Worth the change because the failure is silent and misattributed:
rename either principal and the responder starts denying the one that
stopped matching, a denial reaches a NATS client as a timeout rather
than an error, and a hive that is refused looks exactly like a hive
that has not reported yet.
A swarm service container shares the host netns and force-disables
resolvconf, so it inherits the /etc/resolv.conf nixos-containers copies in
at start (cp --remove-destination, host-side preStart, once per start) and
nothing ever refreshes it. That makes the container's resolver a snapshot of
the host's file at its boot instant.
When that snapshot is wrong the container can never recover, and the symptom
appears arbitrarily far from the cause: swarm-nats-auth cannot resolve
authelia's name, so it denies every auth-callout request and the queue
refuses every client with an authorization violation.
Give each of the four swarm containers a oneshot that writes the resolver
file itself, from the bridge IP, ordered before that container's first DNS
consumer. The shape is the one every agent container already uses.
networking.nameservers cannot do this: resolvconf is its only consumer and
these containers disable it, so setting it renders no file while still
evaluating cleanly. A static environment.etc entry cannot either -- it would
have to survive etc activation landing on the regular file the host already
copied there, which no eval can show.
The client list was `cfg.oidc.clients ++ hiveClients`, where the first
half comes through the submodule and the second was a raw attrset from
this module's `let` block. That list is only half-typed: a field added
to the submodule exists on the declared entries and not on the derived
ones, so reading it plainly is an eval error the moment hive identities
are on. The operator asked whether it should be uniformly typed instead
of guarding each read, and it should.
The hive identities are now declared the same way an operator declares
a client, so the module system applies the submodule to them and every
option's default is present. Downstream reads one uniformly-typed list
and the guard added for the field that broke is gone with it.
allClients concatenates two shapes: cfg.oidc.clients comes through the
submodule and carries every option default, hiveClients is a raw attrset
built in the let block with four fields. renderClient read
tokenEndpointAuthMethod plainly, which is fine for a declared client and
an eval error for a derived one -- so all-local, where hiveIdentities is
on, stopped evaluating.
Read it with `or null`. The comment records that the list is not
uniformly typed, because the next field added to that submodule has the
same trap waiting.
tuwunel authenticates at the token endpoint with the secret in the POST
body. Authelia enforces the method a client is REGISTERED with rather
than accepting whichever one arrives, and its default is
client_secret_basic — so the matrix login completed, consent was
granted, and the very last hop failed:
Client authentication failed ... The request was determined to be
using token_endpoint_auth_method client_secret_post, however the
OAuth 2.0 client registration does not allow this method.
The failure names neither the secret nor the redirect, and it lands
three layers from its cause, which is why it read as a credential
problem.
Adds a per-client tokenEndpointAuthMethod, null by default so every
existing client keeps authelia default (forgejo authenticates with
basic and is unaffected), and sets client_secret_post on the matrix
client only.
Second slice of #3274. Each entry in
`services.hyperhive.swarm.hives` gets an authelia client
`hive-<name>`, so a hive can authenticate to swarm services as
itself.
The clients are derived here rather than declared by a consuming
service, because a hive's identity belongs to the directory and not to
whichever service happens to use it first. mara's constraint is that a
hive's credentials "derive from the same identity" — one hive holds
one client and mints a different token per service from it — so the
queue declaring this list would mean the next consumer collides on the
same client id, and only at the moment it landed.
`oidc.hiveIdentities` defaults to whether the message queue is
enabled, that being the first service to need a hive to prove who it
is. It is an option rather than a hard-coded condition so the swarm
telemetry collector can turn it on without the queue.
No audience and no `access_token_signed_response_alg` here: the queue
learns which hive presented a token from the introspection response
(`{"active":true,"client_id":"hive-alpha",...}`, measured), so it needs
neither. The collector does need both, and they land with it.
Also asserts client ids are unique — newly reachable now that part of
the list is derived, since a hive named `x` and a service client named
`hive-x` never met before.
Refs #3274.
New `services.hyperhive.swarm.controller.links` option (listOf {label,
icon, url}, same shape as the per-agent hyperhive.dashboardLinks) plus
a new GET /api/links route serving it, same pattern as the existing
hives/GET /api/hives.
Rather than one central hardcoded list, each service's own module
contributes its own entry when actually enabled on the controller's
host: swarm-authelia.nix, hive-matrix.nix (gated on gui.enable too,
since / on that vhost only serves fluffychat then) and
hive-forge/default.nix (gated on behindGateway) each push one entry,
the same list-merge idiom services.hyperhive.gateway.localNames
already uses. swarm-ui.nix contributes a static entry for its own
same-origin swagger docs. Adding a future service's link is a nix-only
change to that service's own module.
Verified: cargo build/clippy/test -p swarm-controller clean, a
throwaway nixosSystem eval confirms all 4 entries merge correctly into
SWARM_CONTROLLER_LINKS, nix build .#swarm-controller succeeds.
`renderClient` could only emit the authorization-code shape, so a
daemon client was expressed as an interactive one with an empty
redirect list. Authelia permits only the grants a client names, and an
omitted `grant_types` means authorization-code alone — so that shape
cannot obtain a token at all.
Measured against authelia 4.39.20, rendering exactly what this module
produced for `swarm-nats`:
client_secret_basic → unauthorized_client: The OAuth 2.0 Client is
not allowed to use authorization grant
'client_credentials'
introspection → {"active":false} (works)
Introspection is all the queue's responder needs today, which is why
nothing was visibly broken while the comment in `swarm-nats.nix`
described a grant that was never configured.
Adds `kind = "interactive" | "machine"` rather than inferring from an
empty `redirectUris`, because the two differ in what authelia permits
and not merely in what is populated. `openid` is dropped from a machine
client's scopes because authelia refuses that combination outright — a
daemon receives an access token and never an id-token.
An assertion rejects redirect URIs on a machine client: they are not
harmlessly unused, they mean the author believed a browser was
involved.
Refs #3274.
Moves the authelia vhost out of the gateway's vhosts.nix and its
`address=` rule out of dnsmasq.nix, into swarm-authelia.nix.
Both land inside that module's existing `cfg.enable` guard, which is
the load-bearing part: every hive in a swarm knows `authelia.url`, but
only the host that RUNS the container may claim the name. A client hive
declaring the vhost would answer for a service it does not run, and
publishing the DNS record would point every agent on its bridge at that
wrong answer.
The kit grows a fourth member, `errorPages`, because the vhost aims its
502/503/504 at the gateway's styled sso-unavailable page. Republished
rather than imported per module: a service rendering its own would drift
from the rest of the gateway the first time the theme changed.
Per mara on the PR: the guide someone follows on a fresh deploy is a
different page from the one they read while debugging, and only the
second existed.
Also fixes a contradiction the request surfaced: setup.md and
swarm/sso.md have told every operator to create '--group admins' since
the bootstrap step existed, while the new rule required 'operators'.
This is the first rule that CONSUMES a group name, so it takes the one
the guide already creates - inventing a second would have meant every
account made by following the guide silently failing the check it was
supposed to pass, and for mara specifically a migration step that now
may not be needed at all.
setup.md's step 3 says why --group is not decoration; the new step 4
says what decides whether the page opens (the group, and the name
resolving) rather than listing a command, because there is nothing to
run. Steps renumbered, including the matrix block's internal 4a-4e.
default_policy = one_factor means 'any authenticated user', which is
authentication and not authorisation. The swarm UI is operator-only and
agents are getting authelia accounts of their own, so a session alone
would be enough to open it the day that lands - the vhost's auth_request
would be a check nobody fails.
Adds an access_control rule for the UI's domain requiring
subject = group:operators, present only when the UI is enabled. The
group name is a constant beside the rule: it is also what an operator
types into 'swarmctl user add --group', and a configurable name is one
more way for the rule and the account to disagree silently.
Review catch (argus): most of the pre-existing script body picked up ~14
leading spaces it never had, because replacing an inline `optionalString`
with a named binding changed what the formatter treated as the block's
base indent. Functionally invisible — nix strips the minimum common
indentation and bash ignores the rest — but it made the diff read as
"reindented everything, incidentally added a block" instead of "added a
block".
Net diff on this file is now 231 insertions and 2 deletions.
The question this answers is "what do I have to configure, and where" —
so the table of secrets is the deliverable and the prose is scaffolding
around it.
The organising idea worth keeping: a secret belongs in-container when
nothing outside that container reads it. Every one of authelia's own
secrets passes that test; the client secret's plaintext fails it, which
is what makes delivery a problem at all rather than a detail.
The all-local case from the delivery ruling: when one host runs both the
forge and the swarm's authelia, nothing should need an operator.
Two containers, one secret, and the awkward part is that they share this
host's network namespace but not its filesystem. They reach each other on
127.0.0.1, which makes them feel co-located — the forge still cannot open
a path inside authelia's tree. The host is the only place both are
addressable, so the copy runs there, and `hostClientSecretDir` publishes
the outside view of the inside path exactly as `hostUsersFile` already
does for the users database.
Deliberately a copy rather than a `bindMounts` entry. nixos-container
refuses to start when a bind source is missing, and this secret does not
exist until authelia's first boot has minted it — binding it would make
the forge wait on a file that waits on a container that starts after it.
On a fresh hive that is a permanent stall presenting as "the forge is
broken", several layers from its cause.
The owning uid is discovered from the forge container's own state dir
rather than assumed. Whatever uid maps to forgejo inside that container
already owns the directory it was created with; writing a number here
would be a second place for it to be wrong.
The client entry is contributed to authelia's list by the forge module
itself, from the same source-name constant the registration uses, so the
redirect URI authelia allows and the one forgejo sends cannot drift.
A mismatch there is a rejected login with no error text worth reading.
A client secret has two holders in two containers: the relying party
authenticates with the plaintext, authelia compares a digest. Neither
side can generate it alone, and `settings` is rendered into the
world-readable nix store, so the value cannot be declared.
So it is minted here, once, as two files — `<id>.secret` and
`<id>.digest`. That split is what lets `oidc-clients.yml` be re-rendered
on *every* boot from the nix-declared metadata plus the stored digest: a
new redirect URI or a renamed client takes effect on rebuild without
rotating a credential another container is already holding.
`authelia crypto hash generate pbkdf2 --random` generates the password
itself and prints it beside its digest, so no plaintext is ever handed to
a second process on a command line. The clients file reaches authelia
through `settingsFiles`, which upstream merges at runtime — the same
mechanism it already uses for the issuer JWK.
Minting fails closed: an empty secret or digest aborts the unit, and the
unit is `requiredBy` authelia, so the provider refuses to start rather
than serving a client that can never authenticate. That failure would
otherwise surface three layers away as an opaque 401 from the token
endpoint.
The swarm's authelia has been a session / `auth_request` provider only.
SSO for the forge (and matrix behind it) needs the second role: an OIDC
provider that issues tokens to relying parties.
The provider is derived from `oidc.clients` rather than carrying its own
`enable`, because authelia refuses to start with a provider that has no
clients — a separate flag would be a second fact free to disagree with
the first. The list defaults to empty, so a hive that has not opted in
renders exactly what it rendered before.
Its two secrets are generated in-container by the existing first-boot
unit, which is the same test that unit already applies to the jwt,
session and storage keys: nothing outside this container reads them. The
hmac key is 64 random bytes and joins that loop; the issuer key is an RSA
pair, because it *signs* id tokens and relying parties verify them
against the public half at `/jwks.json` — a symmetric secret cannot serve
that.
No client secret appears here, and that is the point: a client secret has
two holders in two containers, and `settings` is rendered into the world-
readable nix store. Minting it is the next commit's problem.
The swarm-authelia module states that its users database is written by
swarm-controller, but nothing ever granted the means. This adds the tool
that does it.
swarmctl runs as root on the controller's host and acts directly. The
rootless alternative was examined and does not work: relocating the users
file into a directory the controller owns only turns a write problem into
a read problem, because authelia must then reach across the same boundary
in the other direction. Making that read work needs either a hand-pinned
gid or world-readable password hashes.
The user store is two files, one authoritative: users.json is canonical,
users.yml is a rendered artifact. That split is what lets the crate work
without a YAML parser -- the workspace has none, and adding one costs a
crates.io fetch, a lock update and a vendor hash for a schema we fully
control and only ever emit.
Passwords are generated by authelia rather than passed to it: argv is
world-readable, so a password on a command line is readable by any local
process for the lifetime of the call.
The three derived facts swarmctl needs about the authelia container --
machine, unit and the host-side users path -- become readOnly options on
the authelia module rather than literals repeated at the call site.
Third of the three service moves, and the one that fails closed rather
than loudly. The vhost name and the session cookie's domain move in the
SAME commit because authelia validates `authelia_url` is a sub-domain of
the cookie `domain` at STARTUP -- move only the vhost and it does not
misbehave at first login, it refuses to boot.
No migration pin, unlike the forge and matrix: nothing depends on the
old name yet, so it moves outright.
Also gives the container the authelia binary, so an operator with a
shell can run `authelia crypto hash generate` for the users file. That
fix was written for a PR that was closed and is tracked nowhere else.
The accepted scope limit is recorded where the cookie is configured: a
hive keeping a domain outside the swarm's tree does not receive this
cookie, so SSO covers the swarm's services and not that hive's own
dashboard until its domain moves too.
⚠️ Nothing resolves `auth.<swarm.domain>` yet. The hive gateway is
deliberately NOT taught about it: swarm services get their vhost and
their name on the HOST, not inside a per-hive proxy container. Both
halves are the swarm-nginx work, and authelia has no vhost there either
way today.
mara: enableAllLocalDefaults is more of a deployment mode via settings
set, less a default setting.
That reframing is the change. A mode asserts values; an option declares
what it is when nobody asks. Written as default = <flag>, every service
option had to name a switch it has no relationship to, and the answer to
what does all-local turn on was spread across five files.
Two modules now hold the two tiers - local-defaults.nix for the mode and
what it asserts directly, swarm-required-services.nix for the
services-live-here switch and the per-service enables under it. Each
service module keeps its own declaration and loses only the line about
when a deployment wants it.
mkDefault is the right precedence rather than a compromise: it beats an
option default and yields to any explicit definition, so the mode fills
in for an operator who has not spoken and never argues with one who has.
Evaluated config is unchanged in both directions.
One SSO provider per swarm, and a hive is a client of it by default:
`url` exists everywhere so a hive knows where to send people, while the
container only exists where `swarm.enableRequiredServices` says the
swarm's shared services live.
Operator and agents are both subjects of the same provider,
differentiated by roles/claims rather than by mechanism. The users store
is therefore written by swarm-controller rather than maintained by hand
— agents are created and destroyed continuously, so the subject set is
dynamic. This module only guarantees the file exists and parses, so
authelia starts with nobody in it instead of failing to start; a
placeholder account would be a credential nobody meant to create.
Small-deployment defaults, with scope as the justification rather than
preference: file users backend, local sqlite storage (redis buys shared
session state across replicas and there is one instance), filesystem
notifier (SMTP mails humans; provisioning is programmatic).
Secrets are generated in-container on first boot, sharing the authelia
unit's user/group/StateDirectory so systemd creates the directory owned
by the account that reads them. Nothing outside the container reads
these keys, which is what makes that right rather than merely easier —
hive-matrix generates its token host-side only because hive-c0re has to
read that one.
Per-service integration (authelia's auth_request in front of the
gateway's existing auth_basic locations) is deliberately not here:
standing an SSO provider up is reversible, cutting every operator-facing
vhost over to it is not.