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

@ -5,8 +5,8 @@
//! two have to agree on the same path or a delivery fails with a 403 that names
//! nothing.
//!
//! The document has one stanza per kind a hive reads, and the two are scoped
//! differently on purpose:
//! The document has one stanza per kind a hive reads, and they are not all
//! scoped alike — which is the point rather than an inconsistency:
//!
//! ⚠️ The **agent** stanza grants read on *every* agent's credentials rather
//! than on the ones that hive hosts. That is a decision, not an oversight: an
@ -16,9 +16,14 @@
//! narrow one that only looks narrow. The narrower shapes, and what they would
//! cost, are in `docs/trust-boundary/security.md`.
//!
//! ⚠️ The **service** stanza is wide for the same shape of reason: a swarm
//! service's client is registered once per swarm, so its path names the service
//! and never the host, and which hive runs a service is a `deploy.*` fact with
//! no swarm-wide spelling to scope against. Cost: the same doc.
//!
//! The **hive** stanza has no such problem and is therefore narrow: that path
//! names its principal, so scoping it to the reader's own name costs nothing
//! and drifts nowhere. Do not widen it to match its neighbour — the asymmetry
//! and drifts nowhere. Do not widen it to match its neighbours — the asymmetry
//! is the point.
//!
//! Rendering stays separate from writing so the text can be asserted with no
@ -52,8 +57,8 @@ fn read_stanza(path: &str) -> String {
format!("path \"{path}\" {{\n capabilities = [\"read\"]\n}}\n")
}
/// Render `hive`'s policy document: read on every agent's credentials, and on
/// this hive's own.
/// Render `hive`'s policy document: read on every agent's credentials, on this
/// hive's own, and on the swarm services'.
///
/// The name is the only input, and it is deploy-time — so the document is
/// still a deploy-time object rather than derived state with a re-emission to
@ -61,9 +66,15 @@ fn read_stanza(path: &str) -> String {
///
/// Read-only: the controller mints these and never reads one back.
///
/// ⚠️ The service and controller kinds are deliberately absent: a hive has no
/// business reading a service's or the controller's credentials. Adding either
/// is a boundary decision, not a consequence of the namespace growing.
/// ⚠️ The controller kind is deliberately absent: a hive has no business
/// reading the credentials of the thing that provisions it. Adding it is a
/// boundary decision, not a consequence of the namespace growing.
///
/// The **service** kind is granted, and that was such a decision rather than a
/// consequence: a service whose identity provider is on another host reads its
/// own OIDC client secret with the certificate of the hive it runs on, that
/// being the only identity such a host has — so every hive can read every
/// service's. Bought and paid for in `docs/trust-boundary/security.md`.
///
/// The hive's *own* kind is granted, and that is the decision the agent-only
/// version of this grant said had to be made rather than assumed: a hive holds
@ -85,7 +96,11 @@ pub fn render(hive: &str) -> Result<String, Error> {
"{MOUNT}/data/{ROOT}/{}/{hive}/*",
<&str>::from(Kind::Hive)
));
Ok(format!("{agents}{own}"))
let services = read_stanza(&format!(
"{MOUNT}/data/{ROOT}/{}/*",
<&str>::from(Kind::Service)
));
Ok(format!("{agents}{own}{services}"))
}
#[cfg(test)]
@ -93,11 +108,26 @@ mod tests {
use super::*;
#[test]
fn the_document_grants_the_whole_agent_prefix_and_this_hive_alone() {
fn the_document_grants_two_whole_prefixes_and_this_hive_alone() {
assert_eq!(
render("pr1ma").expect("a plain name is legal"),
"path \"secret/data/swarm/agents/*\" {\n capabilities = [\"read\"]\n}\n\
path \"secret/data/swarm/hives/pr1ma/*\" {\n capabilities = [\"read\"]\n}\n"
path \"secret/data/swarm/hives/pr1ma/*\" {\n capabilities = [\"read\"]\n}\n\
path \"secret/data/swarm/services/*\" {\n capabilities = [\"read\"]\n}\n"
);
}
#[test]
fn the_service_stanza_covers_a_service_this_hive_was_never_named_beside() {
// The property the swarm-grafana delivery depends on: the host running a
// swarm service reads that service's client secret with its own hive
// certificate, and the path names the service rather than the host. A
// stanza narrowed to the reader's name would 403 every such read.
let p = render("pr1ma").expect("legal");
assert!(p.contains("path \"secret/data/swarm/services/*\""));
assert!(
!p.contains("services/pr1ma"),
"the service stanza is not scoped to the reader"
);
}