Repository navigation
Harden Supabase grants, de-duplicate share views and index foreign keys (T15: SC-04, SC-08, DB-03, DB-04, DB-05) - #213
Conversation
…ys (T15) SC-04: get_shared_item counts each viewer once per share per UTC day, keyed by the signed-in user or the forwarded client IP, hashed with the share and the day. SC-08: revoke client writes on user_roles, subscriptions, user_promos and admin_grants, and all client access on promo_codes and daily_usage_counters, one migration per table. Every writer is the service role or a SECURITY DEFINER function. DB-05: index 25 unindexed foreign keys. Destructive changes (legacy policy drops, the staging-only function drop, duplicate indexes, a primary key) are proposals in supabase/proposed/ and never run. scripts/agent-stack.sh strips every auth email template block from the overlay config, since the confirmation template broke `start`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Revoke anon SELECT on ai_requests, so a legacy permissive SELECT policy cannot expose usage rows to the anon key. - The proposed legacy-policy drop stops if user_roles_select_own or ai_requests_select_own is missing, and its header no longer calls SELECT policies inert. - Test pruning of earlier-day share views; check column-level write grants. - Duplicate-index proposal also compares uniqueness, validity, operator classes and collations. - Skip the two composite workspace document FK indexes the primary keys already serve; fix comments on lock duration, the viewer hash and who writes user_roles. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- get_shared_item deletes at most 500 earlier-day view records per counted view, so the first view after midnight stays fast. - The proposed legacy-policy drop also checks that the kept policies read only the caller's own rows, and prints them. - The grants test catches column-level SELECT grants too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ℹ️ Recent review info
📝 Walkthrough
Note 🎁 Summarized by CodeRabbit Free
Comment |
Batch T15 from the 22 Oct QA report: Supabase security and database hygiene (SC-04, SC-08, DB-03, DB-04, DB-05).
Every new migration is non-destructive. The destructive steps are proposals in
supabase/proposed/, whichdb pushanddb resetnever run. Nothing has been applied to production (uziwdtkzofptihahfooo) or staging (brifeyypjauyusmkptkx). No Edge Function changed, so there is nothing to deploy.The QA snapshot files (
/workspace/debatelab-qa/supabase/*) aren't on this VPS. The evidence below comes from the repo migrations, the local stack's own security advisor, and catalog queries on a fresh local replay. Where production can differ from the repo, I say so.What changed, by issue
SC-04: share views counted on every reload
20261023060000_share_view_dedup.sqlreplacesget_shared_item(text), keeping the same signature, result columns and grants. It now counts each viewer at most once per share per UTC day:auth.uid()). Otherwise it's the client IP from PostgREST'srequest.headers:cf-connecting-ipfirst (the edge sets it), then the firstx-forwarded-forentry, thenx-real-ip, then'unknown'.public.share_views (share_id, viewed_on, viewer_key)table recordssha256(share id : day : viewer), so rows can't be matched across shares or days. RLS is on, there are no policies, and clients have no grants; only the function writes it.SharedPage.tsxis unchanged.SC-08: advisor warnings
Leaked-password protection is a dashboard setting. It's listed under "For Adam" below. The app signs in with an email code, Google or a demo code, so the impact is low.
SECURITY DEFINER RPCs executable by
authenticated. The local replay has 22 (21 inpublicplusprivate.is_current_workspace_member). Each one has a browser caller (or the collaboration service, which forwards the user's own JWT) and checksauth.uid(), ownership or membership in its body. Every one pins itssearch_path:ai_usage_todayandclear_current_user_datausepublic(onlypostgrescan create objects there), and the rest, including the rewrittenget_shared_item, use"". So no revokes and no body fixes were needed.auth.uid()'s rowauth.uid()onlyauth.uid()becomes ownerprivate.require_workspace_document_memberrequire_workspace_document_memberrequire_active_workspace_memberuserClient(user JWT)auth.uid()auth.uid()The existing pgTAP files already cover each kept RPC: it works for its owner or member and is refused for another user or for anon (
current_debates_test,ai_quota_sessions_test,workspace_*_test,promo_redemption_test,account_deletion_test). Since nothing was revoked, there was no service-role-only path to test.Tables with RLS and no policies. These are deny-all for clients by design: every reader is the service role or a SECURITY DEFINER function, and no client code reads them. Accepted as they are. On the local replay there are 18:
private.opus_default_backup,private.promo_redeem_failures,private.workspace_document_state,private.workspace_document_updates,private.workspace_invite_attempts,private.workspace_invites,private.workspace_version_data,public.ai_quota_sessions,public.daily_usage_counters,public.demo_access_attempts,public.demo_access_codes,public.global_prompts,public.promo_codes,public.shared_items,public.stripe_customers,public.user_data_clear_markers,public.webhook_events, and the newpublic.share_views.Broad table grants that only RLS protects. Tightened only where no client caller exists, one migration per table:
…060100_revoke_client_writes_user_roles…060200_revoke_client_writes_subscriptions…060300_revoke_client_writes_user_promosredeem_promo_code_atomic(definer); read by create-checkout (service role)…060400_revoke_client_writes_admin_grants…060500_revoke_client_access_promo_codes…060600_revoke_client_access_daily_usage_countersai_usage_today()(definer); no policy existed…060800_revoke_client_select_ai_requests_anonThe
user_rolesone matters most. QA saw prod-only permissive policies onuser_roles. With a write grant plus a policy likeFOR ALL USING (user_id = auth.uid()), a user could set their own role toadmin.server_written_tables_grants_test.sqlcreates exactly that policy inside the test and checks the update changes nothing. With the old grant restored, that test fails 3 of 6 (the role becomesadmin).Left as they are, listed only: anon CRUD grants on the owner-scoped tables (
cases,attacks,brainstorms,factoids,chats,messages,sources,source_files,source_folders,flowcharts,debates,evidence_cards,current_debates,api_keys) and oncurated_sources; anon SELECT onuser_settings. Their policies needauth.uid(), which is null for anon, so anon sees and changes nothing.keepalive_*stays anon-writable for the external uptime monitor.DB-03: prod-only legacy policies
supabase/proposed/20261023060000_drop_legacy_policies.sqldrops every policy onshared_items, every policy onuser_rolesexceptuser_roles_select_own, and every policy onai_requestsexceptai_requests_select_own.user_rolesandai_requestsstay live for signed-in users until this runs.BEGIN … ROLLBACKfirst.shared_items(20260819000000 dropped both and revoked client grants), so its twoauth_rls_initplanwarnings come from prod-only policies. The proposed drop resolves them; no migration in this PR can.auth.uid()(api_keys_all,cases_all,flowcharts_*,user_settings_*, and others). The QA run flagged only the two onshared_items, so production's policy set differs from the repo's. I didn't touch them, because ALTER POLICY on a name production may not have would fail the migration.DB-04:
staging_auto_admin()on stagingsupabase/proposed/20261023060100_drop_staging_auto_admin.sql:DROP FUNCTION IF EXISTS public.staging_auto_admin(), with a pre-check query for leftover triggers. Run it by hand on staging only. It is not inmigrations/.DB-05: index and key hygiene
20261023060700_index_foreign_keys.sqladds 21 indexes covering 23 of them:source_files(source_id, user_id),api_keys.user_id,user_promos.promo_code_id,shared_items.created_by,demo_access_codes.created_by, and 15 on the workspace tables (two composite(workspace_id, …)indexes also cover the single-columnworkspace_idFKs).created_byonglobal_prompts,promo_codesandcurated_sources, which are admin-authored tables with a handful of rows.(workspace_id, document_id)keys ofprivate.workspace_document_stateandprivate.workspace_document_updates. Their primary keys already lead withdocument_id, andworkspace_document_updatestakes a write on every collaborative edit. The advisor will still list these five.CREATE INDEX IF NOT EXISTSwith asquawk-ignore-file require-concurrent-index-creation. Each table's writes are blocked until the file's transaction commits. The largest tables here aresource_filesanddemo_access_codes; the workspace tables are empty in production because the feature is hidden.supabase/proposed/20261023060200_drop_duplicate_indexes.sqldropsidx_subscriptions_stripe_customer(duplicate ofidx_subscriptions_stripe_customer_id) and five more exact duplicates of unique-constraint indexes (idx_subscriptions_stripe_sub,idx_promo_codes_code,idx_global_prompts_feature,idx_user_roles_user_id,demo_access_codes_email_idx). Each drop runs only if the index that stays exists, is valid, is unique whenever the dropped one is, and has the same columns, operator classes and collations.private.promo_redeem_failures.supabase/proposed/20261023060300_promo_redeem_failures_primary_key.sqladds a surrogateid bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY. That can't fail on duplicate rows, unlike a key on(user_id, failed_at). It's a proposal because it rewrites the table.Tooling fix outside the batch scope
scripts/agent-stack.shstripped only the magic-link template from its overlay config. T2 added a confirmation template, soagent-stack.sh startfailed withcontent_path … resolves outside the project root. It now strips every[auth.email.template.*]block. Without this I couldn't start the stack.Migrations (all non-destructive)
20261023060000_share_view_dedup.sql: new table and index,CREATE OR REPLACE FUNCTIONwith the same signature20261023060100_revoke_client_writes_user_roles.sql20261023060200_revoke_client_writes_subscriptions.sql20261023060300_revoke_client_writes_user_promos.sql20261023060400_revoke_client_writes_admin_grants.sql20261023060500_revoke_client_access_promo_codes.sql20261023060600_revoke_client_access_daily_usage_counters.sql20261023060700_index_foreign_keys.sql20261023060800_revoke_client_select_ai_requests_anon.sqlEach REVOKE can be undone on its own with the matching GRANT. Migrations 2 to 7 and 9 are independent of each other.
Proposed, never applied (
supabase/proposed/)20261023060000_drop_legacy_policies.sql: production (and staging, if the same policies exist there)20261023060100_drop_staging_auto_admin.sql: staging only20261023060200_drop_duplicate_indexes.sql20261023060300_promo_redeem_failures_primary_key.sqlI dry-ran each one on the local replay inside
BEGIN … ROLLBACK, with two fake legacy policies added. The drops removed exactly the fakes and the six duplicates, and kept the intended policies.Edge Functions to deploy
None.
For Adam (dashboard and manual steps)
supabase/proposed/20261023060100_drop_staging_auto_admin.sqlafter its trigger pre-check.npm run backend:statusfirst). The frontend doesn't depend on them.Verification
CI won't run (no Actions minutes left), so everything below ran locally. A local pass covers only the local commit.
Full
npm run verifyon the final head5c8144b1:check-site: 48 HTML files, no problems.test:sw: all offline checks true.vite previewrunning on this worktree's registry port (18115), and I left it alone; otherwise the script ran as written../.superset/db-gate.sh(full, under the shared lock):5c8144b1(gate run 3): pgTAP 516/516, all database and concurrency scripts passed, full-stack e2e 71 passed and 1 skipped. That covers billing, admin, practice, case generation, the signed-out share link and the workspace RPC flows, on desktop and mobile Chromium.6061ca32(gate run 2): pgTAP 516/516, all database scripts passed, full-stack e2e 71 passed and 1 skipped. That includes the signed-out share link (public.spec.ts), billing and the workspace flows on both viewports.case-generationspecs failed. One hit the app's "Unable to load DebateLab" boot screen before any database call. The other readsourcesbefore the save landed, and this PR doesn't touchsources. Both passed on mobile in the same run, and on both viewports in run 2.Fresh local stack (
scripts/agent-stack.sh):db reset: pgTAP 516/516 and the concurrency scripts.supabase db lint: only 2 existing unused-variable warnings, both in workspace functions this PR doesn't touch.rls_enabled_no_policyINFO entries became 18 (the newshare_views). Nothing else appeared.Mutation checks (do the new tests catch the bug?):
get_shared_itemmakesshare_views_test.sqlfail.UPDATEonuser_rolesmakesserver_written_tables_grants_test.sqlfail 3 of 6, and the user's role becomesadmin.Proposed files: each was dry-run inside
BEGIN … ROLLBACK. The policy-drop guard raised as intended whenuser_roles_select_ownwas missing.Not verified:
cf-connecting-ipheader on hosted Supabase. The tests setrequest.headersby hand.Manual check after deploy: open a share link twice from the same browser and confirm the count goes up once, then once more from another network.
Review log
Each round was one fresh-context Claude Opus subagent reading the full diff against
main.Round 1 (on
4fb9ab35): 2 Medium, all fixed in6061ca32.ai_requests. Fixed with new migration20261023060800, plus a test.user_roles;has_any_column_privilegechecks;Round 2 (on
6061ca32): no Medium or High. All round-1 fixes confirmed. Lows fixed in5c8144b1:Left open (Nits):
indimmediate; none of the six pairs is deferrable.🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Security
Documentation