Watch
0
0
Fork
You've already forked hyperhive
0

swarm metrics store: the read API needs Grafana's bearer or an operator session

The metrics vhost forwarded the store's read API (query, series, labels,
export, vmui, ...) with no credential, so anything that could reach
metrics.<swarm> could read every hive's and agent's metrics. mara ruled on
checked by an auth_request responder, and operators use their authelia
session.

- swarm-metrics-auth (new crate): auth_request responder on the metrics
  store's host. Compares the presented bearer, in constant time, with the
  one stored at swarm/services/<grafana client id>/metrics/bearer, read
  under its own bao leaf and cached for 60s. Static user, the post-#5264
  swarm-logs-auth unit shape.
- swarm-bao-grafana-metrics-bearer: on Grafana's host, creates that bearer
  in bao if absent (create without update, Grafana's existing leaf) and
  writes "Bearer <value>" into the container; the datasource sends it as
  its Authorization header via $__file{}.
- The vhost: read locations take auth_request through a map (a bearer goes
  to swarm-metrics-auth, anything else to authelia, no login redirect);
  vmui takes an authelia session with the login redirect; forceSSL like
  the logs vhost. The authelia subrequest location is renamed from
  /__metrics_push_authz to /__metrics_authelia since it now serves reads.
- Authelia: the collector keeps every path; everyone else is refused the
  collector's push and /metrics paths; group admins is admitted to the
  rest; then deny.
- bao: a swarm-metrics-auth cert role and policy (read on the one leaf),
  its leaf minted by glue-bao-tls, the CN reserved as a hive name, and
  Grafana's grant gains create on the bearer leaf.

Fixes #3596
This commit is contained in:
atlas 2026-10-14 19:53:36 +02:00 • committed by mara
commit 6aab2ebb65
24 changed files with 1126 additions and 200 deletions

View file

@ -287,20 +287,21 @@ let
in
m != null
&& m.proxyPass == "http://unix:/run/swarm-victoriametrics/victoriametrics.sock:/metrics"
&& lib.hasInfix "auth_request /__metrics_push_authz;" m.extraConfig
&& lib.hasInfix "auth_request /__metrics_authelia;" m.extraConfig
&& !(lib.hasInfix "error_page" m.extraConfig)
&& lib.hasInfix "internal;" locs."= /__metrics_push_authz".extraConfig
&& lib.hasInfix "internal;" locs."= /__metrics_authelia".extraConfig
&& 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";
# The metrics store's name admits the collector's client on every path,
# an operator on every path but the collector's two, and nobody else.
# Only the collector's two paths carry `resources`, as prefixes, so no
# path or query spelling of them reaches the operator rule, and no other
# spelling reaches `default_policy`. The rules come before the apex
# rule. The collector's two locations call authelia directly, so a
# bearer there never reaches Grafana's check.
name = "authelia admits the collector anywhere on the metrics store's name and operators only off its two paths";
ok =
let
h = allLocal.services.hyperhive;
@ -311,38 +312,33 @@ let
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 = [
collectorPaths = [
"/opentelemetry/api/v1/push"
"/opentelemetry/api/v1/push?x=1"
"/metrics"
"/metrics?x=1"
];
readPaths = [
"/api/v1/query"
"/api/v1/query_range?query=up"
"/vmui/"
];
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 ];
}
{
lib.length storeRules == 4
&&
removeAttrs (lib.last storeRules) [ "i" ] == {
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"
];
&& lib.all (p: policy collector p == "one_factor") (collectorPaths ++ readPaths)
&& lib.all (p: policy "user:someone" p == "deny") (collectorPaths ++ readPaths)
&& lib.all (p: policy "group:admins" p == "deny") collectorPaths
&& lib.all (p: policy "group:admins" p == "one_factor") readPaths
&& lib.hasInfix "auth_request /__metrics_authelia;" locs."= /opentelemetry/api/v1/push".extraConfig
&& lib.hasInfix "auth_request /__metrics_authelia;" locs."= /metrics".extraConfig;
}
{
# Every published target admits the collector's client and refuses any
@ -398,7 +394,9 @@ let
let
rules =
autheliaOnly.containers.swarm-authelia.config.services.authelia.instances.swarm.settings.access_control.rules;
metricsRules = lib.filter (r: r ? resources) rules;
# The metrics store's own name is the case above's; its rules carry
# `resources` for its push route and `/metrics` together.
metricsRules = lib.filter (r: r ? resources && r.domain != "metrics.t.local") rules;
re = lib.head (lib.head metricsRules).resources;
table = [
{