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:
atlas 2026-09-13 18:14:11 +02:00 committed by mara
commit 4aa982cc2a
9 changed files with 577 additions and 85 deletions

View file

@ -210,14 +210,17 @@ let
secretPublisherPolicyName = "swarm-secret-publisher";
secretPublisherCn = baoDeploy.secretPublisherCommonName;
# One grant, and every narrowing in it is load-bearing.
# Two grants, and every narrowing in each is load-bearing.
#
# `secret/data/` is KV v2's ACL prefix, inserted by the engine rather than
# written by the caller — same trap as the controller's grant above.
#
# `hives/` and not `swarm/*`: this principal has no business with an agent's
# or a service's credentials, and the hive prefix is the only one whose paths
# it produces.
# Two prefixes and not `swarm/*`: this principal has no business with an
# agent's credentials or the controller's, and these two are the only paths
# it produces. It grew the `services/` one when the publisher gained a swarm
# service's OIDC secret to copy, which is the rule ../module-eval.nix states
# for the controller's side of the same wall — a grant widens when a path
# gains a WRITER, not when a kind is declared.
#
# Write-only. It copies secrets in and never reads one back; a read
# capability would let a file-copier recover every hive's credentials.
@ -225,6 +228,10 @@ let
path "${credentialMountPath}/data/swarm/hives/*" {
capabilities = ["create", "update"]
}
path "${credentialMountPath}/data/swarm/services/*" {
capabilities = ["create", "update"]
}
'';
# The KV v2 engine the controller writes agent credentials through. Named