diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index 2195fc50..01d53168 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`, followed by a `deny` for everyone else. An -account without that group authenticates fine and still gets bounced. +requiring `group:admins`. 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 463dd855..6aaa3b53 100644 --- a/nix/host-modules/swarm-authelia.nix +++ b/nix/host-modules/swarm-authelia.nix @@ -43,21 +43,6 @@ 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 @@ -1152,10 +1137,9 @@ 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 - # `operatorsOnly` pairs below are what make the distinction - # real; without them the vhost's `auth_request` is a check - # nobody fails. + # 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. # # The group is a constant rather than an option: it is the # value an operator types into `swarmctl user add --group`, @@ -1165,28 +1149,28 @@ 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. A `deny` with - # no `resources` ends its domain: a rule for that domain - # listed after it is never reached. + # shadowed by a broader domain rule added later. rules = # The collector's client where one is registered, then # `deny` — see `collectorOnly` above, which is where the # reasoning for both halves lives. metricsRules ++ metricsStoreRules - # 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 { + # 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) { 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 these rules any session passes it. - ++ lib.optionals (swarmDomain != null) (operatorsOnly { + # one, and without this rule any session passes it. + ++ lib.optional (swarmDomain != null) { 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/host-modules/swarm-ui.nix b/nix/host-modules/swarm-ui.nix index 3478c1b8..998286f7 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:admins` rule + # the `auth_request` below and authelia's `group:operators` 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` - # 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. + # 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. # # ⚠️ Failure mode here is LOCKED OUT, not unprotected: a subrequest # that wrongly denies takes the whole UI away. That is why the diff --git a/nix/module-eval/lib.nix b/nix/module-eval/lib.nix index 8e733bc9..fd86633e 100644 --- a/nix/module-eval/lib.nix +++ b/nix/module-eval/lib.nix @@ -144,50 +144,6 @@ 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 @@ -265,8 +221,6 @@ 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 eb2b49c4..558ac020 100644 --- a/nix/module-eval/swarm-otel-identity.nix +++ b/nix/module-eval/swarm-otel-identity.nix @@ -21,8 +21,6 @@ let hive runGroup otelSettings - autheliaRules - autheliaSubjectRulesClosed ; # A collector holding no store identity at all. Unlike Grafana's mirror @@ -642,152 +640,6 @@ 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"; - } - { - # 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 - # 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