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