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:
parent
da88d450dd
commit
3895a1e21d
3 changed files with 145 additions and 26 deletions
|
|
@ -442,6 +442,18 @@ in
|
|||
url = "https://${cfg.domain}/";
|
||||
};
|
||||
|
||||
# The address a scraper has to hold a token for. Same `behindGateway`
|
||||
# guard, and for a stronger reason than the two above: with it off
|
||||
# there is no `= /metrics` location and no `auth_request` in front of
|
||||
# it, so the URL this names does not exist to be authorised.
|
||||
#
|
||||
# ⚠️ Written as the exact URL a collector requests, because that is
|
||||
# what authelia compares against — this string agreeing with the
|
||||
# `location` block above it is the whole mechanism. A near miss is a
|
||||
# correctly minted token refused at the target.
|
||||
services.hyperhive.swarm.otel.collectorAudiences =
|
||||
lib.optional cfg.behindGateway "https://${cfg.domain}/metrics";
|
||||
|
||||
# `server_name = forge.domain`, proxies all `/` → forgejo. Tuned for
|
||||
# git: `client_max_body_size 1G`, `proxy_read_timeout 1h` (multi-GB
|
||||
# clones). SSH stays direct on `forge.sshPort`. See
|
||||
|
|
|
|||
|
|
@ -57,6 +57,47 @@ let
|
|||
# failing the check it was supposed to pass.
|
||||
operatorGroup = "admins";
|
||||
|
||||
# Whether the swarm collector's client is actually registered here.
|
||||
#
|
||||
# Asked of the client list rather than re-derived from the conditions
|
||||
# that produce it (`otel.enable`, a non-empty audience set, authelia
|
||||
# being co-located). A second copy of that predicate is a second thing
|
||||
# to keep in step, and the two drifting is not a build failure: a rule
|
||||
# naming an unregistered client is refused by authelia's *startup*
|
||||
# validator, so the whole SSO service fails to restart.
|
||||
#
|
||||
# Reading the registration itself also makes the rule correct for a
|
||||
# registrar this module has never heard of — an operator registering
|
||||
# the collector by hand against a provider that is not co-located with
|
||||
# it gets the same rule, from the same expression.
|
||||
# ⚠️ `swarm.otel` named in full, not through a let binding: there are two
|
||||
# otel options one word apart, and only this one is the swarm's collector.
|
||||
collectorClientId = config.services.hyperhive.swarm.otel.clientId;
|
||||
collectorRegistered = lib.any (c: c.id == collectorClientId) cfg.oidc.clients;
|
||||
|
||||
# The forge's metrics endpoint: deny until a collector exists to allow.
|
||||
#
|
||||
# `deny` is not a placeholder, it is the protection. The endpoint is
|
||||
# always served (a swarm-integrated forge always has metrics) and
|
||||
# `default_policy` is `one_factor`, which means *any* authenticated
|
||||
# subject — every operator today, every agent once they hold authelia
|
||||
# accounts. Nor does the audience stand between a browser session and
|
||||
# this data: `authn_strategies` on the authz endpoint also accepts
|
||||
# `CookieSession`, and a cookie carries no audience at all.
|
||||
metricsRule = {
|
||||
domain = forgeCfg.domain;
|
||||
resources = [ "^/metrics$" ];
|
||||
}
|
||||
// (
|
||||
if collectorRegistered then
|
||||
{
|
||||
policy = "one_factor";
|
||||
subject = [ "oauth2:client:${collectorClientId}" ];
|
||||
}
|
||||
else
|
||||
{ policy = "deny"; }
|
||||
);
|
||||
|
||||
# Upstream's `services.authelia.instances.<name>` derives the unit,
|
||||
# user, group and StateDirectory from the instance name
|
||||
# (`authelia` + `-<name>`). Naming them here rather than repeating the
|
||||
|
|
@ -1281,32 +1322,10 @@ in
|
|||
# The metrics rule is listed first so it cannot be shadowed
|
||||
# by a broader domain rule added later.
|
||||
rules =
|
||||
# The forge's metrics endpoint. `deny` is deliberate and
|
||||
# is the whole protection right now: the endpoint is
|
||||
# always served (a swarm-integrated forge always has
|
||||
# metrics), and `default_policy` is `one_factor`, which
|
||||
# means *any* authenticated subject — every operator
|
||||
# today, every agent once they hold authelia accounts.
|
||||
#
|
||||
# Being reachable by a Bearer token is not sufficient on
|
||||
# its own: `authn_strategies` on this endpoint also
|
||||
# accepts `CookieSession`, and a cookie carries no
|
||||
# audience, so the audience is not what stands between a
|
||||
# browser session and this data.
|
||||
#
|
||||
# The collector gets in by REPLACING this with a
|
||||
# client-scoped allow (`subject = ["oauth2:client:<id>"]`)
|
||||
# once such a client is registered. Denying until then is
|
||||
# what makes publishing the endpoint safe on its own —
|
||||
# authelia refuses a subject naming a client that is not
|
||||
# registered, and it does so in a `preStart` validator,
|
||||
# so naming one early takes the whole SSO service down on
|
||||
# the next restart rather than failing the build.
|
||||
lib.optional forgeCfg.behindGateway {
|
||||
domain = forgeCfg.domain;
|
||||
resources = [ "^/metrics$" ];
|
||||
policy = "deny";
|
||||
}
|
||||
# Denied or client-scoped depending on whether a
|
||||
# collector is registered — see `metricsRule` above,
|
||||
# which is where the reasoning for both halves lives.
|
||||
lib.optional forgeCfg.behindGateway metricsRule
|
||||
++ lib.optional uiCfg.enable {
|
||||
domain = uiCfg.domain;
|
||||
subject = [ "group:${operatorGroup}" ];
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue