ADR-050: Bearer-auth product /metrics endpoints via a gyrum-go helper
Status: Accepted Date: 2026-04-22
Note on ADR number: drafted as ADR-049 in parallel with the
alertmanager bearer-auth work. A parallel agent merged an unrelated
ADR-049: Alertmanager bearer-auth at Caddy (dark-factory#278) before
this one landed, so this was renumbered to 050. The two ADRs are
complementary: ADR-049 puts a bearer on the operator-facing
alertmanager.gyrum.ai route at Caddy; ADR-050 puts a bearer on the
scraper-facing /metrics route inside each product binary.
Context
The 2026-04-22 end-to-end observability wiring exposed a quiet gap. Every
product's /metrics endpoint is reachable without auth over the public
internet:
$ curl -o /dev/null -w "%{http_code}\n" https://distill.gyrum.ai/metrics
200
That response leaks:
app_info{version,commit,go_version,service,release_track}— version disclosure that shortens the window between a published CVE and an attacker knowing the instance is vulnerable.http_requests_total{route}— a route enumeration of the service (the bounded-cardinality label set is still a map of every endpoint the product serves).- Full Go runtime telemetry — memory, GC pause histograms, goroutine counts — which is useful for timing attacks and resource-exhaustion reconnaissance.
- Every counter the product publishes via gyrum-go
pkg/observ/metrics, including per-product business counters likereports_generated_total.
The existing pattern solves the problem for exactly one service —
dark-factory-control-plane — by carrying an inline Bearer token in the
Prometheus scrape config. That works, but the token lives in
/deploy/observability/prometheus/prometheus.yml on dark-factory-ops
(which is in git as a template, but the token is live-only), the match is
done ad-hoc in the control-plane's own router, and nobody else on the
fleet is copying it because there is nothing to import. Distill was
deployed unauth'd, documented as "we'll fix it" (issue #269), and the
same shape will repeat for every future product.
The fleet is about to grow — buzzy, socialproof, guardian,
research, and the other *-gyrum-io products all integrate with the
observ stack via gyrum-observ-integrate (see
ADR-045). Without a
library primitive, each of them will either (a) copy the control-plane
code by hand, (b) skip the auth entirely, or (c) roll their own slight
variation. That is exactly the drift we set up pkg/observ to prevent
(ADR-008).
Related in flight:
- gyrum-go
pkg/observalready owns HTTP middleware, build identity, and the metrics sub-package. - dark-factory
infrastructure/deploy.shruns products viadocker runwith an--env-filepattern; secrets on disk at/deploy/.secrets/. - The Prometheus side supports both inline
credentialsandcredentials_file— the file path reloads on every scrape, which means token rotation is a file write and no restart (the container does not have to be recreated).
Decision
Add observ.MetricsAuth(token string) func(http.Handler) http.Handler
to gyrum-go pkg/observ, wire it into every product's /metrics route
via the gyrum-observ-integrate CLI's routes stage, and standardise on
Prometheus authorization.credentials_file (not inline) on the scrape
side.
The helper:
- Rejects requests with a missing or non-
BearerAuthorization header as 401. - Compares the incoming token against the configured token with
crypto/subtle.ConstantTimeCompareto close the early-return timing side channel. - When constructed with an empty token, is a pass-through middleware AND
logs a single WARN via
observ.Logger()on first construction. This is the deliberate escape hatch that (a) lets local dev curl/metricswithout ceremony and (b) makes the deploy-sequencing story work (see "deploy sequencing" below).
Products wire it exactly once, in their registerInfraRoutes:
mux.Handle("/metrics", observ.MetricsAuth(token)(metrics.Handler()))
Token arrives via a twin-envvar convention that mirrors Prometheus's
own credentials / credentials_file split:
OBSERV_METRICS_TOKEN— inline env var; wins when non-empty.OBSERV_METRICS_TOKEN_FILE— path to a file; standard Docker-secret flow; trailing whitespace stripped. Unreadable file is a WARN, not fatal — the service stays up, the operator sees two log lines telling them what went wrong.
Token storage (no value in git):
- On gyrum-prod-1:
/deploy/.secrets/<product>-metrics-bearer.token, mode0600, ownerdeploy. Injected into the container via--env-fileor explicit-vmount. - On dark-factory-ops:
/deploy/observability/prometheus/<product>-bearer.token, mode0400, chowned to65534:65534(thenobodyuid the officialprom/prometheusimage runs as). This is the gotcha that cost us a scrape cycle during the rollout — writing the file asdeploymakes it unreadable from inside the container. Bootstrap scripts must set the container uid on the file.
The Prometheus scrape config always uses
authorization.credentials_file, never inline credentials:
authorization:
type: Bearer
credentials_file: /etc/prometheus/<product>-bearer.token
File-based credentials reload on every scrape with no Prometheus
restart, so token rotation becomes a write new token → chown nobody → no container action needed flow. That is meaningfully simpler than the
inline pattern the control-plane currently uses.
Deploy sequencing
The helper's empty-token pass-through exists specifically to make a safe deploy sequence possible. Any step fails → roll back the previous one; no step breaks the currently-working scrape.
- Ship the new binary with MetricsAuth wired BUT
OBSERV_METRICS_TOKENunset. Behaviour is identical to before:/metricsis 200 for all comers; a single WARN is emitted. Scrape keeps working. - Drop the token file on both gyrum-prod-1 and dark-factory-ops. No observable behaviour change on either side yet.
- Update prometheus.yml to add
authorization.credentials_file:. Restart Prometheus. Scrape still succeeds because distill's binary does not check the header. - Set
OBSERV_METRICS_TOKEN_FILEon the distill container and restart. Now/metricsreturns 401 for public curls and 200 for the Prometheus scrape. - Verify
up{job="distill"} == 1in Prometheus; verify unauthenticated curl → 401.
Reverse order for rollback: unset the env var first, then revert prometheus.yml, then remove the token files.
Consequences
Good:
- One code path for every product.
gyrum-observ-integratewires it automatically from now on. - No new dependencies on gyrum-go — stdlib
crypto/subtleonly. The basepkg/observstays dep-lean (the point of ADR-030). - Token rotation is a file write plus a chown; neither container restarts. The old control-plane pattern required editing prometheus.yml and recreating the container — not hard, but not routine either.
- Version disclosure and route enumeration are closed for the whole
fleet once
gyrum-observ-integratere-runs on every product repo. - The one-time WARN in empty-token mode means a production deploy that forgot to set the token is loud in Loki — not silently open.
Trade-offs:
- Every product now needs a unique token provisioned on two VPSs. The runbook gains a "rotate a product's metrics bearer" procedure. Not onerous — two file writes, one chown, no restart — but it is new ops surface.
- Prometheus's
credentials_fileloads on every scrape from disk. On a 15s scrape interval across ~10 products the syscall volume is trivial, but it is non-zero. Acceptable. - The empty-token escape hatch is deliberately silent-ish (one WARN) rather than loud (refuse to start). We accept this tradeoff because a product that refuses to start without the env var breaks the step-1 ship-binary-first deploy sequencing. The WARN is the compromise.
What we have signed up to operate:
- Per-product token files in two locations.
- The chown-to-65534 gotcha must stay documented; a future ops VPS rebuild needs to reapply it.
dark-factory-control-planestill uses inlinecredentialsin prometheus.yml — it pre-dates this ADR and we are leaving it alone until a dedicated migration PR lands (tracked against the next quarter). The knowledge-base note inprojects/104-dark-factory-control-plane/.../grafana-dashboards.mdflags this to avoid confusion.
Alternatives considered
Keep
/metricsunauthenticated — leaves the disclosure + enumeration path open on every product. Tenable for a single-tenant hobby VPS, not tenable for a commercial platform. Rejected.IP-restrict via Caddy
remote_ipon*.gyrum.ai/metrics— Prometheus on dark-factory-ops has a stable public IP (178.104.63.31), so Caddy could allow only that IP to hit the/metricspath. Simpler than bearer auth. Rejected because (a) we already have bearer-auth plumbing in gyrum-go, (b) IP allow-lists break the moment the ops VPS moves, (c) bearer-auth composes with future layers (mTLS, per-scraper tokens for multi-tenancy) without a rewrite, and (d) the product repos can test auth in-process without an HTTP hop to Caddy — testable design wins.mTLS between Prometheus and the products — strongest option. Each scrape presents a client cert; Caddy terminates TLS, verifies the client cert, forwards to the app. Rejected for now: adds a cert management surface (issue, rotate, revoke), the Caddy config becomes significantly more complex per site, and we have no evidence the bearer pattern is insufficient. Revisit if we need per-product scraper identity (we do not).
Replace the Prometheus pull model with an OpenTelemetry push — the collector-on-every-host authenticates to a central OTLP endpoint, and
/metricscan disappear entirely. Big architectural move. The current Prometheus setup is <2 weeks old and working; trading it for OTLP to solve an auth problem would be hugely disproportionate. Rejected; revisit only if we also need push (e.g. ephemeral jobs, Lambda-like workloads).Have every product roll its own bearer check — what distill would have done without this ADR. Same code in 10 products is a guaranteed drift vector and a doubled surface for a bug in the comparison path (the non-constant-time
==case). Rejected.
Supersedes: none Superseded by: leave blank until a later ADR reverses this one
Related
- ADR-008 — why a single library for this kind of primitive.
- ADR-030 — metrics split out of the base observ package; this ADR adds an observ primitive that the metrics endpoint uses.
- ADR-045 — the integrate CLI is the tool that wires this everywhere.
observability-runbook.md— operator procedure "I want to enable bearer-auth on a new product's/metrics".observability-stack-topology.md— updated "Where the secrets live" table.- dark-factory#269 — the issue this ADR closes.