diff --git a/docs/swarm/sso.md b/docs/swarm/sso.md index fe17e5b8..cb38d00a 100644 --- a/docs/swarm/sso.md +++ b/docs/swarm/sso.md @@ -242,11 +242,18 @@ page-only check. This was verified that way when the split was introduced. ## What this doesn't do -- **It doesn't disable local login.** Each service keeps its password - database and gains a second door. An identity provider that can take a - service offline when it hiccups is worse than one with two ways in. - Making authelia the only path is a separate, reversible switch per - service (tuwunel's `login_with_password`, forgejo's own setting). +- **It doesn't disable local login — except on Grafana.** Forgejo and + tuwunel keep their password databases and gain a second door: an identity + provider that can take a service offline when it hiccups is worse than one + with two ways in, and making authelia the only path there is a separate, + reversible switch (tuwunel's `login_with_password`, forgejo's own setting). + ⚠️ **Grafana is the exception, and not a reversible one.** Its module sets + `auth.disable_login_form` unconditionally, because Grafana ships an + `admin`/`admin` account and its vhost is on the public gateway — so a + password box there is a way in whatever the deployment. SSO is the only + door, and a dead provider locks everyone out, the built-in admin + included. That's why `swarm.grafana.oidc.role` defaults to `Admin`; see + [`services.md`](services.md#metrics-victoriametrics--grafana). - **It doesn't provision users.** Agents are created and destroyed continuously, so the subject set belongs to a program rather than to a config file; today that program is `swarmctl`. diff --git a/nix/host-modules/swarm-grafana.nix b/nix/host-modules/swarm-grafana.nix index bbc68473..81ec4374 100644 --- a/nix/host-modules/swarm-grafana.nix +++ b/nix/host-modules/swarm-grafana.nix @@ -701,10 +701,17 @@ in # whole host fails to build. The unit below generates it. security.secret_key = "$__file{${secretKeyPath}}"; - # No local password path at all when SSO is configured. This - # is not tidiness: Grafana ships an `admin`/`admin` account, - # and this vhost is on the public gateway. - auth.disable_login_form = ssoLocal; + # No local password path, ever. This is not tidiness: Grafana + # ships an `admin`/`admin` account and this vhost is on the + # public gateway, so SSO is the only way in — not merely the + # preferred one. + # + # Deliberately NOT `ssoLocal`: whether a password box sits on a + # public login page cannot depend on which host happens to run + # authelia. The OIDC block below is still gated on locality; + # making that follow the same swarm-wide question is a larger + # change tracked on its own. + auth.disable_login_form = true; } // lib.optionalAttrs ssoLocal { "auth.generic_oauth" = { diff --git a/nix/module-eval.nix b/nix/module-eval.nix index 839365b0..74b04cc9 100644 --- a/nix/module-eval.nix +++ b/nix/module-eval.nix @@ -655,6 +655,16 @@ let rule: lib.hasInfix "/run/test-grafana-sock" rule ) grafanaOldPath.systemd.tmpfiles.rules; } + { + # This fixture enables grafana and NOT authelia, which is the shape the + # login form used to stay enabled in: the toggle read "both services are + # on this host" rather than "grafana requires SSO". Grafana ships an + # `admin`/`admin` account and its vhost is on the public gateway, so a + # password box there is a way in whatever the topology. + name = "grafana disables its local login form even where authelia is not on this host"; + ok = + grafanaOldPath.containers.swarm-grafana.config.services.grafana.settings.auth.disable_login_form; + } { # 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