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:
parent
338a767a61
commit
223cca2b56
4 changed files with 108 additions and 39 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue