diff --git a/supabase/migrations/20260808000002_booked_slots_trigger.sql b/supabase/migrations/20260808000002_booked_slots_trigger.sql index 959ebf6..6814819 100644 --- a/supabase/migrations/20260808000002_booked_slots_trigger.sql +++ b/supabase/migrations/20260808000002_booked_slots_trigger.sql @@ -3,8 +3,32 @@ -- anon/authenticated/service_role, so without these GRANTs the RLS policies -- from the initial schema migration are unreachable (every request fails at -- the privilege check before RLS is even evaluated). This is the documented --- "explicit grants + RLS" pattern Supabase recommends going forward; the --- actual access is still fully governed by each table's RLS policies. +-- "explicit grants + RLS" pattern Supabase recommends going forward. +-- +-- Unlike the previous blanket grant, these privileges are scoped to match +-- what each role's RLS policies actually permit, so table-level GRANTs are a +-- real second layer of defense rather than a no-op rubber stamp on top of +-- RLS: anon/authenticated can only select the public-read tables and insert +-- into bookings/inquiries; only authenticated (narrowed further by RLS's +-- admin-only policies to the single admin UID) gets full read/write on +-- everything. service_role still needs an explicit full grant here: table +-- privileges and RLS are two independent checks in Postgres — service_role's +-- BYPASSRLS role attribute only skips the RLS policy check, it does not +-- substitute for the base GRANT privilege check, so without this it cannot +-- touch these tables at all (e.g. the test suite's service_role seed step). +grant select on + public.products, + public.services, + public.schedule_template, + public.schedule_exceptions, + public.booked_slots +to anon, authenticated; + +grant insert on + public.bookings, + public.inquiries +to anon, authenticated; + grant select, insert, update, delete on public.products, public.services, @@ -13,7 +37,7 @@ grant select, insert, update, delete on public.bookings, public.booked_slots, public.inquiries -to anon, authenticated, service_role; +to authenticated, service_role; -- Keeps the public, PII-free booked_slots table in sync whenever a booking -- is created. security definer + explicit search_path per Supabase's diff --git a/supabase/tests/database/rls.test.sql b/supabase/tests/database/rls.test.sql index c4ceab3..e17b45a 100644 --- a/supabase/tests/database/rls.test.sql +++ b/supabase/tests/database/rls.test.sql @@ -1,5 +1,5 @@ begin; -select plan(11); +select plan(12); -- Seed reference data as service_role (bypasses RLS) set local role service_role; @@ -38,12 +38,24 @@ select lives_ok( 'anon can create a booking' ); --- Anon cannot read bookings (PII protection) -select is_empty( +-- Anon cannot read bookings (PII protection). No SELECT privilege is granted +-- to anon on this table at all now, so this is blocked at the grant layer +-- (permission denied) rather than merely filtered to empty rows by RLS. +select throws_ok( $$ select * from public.bookings $$, + '42501', + null, 'anon cannot read bookings' ); +-- Anon cannot update bookings (no UPDATE privilege granted, regardless of RLS) +select throws_ok( + $$ update public.bookings set contact_notes = 'hacked' $$, + '42501', + null, + 'anon cannot update bookings' +); + -- Anyone can create an inquiry select lives_ok( $$ insert into public.inquiries (product_id, contact_name, contact_email, contact_notes) @@ -51,9 +63,12 @@ select lives_ok( 'anon can create an inquiry' ); --- Anon cannot read inquiries (PII protection) -select is_empty( +-- Anon cannot read inquiries (PII protection). Same as bookings above: no +-- SELECT privilege is granted, so this fails at the grant layer. +select throws_ok( $$ select * from public.inquiries $$, + '42501', + null, 'anon cannot read inquiries' );