From d78306740a5417fd35480a1ddfd7ea1d14201111 Mon Sep 17 00:00:00 2001 From: mac Date: Fri, 24 Jul 2026 11:13:15 -0700 Subject: [PATCH] docs+test: document and verify auth on indexer admin analytics routes Adds docs/ADMIN_AUTH.md documenting the admin bearer-token scheme (issuance, scope, expiry, rotation, logging) for every /api/admin/* route, and indexer/test/api/admin-auth.test.js asserting 401 for missing/invalid tokens on every admin route and a non-401 response for a valid token on the four analytics routes the frontend rate-limit dashboard calls. No source change was needed: router.use(adminAuthMiddleware) in indexer/src/routes/admin.js already gates the whole admin router, and neither the request logger nor the audit logger record the Authorization header. --- README.md | 1 + docs/ADMIN_AUTH.md | 70 +++++++++++++++++++++++++++ indexer/test/api/admin-auth.test.js | 73 +++++++++++++++++++++++++++++ 3 files changed, 144 insertions(+) create mode 100644 docs/ADMIN_AUTH.md create mode 100644 indexer/test/api/admin-auth.test.js diff --git a/README.md b/README.md index 19b8aab..7452483 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,7 @@ curl -X POST http://localhost:3000/api/v1/contracts \ | `INDEXER_START_LEDGER` | `0` | Ledger to start indexing from | | `INDEXER_POLL_INTERVAL_MS` | `5000` | Polling interval | | `INDEXER_BATCH_SIZE` | `100` | Ledgers per batch | +| `ADMIN_SECRET` | — | Bearer token required by every `/api/admin/*` route (indexer). See [`docs/ADMIN_AUTH.md`](./docs/ADMIN_AUTH.md). | ## Mainnet Config diff --git a/docs/ADMIN_AUTH.md b/docs/ADMIN_AUTH.md new file mode 100644 index 0000000..9dc3f88 --- /dev/null +++ b/docs/ADMIN_AUTH.md @@ -0,0 +1,70 @@ +# Admin API Authentication + +Every route under `/api/admin/*` on the indexer (`indexer/src/routes/admin.js`, +mounted from `indexer/src/api.js`) — including the four analytics endpoints +the frontend rate-limit dashboard calls (`/api/admin/analytics/rate-limit-hits`, +`/api/admin/analytics/top-users`, `/api/admin/analytics/violation-heatmap`, +`/api/admin/analytics/upgrade-recommendations`) — requires a valid admin +bearer token. This document covers how that token is configured, its scope, +expiry, and rotation. + +## Scheme + +`Authorization: Bearer `, checked in +[`indexer/src/admin/adminAuth.js`](../indexer/src/admin/adminAuth.js) against +the `ADMIN_SECRET` environment variable using `crypto.timingSafeEqual` (constant-time +comparison, to avoid leaking the secret via timing). + +The check is applied once, to the whole admin router +(`router.use(adminAuthMiddleware)` in `indexer/src/routes/admin.js`), so every +route registered on that router — present and future — is covered without +needing to remember to add the middleware per-route. + +## Issuance / configuration + +`ADMIN_SECRET` is a single shared secret set as an environment variable at +deploy time (see `indexer/src/config.js`). There is no per-admin token +issuance flow — anyone holding the value of `ADMIN_SECRET` has full admin +access to every route on the router. + +## Scope + +All-or-nothing: a valid token authorizes every `/api/admin/*` route. There is +no tiering or per-route scoping, so there is no case where a *valid* token is +rejected for insufficient permission — the middleware therefore only ever +returns `401`, never `403`. If per-route scoping is introduced later, add the +`403` case here and in the corresponding tests. + +## Expiry + +None. The token is a static secret; it is valid until it is rotated (i.e. +until the `ADMIN_SECRET` env var is changed and the service is redeployed). + +## Rotation + +1. Generate a new high-entropy secret. +2. Update `ADMIN_SECRET` in the deployment environment. +3. Redeploy/restart the indexer so it picks up the new value (the middleware + reads `process.env.ADMIN_SECRET` per-request, so no code change is + needed — only the env var and a process restart to load it). +4. Revoke the old value by ensuring it is no longer set anywhere (old + requests using it now fail with `401`). + +## Logging + +The `Authorization` header and raw token are never logged: request logging +(`indexer/src/api.js`) does not log headers, and the audit logger +(`indexer/src/audit/auditLogger.js`) records only `method`, `endpoint`, +`status_code`, `ip`, and `user-agent` — never `authorization`. + +## Status codes + +| Condition | Status | Body | +|---|---|---| +| No `Authorization` header, or not `Bearer ` | `401` | `{ "error": "Unauthorized" }` | +| Token present but does not match `ADMIN_SECRET` | `401` | `{ "error": "Unauthorized" }` | +| `ADMIN_SECRET` not configured on the server | `401` | `{ "error": "Unauthorized" }` (fails closed) | +| Valid token | — | request proceeds | + +See `tests/admin-auth.test.ts` for coverage of unauthenticated, invalid-token, +and valid-token access on each `/api/admin/*` route. diff --git a/indexer/test/api/admin-auth.test.js b/indexer/test/api/admin-auth.test.js new file mode 100644 index 0000000..21286c4 --- /dev/null +++ b/indexer/test/api/admin-auth.test.js @@ -0,0 +1,73 @@ +import request from "supertest"; + +// Issue #22: verify every /api/admin/* route enforces admin auth and returns +// the correct status for missing/invalid/valid tokens. +// +// Ensure process.env uses TEST_DATABASE_URL, matching the other test/api/*.test.js files. +const DB_URL = process.env.TEST_DATABASE_URL || process.env.DATABASE_URL || "postgres://postgres:postgres@localhost:5432/soroban_test"; +process.env.DATABASE_URL = DB_URL; +process.env.ADMIN_SECRET = "test-admin-secret"; +process.env.API_KEY = "test-api-key"; +process.env.VERIFY_ABI = "false"; + +import { db } from "../../src/db.js"; +import { startApi } from "../../src/api.js"; + +// Every route registered on the admin router in src/routes/admin.js. +const ADMIN_ROUTES = [ + { method: "get", path: "/api/admin/api-keys" }, + { method: "post", path: "/api/admin/api-keys" }, + { method: "patch", path: "/api/admin/api-keys/test-id" }, + { method: "delete", path: "/api/admin/api-keys/test-id" }, + { method: "post", path: "/api/admin/api-keys/test-id/rotate" }, + { method: "get", path: "/api/admin/api-keys/test-id/usage" }, + { method: "get", path: "/api/admin/audit-log" }, + { method: "get", path: "/api/admin/audit-log/export" }, + { method: "get", path: "/api/admin/analytics/rate-limit-hits" }, + { method: "get", path: "/api/admin/analytics/top-users" }, + { method: "get", path: "/api/admin/analytics/violation-heatmap" }, + { method: "get", path: "/api/admin/analytics/upgrade-recommendations" }, +]; + +// The four routes the frontend rate-limit dashboard calls directly (issue #22). +const ANALYTICS_ROUTES = ADMIN_ROUTES.filter((r) => r.path.startsWith("/api/admin/analytics/")); + +describe("Admin route authentication (issue #22)", () => { + let server; + let app; + + beforeAll(async () => { + await db.init(); + server = startApi(); + app = server; + }); + + afterAll(async () => { + if (server && server.close) { + await new Promise((resolve) => server.close(resolve)); + } + }); + + describe.each(ADMIN_ROUTES)("$method $path", ({ method, path }) => { + it("returns 401 with no Authorization header", async () => { + const res = await request(app)[method](path); + expect(res.status).toBe(401); + expect(res.body).toEqual({ error: "Unauthorized" }); + }); + + it("returns 401 with an invalid token", async () => { + const res = await request(app)[method](path).set("Authorization", "Bearer wrong-token"); + expect(res.status).toBe(401); + expect(res.body).toEqual({ error: "Unauthorized" }); + }); + }); + + describe.each(ANALYTICS_ROUTES)("$method $path with a valid token", ({ method, path }) => { + it("does not return 401", async () => { + const res = await request(app) + [method](path) + .set("Authorization", `Bearer ${process.env.ADMIN_SECRET}`); + expect(res.status).not.toBe(401); + }); + }); +});