feat(#3517): register the swarm collector as an audienced oauth2 client

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.
This commit is contained in:
atlas 2026-08-24 12:53:23 +02:00
commit 3895a1e21d
3 changed files with 145 additions and 26 deletions

View file

@ -223,6 +223,57 @@ in
scrape is the inert configuration this option exists to avoid.
'';
};
collectorAudiences = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = lib.literalExpression ''[ "https://forge.example.com/metrics" ]'';
description = ''
URLs this collector may mint an access token for, contributed by
the modules that publish them.
A service module says **which URL it exposes**; this module turns
the set into the collector's single OAuth2 client the same split
as `services.hyperhive.gateway.localNames`, and for the same
reason. The collector has no business knowing another service's
routes, and a service cannot reach into the client list to add
itself: client definitions concatenate rather than merging into a
shared entry, so the entry has to be built in one place from a
list many modules can write.
An audience is a **URL**, not a label. Authelia validates a
bearer token against the address being requested, so a token
minted for one target is refused at another and a client may only
request an audience it is registered for registration is the
authorisation. A near miss (a trailing slash, `http` for `https`)
presents as a valid token rejected at the target, several layers
from its cause.
Deliberately not part of `scrapeTargets`. An entry there is
trusted because the scraper and the target share a host; an entry
here is trusted because it presents a credential. Folding the two
together would leave a reader unable to tell which of those a
given target relies on.
'';
};
clientId = lib.mkOption {
type = lib.types.str;
readOnly = true;
default = "swarm-collector";
description = ''
OAuth2 client id this collector authenticates as. Published so
authelia's `access_control` rules can name it without carrying a
second copy of the string, exactly as
`services.hyperhive.swarm.authelia.hiveClientPrefix` is published
for the queue's responder.
Two spellings drifting apart is not a build failure: authelia
refuses a rule naming an unregistered client in its startup
validator, so the swarm's SSO service fails to *restart* long
after the change that caused it evaluated cleanly.
'';
};
};
config = lib.mkIf (config.services.hyperhive.enable && cfg.enable) {
@ -267,6 +318,43 @@ in
# telemetry.
services.hyperhive.swarm.authelia.oidc.hiveIdentities = true;
# The collector's own identity, for the other direction: the hive
# identities above are how this collector authenticates its *callers*,
# this is how it authenticates *itself* to a service published behind
# the gateway.
#
# Only where authelia is co-located. A client is a row in this host's
# provider config, so declaring one against a remote provider would
# render nothing while reading as done; a swarm whose authelia lives
# elsewhere registers it there.
#
# ⚠️ Also conditional on the audience set being non-empty, and that is
# the shipped case rather than an edge — nothing contributes a URL
# until some service publishes one behind the gateway. Authelia
# refuses a bearer-authz client with no audience, so an unconditional
# declaration would break every hive that runs a collector and
# publishes nothing.
services.hyperhive.swarm.authelia.oidc.clients =
lib.mkIf (autheliaCfg.enable && cfg.collectorAudiences != [ ])
[
{
id = cfg.clientId;
description = "HyperHive swarm collector";
kind = "machine";
# Grants `authelia.bearer.authz`, without which the authz
# endpoint refuses an otherwise valid token and blames the
# token rather than the missing grant.
bearerAuthz = true;
audience = cfg.collectorAudiences;
# Stated rather than left on authelia's default, because the
# two agreeing today is not the same as this being the
# required value: authelia permits only basic / JWT methods
# for a confidential client holding that scope, and enforces
# it in the startup validator.
tokenEndpointAuthMethod = "client_secret_basic";
}
];
# The CA bind source is written at runtime by a host unit, so the
# container has to start after it — otherwise nspawn sets up a mount
# over a file that does not exist yet.