Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

...
Author SHA1 Message Date
atlas
6f071f7963 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.
2026-10-14 23:41:13 +02:00
atlas
2a25ec3dff 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
2026-10-14 23:41:13 +02:00
atlas
4a9acd1d0c 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
2026-10-14 23:41:13 +02:00
5 changed files with 231 additions and 21 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
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 <you> --email <you>@example.com --group admins

View file

@ -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,28 +1165,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.
# 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
# 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.optional (deployCfg.swarm-controller.enable && swarmDomain != null) {
# 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;
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

View file

@ -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

View file

@ -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;

View file

@ -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,152 @@ 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