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
|
|
@ -223,6 +223,37 @@ let
|
|||
swarm.grafana.package = pkgs.emptyDirectory;
|
||||
};
|
||||
|
||||
# The metrics UI beside the IdP, which is the deployment whose secret was
|
||||
# already delivered: a host copy out of authelia's own tree.
|
||||
grafanaWithAuthelia = hive {
|
||||
deploy.grafana.enable = true;
|
||||
deploy.grafana.plugins = [ ];
|
||||
deploy.grafana.package = pkgs.emptyDirectory;
|
||||
deploy.authelia.enable = true;
|
||||
};
|
||||
# The same UI with the IdP on ANOTHER host and a store leaf placed by hand —
|
||||
# the deployment that had no delivery path at all. Knowing an IdP is not
|
||||
# running one: `swarm.authelia.url` is what says this swarm has SSO, and
|
||||
# nothing about this host does.
|
||||
grafanaRemoteAuthelia = hive {
|
||||
deploy.grafana.enable = true;
|
||||
deploy.grafana.plugins = [ ];
|
||||
deploy.grafana.package = pkgs.emptyDirectory;
|
||||
swarm.authelia.url = "https://auth.example.invalid";
|
||||
deploy.bao.clientCertFile = "/etc/pki/bao-client.pem";
|
||||
deploy.bao.clientKeyFile = "/etc/pki/bao-client-key.pem";
|
||||
};
|
||||
# The same again with the store identity taken away, which is the one shape
|
||||
# that can deliver this secret by NO route. It separates "the swarm has an
|
||||
# IdP" from "something here can fetch what it minted", and it is what keeps
|
||||
# Grafana's config from naming a file nothing writes.
|
||||
grafanaRemoteAutheliaNoIdentity = hive {
|
||||
deploy.grafana.enable = true;
|
||||
deploy.grafana.plugins = [ ];
|
||||
deploy.grafana.package = pkgs.emptyDirectory;
|
||||
swarm.authelia.url = "https://auth.example.invalid";
|
||||
};
|
||||
|
||||
# The first slice to leave options on BOTH sides of the split, so the
|
||||
# fixture sets all three of them through the paths an existing config uses:
|
||||
# the two movers via their rename entries, `tokenEndpoint` via the path it
|
||||
|
|
@ -836,6 +867,97 @@ let
|
|||
ok =
|
||||
grafanaOldPath.containers.swarm-grafana.config.services.grafana.settings.auth.disable_login_form;
|
||||
}
|
||||
{
|
||||
# The absence class this whole file is for, and the reported defect in one
|
||||
# arm: the OIDC block hung off "authelia is on this host", so the split
|
||||
# deployment got a Grafana with no SSO settings and no login form — no way
|
||||
# in at all. The question it asks now is whether the SWARM has an IdP.
|
||||
name = "grafana is wired for SSO against an IdP on another host";
|
||||
ok =
|
||||
let
|
||||
s =
|
||||
grafanaRemoteAuthelia.containers.swarm-grafana.config.services.grafana.settings."auth.generic_oauth";
|
||||
in
|
||||
s.enabled && lib.hasInfix "https://auth.example.invalid/api/oidc/token" s.token_url;
|
||||
}
|
||||
{
|
||||
# The control for the arm above, and the reason the gate is a conjunction:
|
||||
# a block naming a file no unit on this host writes cannot produce a
|
||||
# working login, so it is emitted only where a delivery route exists —
|
||||
# leaving a host with neither exactly as it was rather than pointing
|
||||
# Grafana's config at a secret that never arrives.
|
||||
name = "grafana emits no OIDC block when nothing on this host can deliver the secret";
|
||||
ok =
|
||||
!(
|
||||
grafanaRemoteAutheliaNoIdentity.containers.swarm-grafana.config.services.grafana.settings
|
||||
? "auth.generic_oauth"
|
||||
);
|
||||
}
|
||||
{
|
||||
# The other half of the arm above, and the only deliverable that shape
|
||||
# has: silence there is a Grafana nobody can log into for a reason no log
|
||||
# names. A warning is read back by nothing, so an unevaluated one is a
|
||||
# warning nobody sees — the same reason the peers fixture exists.
|
||||
name = "the undeliverable-secret shape warns instead of going quiet";
|
||||
ok =
|
||||
lib.any (
|
||||
w: lib.hasInfix "no swarm-secret-store identity" w
|
||||
) grafanaRemoteAutheliaNoIdentity.warnings
|
||||
&& !(lib.any (w: lib.hasInfix "no swarm-secret-store identity" w) grafanaRemoteAuthelia.warnings);
|
||||
}
|
||||
{
|
||||
# Exactly one delivery unit per deployment, and which one is the only
|
||||
# thing locality decides. Two units writing one path is a race over which
|
||||
# secret Grafana parses; zero is the defect this pair replaced.
|
||||
name = "grafana's OIDC secret has one delivery unit, chosen by where authelia is";
|
||||
ok =
|
||||
let
|
||||
local = grafanaWithAuthelia.systemd.services;
|
||||
remote = grafanaRemoteAuthelia.systemd.services;
|
||||
in
|
||||
local ? swarm-grafana-oidc-secret
|
||||
&& !(local ? swarm-bao-grafana-oidc)
|
||||
&& remote ? swarm-bao-grafana-oidc
|
||||
&& !(remote ? swarm-grafana-oidc-secret);
|
||||
}
|
||||
{
|
||||
# Same 403-not-a-miss reason as the matrix and queue arms below: the
|
||||
# reader's grant covers the `services` prefix, so a path outside it is
|
||||
# refused rather than empty, however correct it reads. The negative arm is
|
||||
# the rename this is exposed to — a secret filed under the hive that runs
|
||||
# the service instead of under the service itself.
|
||||
name = "grafana's OIDC secret is read from the prefix the publisher writes";
|
||||
ok =
|
||||
let
|
||||
s = grafanaRemoteAuthelia.systemd.services.swarm-bao-grafana-oidc.script;
|
||||
in
|
||||
lib.hasInfix "secret/swarm/services/swarm-grafana/oidc/client" s
|
||||
&& !(lib.hasInfix "secret/swarm/hives/" s);
|
||||
}
|
||||
{
|
||||
# Both ends of a wire nothing at eval time carries end to end: the
|
||||
# publisher on authelia's host writes the path the reader on Grafana's host
|
||||
# reads, and the two files agree only because both compose it from the same
|
||||
# swarm-wide client id.
|
||||
name = "the publisher writes the swarm service path grafana reads";
|
||||
ok = lib.hasInfix "secret/swarm/services/swarm-grafana/oidc/client" (
|
||||
secretPublisherHere.systemd.services.swarm-secret-publish.script
|
||||
);
|
||||
}
|
||||
{
|
||||
# Registering the client cannot live where the rest of grafana's module
|
||||
# lives: that block is gated on this host RUNNING grafana, so on the split
|
||||
# deployment nothing registered the client, authelia minted no secret, and
|
||||
# every layer below had nothing to carry. The second arm is the control —
|
||||
# a host with no IdP registers nothing.
|
||||
name = "the swarm's grafana client is registered wherever authelia runs";
|
||||
ok =
|
||||
let
|
||||
clients = m: map (c: c.id) m.services.hyperhive.swarm.authelia.oidc.clients;
|
||||
in
|
||||
lib.elem "swarm-grafana" (clients secretPublisherHere)
|
||||
&& !(lib.elem "swarm-grafana" (clients grafanaRemoteAuthelia));
|
||||
}
|
||||
{
|
||||
# Reads the daemon's rendered unit, not the options: the queue address
|
||||
# arrives as an env var whose whole attrset is guarded on `natsUrl`, and
|
||||
|
|
@ -934,17 +1056,18 @@ let
|
|||
!(baoGrantHere.containers.swarm-bao.config.systemd.services ? swarm-bao-secret-publisher-policy);
|
||||
}
|
||||
{
|
||||
# The whole point of a second principal. `hives/` and not `swarm/`, so it
|
||||
# cannot touch an agent's or a service's credentials; and no `read`, so a
|
||||
# unit whose job is copying a file cannot recover what is already there.
|
||||
# Pinned as the full capability list, because an added capability is
|
||||
# exactly what a presence check misses.
|
||||
name = "the publisher's grant is write-only and reaches the hive prefix alone";
|
||||
# The whole point of a second principal. The two prefixes it publishes to
|
||||
# and not `swarm/`, so it cannot touch an agent's credentials; and no
|
||||
# `read`, so a unit whose job is copying a file cannot recover what is
|
||||
# already there. Pinned as the full capability list per prefix, because an
|
||||
# added capability is exactly what a presence check misses.
|
||||
name = "the publisher's grant is write-only and reaches the hive and service prefixes alone";
|
||||
ok =
|
||||
let
|
||||
s = baoGrantHere.systemd.services.swarm-bao-secret-publisher-policy.script;
|
||||
in
|
||||
lib.hasInfix "path \"secret/data/swarm/hives/*\" {\n capabilities = [\"create\", \"update\"]" s
|
||||
&& lib.hasInfix "path \"secret/data/swarm/services/*\" {\n capabilities = [\"create\", \"update\"]" s
|
||||
&& !(lib.hasInfix "secret/data/swarm/agents" s)
|
||||
&& !(lib.hasInfix "secret/data/swarm/*" s)
|
||||
&& !(lib.hasInfix "sys/policies/acl" s);
|
||||
|
|
|
|||
Loading…
Reference in a new issue