From 4a9acd1d0c7e0d04270fa98ec55203c7bef4cbe6 Mon Sep 17 00:00:00 2001 From: atlas Date: Wed, 14 Oct 2026 21:27:13 +0200 Subject: [PATCH 1/3] authelia: a user outside a rule's group is denied instead of falling through to one_factor authelia takes the first matching rule, and a rule with a `subject` does not match a subject outside it. The swarm domain's and the bao UI's `group:admins` rules had nothing after them, so an account in no group skipped them and landed on `default_policy = one_factor`: any authenticated account reached the swarm UI, its `/api/` (by cookie or by bearer) and the bao UI. Each of the two group rules is now an `operatorsOnly` pair: the group allow, then a subject-less `deny` for the same domain, the shape `collectorOnly` already gives the collector's metrics rules. `default_policy` stays `one_factor`, which the forge and chat rely on. module-eval-swarm-otel-identity gains two cases on `allLocal`: an account in no group is refused the swarm domain (four paths) and the bao UI while `group:admins` passes and the forge stays open; and a predicate, `autheliaSubjectRulesClosed` in lib.nix, holds over the rendered list: every subject-scoped allow is closed by a later subject-less `deny` on its domain with no subject-less allow in between, and nothing follows a domain-wide `deny` on its domain. Fixes #5120 --- docs/swarm/ui.md | 4 +- nix/host-modules/swarm-authelia.nix | 40 +++++--- nix/module-eval/lib.nix | 46 +++++++++ nix/module-eval/swarm-otel-identity.nix | 119 ++++++++++++++++++++++++ 4 files changed, 194 insertions(+), 15 deletions(-) diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index 01d53168..2195fc50 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -379,8 +379,8 @@ reader and to a certificate — so there is no separate option to pin. **This is the step that separates "protected" from "locked out."** The vhost's `auth_request` asks authelia _"is there a session"_; the rule that makes it mean _"is this an operator"_ is an `access_control` entry -requiring `group:admins`. An account without that group authenticates -fine and still gets bounced. +requiring `group:admins`, followed by a `deny` for everyone else. An +account without that group authenticates fine and still gets bounced. ```sh swarmctl user add --email @example.com --group admins diff --git a/nix/host-modules/swarm-authelia.nix b/nix/host-modules/swarm-authelia.nix index 6aaa3b53..dda1d29d 100644 --- a/nix/host-modules/swarm-authelia.nix +++ b/nix/host-modules/swarm-authelia.nix @@ -43,6 +43,21 @@ let # failing the check it was supposed to pass. operatorGroup = "admins"; + # `target`, for `operatorGroup` and nobody else. The `deny` is what makes + # the group rule exclusive, for the reason `collectorOnly` gives: a subject + # outside the group skips the first rule and would otherwise land on + # `default_policy`. + operatorsOnly = target: [ + ( + target + // { + policy = "one_factor"; + subject = [ "group:${operatorGroup}" ]; + } + ) + (target // { policy = "deny"; }) + ]; + # Whether the swarm collector's client is actually registered here. # # Asked of the client list rather than re-derived from the conditions @@ -1137,9 +1152,10 @@ in # user", which is authentication, not authorisation. The # swarm UI is operator-only, and agents are getting # authelia accounts of their own — so the day that lands, - # a session alone would be enough to open it. The rule - # below is what makes the distinction real; without it - # the vhost's `auth_request` is a check nobody fails. + # a session alone would be enough to open it. The + # `operatorsOnly` pairs below are what make the distinction + # real; without them the vhost's `auth_request` is a check + # nobody fails. # # The group is a constant rather than an option: it is the # value an operator types into `swarmctl user add --group`, @@ -1149,7 +1165,9 @@ in default_policy = "one_factor"; # ⚠️ ORDER MATTERS — authelia takes the FIRST matching rule. # The metrics rules are listed first so they cannot be - # shadowed by a broader domain rule added later. + # shadowed by a broader domain rule added later. A `deny` with + # no `resources` ends its domain: a rule for that domain + # listed after it is never reached. rules = # The collector's client where one is registered, then # `deny` — see `collectorOnly` above, which is where the @@ -1158,19 +1176,15 @@ in ++ metricsStoreRules # Also guarded on the domain being set: without it a null # apex would render a rule matching the string "null". - ++ lib.optional (deployCfg.swarm-controller.enable && swarmDomain != null) { + ++ lib.optionals (deployCfg.swarm-controller.enable && swarmDomain != null) (operatorsOnly { domain = swarmDomain; - subject = [ "group:${operatorGroup}" ]; - policy = "one_factor"; - } + }) # The store's browser UI. Not keyed to a deploy flag: its # vhost is on the store's host, which need not be this - # one, and without this rule any session passes it. - ++ lib.optional (swarmDomain != null) { + # one, and without these rules any session passes it. + ++ lib.optionals (swarmDomain != null) (operatorsOnly { domain = hyperhiveCfg.swarm.bao.ui.domain; - subject = [ "group:${operatorGroup}" ]; - policy = "one_factor"; - }; + }); }; # The cookie domain is the SWARM's domain, NOT authelia's diff --git a/nix/module-eval/lib.nix b/nix/module-eval/lib.nix index fd86633e..8e733bc9 100644 --- a/nix/module-eval/lib.nix +++ b/nix/module-eval/lib.nix @@ -144,6 +144,50 @@ let otelSettings = machine: machine.containers.swarm-otel.config.services.opentelemetry-collector.settings; + autheliaRules = + machine: + machine.containers.swarm-authelia.config.services.authelia.instances.swarm.settings.access_control.rules; + + # Whether authelia's `rules`, read first-match-wins, leave no subject-scoped + # allow open to other subjects, and leave no rule unreachable. + # + # A rule with a `subject` does not match a subject outside it, so that + # subject falls through to `default_policy` unless a later subject-less + # `deny` on the same domain, with no `resources` or the same ones, catches + # it first. Walking forward from the allow, the first subject-less rule on + # its domain that is either an allow or such a `deny` must be the `deny`. + # A subject-less `deny` on narrower `resources` refuses everyone it + # matches, so it is passed over. Rules naming a subject are passed over too. + # + # A subject-less `deny` with no `resources` matches every request on its + # domain, so no rule for that domain may follow it. + # ⚠️ True of a list with no subject rule at all; a case using it also + # asserts that the rules it reads contain the ones it means. + autheliaSubjectRulesClosed = + rules: + let + n = lib.length rules; + at = lib.elemAt rules; + after = i: map at (lib.range (i + 1) (n - 1)); + anyone = r: !(r ? subject); + closes = r: d: d.policy == "deny" && (!(d ? resources) || d.resources == (r.resources or null)); + closed = + i: + let + r = at i; + stop = lib.findFirst ( + d: d.domain == r.domain && anyone d && (d.policy != "deny" || closes r d) + ) null (after i); + in + stop != null && closes r stop; + endsDomain = d: anyone d && d.policy == "deny" && !(d ? resources); + nothingAfter = i: lib.all (r: r.domain != (at i).domain) (after i); + in + lib.all ( + i: + (anyone (at i) || (at i).policy == "deny" || closed i) && (!(endsDomain (at i)) || nothingAfter i) + ) (lib.range 0 (n - 1)); + agentHarness = machine: machine.systemd.services.hive-agent; # The PRIORITY → severity operator both journald receivers attach, read @@ -221,6 +265,8 @@ in inherit bridgePortsOrNone; inherit baoSettings; inherit otelSettings; + inherit autheliaRules; + inherit autheliaSubjectRulesClosed; inherit agentHarness; inherit journaldSeverityParser; inherit carriesJournaldSeverity; diff --git a/nix/module-eval/swarm-otel-identity.nix b/nix/module-eval/swarm-otel-identity.nix index 558ac020..ada466a1 100644 --- a/nix/module-eval/swarm-otel-identity.nix +++ b/nix/module-eval/swarm-otel-identity.nix @@ -21,6 +21,8 @@ let hive runGroup otelSettings + autheliaRules + autheliaSubjectRulesClosed ; # A collector holding no store identity at all. Unlike Grafana's mirror @@ -640,6 +642,123 @@ let in !(lib.any (n: lib.elem n (u.after ++ u.wants ++ u.requires)) storeUnits); } + { + # An account in no group, on the swarm UI and the store's UI. A group + # rule alone does not refuse it: it skips the rule and lands on + # `default_policy`. `swarmctl --swarm`'s bearer is an operator's own + # token, so it is the `group:admins` arm here. The forge has no rule and + # stays open to any session. + name = "authelia refuses an account outside the operator group on the swarm UI and the store's UI"; + ok = + let + h = allLocal.services.hyperhive; + ui = h.swarm.bao.ui.domain; + policy = autheliaPolicy allLocal; + paths = [ + "/" + "/api/agents" + "/api/whoami" + "/api/docs/" + ]; + in + h.deploy.swarm-controller.enable + && h.swarm.domain == "t.local" + && ui == "bao-ui.t.local" + && lib.all (p: policy "user:nobody" "t.local" p == "deny") paths + && policy "user:nobody" ui "/" == "deny" + && lib.all (p: policy "group:admins" "t.local" p == "one_factor") paths + && policy "group:admins" ui "/" == "one_factor" + && policy "user:nobody" "forge.t.local" "/" == "one_factor"; + } + { + # Every subject-scoped rule the host renders, read in order. The `elem` + # clauses are the control for the predicate's vacuous case; the + # synthetic lists pin what it accepts and refuses, the first of them + # being the real rules with the operator denies cut out. + name = "every subject-scoped authelia rule is closed by a deny on its domain"; + ok = + let + rules = autheliaRules allLocal; + scoped = lib.filter (r: r ? subject) rules; + closed = autheliaSubjectRulesClosed; + rejects = rs: !(closed rs); + allow = { + domain = "t.local"; + subject = [ "group:admins" ]; + policy = "one_factor"; + }; + deny = { + domain = "t.local"; + policy = "deny"; + }; + narrow = deny // { + resources = [ "^/m$" ]; + }; + operatorDeny = + r: + r.policy == "deny" + && !(r ? subject) + && !(r ? resources) + && lib.elem r.domain [ + "t.local" + "bao-ui.t.local" + ]; + in + closed rules + && lib.elem allow scoped + && lib.elem (allow // { domain = "bao-ui.t.local"; }) scoped + && lib.any ( + r: r.subject == [ "oauth2:client:${allLocal.services.hyperhive.swarm.otel.clientId}" ] + ) scoped + && rejects (lib.filter (r: !(operatorDeny r)) rules) + && closed [ + allow + deny + ] + # a rule for anyone needs no deny behind it + && closed [ (removeAttrs allow [ "subject" ]) ] + && closed [ + (allow // { resources = narrow.resources; }) + narrow + ] + # a narrower deny between two allows refuses everyone it matches + && closed [ + (allow // { subject = [ "oauth2:client:c" ]; }) + narrow + allow + deny + ] + && rejects [ allow ] + && rejects [ + allow + narrow + ] + && rejects [ + allow + (deny // { domain = "other.local"; }) + ] + && rejects [ + allow + (deny // { subject = [ "user:x" ]; }) + ] + # a subject-less allow in between admits everyone first + && rejects [ + allow + (deny // { policy = "one_factor"; }) + deny + ] + # nothing reaches a rule after a domain-wide deny + && rejects [ + deny + allow + ] + && rejects [ + allow + deny + (allow // { subject = [ "group:other" ]; }) + deny + ]; + } ]; in runGroup "swarm-otel-identity" cases From 2a25ec3dff52077b94cc375170650e0d240da188 Mon Sep 17 00:00:00 2001 From: atlas Date: Wed, 14 Oct 2026 21:47:15 +0200 Subject: [PATCH 2/3] authelia: render the swarm apex's operator rules wherever authelia runs The apex's `operatorsOnly` pair was guarded on `deploy.swarm-controller.enable`, which is true only on the host that runs the controller and with it the swarm UI's vhost. When authelia runs on another host, that vhost still asks it, but the authelia host rendered no rule for the apex at all, so every session reached the swarm UI and its `/api/` through `default_policy = one_factor`. The pair now renders whenever the swarm domain is set, like the bao UI's pair. No vhost on a host that doesn't serve the apex asks about it, so the extra rules there are inert. authelia 4.39.20's `validate-config` accepts them on an authelia-only host's rendered access_control. module-eval-swarm-otel-identity gains a case on the existing `autheliaOnly` fixture (authelia without the controller): the apex has exactly the allow+deny pair, the rule list is closed, and an account in no group is refused `/api/agents` while `group:admins` passes. Fixes #5120 --- nix/host-modules/swarm-authelia.nix | 8 ++++--- nix/module-eval/swarm-otel-identity.nix | 29 +++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/nix/host-modules/swarm-authelia.nix b/nix/host-modules/swarm-authelia.nix index dda1d29d..463dd855 100644 --- a/nix/host-modules/swarm-authelia.nix +++ b/nix/host-modules/swarm-authelia.nix @@ -1174,9 +1174,11 @@ in # reasoning for both halves lives. metricsRules ++ metricsStoreRules - # Also guarded on the domain being set: without it a null - # apex would render a rule matching the string "null". - ++ lib.optionals (deployCfg.swarm-controller.enable && swarmDomain != null) (operatorsOnly { + # The swarm UI on the apex. Not keyed to a deploy flag: its + # vhost is on the swarm controller's host, which need not + # be this one. Guarded on the domain being set: without it + # a null apex would render a rule matching the string "null". + ++ lib.optionals (swarmDomain != null) (operatorsOnly { domain = swarmDomain; }) # The store's browser UI. Not keyed to a deploy flag: its diff --git a/nix/module-eval/swarm-otel-identity.nix b/nix/module-eval/swarm-otel-identity.nix index ada466a1..eb2b49c4 100644 --- a/nix/module-eval/swarm-otel-identity.nix +++ b/nix/module-eval/swarm-otel-identity.nix @@ -670,6 +670,35 @@ let && policy "group:admins" ui "/" == "one_factor" && policy "user:nobody" "forge.t.local" "/" == "one_factor"; } + { + # Authelia on a host that runs no swarm controller, so the swarm UI's + # vhost is elsewhere. That vhost still asks this authelia, so the apex's + # pair renders here regardless. + name = "authelia confines the swarm UI to operators on a host without the swarm controller"; + ok = + let + h = autheliaOnly.services.hyperhive; + rules = autheliaRules autheliaOnly; + policy = autheliaPolicy autheliaOnly; + in + h.deploy.authelia.enable + && !h.deploy.swarm-controller.enable + && + lib.filter (r: r.domain == "t.local") rules == [ + { + domain = "t.local"; + subject = [ "group:admins" ]; + policy = "one_factor"; + } + { + domain = "t.local"; + policy = "deny"; + } + ] + && autheliaSubjectRulesClosed rules + && policy "user:nobody" "t.local" "/api/agents" == "deny" + && policy "group:admins" "t.local" "/api/agents" == "one_factor"; + } { # Every subject-scoped rule the host renders, read in order. The `elem` # clauses are the control for the predicate's vacuous case; the From 6f071f7963852a2b4890c6a398a5c74997332ae8 Mon Sep 17 00:00:00 2001 From: atlas Date: Wed, 14 Oct 2026 21:47:15 +0200 Subject: [PATCH 3/3] swarm-ui: name the group the operator rule actually requires Two comments said authelia's rule requires `group:operators`. The group is `admins` (`operatorGroup` in swarm-authelia.nix), and the rule is now an `operatorsOnly` pair. --- nix/host-modules/swarm-ui.nix | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/host-modules/swarm-ui.nix b/nix/host-modules/swarm-ui.nix index 998286f7..3478c1b8 100644 --- a/nix/host-modules/swarm-ui.nix +++ b/nix/host-modules/swarm-ui.nix @@ -127,7 +127,7 @@ in # The swarm UI's own gateway surface. Published to agents on the # bridge deliberately: reachability is not the access control here — - # the `auth_request` below and authelia's `group:operators` rule + # the `auth_request` below and authelia's `group:admins` rule # are, and an agent that resolves the name still cannot open the # page. # @@ -159,9 +159,9 @@ in # # ⚠️ `auth_request` answers "is there a session", not "is this an # operator". The operator-only part is authelia's `access_control` - # rule (./swarm-authelia.nix) requiring `group:operators` — agents - # have authelia accounts of their own, and without that rule a - # session alone would open this page. + # rules (`operatorsOnly` in ./swarm-authelia.nix): `group:admins`, then + # a `deny` for everyone else. Agents have authelia accounts of their + # own, and without those rules a session alone would open this page. # # ⚠️ Failure mode here is LOCKED OUT, not unprotected: a subrequest # that wrongly denies takes the whole UI away. That is why the