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:
parent
b99ce2b572
commit
4a9acd1d0c
4 changed files with 194 additions and 15 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue