Watch
0
0
Fork
You've already forked hyperhive
0

swarm-authelia: admit only the collector anywhere on the metrics store's name

No access rule covered the push route on `metrics.<swarm>`, so a request
there fell to `default_policy = one_factor`: any logged-in session cookie
for the swarm could write samples into VictoriaMetrics.

The name now gets two rules with no `resources`: the collector's client
at `one_factor`, then `deny`. Only the push route and `/metrics` on that
vhost send an authelia subrequest (the read API, Grafana's datasource
included, is served with no check), and the collector is the one caller
of both, so a domain-wide pair covers them with no path or query regex
to slip past. It replaces the per-path `/metrics` pair the published
target would otherwise derive for that name. Both rules sit ahead of the
apex and store-UI rules, and authelia matches `domain` exactly, so they
shadow no other name.

Authelia 4.39.20, read from source: a rule with no `domain` wildcard
matches with `strings.EqualFold` (access_control_domain.go), an empty
`resources` or `subject` list matches everything (access_control_rule.go),
and the first matching rule wins (authorizer.go).

Fixes #5218
This commit is contained in:
atlas 2026-10-13 11:01:22 +02:00 • committed by mara
commit 223cca2b56
4 changed files with 108 additions and 39 deletions

View file

@ -61,7 +61,7 @@ let
collectorClientId = config.services.hyperhive.swarm.otel.clientId;
collectorRegistered = lib.any (c: c.id == collectorClientId) cfg.oidc.clients;
# Every published metrics endpoint: the collector's client, and nobody else.
# `target`, for the collector's client and nobody else.
#
# The `deny` is the protection, and it has to be a rule of its own. A rule
# with a `subject` does not match a subject outside it: authelia skips it and
@ -71,38 +71,52 @@ let
# between a browser session and this data: `authn_strategies` on the authz
# endpoint also accepts `CookieSession`, and a cookie carries no audience.
# Until a collector is registered, the `deny` is the only rule.
#
collectorOnly =
target:
lib.optional collectorRegistered (
target
// {
policy = "one_factor";
subject = [ "oauth2:client:${collectorClientId}" ];
}
)
++ [ (target // { policy = "deny"; }) ];
# The metrics store's name, every path. Only its push route and `/metrics`
# consult authelia (its read API is served with no check), and the
# collector is the one caller of both, so the rules take no `resources`
# and leave no path or query form to fall through to `default_policy`.
# ⚠️ An authenticated location added to that vhost for anyone else is
# refused here until it gets a rule ahead of the `deny`.
metricsStoreDomain = config.services.hyperhive.swarm.victoriametrics.domain;
metricsStoreRules = collectorOnly { domain = metricsStoreDomain; };
# One pair per `swarm.otel.publishedScrapeTargets` entry, read off the same
# URL the collector requests and its token is minted for, so a target
# cannot be published without the rules that confine it to the collector.
metricsRules = lib.concatLists (
lib.mapAttrsToList (
job: url:
let
# The split `parsePublished` in ./swarm-otel.nix makes for the scrape
# job; the two must agree on what the URL's host and path are.
parts = builtins.match "https://([^/]+)(/.*)" url;
target = {
domain = builtins.elemAt parts 0;
# ⚠️ Authelia matches `resources` against the cleaned path PLUS the
# query string, while nginx's `= <path>` location ignores the query.
# A bare `^<path>$` therefore misses `<path>?x`, which the location
# still serves, and that request falls through to `default_policy`.
resources = [ "^${lib.escapeRegex (builtins.elemAt parts 1)}([?].*)?$" ];
};
in
if parts == null then
throw "swarm.otel.publishedScrapeTargets.${job} = \"${url}\" is not an https URL with a path; authelia's access rule for it cannot be derived."
else
lib.optional collectorRegistered (
target
// {
policy = "one_factor";
subject = [ "oauth2:client:${collectorClientId}" ];
# An entry on the metrics store's name is covered by `metricsStoreRules`.
metricsRules = lib.filter (r: r.domain != metricsStoreDomain) (
lib.concatLists (
lib.mapAttrsToList (
job: url:
let
# The split `parsePublished` in ./swarm-otel.nix makes for the scrape
# job; the two must agree on what the URL's host and path are.
parts = builtins.match "https://([^/]+)(/.*)" url;
in
if parts == null then
throw "swarm.otel.publishedScrapeTargets.${job} = \"${url}\" is not an https URL with a path; authelia's access rule for it cannot be derived."
else
collectorOnly {
domain = builtins.elemAt parts 0;
# ⚠️ Authelia matches `resources` against the cleaned path PLUS the
# query string, while nginx's `= <path>` location ignores the query.
# A bare `^<path>$` therefore misses `<path>?x`, which the location
# still serves, and that request falls through to `default_policy`.
resources = [ "^${lib.escapeRegex (builtins.elemAt parts 1)}([?].*)?$" ];
}
)
++ [ (target // { policy = "deny"; }) ]
) config.services.hyperhive.swarm.otel.publishedScrapeTargets
) config.services.hyperhive.swarm.otel.publishedScrapeTargets
)
);
# Upstream's `services.authelia.instances.<name>` derives the unit,
@ -1138,9 +1152,10 @@ in
# shadowed by a broader domain rule added later.
rules =
# The collector's client where one is registered, then
# `deny` — see `metricsRules` above, which is where the
# `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) {

View file

@ -159,12 +159,14 @@ in
'';
};
# The subrequest for both gated locations above. It carries the
# requested URL, and authelia decides per URL, so the scrape needs no
# subrequest of its own. Same target and header set as the sibling log
# store's, which took them from `swarm-ui.nix` — `X-Original-URL` and
# `X-Original-Method` are what authelia's auth-request implementation
# reads, and the address it compares the token's audience against.
# The subrequest for both gated locations above. Authelia admits the
# collector's client anywhere on this name and refuses every other
# subject (`metricsStoreRules` in ./swarm-authelia.nix), so the scrape
# needs no subrequest of its own. Same target and header set as the
# sibling log store's, which took them from `swarm-ui.nix` —
# `X-Original-URL` and `X-Original-Method` are what authelia's
# auth-request implementation reads, and the address it compares the
# token's audience against.
"= /__metrics_push_authz" = {
proxyPass = "https://${hyperhiveCfg.swarm.authelia.domain}/api/authz/auth-request";
# nixpkgs appends its OWN `Host $host` after extraConfig, which

View file

@ -288,6 +288,57 @@ let
&& locs."/".return == "403"
&& locs."/".proxyPass == null;
}
{
# The metrics store's name admits the collector's client on every path
# and refuses every other subject. The rules carry no `resources`, so no
# path or query spelling misses them and reaches `default_policy`, and
# they come before the apex rule. That is only right while the push
# route and `/metrics` are the vhost's sole authelia-gated locations,
# which the last clause pins.
name = "authelia admits only the collector anywhere on the metrics store's name";
ok =
let
h = allLocal.services.hyperhive;
collector = "oauth2:client:${h.swarm.otel.clientId}";
rules =
allLocal.containers.swarm-authelia.config.services.authelia.instances.swarm.settings.access_control.rules;
indexed = lib.imap0 (i: r: r // { inherit i; }) rules;
storeRules = lib.filter (r: r.domain == "metrics.t.local") indexed;
apex = lib.findFirst (r: r.domain == "t.local") null indexed;
policy = subject: autheliaPolicy allLocal subject "metrics.t.local";
paths = [
"/opentelemetry/api/v1/push"
"/metrics"
"/metrics?x=1"
"/api/v1/query"
];
locs = allLocal.services.nginx.virtualHosts.${h.swarm.victoriametrics.domain}.locations;
in
map (r: removeAttrs r [ "i" ]) storeRules == [
{
domain = "metrics.t.local";
policy = "one_factor";
subject = [ collector ];
}
{
domain = "metrics.t.local";
policy = "deny";
}
]
&& apex != null
&& lib.all (r: r.i < apex.i) storeRules
&& lib.all (p: policy collector p == "one_factor") paths
&& lib.all (p: policy "user:someone" p == "deny") paths
&& lib.all (p: policy "group:admins" p == "deny") paths
&&
lib.hasInfix "auth_request /__metrics_push_authz;"
locs."= /opentelemetry/api/v1/push".extraConfig
&&
lib.attrNames (lib.filterAttrs (_: l: lib.hasInfix "auth_request" (l.extraConfig or "")) locs) == [
"= /metrics"
"= /opentelemetry/api/v1/push"
];
}
{
# Every published target admits the collector's client and refuses any
# other authenticated subject, with or without a query string. Read
@ -322,7 +373,7 @@ let
forgeMetricsUrl
vmMetricsUrl
]
&& lib.length targets == 4
&& lib.length targets == 6
&& lib.all (t: policy collector t == "one_factor") targets
&& lib.all (t: policy "user:someone" t == "deny") targets
&& lib.all (t: policy "group:admins" t == "deny") targets;