From 223cca2b5662b9e2d82a936f1ecebfce9e2406c9 Mon Sep 17 00:00:00 2001 From: atlas Date: Tue, 13 Oct 2026 11:01:22 +0200 Subject: [PATCH] swarm-authelia: admit only the collector anywhere on the metrics store's name No access rule covered the push route on `metrics.`, 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 --- docs/swarm/services.md | 5 +- nix/host-modules/swarm-authelia.nix | 75 +++++++++++++--------- nix/host-modules/swarm-victoriametrics.nix | 14 ++-- nix/module-eval/swarm-otel-identity.nix | 53 ++++++++++++++- 4 files changed, 108 insertions(+), 39 deletions(-) diff --git a/docs/swarm/services.md b/docs/swarm/services.md index afb03b07..290ba095 100644 --- a/docs/swarm/services.md +++ b/docs/swarm/services.md @@ -173,8 +173,9 @@ loopback, and VictoriaMetrics authenticates nothing itself. The `metrics.` vhost forwards only the store's read API (the Prometheus query, series, label and metadata routes, plus the vmui web UI) 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 -token, and refuses every other path, the store's write and admin +collector's scrape of the store's own `/metrics`, only for the swarm +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. ### Logs (VictoriaLogs) diff --git a/nix/host-modules/swarm-authelia.nix b/nix/host-modules/swarm-authelia.nix index a5c5d760..6aaa3b53 100644 --- a/nix/host-modules/swarm-authelia.nix +++ b/nix/host-modules/swarm-authelia.nix @@ -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 `= ` location ignores the query. - # A bare `^$` therefore misses `?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 `= ` location ignores the query. + # A bare `^$` therefore misses `?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.` 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) { diff --git a/nix/host-modules/swarm-victoriametrics.nix b/nix/host-modules/swarm-victoriametrics.nix index a9da3e21..ffd6e828 100644 --- a/nix/host-modules/swarm-victoriametrics.nix +++ b/nix/host-modules/swarm-victoriametrics.nix @@ -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 diff --git a/nix/module-eval/swarm-otel-identity.nix b/nix/module-eval/swarm-otel-identity.nix index fc626023..ba6eb295 100644 --- a/nix/module-eval/swarm-otel-identity.nix +++ b/nix/module-eval/swarm-otel-identity.nix @@ -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;