swarm-grafana: one delivery route for the OIDC client secret
The previous commit left two delivery paths and a three-way gate: `swarm-grafana-oidc-secret.service` copied authelia's minted plaintext out of its host tree wherever the two were co-located, `swarm-bao-grafana-oidc.service` read the same value from the swarm secret store wherever they were not, and `ssoConfigured && (ssoLocal || haveClientIdentity)` decided whether Grafana got an OIDC block at all. Delete the co-located path. The store reader is now THE delivery unit, in every deployment — the publisher on authelia's host writes `swarm/services/<id>/oidc/client` whether the reader is a network away or in the container next door. The ruling behind it: the store exists so a host holds ONE out-of-band secret, its client certificate, and reads everything else with it. Skipping the store when the producer happens to be local saves a round trip and costs a second delivery unit, a second way for the file to be wrong, and a gate to choose between them. The gate goes too, and both of its questions become assertions, scoped to hosts that run Grafana: - `swarm.authelia.url` must be set. `auth.disable_login_form` is unconditional — Grafana ships an admin/admin account on a public vhost — so dropping the OIDC block when the swarm names no IdP produced a container with no SSO and no password box, silently. An eval-time refusal naming the option is the only report that reaches anyone, the shape swarm-nats.nix already uses for the same option. - `deploy.bao.clientCertFile` / `clientKeyFile` must be set. This replaces a warning that nothing reads back, and its message names both options and where the leaf comes from. Fixtures follow. `grafanaWithAuthelia` gains the cert pair, because a co-located host is a store reader like any other. The old `grafanaRemoteAutheliaNoIdentity` is kept rather than deleted, renamed `grafanaNoIdentity`: the shape is still reachable, only its deliverable changed from silence to a refusal, and an arm now reads that refusal back. Its mirror `grafanaNoSso` covers the other assertion, each fixture wrong in exactly one way so an arm can name which refusal fired. Every positive keeps an explicit negative — the one-delivery-unit arm asserts the deleted unit is absent in both topologies rather than merely that the store reader is present. Refs #4234 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
4aa982cc2a
commit
815f977d7c
5 changed files with 295 additions and 233 deletions
|
|
@ -223,18 +223,22 @@ 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.
|
||||
# The metrics UI beside the IdP. It reads its secret out of the store like
|
||||
# every other Grafana host, so it needs a store identity like every other
|
||||
# Grafana host — the cert pair here is not scenery, it is the arm that would
|
||||
# have caught the deleted co-located copy unit coming back.
|
||||
grafanaWithAuthelia = hive {
|
||||
deploy.grafana.enable = true;
|
||||
deploy.grafana.plugins = [ ];
|
||||
deploy.grafana.package = pkgs.emptyDirectory;
|
||||
deploy.authelia.enable = true;
|
||||
deploy.bao.clientCertFile = "/etc/pki/bao-client.pem";
|
||||
deploy.bao.clientKeyFile = "/etc/pki/bao-client-key.pem";
|
||||
};
|
||||
# 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.
|
||||
# The same UI with the IdP on ANOTHER host and a store leaf placed by hand.
|
||||
# Knowing an IdP is not running one: `swarm.authelia.url` is what says this
|
||||
# swarm has SSO, and nothing about this host does. Identical to the fixture
|
||||
# above in everything the delivery path reads, which is the point.
|
||||
grafanaRemoteAuthelia = hive {
|
||||
deploy.grafana.enable = true;
|
||||
deploy.grafana.plugins = [ ];
|
||||
|
|
@ -243,16 +247,48 @@ let
|
|||
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 {
|
||||
# A Grafana host holding no store identity. This used to be the shape the
|
||||
# module went QUIET on — no OIDC block, a warning, and a container whose
|
||||
# login form is off regardless, so no way in and nothing failed. It is kept
|
||||
# rather than deleted because the shape is still reachable by an operator;
|
||||
# what changed is the deliverable, from a warning nothing reads back to a
|
||||
# refusal naming the two options to set. Only the identity is missing, so an
|
||||
# arm below can name which refusal fired.
|
||||
grafanaNoIdentity = hive {
|
||||
deploy.grafana.enable = true;
|
||||
deploy.grafana.plugins = [ ];
|
||||
deploy.grafana.package = pkgs.emptyDirectory;
|
||||
swarm.authelia.url = "https://auth.example.invalid";
|
||||
};
|
||||
# The mirror image: the identity is placed, and the swarm names no IdP. The
|
||||
# other half of "SSO must always be configured", and isolated the same way —
|
||||
# exactly one thing wrong, so the arm reads one refusal.
|
||||
grafanaNoSso = hive {
|
||||
deploy.grafana.enable = true;
|
||||
deploy.grafana.plugins = [ ];
|
||||
deploy.grafana.package = pkgs.emptyDirectory;
|
||||
deploy.bao.clientCertFile = "/etc/pki/bao-client.pem";
|
||||
deploy.bao.clientKeyFile = "/etc/pki/bao-client-key.pem";
|
||||
};
|
||||
|
||||
# Did ./host-modules/swarm-grafana.nix refuse this host, and for which of its
|
||||
# two reasons. An assertion is a config VALUE until something forces it —
|
||||
# `.config` never throws — so a fixture in a state the module refuses is
|
||||
# evaluable and the refusal is readable as data. That is what lets a case
|
||||
# check that a misconfiguration is REPORTED, rather than only that it is not
|
||||
# silently accepted.
|
||||
#
|
||||
# Matched on the option name the message names, not on its prose, so the
|
||||
# wording stays rewordable: the option name is the part an operator has to
|
||||
# act on, and a message that stopped naming it would be the actual defect.
|
||||
grafanaRefusedFor =
|
||||
m: option:
|
||||
lib.any (
|
||||
a:
|
||||
!a.assertion
|
||||
&& lib.hasInfix "services.hyperhive.deploy.grafana.enable requires" a.message
|
||||
&& lib.hasInfix option a.message
|
||||
) m.assertions;
|
||||
|
||||
# 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:
|
||||
|
|
@ -871,55 +907,80 @@ let
|
|||
# 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";
|
||||
# in at all. The block is emitted in every deployment now, so the negative
|
||||
# arm is not "no block elsewhere" but "the two do not name the same IdP":
|
||||
# each host's block has to point at the URL the SWARM names, and a block
|
||||
# built from `deploy.authelia` rather than `swarm.authelia.url` would pass
|
||||
# a presence check on both fixtures while sending one of them nowhere.
|
||||
name = "grafana's OIDC block names the swarm's IdP, wherever that IdP runs";
|
||||
ok =
|
||||
let
|
||||
s =
|
||||
grafanaRemoteAuthelia.containers.swarm-grafana.config.services.grafana.settings."auth.generic_oauth";
|
||||
oauth = m: m.containers.swarm-grafana.config.services.grafana.settings."auth.generic_oauth";
|
||||
remote = oauth grafanaRemoteAuthelia;
|
||||
local = oauth grafanaWithAuthelia;
|
||||
in
|
||||
s.enabled && lib.hasInfix "https://auth.example.invalid/api/oidc/token" s.token_url;
|
||||
remote.enabled
|
||||
&& lib.hasInfix "https://auth.example.invalid/api/oidc/token" remote.token_url
|
||||
&& local.enabled
|
||||
&& lib.hasInfix "https://auth.t.local/api/oidc/token" local.token_url
|
||||
&& !(lib.hasInfix "auth.example.invalid" local.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";
|
||||
# 🩸 The arm that guards the ruling this slice landed under. There is ONE
|
||||
# delivery route: the store reader, on every host that runs Grafana. The
|
||||
# negative names the deleted unit rather than a generic absence, because
|
||||
# the way this regresses is someone re-adding the co-located copy as an
|
||||
# optimisation — a second writer of one path, and a second shape of "the
|
||||
# secret is wrong" to debug.
|
||||
name = "grafana's OIDC secret has exactly one delivery unit, the store reader, in both topologies";
|
||||
ok =
|
||||
let
|
||||
local = grafanaWithAuthelia.systemd.services;
|
||||
remote = grafanaRemoteAuthelia.systemd.services;
|
||||
in
|
||||
local ? swarm-grafana-oidc-secret
|
||||
&& !(local ? swarm-bao-grafana-oidc)
|
||||
local ? swarm-bao-grafana-oidc
|
||||
&& remote ? swarm-bao-grafana-oidc
|
||||
&& !(local ? swarm-grafana-oidc-secret)
|
||||
&& !(remote ? swarm-grafana-oidc-secret);
|
||||
}
|
||||
{
|
||||
# What the deleted warning became. The shape is unchanged — a Grafana host
|
||||
# holding no store leaf — but silence there is a container nobody can log
|
||||
# into for a reason no log names, and a warning is read back by nothing.
|
||||
# The second arm is what makes this a refusal about the IDENTITY: this
|
||||
# fixture names an IdP, so a message about `swarm.authelia.url` here would
|
||||
# mean the two assertions had been collapsed into one conjunction.
|
||||
name = "a grafana host with no store identity is refused, naming the options to set";
|
||||
ok =
|
||||
grafanaRefusedFor grafanaNoIdentity "deploy.bao.clientCertFile"
|
||||
&& grafanaRefusedFor grafanaNoIdentity "deploy.bao.clientKeyFile"
|
||||
&& !(grafanaRefusedFor grafanaNoIdentity "swarm.authelia.url");
|
||||
}
|
||||
{
|
||||
# "SSO must always be configured", as an eval-time refusal rather than a
|
||||
# gate. A null URL used to drop the OIDC block silently, and
|
||||
# `disable_login_form` is unconditional a hundred lines below it, so that
|
||||
# combination produced a Grafana with no SSO and no password box — an
|
||||
# outage whose cause is a boolean that evaluated to false at build time
|
||||
# and left no trace. Same isolation as the arm above, mirrored.
|
||||
name = "a grafana host in a swarm with no IdP is refused, naming swarm.authelia.url";
|
||||
ok =
|
||||
grafanaRefusedFor grafanaNoSso "services.hyperhive.swarm.authelia.url"
|
||||
&& !(grafanaRefusedFor grafanaNoSso "deploy.bao.clientCertFile");
|
||||
}
|
||||
{
|
||||
# Without this the two arms above prove nothing: a refusal that fires on
|
||||
# every host is not a check, and both of these are hosts a swarm is
|
||||
# expected to have. Read through the same helper, so a message that
|
||||
# stopped naming its option would fail the arms above rather than pass
|
||||
# this one by accident.
|
||||
name = "neither grafana refusal fires on a correctly configured host, co-located or not";
|
||||
ok =
|
||||
!(grafanaRefusedFor grafanaWithAuthelia "services.hyperhive.swarm.authelia.url")
|
||||
&& !(grafanaRefusedFor grafanaWithAuthelia "deploy.bao.clientCertFile")
|
||||
&& !(grafanaRefusedFor grafanaRemoteAuthelia "services.hyperhive.swarm.authelia.url")
|
||||
&& !(grafanaRefusedFor grafanaRemoteAuthelia "deploy.bao.clientCertFile");
|
||||
}
|
||||
{
|
||||
# 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
|
||||
|
|
|
|||
Loading…
Reference in a new issue