Files
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

77 lines
4.2 KiB
Markdown

# ADR 0003: Authentication for the maintainer admin API
- **Status:** Accepted
- **Date:** 2026-07-02
- **Deciders:** Maintainer (single-maintainer project)
- **Ticket:** [#11](https://github.com/JMR-dev/LibreMail-Bug-Report-Ingest/issues/11) — Manual review/removal path for maintainers
- **Depends on:** [#10](https://github.com/JMR-dev/LibreMail-Bug-Report-Ingest/issues/10) (report lifecycle: `MarkRemoved`)
- **Related:** [#13](https://github.com/JMR-dev/LibreMail-Bug-Report-Ingest/issues/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.