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>
225 lines
9.2 KiB
Go
225 lines
9.2 KiB
Go
package handler
|
|
|
|
// Maintainer admin API (#11): an authenticated path for the single maintainer to
|
|
// review the pending queue and pull a report before the weekly publish run.
|
|
//
|
|
// 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 {"status":"removed","id":<id>} (alias)
|
|
//
|
|
// Removing a report transitions it pending -> removed via the lifecycle Manager
|
|
// (#10); #13's ListPending no longer returns it, so it is excluded from the next
|
|
// Friday publish. Remove is idempotent (removing an already-removed report still
|
|
// succeeds); an id that was never pending returns 404.
|
|
//
|
|
// # Authentication
|
|
//
|
|
// A shared-secret bearer token, chosen over Cloudflare Access for a
|
|
// single-maintainer, low-volume tool: it needs no Zero Trust org/policy setup,
|
|
// is trivially callable from curl/scripts/CI, and injects cleanly for tests and
|
|
// the dev server. See docs/decisions/admin-auth.md.
|
|
//
|
|
// Every admin request must carry `Authorization: Bearer <token>`. The presented
|
|
// token is compared to the configured secret with crypto/subtle.ConstantTimeCompare
|
|
// so a wrong token cannot be recovered by timing. The endpoint fails closed: if
|
|
// the server has no secret configured (unset/empty), every request is rejected
|
|
// with 401 regardless of what the client sends, so a missing binding can never
|
|
// silently disable auth. Missing/malformed/wrong credentials also return 401 with
|
|
// a `WWW-Authenticate: Bearer` challenge.
|
|
|
|
import (
|
|
"context"
|
|
"crypto/subtle"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/JMR-dev/LibreMail-Bug-Report-Ingest/internal/lifecycle"
|
|
)
|
|
|
|
// AdminBackend supplies the admin API's dependencies. It is an interface, not a
|
|
// concrete *lifecycle.Manager, so the same routes serve both deployment targets:
|
|
// the host (dev server, tests) wires a static Manager + injected token via
|
|
// NewManagerBackend, while the Cloudflare Worker wires a lazy implementation that
|
|
// reads the token from Secrets Store and builds an R2-backed Manager per request
|
|
// (the token and the R2 binding are only available inside a request there).
|
|
type AdminBackend interface {
|
|
// AdminToken returns the configured shared secret, or "" if none is set (in
|
|
// which case auth fails closed). ctx-scoped so the Worker can read Secrets
|
|
// Store per request. A non-nil error (e.g. a failed secret load) yields 503.
|
|
AdminToken(ctx context.Context) (string, error)
|
|
// ListPending returns the ids of all pending reports (lifecycle.Manager.ListPending).
|
|
ListPending(ctx context.Context) ([]string, error)
|
|
// MarkRemoved transitions a pending report to removed (lifecycle.Manager.MarkRemoved),
|
|
// returning lifecycle.ErrUnknownReport if the id is not pending.
|
|
MarkRemoved(ctx context.Context, id string) error
|
|
}
|
|
|
|
// managerBackend adapts a ready *lifecycle.Manager and a fixed shared secret to
|
|
// AdminBackend. It is the host wiring (dev server, tests); the Worker supplies
|
|
// its own lazy backend.
|
|
type managerBackend struct {
|
|
mgr *lifecycle.Manager
|
|
token string
|
|
}
|
|
|
|
// NewManagerBackend wires an injected *lifecycle.Manager and shared secret into
|
|
// an AdminBackend for the dev server and tests. An empty token leaves the admin
|
|
// API authenticated-but-unopenable (every request 401s), which is the intended
|
|
// fail-closed behaviour when no secret is provisioned.
|
|
func NewManagerBackend(mgr *lifecycle.Manager, token string) AdminBackend {
|
|
return managerBackend{mgr: mgr, token: token}
|
|
}
|
|
|
|
func (b managerBackend) AdminToken(context.Context) (string, error) { return b.token, nil }
|
|
|
|
func (b managerBackend) ListPending(ctx context.Context) ([]string, error) {
|
|
return b.mgr.ListPending(ctx)
|
|
}
|
|
|
|
func (b managerBackend) MarkRemoved(ctx context.Context, id string) error {
|
|
return b.mgr.MarkRemoved(ctx, id)
|
|
}
|
|
|
|
// denyAllBackend is substituted when New is called with a nil AdminBackend. It
|
|
// reports an empty secret, so authentication always fails closed (401) and the
|
|
// list/remove methods are never reached.
|
|
type denyAllBackend struct{}
|
|
|
|
func (denyAllBackend) AdminToken(context.Context) (string, error) { return "", nil }
|
|
func (denyAllBackend) ListPending(context.Context) ([]string, error) { return nil, errNotConfigured }
|
|
func (denyAllBackend) MarkRemoved(context.Context, string) error { return errNotConfigured }
|
|
|
|
var errNotConfigured = errors.New("handler: admin backend not configured")
|
|
|
|
// adminAPI holds the admin route handlers over an AdminBackend.
|
|
type adminAPI struct {
|
|
backend AdminBackend
|
|
}
|
|
|
|
// register mounts the admin routes on mux. Patterns are method-agnostic and each
|
|
// handler dispatches on the method itself, matching the rest of this package
|
|
// (isGet, the ingest handler): a broad "/" catch-all is also registered on the
|
|
// same mux, and it would otherwise absorb a method-mismatched request before the
|
|
// mux's own 405 logic could fire, so the method check lives in-handler.
|
|
func (a *adminAPI) register(mux *http.ServeMux) {
|
|
mux.HandleFunc("/v1/admin/reports", a.reports)
|
|
mux.HandleFunc("/v1/admin/reports/{id}/remove", a.removeViaPost)
|
|
mux.HandleFunc("/v1/admin/reports/{id}", a.removeViaDelete)
|
|
}
|
|
|
|
// reports serves /v1/admin/reports: GET lists the pending report ids.
|
|
func (a *adminAPI) reports(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
methodNotAllowed(w, http.MethodGet)
|
|
return
|
|
}
|
|
if !a.authorize(w, r) {
|
|
return
|
|
}
|
|
ids, err := a.backend.ListPending(r.Context())
|
|
if err != nil {
|
|
writeAdmin(w, http.StatusServiceUnavailable, adminResponse{Status: "error", Error: "admin backend unavailable"})
|
|
return
|
|
}
|
|
if ids == nil {
|
|
ids = []string{} // marshal an empty JSON array, never null
|
|
}
|
|
writeAdmin(w, http.StatusOK, adminList{Status: "ok", Reports: ids})
|
|
}
|
|
|
|
// removeViaPost serves POST /v1/admin/reports/{id}/remove.
|
|
func (a *adminAPI) removeViaPost(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
methodNotAllowed(w, http.MethodPost)
|
|
return
|
|
}
|
|
a.remove(w, r)
|
|
}
|
|
|
|
// removeViaDelete serves DELETE /v1/admin/reports/{id}, the REST-style alias for
|
|
// removeViaPost.
|
|
func (a *adminAPI) removeViaDelete(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodDelete {
|
|
methodNotAllowed(w, http.MethodDelete)
|
|
return
|
|
}
|
|
a.remove(w, r)
|
|
}
|
|
|
|
// remove transitions the {id} report pending -> removed, after authenticating.
|
|
func (a *adminAPI) remove(w http.ResponseWriter, r *http.Request) {
|
|
if !a.authorize(w, r) {
|
|
return
|
|
}
|
|
id := r.PathValue("id")
|
|
err := a.backend.MarkRemoved(r.Context(), id)
|
|
switch {
|
|
case err == nil:
|
|
writeAdmin(w, http.StatusOK, adminResponse{Status: "removed", ID: id})
|
|
case errors.Is(err, lifecycle.ErrUnknownReport):
|
|
writeAdmin(w, http.StatusNotFound, adminResponse{Status: "error", Error: "unknown report"})
|
|
default:
|
|
writeAdmin(w, http.StatusServiceUnavailable, adminResponse{Status: "error", Error: "admin backend unavailable"})
|
|
}
|
|
}
|
|
|
|
// methodNotAllowed writes a 405 with the Allow header advertising the one method
|
|
// the admin route accepts.
|
|
func methodNotAllowed(w http.ResponseWriter, allow string) {
|
|
w.Header().Set("Allow", allow)
|
|
writeAdmin(w, http.StatusMethodNotAllowed, adminResponse{Status: "error", Error: "method not allowed"})
|
|
}
|
|
|
|
// authorize enforces the shared-secret bearer token. It returns true when the
|
|
// request is authenticated; otherwise it writes a 401 (missing/bad token or unset
|
|
// server secret) or 503 (secret load failed) and returns false. The comparison is
|
|
// constant-time and the endpoint fails closed on an empty configured secret.
|
|
func (a *adminAPI) authorize(w http.ResponseWriter, r *http.Request) bool {
|
|
secret, err := a.backend.AdminToken(r.Context())
|
|
if err != nil {
|
|
writeAdmin(w, http.StatusServiceUnavailable, adminResponse{Status: "error", Error: "admin backend unavailable"})
|
|
return false
|
|
}
|
|
if secret == "" || !bearerMatches(r.Header.Get("Authorization"), secret) {
|
|
w.Header().Set("WWW-Authenticate", "Bearer")
|
|
writeAdmin(w, http.StatusUnauthorized, adminResponse{Status: "error", Error: "unauthorized"})
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// bearerMatches reports whether the Authorization header carries a Bearer token
|
|
// equal to secret. The scheme is matched case-insensitively (per RFC 7235); the
|
|
// token is compared in constant time. secret is assumed non-empty (the caller
|
|
// fails closed on an empty secret before calling this).
|
|
func bearerMatches(header, secret string) bool {
|
|
scheme, token, found := strings.Cut(header, " ")
|
|
if !found || !strings.EqualFold(scheme, "Bearer") {
|
|
return false
|
|
}
|
|
return subtle.ConstantTimeCompare([]byte(token), []byte(secret)) == 1
|
|
}
|
|
|
|
// adminList is the GET /v1/admin/reports success body. Reports has no omitempty,
|
|
// so an empty pending set still marshals as {"reports":[]} (an array, never null),
|
|
// which clients and the Bruno tests rely on.
|
|
type adminList struct {
|
|
Status string `json:"status"`
|
|
Reports []string `json:"reports"`
|
|
}
|
|
|
|
// adminResponse is the JSON body shape for the remove and error responses.
|
|
type adminResponse struct {
|
|
Status string `json:"status"`
|
|
ID string `json:"id,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
func writeAdmin(w http.ResponseWriter, status int, body any) {
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(body)
|
|
}
|