Skip to content

feat(admin): HTTP API for request-line fingerprint bans#153

Merged
jakebromberg merged 2 commits into
mainfrom
feature/151-admin-bans-api
Jun 1, 2026
Merged

feat(admin): HTTP API for request-line fingerprint bans#153
jakebromberg merged 2 commits into
mainfrom
feature/151-admin-bans-api

Conversation

@jakebromberg

Copy link
Copy Markdown
Member

Summary

Three operator endpoints under /admin/bans, gated by ADMIN_TOKEN bearer auth (mirror of LML's /admin/upload-library-db pattern). request-o-matic is a thin proxy: every write forwards to Backend-Service's /internal/banned-fingerprints CRUD (BS#1261). No local ban state, no JWT verification — rom's value-add is being co-located with the request-line surface operators already think about.

The shared mutation surface lives in services/ban_service.py so the future Slack-native router (#152) plugs into the same ban / unban / list_bans functions without spawning a second codepath.

End-to-end idempotency is preserved:

  • Re-banning a fingerprint returns 200 with the updated row.
  • Unbanning a non-existent fingerprint returns 204.
  • 4xx upstream errors forward verbatim (operator typos surface as 400, not 500).
  • 5xx upstream becomes 502 so a BS outage doesn't look like a rom bug.

Endpoints

Method Path Behavior
POST /admin/bans Create or update a ban (idempotent)
DELETE /admin/bans/{fingerprint} Remove a ban (idempotent — 204 whether or not row existed)
GET /admin/bans?limit=&cursor= List bans (keyset-paginated, mirrors BS shape)

Files

Test plan

  • Unit tests for ban_admin_client (httpx-mock-based: header forwarding, camelCase serialization, success + 4xx + 5xx + non-JSON body)
  • Unit tests for ban_service (passthrough, actorbanned_by_user_id translation)
  • Unit tests for router (auth matrix across all three endpoints, idempotency, upstream-error mapping, fail-closed when BS_INTERNAL_* unset)
  • Unit tests for require_admin_token + get_ban_admin_client dependencies
  • test_main.py pin that /admin/bans mounts at root, not under /api/v1
  • pytest tests/unit/ — 413 passing locally
  • ruff check ., ruff format --check ., mypy . --ignore-missing-imports — all clean
  • Staging smoke: after merge, set ADMIN_TOKEN + BS_INTERNAL_BANS_URL + BS_INTERNAL_KEY on the Railway service, then curl the runbook examples in docs/admin-bans.md

Related

Closes #151

Three operator endpoints under /admin/bans, gated by ADMIN_TOKEN bearer auth (mirror of LML's /admin/upload-library-db pattern). request-o-matic is a thin proxy: every write forwards to Backend-Service's /internal/banned-fingerprints CRUD (BS#1261). No local ban state, no JWT verification — rom's value-add is being co-located with the request-line surface operators already think about.

The shared mutation surface lives in services/ban_service.py so the future Slack-native router (#152) can plug into the same ban/unban/list functions without spawning a second codepath.

End-to-end idempotency is preserved: re-banning returns 200 with the updated row, unbanning a non-existent fingerprint returns 204. Operator-facing 4xx upstream errors forward verbatim; 5xx upstream becomes 502 so a BS outage doesn't look like a rom bug.

Operator runbook in docs/admin-bans.md.

Closes #151
…d bad input

Addresses the substantive bucket of the max-effort review (PR #153):

* `_map_upstream_error`: BS 401/403/429 now remap to 502 (these reflect the rom->BS hop, not the operator->rom request — forwarding them verbatim made an X-Internal-Key rotation indistinguishable from an ADMIN_TOKEN typo).
* `BanAdminClient` wraps httpx.HTTPError (connect, read, timeout, protocol) as `BanAdminClientError(0, ...)` so transport failures render as 502 instead of escaping as unhandled 500. The router's `responses=` table promised 502; it now delivers.
* `require_admin_token` switches to `hmac.compare_digest` (encoded to bytes so non-ASCII bearer values don't TypeError into a 500) and tolerates RFC 7235-allowed whitespace (`Bearer  token`, leading/trailing space, tabs).
* `BanCreateRequest`: `extra='forbid'` + UUID validator + reason length bounds + `gt=0` on expires_in_seconds. Operator typos now surface as a clean 422 instead of a nested BS 400.
* DELETE path-param validates as UUID before reaching BS.
* `BanAdminClient.unban` URL-quotes the fingerprint so a future Slack-native caller (#152) bypassing the FastAPI segment validator can't inject `?`/`#`/`/`.
* Success-path `response.json()` runs through `_safe_json` so a 200-with-non-JSON body (HTML from a reverse proxy, content-type drift) becomes a normal upstream-error 502 instead of an unhandled JSONDecodeError.
* `ban_service.ban/unban` log only the first 8 chars of the fingerprint (it's a stable per-device identifier; full UUID shouldn't sit in Railway/Sentry logs).
* Status-code check widens from `!= 200` to `200 <= status < 300` so a future BS refinement to 201-Created-vs-200-OK doesn't break rom.

Tests: +13 cases across the four affected files (102 -> 115 in the admin-bans scope; 437 total unit tests still passing).
@jakebromberg jakebromberg merged commit 715d924 into main Jun 1, 2026
7 checks passed
@jakebromberg jakebromberg deleted the feature/151-admin-bans-api branch June 1, 2026 04:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Admin HTTP API for managing request-line fingerprint bans

1 participant