diff --git a/supabase/migrations/20260826101006_channel_overrides_require_membership.sql b/supabase/migrations/20260826101006_channel_overrides_require_membership.sql new file mode 100644 index 0000000000..568a163644 --- /dev/null +++ b/supabase/migrations/20260826101006_channel_overrides_require_membership.sql @@ -0,0 +1,141 @@ +-- GHSA-626c-p6fq-3whq: channel permission overrides must target a principal +-- that already belongs to the channel's owner org. Caller still needs +-- app.update_user_roles. Membership matches rbac_principal_has_org_binding +-- (any non-expired org/app/channel binding, or user via group binding, or +-- group in org). SECURITY DEFINER wrapper so the check is not filtered by +-- groups/role_bindings RLS (app-scoped admins cannot SELECT those rows). +-- Join channels -> apps for owner_org, same as the previous policies. + +CREATE SCHEMA IF NOT EXISTS rbac_internal; + +CREATE OR REPLACE FUNCTION rbac_internal.channel_override_principal_in_org( + p_principal_type text, + p_principal_id uuid, + p_org_id uuid +) +RETURNS boolean +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path = '' +AS $$ + SELECT public.rbac_principal_has_org_binding( + p_principal_type, + p_principal_id, + p_org_id + ); +$$; + +ALTER FUNCTION rbac_internal.channel_override_principal_in_org(text, uuid, uuid) + OWNER TO postgres; +REVOKE ALL ON FUNCTION rbac_internal.channel_override_principal_in_org(text, uuid, uuid) + FROM PUBLIC; +GRANT USAGE ON SCHEMA rbac_internal TO authenticated, service_role; +GRANT EXECUTE ON FUNCTION rbac_internal.channel_override_principal_in_org(text, uuid, uuid) + TO authenticated, service_role; + +COMMENT ON FUNCTION rbac_internal.channel_override_principal_in_org(text, uuid, uuid) IS + 'RLS helper: delegates to rbac_principal_has_org_binding (any scope binding, ' + 'group membership, or group in org). Called from channel_permission_overrides ' + 'INSERT/UPDATE policies and cleanup DELETE (once per written row, ' + 'authenticated only). SECURITY DEFINER so app-scoped admins are not blocked ' + 'by groups/role_bindings SELECT RLS. Lives in rbac_internal (not ' + 'PostgREST-exposed).'; + +DROP FUNCTION IF EXISTS public.channel_override_principal_in_org(text, uuid, uuid); + +DELETE FROM public.channel_permission_overrides AS overrides +USING public.channels AS channels +JOIN public.apps AS apps + ON channels.app_id::text = apps.app_id::text +WHERE overrides.channel_id = channels.id + AND NOT rbac_internal.channel_override_principal_in_org( + overrides.principal_type, + overrides.principal_id, + apps.owner_org + ); + +DROP POLICY IF EXISTS "channel_permission_overrides_admin_insert" + ON public.channel_permission_overrides; +CREATE POLICY "channel_permission_overrides_admin_insert" +ON public.channel_permission_overrides +FOR INSERT +TO authenticated +WITH CHECK ( + EXISTS ( + SELECT 1 + FROM public.channels + JOIN public.apps + ON channels.app_id::text = apps.app_id::text + WHERE channels.id = channel_permission_overrides.channel_id + AND public.rbac_check_permission( + public.rbac_perm_app_update_user_roles(), + apps.owner_org, + apps.app_id, + NULL::bigint + ) + AND rbac_internal.channel_override_principal_in_org( + channel_permission_overrides.principal_type, + channel_permission_overrides.principal_id, + apps.owner_org + ) + ) +); + +COMMENT ON POLICY "channel_permission_overrides_admin_insert" + ON public.channel_permission_overrides IS + 'Authenticated app admins can insert channel permission overrides only for ' + 'principals that belong to the channel org.'; + +DROP POLICY IF EXISTS "channel_permission_overrides_admin_update" + ON public.channel_permission_overrides; +CREATE POLICY "channel_permission_overrides_admin_update" +ON public.channel_permission_overrides +FOR UPDATE +TO authenticated +USING ( + EXISTS ( + SELECT 1 + FROM public.channels + JOIN public.apps + ON channels.app_id::text = apps.app_id::text + WHERE channels.id = channel_permission_overrides.channel_id + AND public.rbac_check_permission( + public.rbac_perm_app_update_user_roles(), + apps.owner_org, + apps.app_id, + NULL::bigint + ) + AND rbac_internal.channel_override_principal_in_org( + channel_permission_overrides.principal_type, + channel_permission_overrides.principal_id, + apps.owner_org + ) + ) +) +WITH CHECK ( + EXISTS ( + SELECT 1 + FROM public.channels + JOIN public.apps + ON channels.app_id::text = apps.app_id::text + WHERE channels.id = channel_permission_overrides.channel_id + AND public.rbac_check_permission( + public.rbac_perm_app_update_user_roles(), + apps.owner_org, + apps.app_id, + NULL::bigint + ) + AND rbac_internal.channel_override_principal_in_org( + channel_permission_overrides.principal_type, + channel_permission_overrides.principal_id, + apps.owner_org + ) + ) +); + +COMMENT ON POLICY "channel_permission_overrides_admin_update" + ON public.channel_permission_overrides IS + 'Authenticated app admins can update channel permission overrides only for ' + 'principals that belong to the channel org. Legacy outsider rows are deleted ' + 'by this migration; remaining rows must still target org members.'; diff --git a/supabase/tests/69_test_channel_overrides_require_membership.sql b/supabase/tests/69_test_channel_overrides_require_membership.sql new file mode 100644 index 0000000000..c0a8077b75 --- /dev/null +++ b/supabase/tests/69_test_channel_overrides_require_membership.sql @@ -0,0 +1,484 @@ +-- GHSA-626c-p6fq-3whq: app admins cannot grant channel permission overrides +-- to principals outside the channel owner org. +BEGIN; + +SELECT plan(12); + +SELECT tests.create_supabase_user('channel_override_admin', 'channel_override_admin@test.local'); +SELECT tests.create_supabase_user('channel_override_member', 'channel_override_member@test.local'); +SELECT tests.create_supabase_user('channel_override_outsider', 'channel_override_outsider@test.local'); +SELECT tests.create_supabase_user('channel_override_app_admin', 'channel_override_app_admin@test.local'); +SELECT tests.create_supabase_user('channel_override_app_member', 'channel_override_app_member@test.local'); +SELECT tests.create_supabase_user('channel_override_channel_member', 'channel_override_channel_member@test.local'); +SELECT tests.create_supabase_user('channel_override_group_user', 'channel_override_group_user@test.local'); + +INSERT INTO public.users (id, email, created_at, updated_at) +VALUES + (tests.get_supabase_uid('channel_override_admin'), 'channel_override_admin@test.local', NOW(), NOW()), + (tests.get_supabase_uid('channel_override_member'), 'channel_override_member@test.local', NOW(), NOW()), + (tests.get_supabase_uid('channel_override_outsider'), 'channel_override_outsider@test.local', NOW(), NOW()), + (tests.get_supabase_uid('channel_override_app_admin'), 'channel_override_app_admin@test.local', NOW(), NOW()), + (tests.get_supabase_uid('channel_override_app_member'), 'channel_override_app_member@test.local', NOW(), NOW()), + (tests.get_supabase_uid('channel_override_channel_member'), 'channel_override_channel_member@test.local', NOW(), NOW()), + (tests.get_supabase_uid('channel_override_group_user'), 'channel_override_group_user@test.local', NOW(), NOW()) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO public.orgs (id, created_by, name, management_email) +VALUES + ( + '69000000-0000-4000-8000-000000000069', + tests.get_supabase_uid('channel_override_admin'), + 'Channel override membership org', + 'channel-override-membership@test.local' + ), + ( + '69000000-0000-4000-8000-000000000070', + tests.get_supabase_uid('channel_override_outsider'), + 'Channel override outsider org', + 'channel-override-outsider@test.local' + ) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO public.apps (app_id, icon_url, user_id, name, owner_org) +VALUES ( + 'com.test.channel.overrides.membership', + '', + tests.get_supabase_uid('channel_override_admin'), + 'Channel override membership app', + '69000000-0000-4000-8000-000000000069' +) +ON CONFLICT (app_id) DO NOTHING; + +INSERT INTO public.channels (id, name, app_id, owner_org, created_by) +VALUES ( + 6900401, + 'overrides-membership', + 'com.test.channel.overrides.membership', + '69000000-0000-4000-8000-000000000069', + tests.get_supabase_uid('channel_override_admin') +) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO public.role_bindings ( + principal_type, + principal_id, + role_id, + scope_type, + org_id, + granted_by, + reason, + is_direct +) +SELECT + public.rbac_principal_user(), + tests.get_supabase_uid('channel_override_member'), + roles.id, + public.rbac_scope_org(), + '69000000-0000-4000-8000-000000000069'::uuid, + tests.get_supabase_uid('channel_override_admin'), + 'pgTAP channel override member fixture', + true +FROM public.roles +WHERE roles.name = public.rbac_role_org_member() + AND roles.scope_type = public.rbac_scope_org() +ON CONFLICT DO NOTHING; + +INSERT INTO public.role_bindings ( + principal_type, + principal_id, + role_id, + scope_type, + org_id, + app_id, + granted_by, + reason, + is_direct +) +SELECT + public.rbac_principal_user(), + tests.get_supabase_uid('channel_override_app_admin'), + roles.id, + public.rbac_scope_app(), + '69000000-0000-4000-8000-000000000069'::uuid, + apps.id, + tests.get_supabase_uid('channel_override_admin'), + 'pgTAP channel override app admin fixture', + true +FROM public.roles +CROSS JOIN public.apps +WHERE roles.name = public.rbac_role_app_admin() + AND roles.scope_type = public.rbac_scope_app() + AND apps.app_id = 'com.test.channel.overrides.membership' +ON CONFLICT DO NOTHING; + +INSERT INTO public.role_bindings ( + principal_type, + principal_id, + role_id, + scope_type, + org_id, + app_id, + granted_by, + reason, + is_direct +) +SELECT + public.rbac_principal_user(), + tests.get_supabase_uid('channel_override_app_member'), + roles.id, + public.rbac_scope_app(), + '69000000-0000-4000-8000-000000000069'::uuid, + apps.id, + tests.get_supabase_uid('channel_override_admin'), + 'pgTAP channel override app member fixture', + true +FROM public.roles +CROSS JOIN public.apps +WHERE roles.name = public.rbac_role_app_developer() + AND roles.scope_type = public.rbac_scope_app() + AND apps.app_id = 'com.test.channel.overrides.membership' +ON CONFLICT DO NOTHING; + +INSERT INTO public.role_bindings ( + principal_type, + principal_id, + role_id, + scope_type, + org_id, + app_id, + channel_id, + granted_by, + reason, + is_direct +) +SELECT + public.rbac_principal_user(), + tests.get_supabase_uid('channel_override_channel_member'), + roles.id, + public.rbac_scope_channel(), + '69000000-0000-4000-8000-000000000069'::uuid, + apps.id, + channels.rbac_id, + tests.get_supabase_uid('channel_override_admin'), + 'pgTAP channel override channel member fixture', + true +FROM public.roles +CROSS JOIN public.apps +CROSS JOIN public.channels +WHERE roles.name = public.rbac_role_channel_reader() + AND roles.scope_type = public.rbac_scope_channel() + AND apps.app_id = 'com.test.channel.overrides.membership' + AND channels.id = 6900401 +ON CONFLICT DO NOTHING; + +INSERT INTO public.groups (id, org_id, name, created_by) +VALUES + ( + '69000000-0000-4000-8000-000000000071', + '69000000-0000-4000-8000-000000000069', + 'Channel override member group', + tests.get_supabase_uid('channel_override_admin') + ), + ( + '69000000-0000-4000-8000-000000000072', + '69000000-0000-4000-8000-000000000070', + 'Channel override outsider group', + tests.get_supabase_uid('channel_override_outsider') + ) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO public.role_bindings ( + principal_type, + principal_id, + role_id, + scope_type, + org_id, + granted_by, + reason, + is_direct +) +SELECT + public.rbac_principal_group(), + '69000000-0000-4000-8000-000000000071'::uuid, + roles.id, + public.rbac_scope_org(), + '69000000-0000-4000-8000-000000000069'::uuid, + tests.get_supabase_uid('channel_override_admin'), + 'pgTAP channel override group member fixture', + true +FROM public.roles +WHERE roles.name = public.rbac_role_org_member() + AND roles.scope_type = public.rbac_scope_org() +ON CONFLICT DO NOTHING; + +INSERT INTO public.group_members (group_id, user_id, added_by) +VALUES ( + '69000000-0000-4000-8000-000000000071', + tests.get_supabase_uid('channel_override_group_user'), + tests.get_supabase_uid('channel_override_admin') +) +ON CONFLICT DO NOTHING; + +SELECT tests.create_v2_apikey( + 690040001, + tests.get_supabase_uid('channel_override_admin'), + 'channel-override-member-apikey', + 'Channel override member apikey', + '69000000-0000-4000-8000-000000000069', + public.rbac_role_org_member() +); + +SELECT tests.create_v2_apikey( + 690040002, + tests.get_supabase_uid('channel_override_outsider'), + 'channel-override-outsider-apikey', + 'Channel override outsider apikey', + '69000000-0000-4000-8000-000000000070', + public.rbac_role_org_member() +); + +CREATE TEMP TABLE channel_override_apikey_rbac AS +SELECT apikeys.id, apikeys.rbac_id +FROM public.apikeys +WHERE apikeys.id IN (690040001, 690040002); + +GRANT SELECT ON channel_override_apikey_rbac TO authenticated; + +SELECT tests.authenticate_as('channel_override_admin'); + +SELECT throws_ok( + $$ + INSERT INTO public.channel_permission_overrides ( + principal_type, + principal_id, + channel_id, + permission_key, + is_allowed + ) + VALUES ( + public.rbac_principal_user(), + tests.get_supabase_uid('channel_override_outsider'), + 6900401, + public.rbac_perm_channel_promote_bundle(), + true + ) + $$, + '42501', + 'new row violates row-level security policy for table "channel_permission_overrides"', + 'admin cannot insert a channel permission override for a non-member user' +); + +SELECT lives_ok( + $$ + INSERT INTO public.channel_permission_overrides ( + principal_type, + principal_id, + channel_id, + permission_key, + is_allowed + ) + VALUES ( + public.rbac_principal_user(), + tests.get_supabase_uid('channel_override_member'), + 6900401, + public.rbac_perm_channel_promote_bundle(), + true + ) + $$, + 'admin can insert a channel permission override for an org member' +); + +SELECT throws_ok( + $$ + UPDATE public.channel_permission_overrides + SET principal_id = tests.get_supabase_uid('channel_override_outsider') + WHERE channel_id = 6900401 + AND principal_id = tests.get_supabase_uid('channel_override_member') + AND permission_key = public.rbac_perm_channel_promote_bundle() + $$, + '42501', + 'new row violates row-level security policy for table "channel_permission_overrides"', + 'admin cannot retarget a channel permission override to a non-member user' +); + +SELECT lives_ok( + $$ + UPDATE public.channel_permission_overrides + SET is_allowed = false + WHERE channel_id = 6900401 + AND principal_id = tests.get_supabase_uid('channel_override_member') + AND permission_key = public.rbac_perm_channel_promote_bundle() + $$, + 'admin can update a channel permission override that still targets an org member' +); + +SELECT throws_ok( + $$ + INSERT INTO public.channel_permission_overrides ( + principal_type, + principal_id, + channel_id, + permission_key, + is_allowed + ) + VALUES ( + public.rbac_principal_group(), + '69000000-0000-4000-8000-000000000072', + 6900401, + public.rbac_perm_channel_promote_bundle(), + true + ) + $$, + '42501', + 'new row violates row-level security policy for table "channel_permission_overrides"', + 'admin cannot insert a channel permission override for an outsider group' +); + +SELECT lives_ok( + $$ + INSERT INTO public.channel_permission_overrides ( + principal_type, + principal_id, + channel_id, + permission_key, + is_allowed + ) + VALUES ( + public.rbac_principal_group(), + '69000000-0000-4000-8000-000000000071', + 6900401, + public.rbac_perm_channel_promote_bundle(), + true + ) + $$, + 'admin can insert a channel permission override for an org group' +); + +SELECT throws_ok( + $$ + INSERT INTO public.channel_permission_overrides ( + principal_type, + principal_id, + channel_id, + permission_key, + is_allowed + ) + VALUES ( + public.rbac_principal_apikey(), + (SELECT rbac_id FROM channel_override_apikey_rbac WHERE id = 690040002), + 6900401, + public.rbac_perm_channel_promote_bundle(), + true + ) + $$, + '42501', + 'new row violates row-level security policy for table "channel_permission_overrides"', + 'admin cannot insert a channel permission override for an outsider apikey' +); + +SELECT lives_ok( + $$ + INSERT INTO public.channel_permission_overrides ( + principal_type, + principal_id, + channel_id, + permission_key, + is_allowed + ) + VALUES ( + public.rbac_principal_apikey(), + (SELECT rbac_id FROM channel_override_apikey_rbac WHERE id = 690040001), + 6900401, + public.rbac_perm_channel_rollback_bundle(), + true + ) + $$, + 'admin can insert a channel permission override for an org apikey' +); + +SELECT lives_ok( + $$ + INSERT INTO public.channel_permission_overrides ( + principal_type, + principal_id, + channel_id, + permission_key, + is_allowed + ) + VALUES ( + public.rbac_principal_user(), + tests.get_supabase_uid('channel_override_app_member'), + 6900401, + public.rbac_perm_channel_promote_bundle(), + true + ) + $$, + 'admin can insert a channel permission override for an app-scoped member' +); + +SELECT lives_ok( + $$ + INSERT INTO public.channel_permission_overrides ( + principal_type, + principal_id, + channel_id, + permission_key, + is_allowed + ) + VALUES ( + public.rbac_principal_user(), + tests.get_supabase_uid('channel_override_channel_member'), + 6900401, + public.rbac_perm_channel_promote_bundle(), + true + ) + $$, + 'admin can insert a channel permission override for a channel-scoped member' +); + +SELECT lives_ok( + $$ + INSERT INTO public.channel_permission_overrides ( + principal_type, + principal_id, + channel_id, + permission_key, + is_allowed + ) + VALUES ( + public.rbac_principal_user(), + tests.get_supabase_uid('channel_override_group_user'), + 6900401, + public.rbac_perm_channel_promote_bundle(), + true + ) + $$, + 'admin can insert a channel permission override for a group-only member user' +); + +SELECT tests.authenticate_as('channel_override_app_admin'); + +SELECT lives_ok( + $$ + INSERT INTO public.channel_permission_overrides ( + principal_type, + principal_id, + channel_id, + permission_key, + is_allowed + ) + VALUES ( + public.rbac_principal_user(), + tests.get_supabase_uid('channel_override_member'), + 6900401, + public.rbac_perm_channel_rollback_bundle(), + true + ) + $$, + 'app-scoped admin can insert a channel permission override for an org member' +); + +SELECT tests.clear_authentication(); + +SELECT * FROM finish(); +ROLLBACK;