diff --git a/supabase/migrations/20260227000000_restrict_global_stats_public_access.sql b/supabase/migrations/20260227000000_restrict_global_stats_public_access.sql new file mode 100644 index 0000000000..7788ebcaa7 --- /dev/null +++ b/supabase/migrations/20260227000000_restrict_global_stats_public_access.sql @@ -0,0 +1,27 @@ +-- ============================================================================= +-- Migration: Restrict global_stats access to platform admins only +-- ============================================================================= + +-- Remove the permissive anonymous read policy that currently exposes KPI data. +DROP POLICY IF EXISTS "Allow anon to select" ON public.global_stats; + +-- Replace with an admin-only read policy. +DROP POLICY IF EXISTS "Deny anon and authenticated reads" ON public.global_stats; +DROP POLICY IF EXISTS "Allow admin users to select global_stats" ON public.global_stats; +CREATE POLICY "Allow admin users to select global_stats" +ON public.global_stats +FOR SELECT TO authenticated +USING ( + EXISTS ( + SELECT + 1 + FROM + (SELECT auth.uid() AS uid) AS auth_user + WHERE + public.is_admin(auth_user.uid) + ) +); + +-- Remove table privileges for low-trust roles. +REVOKE ALL PRIVILEGES ON TABLE public.global_stats FROM anon, authenticated; +GRANT SELECT ON TABLE public.global_stats TO authenticated; diff --git a/supabase/tests/26_test_rls_policies.sql b/supabase/tests/26_test_rls_policies.sql index 6e1f22f64a..8b9e1c1cdd 100644 --- a/supabase/tests/26_test_rls_policies.sql +++ b/supabase/tests/26_test_rls_policies.sql @@ -41,7 +41,7 @@ SELECT policies_are( 'public', 'global_stats', - ARRAY['Allow anon to select'], + ARRAY['Allow admin users to select global_stats'], 'global_stats should have correct policies' ); diff --git a/supabase/tests/27_test_rls_scenarios.sql b/supabase/tests/27_test_rls_scenarios.sql index 6df123f892..019730e3ae 100644 --- a/supabase/tests/27_test_rls_scenarios.sql +++ b/supabase/tests/27_test_rls_scenarios.sql @@ -13,7 +13,7 @@ BEGIN; -- 'com.demoadmin.app', 'com.demo.app' -- Plan tests SELECT - plan (6); + plan (8); -- Test 1: Users can see organizations they belong to SET @@ -55,11 +55,42 @@ SELECT 'Anonymous users should be able to select from plans table' ); --- Test 4: Global stats is accessible to anonymous +-- Test 4: Anonymous users should not access global_stats +SELECT + throws_ok ( + 'SELECT COUNT(*) FROM public.global_stats', + '42501', + 'permission denied', + 'Anonymous users should not be able to select from global_stats' + ); +-- Test 4b: Authenticated users should not be able to read global_stats +SET + LOCAL role TO authenticated; +SET + LOCAL request.jwt.claims TO '{"sub": "6aa76066-55ef-4238-ade6-0b32334a4097"}'; + +SELECT + is ( + ( + SELECT + COUNT(*) + FROM + public.global_stats + ), + 0::bigint, + 'Authenticated non-admin users should see no rows in global_stats' + ); + +-- Test 4c: Non-admin can be replaced with admin and still query this table +SET + LOCAL role TO authenticated; +SET + LOCAL request.jwt.claims TO '{"sub": "c591b04e-cf29-4945-b9a0-776d0672061a"}'; + SELECT lives_ok ( 'SELECT COUNT(*) FROM public.global_stats', - 'Anonymous users should be able to select from global_stats' + 'Admin users should be able to select from global_stats' ); -- Test 5: Users table has RLS enabled