feat(swarm-authelia): one machine client per hive in the roster

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.
This commit is contained in:
atlas 2026-08-15 14:07:45 +02:00 committed by mara
commit b3f46e4f43

View file

@ -72,12 +72,35 @@ let
# be what an operator sees, not a coercion error from here.
cookieDomain = if swarmDomain == null then "invalid" else swarmDomain;
# One machine client per hive in the roster. A hive's identity belongs
# to the DIRECTORY, not to whichever service happens to consume it:
# the rule is that a hive's credentials all derive from the SAME
# identity, so one hive holds ONE client and mints a different token
# per service from it. Were the queue to declare this list, the next
# consumer would collide on the same client id — and only at the
# moment it landed.
hiveClients = lib.mapAttrsToList (name: _: {
id = "hive-${name}";
description = "HyperHive hive ${name}";
kind = "machine";
redirectUris = [ ];
}) hyperhiveCfg.swarm.hives;
# Everything downstream renders and mints from this, not from the
# declared list alone.
allClients = cfg.oidc.clients ++ lib.optionals cfg.oidc.hiveIdentities hiveClients;
# authelia refuses to start with an OIDC provider that has no clients,
# so the provider is derived from the client list rather than carrying
# its own `enable`: one fact, and it cannot contradict itself. An empty
# list is the default, which makes every hive that has not opted in
# byte-identical to before.
oidcEnabled = cfg.oidc.clients != [ ];
#
# ⚠️ Derived from `allClients`, so hive identities can turn the provider
# on by themselves. That is only reachable where the queue is already
# enabled (`hiveIdentities` defaults to it) — and a queue-enabled hive
# already contributes a client, so no existing deployment flips.
oidcEnabled = allClients != [ ];
# Secrets that are 64 random bytes of hex and nothing more. The OIDC
# hmac key joins them; the issuer key does not (see below — it is RSA).
@ -193,7 +216,7 @@ let
${lib.concatMapStrings (c: ''
mint ${lib.escapeShellArg c.id}
'') cfg.oidc.clients}
'') allClients}
# Re-rendered every boot, deliberately: the secret is minted once,
# but the metadata around it (a new redirect URI, a renamed client)
@ -204,7 +227,7 @@ let
printf -- 'identity_providers:\n'
printf -- ' oidc:\n'
printf -- ' clients:\n'
${lib.concatMapStrings renderClient cfg.oidc.clients}
${lib.concatMapStrings renderClient allClients}
} > ${lib.escapeShellArg "${clientsFile}.tmp"}
chmod 0600 ${lib.escapeShellArg "${clientsFile}.tmp"}
mv ${lib.escapeShellArg "${clientsFile}.tmp"} ${lib.escapeShellArg clientsFile}
@ -308,6 +331,29 @@ in
'';
};
oidc.hiveIdentities = lib.mkOption {
type = lib.types.bool;
default = hyperhiveCfg.swarm.nats.enable;
defaultText = lib.literalExpression "services.hyperhive.swarm.nats.enable";
description = ''
Mint one machine client per hive in
{option}`services.hyperhive.swarm.hives`, so each hive can
authenticate to swarm services as itself.
Defaults to whether the swarm message queue is enabled, because
that is the first service that needs a hive to prove who it is.
It is an option rather than a hard-coded condition so a second
consumer the swarm telemetry collector can turn it on
without the queue, and so a swarm that wants the identities
provisioned ahead of either can say so.
The clients are inert until something authenticates with them:
each is a client id and a secret sitting on this host. What
delivers a secret to a hive that is not this host is a separate
problem and deliberately not solved here.
'';
};
oidc.clients = lib.mkOption {
type = lib.types.listOf (
lib.types.submodule {
@ -464,14 +510,35 @@ in
# means whoever wrote it believes a browser is involved. Failing here
# is how that belief gets corrected at the point it was expressed,
# rather than at a token endpoint months later.
assertions = map (c: {
assertion = c.kind != "machine" || c.redirectUris == [ ];
message =
"services.hyperhive.swarm.authelia.oidc.clients: client '${c.id}' is "
+ "kind = \"machine\" but declares redirectUris. A client_credentials "
+ "client has nobody to redirect; drop the URIs or make it "
+ "kind = \"interactive\".";
}) cfg.oidc.clients;
assertions =
map (c: {
assertion = c.kind != "machine" || c.redirectUris == [ ];
message =
"services.hyperhive.swarm.authelia.oidc.clients: client '${c.id}' is "
+ "kind = \"machine\" but declares redirectUris. A client_credentials "
+ "client has nobody to redirect; drop the URIs or make it "
+ "kind = \"interactive\".";
}) allClients
# Two clients sharing an id renders two YAML entries under one name.
# Newly reachable now that part of the list is DERIVED: a hive called
# `x` and a service client called `hive-x` never met before. Authelia
# would reject it, but three layers away and at boot — naming both
# sources here is the cheaper failure.
++ [
{
assertion = lib.length (lib.unique (map (c: c.id) allClients)) == lib.length allClients;
message =
"services.hyperhive.swarm.authelia: duplicate OIDC client id(s): "
+ lib.concatStringsSep ", " (
lib.unique (
lib.filter (id: lib.count (x: x == id) (map (c: c.id) allClients) > 1) (map (c: c.id) allClients)
)
)
+ ". Hive identities are named `hive-<name>` from "
+ "services.hyperhive.swarm.hives; rename the hive or the "
+ "colliding client.";
}
];
# Authelia's own gateway surface: the vhost that fronts it and the
# name the hive resolver answers for. Both live here rather than in