Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

..
5 changed files with 21 additions and 231 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`, 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 <you> --email <you>@example.com --group admins

View file

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

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

View file

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

View file

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