swarm-grafana: deliver the OIDC client secret through the secret store
Grafana's OIDC client secret only existed where authelia did. One `ssoLocal` gate — `grafana.enable && authelia.enable` — decided the client registration, the minted secret's delivery and the whole `auth.generic_oauth` block, so a swarm whose authelia runs on another host got Grafana with no SSO wiring at all. The local login form is disabled unconditionally, so that is no way in. Split the one gate into the two questions it was conflating: - `ssoConfigured` — does this SWARM have an identity provider (`swarm.authelia.url`, which is swarm-wide and whose own description makes null mean "no SSO configured"). With a delivery route present this is what emits Grafana's OIDC block. - `ssoLocal` — is authelia on THIS host, now spelled as the forge and matrix modules spell it. It decides only which unit delivers the secret. Where authelia is elsewhere, `swarm-bao-grafana-oidc.service` reads the secret from the swarm secret store, shaped after glue-queue-agent-credential.nix: cert login fails loudly because a retry fixes every state it fails on, the read degrades quietly because no retry turns "no value there" into a value, and nothing writes a stand-in. The producer is the publisher that already runs on authelia's host, which gains the swarm's service clients beside the per-hive ones at `swarm/services/<id>/oidc/client` — with the write grant in swarm-bao.nix and the hive read grant in `policy::render` to match. Registration moved to glue-grafana-oidc-client.nix. It has to be declared where authelia's config is rendered, and swarm-grafana.nix's config block hangs off this host running Grafana. Two judgement calls stated rather than buried: a hive's read policy now grants the whole `services` prefix, because a service's path names the service and nothing swarm-wide records which hive runs it (cost recorded in docs/trust-boundary/security.md); and the client is registered on any authelia host, because no swarm-wide "this swarm has a Grafana" fact exists to gate it on. Refs #4234 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
fbd9afa7fa
commit
4aa982cc2a
9 changed files with 577 additions and 85 deletions
|
|
@ -1,5 +1,6 @@
|
|||
# The unit that copies authelia's minted OIDC client secrets into the swarm's
|
||||
# secret store, so a hive that does not host authelia can read its own.
|
||||
# secret store, so whoever needs one without hosting authelia can read it: a
|
||||
# hive its agents' credential, a swarm service its own.
|
||||
#
|
||||
# ⚠️ IT RUNS WHERE AUTHELIA DOES, and that is the whole reason it exists as a
|
||||
# separate thing. `deploy.authelia.hostClientSecretDir`'s own description says
|
||||
|
|
@ -47,6 +48,18 @@ let
|
|||
# ./swarm-authelia.nix composes it, from the same two read-only options, so a
|
||||
# rename there cannot leave this spelling behind.
|
||||
agentClientId = hive: "${autheliaCfg.hiveClientPrefix}${hive}${autheliaCfg.agentClientSuffix}";
|
||||
|
||||
# The swarm's own services, as opposed to its hives. One client for the whole
|
||||
# swarm rather than one per hive, so one value in the store rather than a copy
|
||||
# each: `swarm/services/<id>/oidc/client`, under the `services` kind
|
||||
# `swarm-secret-client`'s `path::Kind` declares.
|
||||
#
|
||||
# The ids come from `swarm.*`, which is identical on every host — that is what
|
||||
# lets this host name a service's client while running none of them, and it is
|
||||
# the same read the service's own module registers the client with. A swarm
|
||||
# that runs no Grafana mints no secret for it, so its entry skips below rather
|
||||
# than needing a condition here.
|
||||
serviceClientIds = [ hyperhiveCfg.swarm.grafana.oidc.clientId ];
|
||||
in
|
||||
{
|
||||
options.services.hyperhive.deploy.swarm-secret-publisher = {
|
||||
|
|
@ -56,7 +69,8 @@ in
|
|||
defaultText = lib.literalExpression "deploy.authelia.enable";
|
||||
description = ''
|
||||
Publish the OIDC client secrets this host mints into the swarm's
|
||||
secret store, so hives that do not run authelia can read their own.
|
||||
secret store, so a hive that does not run authelia can read its
|
||||
agents' credential and a swarm service elsewhere can read its own.
|
||||
|
||||
Defaults to whether this host mints them, which is the only half of
|
||||
the question that is a property of *this* host.
|
||||
|
|
@ -177,6 +191,29 @@ in
|
|||
fi
|
||||
'') hiveNames}
|
||||
|
||||
${lib.concatMapStringsSep "\n" (id: ''
|
||||
src=${lib.escapeShellArg "${deployCfg.authelia.hostClientSecretDir}/${id}.secret"}
|
||||
if [ -s "$src" ]; then
|
||||
# `value=@$src` for the same reason as the hive loop above: bao
|
||||
# opens the file itself, so the plaintext is never an argument of
|
||||
# this process.
|
||||
#
|
||||
# No `client_id` field beside it, unlike a hive's credential: that
|
||||
# one is derived per hive and has to be reconstructable from the
|
||||
# store alone, whereas a service's client id is the swarm-wide
|
||||
# option both ends already read.
|
||||
bao kv put ${lib.escapeShellArg "secret/swarm/services/${id}/oidc/client"} \
|
||||
value=@"$src"
|
||||
published=$((published + 1))
|
||||
else
|
||||
# Not an error, and the ordinary state of a swarm that runs this
|
||||
# service nowhere: nothing registered the client, so authelia minted
|
||||
# nothing to publish.
|
||||
echo "no minted secret at $src yet; the path unit will re-run this" >&2
|
||||
skipped=$((skipped + 1))
|
||||
fi
|
||||
'') serviceClientIds}
|
||||
|
||||
echo "published $published client secret(s), skipped $skipped"
|
||||
'';
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue