Watch
0
0
Fork
You've already forked hyperhive
0

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
This commit is contained in:
atlas 2026-10-14 21:27:13 +02:00 • committed by mara
commit 4a9acd1d0c
4 changed files with 194 additions and 15 deletions

View file

@ -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 **This is the step that separates "protected" from "locked out."** The
vhost's `auth_request` asks authelia _"is there a session"_; the rule 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 that makes it mean _"is this an operator"_ is an `access_control` entry
requiring `group:admins`. An account without that group authenticates requiring `group:admins`, followed by a `deny` for everyone else. An
fine and still gets bounced. account without that group authenticates fine and still gets bounced.
```sh ```sh
swarmctl user add <you> --email <you>@example.com --group admins swarmctl user add <you> --email <you>@example.com --group admins

View file

@ -43,6 +43,21 @@ let
# failing the check it was supposed to pass. # failing the check it was supposed to pass.
operatorGroup = "admins"; 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. # Whether the swarm collector's client is actually registered here.
# #
# Asked of the client list rather than re-derived from the conditions # Asked of the client list rather than re-derived from the conditions
@ -1137,9 +1152,10 @@ in
# user", which is authentication, not authorisation. The # user", which is authentication, not authorisation. The
# swarm UI is operator-only, and agents are getting # swarm UI is operator-only, and agents are getting
# authelia accounts of their own — so the day that lands, # authelia accounts of their own — so the day that lands,
# a session alone would be enough to open it. The rule # a session alone would be enough to open it. The
# below is what makes the distinction real; without it # `operatorsOnly` pairs below are what make the distinction
# the vhost's `auth_request` is a check nobody fails. # 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 # The group is a constant rather than an option: it is the
# value an operator types into `swarmctl user add --group`, # value an operator types into `swarmctl user add --group`,
@ -1149,7 +1165,9 @@ in
default_policy = "one_factor"; default_policy = "one_factor";
# ⚠️ ORDER MATTERS — authelia takes the FIRST matching rule. # ⚠️ ORDER MATTERS — authelia takes the FIRST matching rule.
# The metrics rules are listed first so they cannot be # 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 = rules =
# The collector's client where one is registered, then # The collector's client where one is registered, then
# `deny` — see `collectorOnly` above, which is where the # `deny` — see `collectorOnly` above, which is where the
@ -1158,19 +1176,15 @@ in
++ metricsStoreRules ++ metricsStoreRules
# Also guarded on the domain being set: without it a null # Also guarded on the domain being set: without it a null
# apex would render a rule matching the string "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; domain = swarmDomain;
subject = [ "group:${operatorGroup}" ]; })
policy = "one_factor";
}
# The store's browser UI. Not keyed to a deploy flag: its # The store's browser UI. Not keyed to a deploy flag: its
# vhost is on the store's host, which need not be this # vhost is on the store's host, which need not be this
# one, and without this rule any session passes it. # one, and without these rules any session passes it.
++ lib.optional (swarmDomain != null) { ++ lib.optionals (swarmDomain != null) (operatorsOnly {
domain = hyperhiveCfg.swarm.bao.ui.domain; domain = hyperhiveCfg.swarm.bao.ui.domain;
subject = [ "group:${operatorGroup}" ]; });
policy = "one_factor";
};
}; };
# The cookie domain is the SWARM's domain, NOT authelia's # The cookie domain is the SWARM's domain, NOT authelia's

View file

@ -144,6 +144,50 @@ let
otelSettings = otelSettings =
machine: machine.containers.swarm-otel.config.services.opentelemetry-collector.settings; 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; agentHarness = machine: machine.systemd.services.hive-agent;
# The PRIORITY → severity operator both journald receivers attach, read # The PRIORITY → severity operator both journald receivers attach, read
@ -221,6 +265,8 @@ in
inherit bridgePortsOrNone; inherit bridgePortsOrNone;
inherit baoSettings; inherit baoSettings;
inherit otelSettings; inherit otelSettings;
inherit autheliaRules;
inherit autheliaSubjectRulesClosed;
inherit agentHarness; inherit agentHarness;
inherit journaldSeverityParser; inherit journaldSeverityParser;
inherit carriesJournaldSeverity; inherit carriesJournaldSeverity;

View file

@ -21,6 +21,8 @@ let
hive hive
runGroup runGroup
otelSettings otelSettings
autheliaRules
autheliaSubjectRulesClosed
; ;
# A collector holding no store identity at all. Unlike Grafana's mirror # A collector holding no store identity at all. Unlike Grafana's mirror
@ -640,6 +642,123 @@ let
in in
!(lib.any (n: lib.elem n (u.after ++ u.wants ++ u.requires)) storeUnits); !(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 in
runGroup "swarm-otel-identity" cases runGroup "swarm-otel-identity" cases