diff --git a/nix/host-modules/swarm-authelia.nix b/nix/host-modules/swarm-authelia.nix index 35653e48..4aa98003 100644 --- a/nix/host-modules/swarm-authelia.nix +++ b/nix/host-modules/swarm-authelia.nix @@ -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-` 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