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>
77 lines
4.2 KiB
Markdown
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.
|