From 1f5bbb008082e883574394b2a61b9969e9dae9e4 Mon Sep 17 00:00:00 2001 From: Gabriel Thom <61333600+thomgabriel@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:55:14 -0300 Subject: [PATCH] fix: drop TRUNCATE and unused DELETE grants, make public_* views read-only public_profiles is an auto-updatable view owned by postgres, so the insert/update/delete the default ACL gave authenticated let any signed-in user rewrite or delete another participant's users row past RLS. --- CLAUDE.md | 1 + .../00069_least_privilege_table_grants.sql | 27 +++++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 supabase/migrations/00069_least_privilege_table_grants.sql diff --git a/CLAUDE.md b/CLAUDE.md index a766f6d2..d1d586a2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,6 +46,7 @@ Next.js 16 App Router, TypeScript, Tailwind v4, Supabase. Multi-edition hackatho - `create-next-app` fails in non-empty dirs. The project was scaffolded by hand (mirroring `superteam-maker`'s approach). - **The DB was repurposed from `supabase-solana-lms`**, which dropped the `ALTER DEFAULT PRIVILEGES` rows that normally auto-grant `anon`/`authenticated`/`service_role` on every new table. Migrations 00009/00010 restore them explicitly. If you ever fork to a fresh Supabase project, those two migrations become no-ops but should still run for parity. Symptom of missing grants: every PostgREST call 403s with "permission denied for table X" *before* RLS even runs. +- **Default table privileges are trimmed (00069):** a new table gives `anon` SELECT and `authenticated` SELECT/INSERT/UPDATE, nothing more. A table with a DELETE policy needs `grant delete ... to authenticated` next to it. A new `public_*` view needs `revoke all ... from anon, authenticated` plus `grant select`: those views are owned by postgres, so an auto-updatable one otherwise accepts INSERT/UPDATE past RLS. - Supabase Auth doesn't backfill the `users` mirror table for accounts that existed before the trigger was added. For a fresh project this won't bite, but if you re-apply migrations to a DB with pre-existing auth users: ```sql INSERT INTO public.users (id, email) diff --git a/supabase/migrations/00069_least_privilege_table_grants.sql b/supabase/migrations/00069_least_privilege_table_grants.sql new file mode 100644 index 00000000..bff8d9dd --- /dev/null +++ b/supabase/migrations/00069_least_privilege_table_grants.sql @@ -0,0 +1,27 @@ +-- Supabase's default ACL for tables created by postgres hands anon and +-- authenticated every privilege; 00025 only took insert/update/delete back +-- from anon. TRUNCATE ignores RLS, and REFERENCES, TRIGGER and MAINTAIN are +-- owner tools no request needs. +revoke truncate, references, trigger, maintain on all tables in schema public from anon, authenticated; + +-- RLS allows a DELETE only where a DELETE policy exists, and the one such +-- policy is team_members_leader_delete. Every other delete in the app goes +-- through a SECURITY DEFINER RPC or the service role. +revoke delete on all tables in schema public from anon, authenticated; +grant delete on public.team_members to authenticated; + +-- The public_* views are owned by postgres, so a write through them skips +-- RLS on the base table. public_profiles is auto-updatable: with the +-- insert/update/delete the default ACL gave authenticated, any signed-in +-- user could rewrite or delete another participant's users row. +revoke all on public.public_profiles, public.public_submissions, + public.public_team_members, public.public_schedule from anon, authenticated; +grant select on public.public_profiles, public.public_submissions, + public.public_team_members, public.public_schedule to anon, authenticated; + +-- New tables stop inheriting the same set. A table that needs DELETE grants +-- it next to the policy that allows it. +alter default privileges in schema public + revoke truncate, references, trigger, maintain on tables from anon, authenticated; +alter default privileges in schema public + revoke delete on tables from anon, authenticated;