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

@ -60,10 +60,22 @@ advertises a boundary it doesn't actually hold. A wide grant that says what it
is beats a narrow one that only looks narrow.
What still holds: the grant is **read-only** (a hive can't write an agent's
credential, so it can't hand itself an agent's identity), and it reaches two
prefixes and nothing else in the store — every agent's credentials, and the
reader's _own_ entry under the hive namespace, which names the hive asking and
so widens nothing between them.
credential, so it can't hand itself an agent's identity), and it reaches three
prefixes and nothing else in the store — every agent's credentials, the reader's
_own_ entry under the hive namespace, which names the hive asking and so widens
nothing between them, and every **swarm service's** OIDC client secret.
That third prefix has the same shape of reason as the first, and the same honest
cost. A swarm service (Grafana is the one there today) registers **one** client
for the whole swarm, so its credential's path names the service and never the
host — and which hive runs a given service is a `deploy.*` fact, per-host by
definition, so nothing swarm-wide exists to scope the grant to. The host running
such a service has no store identity of its own either; it reads with the
certificate of the hive it is. So **any hive can read any swarm service's client
secret**, which is worth what it buys: a swarm service whose identity provider
lives on another machine can get its secret at all, instead of only working
co-located. Giving such a service its own store identity is what would remove
this rather than re-scope it.
**A tracked follow-up** narrows this, with the two candidate directions: scope
the grant per hive (and pay for the re-emission), or give each agent container