Skip to content

fix(security): close anon + lock authenticated SECURITY DEFINER RPC exposure (ADR-006)#8

Merged
mleihs merged 5 commits into
mainfrom
fix/secdef-anon-rpc-revoke
Jun 15, 2026
Merged

fix(security): close anon + lock authenticated SECURITY DEFINER RPC exposure (ADR-006)#8
mleihs merged 5 commits into
mainfrom
fix/secdef-anon-rpc-revoke

Conversation

@mleihs

@mleihs mleihs commented Jun 15, 2026

Copy link
Copy Markdown
Owner

Closes the pre-existing SECURITY DEFINER RPC exposure (ADR-006 / incident-147 class): PostgREST exposes every EXECUTE-granted function at /rest/v1/rpc/<fn>, and a SECURITY DEFINER function runs as its owner — so an anon/authenticated grant lets a caller bypass the FastAPI Depends() role gate. 73 such functions were anon-callable on prod; several were catastrophic (fn_admin_grant_tokens let anyone with the public anon key grant tokens to any user).

Commits

  1. dfee580 — 257, anon close. anon-callable SECDEF (non-trigger) 73 → 5 (get_bleed_status + 4 RLS helpers). 22 Tier-A admin functions → service_role-only; 41 Tier-B → anon-revoked, authenticated kept. ACL-shape-agnostic pattern (REVOKE FROM PUBLIC, anon[, authenticated] then GRANT back) + fail-closed self-verifying DO block.
  2. 362d341 — 258, authenticated lockdown. Of the Tier-B writes, 37 trust their ID params with no caller guard → revoked from authenticated, making them service_role-only (horizontal-priv-esc fix). 12 backend call sites that reached them with the user-JWT client switched to the service-role client (get_admin_supabase_client()) — behaviorally neutral (SECURITY DEFINER already runs as owner; none reference auth.uid()), surrounding RLS-enforced table ops stay on the user client. Kept authenticated: 3 self-validating writes + 6 reads + 6 DRIFT player actions (each guards auth.uid() <> p_user). 11 unit tests updated via a shared route_secdef_admin conftest fixture.
  3. 462e45c — CI guard. scripts/lint-no-secdef-public-grant.sh pins the anon surface (post-migration DB-state check in the test-backend job; allowlist = the 5 known-safe). CLAUDE.md rule expanded. (ALTER DEFAULT PRIVILEGES was tested and is ineffective on Supabase — pg_default_acl grants anon/auth directly — so the CI guard is the prevention.)

Verification

  • Both migrations are idempotent + self-verifying (abort on any failed invariant).
  • Independent grant audit: 37 service_role-only, 15 keep-auth intact, anon surface 5.
  • 2043 unit + 20 integration tests pass; ruff (incl. --select S) + backend bash lints clean.

Deferred (P1, intentionally out of scope)

4 ID-trusting reads (fn_get_wallet_summary, fn_get_ward_strength, fn_user_byok_allowed, fn_user_has_byok_bypass) — low-severity horizontal info-leak, no privilege/PII.

⚠️ Prod / deploy ordering (read before applying to prod)

  • 257 is already applied to production (anon surface closed there).
  • 258 must be applied to prod ONLY AFTER this merges and the deployed backend includes the call-site switches. Applying 258 while prod still runs code that calls the 37 with the user-JWT client would 403 real user actions (deploy / epoch join / forge purchase / lore edit / scoring). It was briefly applied + reverted during validation (Sentry confirmed zero impact). Prod migration application is manual via the Management API (prod migration history is drifted; db push is unusable).
  • Sequence: merge → Coolify deploy → then apply 258 to prod + re-audit.

🤖 Generated with Claude Code

mleihs and others added 3 commits June 15, 2026 06:38
…R-006), part 1/2

Anon (public-internet) surface lockdown. 73 SECURITY DEFINER non-trigger functions were
anon-EXECUTEable via PostgREST with the public anon key, bypassing the FastAPI role gate.
Several privileged with no internal caller check (fn_admin_grant_tokens grants tokens to
anyone; epoch clone/delete; RP grants; get_user_emails_batch leaks all emails).

Per-function classification (backend caller client vs public.py anon surface vs RLS deps
vs pg_proc read/write + auth.uid self-validation):
  - Tier A (22): privileged, admin/service_role-only or SQL-internal -> REVOKE
      PUBLIC+anon+authenticated; GRANT service_role.
  - Tier B (46): legitimate backend caller, NOT reachable from any anon public.py endpoint
      -> REVOKE PUBLIC+anon; GRANT authenticated+service_role.
  - KEEP (5): 4 RLS helpers (revoking breaks 129 policy refs) + get_bleed_status (the one
      genuine public.py anon read).

GRANT mechanics: EXECUTE flowed via a mix of the PUBLIC default grant (`=X`) and direct
role grants; REVOKE FROM anon alone is a no-op when PUBLIC also grants. Fix = REVOKE FROM
PUBLIC+anon(+authenticated) then GRANT back the needed roles; service_role re-granted
explicitly. Array-driven loop = single source of truth; self-verifying DO block (4
invariants) aborts the txn on any hole/breakage.

Fresh-eyes review caught 5 functions first mis-classified as KEEP (fn_compute_cycle_scores,
fn_increment_progress, fn_get_ward_strength, get_broadsheet_source_data,
get_message_reactions) — the matching public.py methods do plain table SELECTs and never
call these RPCs, so leaving anon was a hole. Now in Tier B (anon revoked).

Verified local (psql + idempotent supabase CLI): anon-callable secdef non-trigger 73 -> 5
(exactly KEEP). content_drafts get_user_emails_batch path confirmed service_role via
get_effective_supabase + non-fatal.

PART 2/2 = migration 258 (the authenticated lockdown, immediate continuation): of the
Tier-B writes only 3 self-validate via auth.uid() (fn_purchase_tokens,
fn_update_user_byok_keys, toggle_message_reaction) and stay authenticated; the other ~35
trust ID params with no guard -> switch backend call sites to service_role + REVOKE
authenticated. The 6 Tier-B reads keep authenticated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…06), part 2/2

Continues dfee580 (part 1/2, migration 257). Migration 257 closed the anon
surface and split the survivors into Tier A (service_role-only) and Tier B
(anon revoked, authenticated kept). This finishes the lockdown for Tier B.

WHY. Of the 46 Tier-B functions, pg_proc inspection (prosrc, not name-match)
shows only a minority self-validate the caller. The other 37 are WRITES that
trust their ID parameters with no internal guard, so any logged-in user could
POST /rest/v1/rpc/<fn> against a foreign epoch / simulation / user — a
horizontal privilege escalation that bypasses the FastAPI Depends() role gate
(e.g. fn_purchase_feature deducts an arbitrary user's tokens; fn_spend_rp_atomic
/ fn_join_epoch_atomic mutate foreign epoch state; fn_materialize_shard
processes another user's draft). This is the ADR-006 / incident-147 class.

WHAT. Migration 258 revokes authenticated (and re-asserts PUBLIC/anon revoked,
service_role granted) on the 37 writes, making them service_role-only. Same
ACL-shape-agnostic pattern as 257 (REVOKE FROM PUBLIC, anon, authenticated;
GRANT service_role) and the same fail-closed self-verifying DO block.

12 backend call sites that reached these writes with the router-injected
user-JWT client are switched to the service-role client
(get_admin_supabase_client()), so the revoke does not break them. The switch is
behaviourally neutral: SECURITY DEFINER already runs the body as the owner and
none of the 37 reference auth.uid(), so only the EXECUTE-check role changes;
surrounding RLS-enforced table ops stay on the user client. Switched:
forge_feature_service (purchase/refund/darkroom), scoring_service
(compute_cycle_scores), epoch_participation_service (join_epoch/join_team),
lore_service (delete/reorder), cycle_resolution_service
(_grant_rp_batch/spend_rp/grant_rp — shared chokepoints, mixed callers),
forge_orchestrator_service (materialize_shard). The other ~25 writes were
already reached only via admin/scheduler clients (heartbeat, epoch-cycle
resolution, mission resolution, cipher redeem) or have no caller at all
(fn_deploy_operative_atomic, fn_dissolve_alliance_atomic are defined-but-dead);
those are a pure revoke, no code change.

KEPT authenticated (verified genuine guards, not name-match): 3 self-validating
writes (fn_purchase_tokens, fn_update_user_byok_keys, toggle_message_reaction),
6 reads, and the 6 DRIFT player actions (fn_travel_move/_complete/_abandon/
_run_open, fn_quest_accept/_advance) which each guard auth.uid() <> p_user.
RLS helpers + get_bleed_status untouched.

DEFERRED (P1 follow-up, not closed here to keep this write-focused): 4
ID-trusting reads (fn_get_wallet_summary, fn_get_ward_strength,
fn_user_byok_allowed, fn_user_has_byok_bypass) — a low-severity horizontal
info-leak, no privilege/PII.

VERIFICATION. Migration applied locally + self-verify passed; independent grant
audit confirms the 37 are service_role-only (anon=0, auth=0, srole=37), the 15
keep-auth functions remain authenticated, anon surface unchanged (5), RLS
helpers intact. Test seam: new conftest fixture route_secdef_admin points
get_admin_supabase_client() at a test's mock (11 unit tests updated — the RPC
now lands on the service-role client, not the passed client). 2043 unit + 20
integration tests pass; ruff (incl. --select S security gate) + backend lints
clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Systemic prevention for the migration-257/258 exposure class: a new SECURITY
DEFINER function left anon-callable would silently reopen the hole. This adds a
deterministic guard rather than relying on review.

scripts/lint-no-secdef-public-grant.sh runs the post-migration grant audit
against the DB and fails if any SECURITY DEFINER, non-trigger function is
anon-EXECUTEable outside the allowlist (get_bleed_status + the 4 RLS helpers).
Wired into the CI test-backend job, right after migrations are applied via psql
(where the DB + psql are available). Validated both ways: passes on the current
tree; correctly flags a function dropped from the allowlist.

WHY a DB-state check and NOT `ALTER DEFAULT PRIVILEGES` (the obvious first idea):
verified 2026-06-15 that `ALTER DEFAULT PRIVILEGES IN SCHEMA public REVOKE
EXECUTE ON FUNCTIONS FROM PUBLIC` does NOT stop the exposure on Supabase — a
freshly-created function was still anon/authenticated-executable afterward,
because pg_default_acl grants EXECUTE *directly* to anon/authenticated on new
public functions (for both the `postgres` role and the managed `supabase_admin`
role). Revoking those defaults instead is fragile: prod `postgres` is not a
superuser and cannot alter supabase_admin's defaults, Supabase platform ops may
re-apply them, and it would force an explicit GRANT on every legitimate new RPC.
The authoritative signal is the actual grant state after migrations — exactly
what this guard checks.

Also expands the CLAUDE.md NEVER rule: the service-role call pattern
(get_admin_supabase_client for the .rpc(), user client for RLS-enforced table
ops), the two narrow authenticated exceptions (self-validating fns + genuine
public reads), the CI guard, and the ALTER-DEFAULT-PRIVILEGES caveat.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
mleihs and others added 2 commits June 15, 2026 12:04
…evoke

# Conflicts:
#	.github/workflows/ci.yml
…migration 258 fallout)

PR #8's Test Backend job hung 54+ min (normal ~5 min) and never failed:
there is no per-test or job timeout, so one wedged test runs until
GitHub's 6h default. Root-caused to a deterministic cross-event-loop
deadlock, reproduced locally with pytest-timeout (thread method) — the
worker thread's loop blocks forever in asyncio base_events
`_run_once -> selector.select(None)`.

Why now: migration 258 (ADR-006 part 2) switched CycleResolutionService's
RP-grant chokepoints (`_grant_rp_batch`, `spend_rp`, `grant_rp_single`)
to obtain their RPC client from the process-global
`get_admin_supabase_client()` cache instead of the caller-supplied
client. `test_race_conditions` deliberately drives `resolve_cycle` from
two threads, each with its own `asyncio.run` loop, to exercise the DB
optimistic lock under real concurrency. The first thread populates the
global cache with a client bound to its loop; the second reuses that
cached client on a different loop, so its httpx socket is registered on
a now-dead loop and the live loop's selector waits forever — exactly the
"NOT thread-safe / event-loop affinity" hazard documented in
`supabase_admin_cache.py`. (PR #8's earlier run failed fast at 5.6 min
rather than hanging because the table-grant step was absent; tests
short-circuited on 42501 before reaching this path. With #9's grant step
merged in, the test now runs to the hang.)

Test-only fix — production is unaffected: uvicorn runs one event loop for
the process lifetime, and a repo-wide grep confirms zero
`run_in_executor` / `ThreadPoolExecutor` / `threading.Thread` /
`asyncio.run(` in non-test backend code, so the global cache is only
touched from a single loop in prod. The cache's docstring already
mandates that a thread with its own loop "must construct its own client
inside the thread's loop." The fix honours that: monkeypatch
`cycle_resolution_service.get_admin_supabase_client` to a per-call
factory that builds a fresh service-role client on whatever loop is
running, so each worker thread gets a loop-local client. Real
concurrency and the optimistic-lock assertion are preserved; mirrors the
existing `route_secdef_admin` conftest seam.

Verification:
- test_concurrent_resolve_does_not_double_increment: 0.28s (was 54+ min hang)
- Full backend suite: 3430 passed, 11 skipped, 0 failed (55.6s), thread-timeout armed
- ruff check: clean

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@mleihs
mleihs merged commit c6558d9 into main Jun 15, 2026
7 checks passed
mleihs added a commit that referenced this pull request Jun 16, 2026
…ts (P2)

WHY: heartbeat.py + news_scanner.py annotated their endpoints as bare
`-> SuccessResponse:` / `-> PaginatedResponse:` while backend/models/heartbeat.py
and backend/models/news_scanner.py defined matching *Response models that were
never used (dead). CLAUDE.md: "Return type annotation is the single source of
truth ... Response models live in backend/models/<domain>.py."

RISK handled: a return annotation makes FastAPI FILTER the response through the
model -- dropping any FE-read field the model lacks, and 500-ing on any Literal
value / required-field-null it can't accept. So each endpoint was VERIFIED, and
the audit's "just wire them" would have shipped TWO latent 500s:
  - entry_type Literal was MISSING 'resonance_mood' (present in the live CHECK
    constraint) -> typing list_heartbeat_entries as-is would 500 the user-facing
    chronicle feed. Added it to HeartbeatEntryType.
  - get_heartbeat_overview's no-heartbeat early return {"last_tick": 0} omitted
    the required simulation_id -> 500. Fixed the service to return a complete
    overview ({simulation_id, last_tick: 0}; other fields default).

VERIFICATION per endpoint (verify-don't-trust): all Literals checked vs live
CHECK constraints (severity / arc_type / arc-status / bureau response_type+status
/ anchor-status all matched; only entry_type drifted); all list SELECTs confirmed
select("*"); all required model fields confirmed NOT NULL in the live schema; the
two admin dashboards confirmed built key-for-key to their models; all mutation
services confirmed to return full rows (response.data[0]). Frontend consumption
mapped to ensure no read-field is dropped.

TYPED (18 endpoints): heartbeat overview, entries (+public), arcs, bureau
responses (list/create/cancel), attunements (list/set/remove), anchors
(list/create/join/leave), admin dashboard, cascade-rules; news dashboard +
candidates. Candidates keeps its FE-locked {items, meta, recommended_threshold}
shape via a NEW ScanCandidateListResponse envelope -- NOT switched to the
standard paginated() {data, meta}, which would break the admin UI's
.items/.recommended_threshold reads (the audit's proposal there was unsafe).

LEFT UNTYPED + documented: get_daily_briefing (no model), force_tick
(process-summary), news list_adapters (team models adapters as list[dict]),
toggle_adapter/reject_candidate (status dicts), trigger_scan (cycle metrics),
approve_candidate (returns a resonance), update_candidate (dead), scan_log
(no model). paginated()-generic suggestion skipped (static-only; the per-endpoint
annotation already carries the element type for FastAPI).

VERIFICATION: ruff clean; app.openapi() builds (471 paths); 528
route/heartbeat/scanner tests green; representative payloads (incl. resonance_mood
entry, fixed early-return, candidates envelope, arbitrary adapter dicts) validate
through the typed wrappers. No SQL, no migration.

[Task 5 audit remediation -- routers+models #8, P2]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

1 participant