Files
LibreMail-Bug-Report-Ingest/docs/decisions/admin-auth.md
T
JMR-devandClaude Opus 4.8 047391d01c #11 Manual review/removal path for maintainers
Add an authenticated admin API to the ingest Worker so the single maintainer
can review the pending queue and pull a report before Friday's publish run.

Endpoints (on the existing handler):
  GET    /v1/admin/reports              list pending report ids
  POST   /v1/admin/reports/{id}/remove  mark a report removed
  DELETE /v1/admin/reports/{id}         remove alias

Remove calls lifecycle.MarkRemoved (#10), transitioning pending -> removed so
#13's ListPending excludes it from the next publish. Codes: 200 list/remove,
404 unknown id, 401 missing/bad/unset-secret token, 405 wrong method.

Auth: shared-secret Bearer token compared with crypto/subtle.ConstantTimeCompare,
fail-closed when the secret is unset. Injected via handler.New's new AdminBackend
arg: the dev server and tests wire a memory-backed lifecycle.Manager + ADMIN_TOKEN
env; the Worker reads ADMIN_TOKEN from Secrets Store and builds an R2-backed
Manager per request. Choice documented in docs/decisions/admin-auth.md.

Tests: Go httptest unit tests (list, remove+exclusion, 404, 401 incl. fail-closed,
405) and a Bruno api-tests flow (seed, authed list/remove, exclusion, no/bad
token 401). wrangler.jsonc gains only the ADMIN_TOKEN secret binding; worker
triggers untouched (owned by #13).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 15:56:59 -05:00

4.2 KiB

ADR 0003: Authentication for the maintainer admin API

  • Status: Accepted
  • Date: 2026-07-02
  • Deciders: Maintainer (single-maintainer project)
  • Ticket: #11 — Manual review/removal path for maintainers
  • Depends on: #10 (report lifecycle: MarkRemoved)
  • Related: #13 (weekly publish reads ListPending; a removed report is excluded)

Context

The weekly job publishes every still-pending report as a GitHub issue. Before that run the single maintainer needs an authenticated way to (a) list the pending reports and (b) remove a specific one so it is never published. This adds two admin endpoints to the existing Worker HTTP handler:

GET    /v1/admin/reports              -> 200 {"status":"ok","reports":[<id>...]}
POST   /v1/admin/reports/{id}/remove  -> 200 {"status":"removed","id":<id>}
DELETE /v1/admin/reports/{id}         -> 200 (REST alias of the POST above)

remove calls lifecycle.Manager.MarkRemoved (#10), transitioning the report pending -> removed; #13's ListPending then no longer returns it, so it is excluded from the next publish run. These endpoints are destructive and expose report ids, so they must be authenticated. The endpoints run in a Cloudflare Worker (Go/TinyGo/Wasm) with secrets in Cloudflare Secrets Store.

Decision

Authenticate with a shared-secret Bearer token, compared in constant time.

  • Every admin request must send Authorization: Bearer <token>.
  • The presented token is compared to the configured secret with crypto/subtle.ConstantTimeCompare, so a wrong guess leaks no timing signal.
  • The scheme (Bearer) is matched case-insensitively per RFC 7235; anything else (missing header, wrong scheme, wrong token) returns 401 with a WWW-Authenticate: Bearer challenge. A wrong method on an admin path returns 405; an unknown/never-pending id returns 404.
  • Fail closed: if the server has no secret configured (unset or empty), every admin request is rejected with 401 regardless of what the client sends. A missing secret binding can therefore never silently disable authentication.
  • Secret custody: in production the secret is the Cloudflare Secrets Store binding ADMIN_TOKEN (wrangler.jsonc secrets_store_secrets), read per request (like the encryption keyring in ADR #5) and never logged or echoed. The dev server and tests inject the token directly (env var ADMIN_TOKEN for the dev server), so the exact same handler is exercised locally.

Alternatives considered

  • Cloudflare Access (Zero Trust) in front of the route. Strong (SSO, device posture, short-lived JWTs, per-request audit) and requires no app-side secret. Rejected for v1: it needs a Zero Trust org, an application, and an access policy to be provisioned and maintained, and it complicates scripted/curl/CI access — disproportionate for a single maintainer removing the occasional report. It remains the natural upgrade if the maintainer set grows or richer audit is wanted; it can be layered in front of the Bearer check later without changing the handler.
  • mTLS / client certificates. Operationally heavy (cert issuance, rotation, client provisioning) for one operator. Rejected.
  • No dedicated auth, rely on an unguessable URL. Rejected: not real authentication, leaks via logs/history, and cannot be rotated cleanly.

Consequences

  • Positive: minimal moving parts; one secret to rotate (rotate the Secrets Store value); trivially callable from curl, scripts, or CI; the auth is plain, build-tag-free Go that is fully host-testable (httptest) and exercised end to end by the Bruno API tests against the dev server.
  • Negative / limitations: a single shared secret has no per-user identity or built-in audit trail, and if leaked it grants full admin until rotated. Mitigated by constant-time comparison, fail-closed behaviour, never logging the token, and HTTPS-only transport (the Worker is HTTPS). Revisit with Cloudflare Access if the maintainer set grows or per-actor audit becomes a requirement.