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

@ -173,8 +173,9 @@ loopback, and VictoriaMetrics authenticates nothing itself. The
`metrics.<swarm-domain>` vhost forwards only the store's read API (the `metrics.<swarm-domain>` vhost forwards only the store's read API (the
Prometheus query, series, label and metadata routes, plus the vmui web UI) Prometheus query, series, label and metadata routes, plus the vmui web UI)
without a credential. It forwards the collector's OTLP push, and the without a credential. It forwards the collector's OTLP push, and the
collector's scrape of the store's own `/metrics`, only with an authelia collector's scrape of the store's own `/metrics`, only for the swarm
token, and refuses every other path, the store's write and admin collector's authelia client: authelia refuses any other session or client
there. It refuses every other path, the store's write and admin
API included, with 403. The bare domain redirects to vmui. API included, with 403. The bare domain redirects to vmui.
### Logs (VictoriaLogs) ### Logs (VictoriaLogs)

View file

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

View file

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

View file

@ -288,6 +288,57 @@ let
&& locs."/".return == "403" && locs."/".return == "403"
&& locs."/".proxyPass == null; && 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 # Every published target admits the collector's client and refuses any
# other authenticated subject, with or without a query string. Read # other authenticated subject, with or without a query string. Read
@ -322,7 +373,7 @@ let
forgeMetricsUrl forgeMetricsUrl
vmMetricsUrl vmMetricsUrl
] ]
&& lib.length targets == 4 && lib.length targets == 6
&& lib.all (t: policy collector t == "one_factor") targets && lib.all (t: policy collector t == "one_factor") targets
&& lib.all (t: policy "user:someone" t == "deny") targets && lib.all (t: policy "user:someone" t == "deny") targets
&& lib.all (t: policy "group:admins" t == "deny") targets; && lib.all (t: policy "group:admins" t == "deny") targets;