diff --git a/supabase/migrations/20260302000000_rbac_default_for_new_orgs.sql b/supabase/migrations/20260302000000_rbac_default_for_new_orgs.sql new file mode 100644 index 0000000000..61780df24a --- /dev/null +++ b/supabase/migrations/20260302000000_rbac_default_for_new_orgs.sql @@ -0,0 +1,3 @@ +-- Make RBAC the default for all newly created organizations. +-- Existing orgs are not affected (their current use_new_rbac value is preserved). +ALTER TABLE public.orgs ALTER COLUMN use_new_rbac SET DEFAULT true; diff --git a/supabase/migrations/20260302185011_fix_rbac_check_effective_user.sql b/supabase/migrations/20260302185011_fix_rbac_check_effective_user.sql new file mode 100644 index 0000000000..790d4d62d0 --- /dev/null +++ b/supabase/migrations/20260302185011_fix_rbac_check_effective_user.sql @@ -0,0 +1,304 @@ +-- Fix rbac_check_permission_direct: use v_effective_user_id instead of p_user_id +-- in the RBAC path. When called via API key auth (auth.uid() = NULL), the function +-- resolves the user from the API key into v_effective_user_id, but the RBAC path +-- was still checking p_user_id (the original NULL parameter), causing permission +-- checks to be skipped entirely for API key authenticated requests. +-- +-- The _no_password_policy variant already uses v_effective_user_id correctly. + +CREATE OR REPLACE FUNCTION "public"."rbac_check_permission_direct"("p_permission_key" "text", "p_user_id" "uuid", "p_org_id" "uuid", "p_app_id" character varying, "p_channel_id" bigint, "p_apikey" "text" DEFAULT NULL::"text") RETURNS boolean + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_allowed boolean := false; + v_use_rbac boolean; + v_effective_org_id uuid := p_org_id; + v_effective_user_id uuid := p_user_id; + v_legacy_right public.user_min_right; + v_apikey_principal uuid; + v_override boolean; + v_channel_scope boolean := false; + v_org_enforcing_2fa boolean; + v_password_policy_ok boolean; +BEGIN + -- Validate permission key + IF p_permission_key IS NULL OR p_permission_key = '' THEN + PERFORM public.pg_log('deny: RBAC_CHECK_PERM_NO_KEY', jsonb_build_object('user_id', p_user_id)); + RETURN false; + END IF; + + IF p_channel_id IS NOT NULL AND p_permission_key LIKE 'channel.%' THEN + v_channel_scope := true; + END IF; + + -- Derive org from app/channel when not provided + IF v_effective_org_id IS NULL AND p_app_id IS NOT NULL THEN + SELECT owner_org INTO v_effective_org_id + FROM public.apps + WHERE app_id = p_app_id + LIMIT 1; + END IF; + + IF v_effective_org_id IS NULL AND p_channel_id IS NOT NULL THEN + SELECT owner_org INTO v_effective_org_id + FROM public.channels + WHERE id = p_channel_id + LIMIT 1; + END IF; + + -- Resolve user from API key when needed (handles hashed keys too). + IF v_effective_user_id IS NULL AND p_apikey IS NOT NULL THEN + SELECT user_id INTO v_effective_user_id + FROM public.find_apikey_by_value(p_apikey) + LIMIT 1; + END IF; + + -- Enforce 2FA if the org requires it. + IF v_effective_org_id IS NOT NULL THEN + SELECT enforcing_2fa INTO v_org_enforcing_2fa + FROM public.orgs + WHERE id = v_effective_org_id; + + IF v_org_enforcing_2fa = true AND (v_effective_user_id IS NULL OR NOT public.has_2fa_enabled(v_effective_user_id)) THEN + PERFORM public.pg_log('deny: RBAC_CHECK_PERM_2FA_ENFORCEMENT', jsonb_build_object( + 'permission', p_permission_key, + 'org_id', v_effective_org_id, + 'app_id', p_app_id, + 'channel_id', p_channel_id, + 'user_id', v_effective_user_id, + 'has_apikey', p_apikey IS NOT NULL + )); + RETURN false; + END IF; + END IF; + + -- Enforce password policy if enabled for the org. + IF v_effective_org_id IS NOT NULL THEN + v_password_policy_ok := public.user_meets_password_policy(v_effective_user_id, v_effective_org_id); + IF v_password_policy_ok = false THEN + PERFORM public.pg_log('deny: RBAC_CHECK_PERM_PASSWORD_POLICY_ENFORCEMENT', jsonb_build_object( + 'permission', p_permission_key, + 'org_id', v_effective_org_id, + 'app_id', p_app_id, + 'channel_id', p_channel_id, + 'user_id', v_effective_user_id, + 'has_apikey', p_apikey IS NOT NULL + )); + RETURN false; + END IF; + END IF; + + -- Check if RBAC is enabled for this org + v_use_rbac := public.rbac_is_enabled_for_org(v_effective_org_id); + + IF v_use_rbac THEN + -- RBAC path: Check user permission directly (use v_effective_user_id, NOT p_user_id) + IF v_effective_user_id IS NOT NULL THEN + v_allowed := public.rbac_has_permission(public.rbac_principal_user(), v_effective_user_id, p_permission_key, v_effective_org_id, p_app_id, p_channel_id); + + IF v_channel_scope THEN + -- Direct user override + SELECT o.is_allowed INTO v_override + FROM public.channel_permission_overrides o + WHERE o.principal_type = public.rbac_principal_user() + AND o.principal_id = v_effective_user_id + AND o.channel_id = p_channel_id + AND o.permission_key = p_permission_key + LIMIT 1; + + IF v_override IS NOT NULL THEN + v_allowed := v_override; + ELSE + -- Group overrides (deny > allow) + IF EXISTS ( + SELECT 1 + FROM public.channel_permission_overrides o + JOIN public.group_members gm ON gm.group_id = o.principal_id AND gm.user_id = v_effective_user_id + JOIN public.groups g ON g.id = gm.group_id + WHERE o.principal_type = public.rbac_principal_group() + AND o.channel_id = p_channel_id + AND o.permission_key = p_permission_key + AND o.is_allowed = false + AND g.org_id = v_effective_org_id + ) THEN + v_allowed := false; + ELSIF EXISTS ( + SELECT 1 + FROM public.channel_permission_overrides o + JOIN public.group_members gm ON gm.group_id = o.principal_id AND gm.user_id = v_effective_user_id + JOIN public.groups g ON g.id = gm.group_id + WHERE o.principal_type = public.rbac_principal_group() + AND o.channel_id = p_channel_id + AND o.permission_key = p_permission_key + AND o.is_allowed = true + AND g.org_id = v_effective_org_id + ) THEN + v_allowed := true; + END IF; + END IF; + END IF; + END IF; + + -- If user doesn't have permission, check apikey permission + IF NOT v_allowed AND p_apikey IS NOT NULL THEN + SELECT rbac_id INTO v_apikey_principal + FROM public.find_apikey_by_value(p_apikey) + LIMIT 1; + + IF v_apikey_principal IS NOT NULL THEN + v_allowed := public.rbac_has_permission(public.rbac_principal_apikey(), v_apikey_principal, p_permission_key, v_effective_org_id, p_app_id, p_channel_id); + + IF v_channel_scope THEN + SELECT o.is_allowed INTO v_override + FROM public.channel_permission_overrides o + WHERE o.principal_type = public.rbac_principal_apikey() + AND o.principal_id = v_apikey_principal + AND o.channel_id = p_channel_id + AND o.permission_key = p_permission_key + LIMIT 1; + + IF v_override IS NOT NULL THEN + v_allowed := v_override; + END IF; + END IF; + END IF; + END IF; + + IF NOT v_allowed THEN + PERFORM public.pg_log('deny: RBAC_CHECK_PERM_DIRECT', jsonb_build_object( + 'permission', p_permission_key, + 'user_id', v_effective_user_id, + 'org_id', v_effective_org_id, + 'app_id', p_app_id, + 'channel_id', p_channel_id, + 'has_apikey', p_apikey IS NOT NULL + )); + END IF; + + RETURN v_allowed; + ELSE + -- Legacy path: Map permission to min_right and use legacy check + v_legacy_right := public.rbac_legacy_right_for_permission(p_permission_key); + + IF v_legacy_right IS NULL THEN + PERFORM public.pg_log('deny: RBAC_CHECK_PERM_UNKNOWN_LEGACY', jsonb_build_object( + 'permission', p_permission_key, + 'user_id', p_user_id + )); + RETURN false; + END IF; + + IF p_apikey IS NOT NULL AND p_app_id IS NOT NULL THEN + RETURN public.has_app_right_apikey(p_app_id, v_legacy_right, p_user_id, p_apikey); + ELSIF p_app_id IS NOT NULL THEN + RETURN public.has_app_right_userid(p_app_id, v_legacy_right, p_user_id); + ELSE + RETURN public.check_min_rights_legacy(v_legacy_right, p_user_id, v_effective_org_id, p_app_id, p_channel_id); + END IF; + END IF; +END; +$$; + +-- Fix invite_user_to_org_rbac: create role_binding directly after org_users insert. +-- The sync trigger (sync_org_user_to_role_binding_on_insert) intentionally skips +-- role_binding creation when use_new_rbac=true AND rbac_role_name IS NOT NULL, +-- expecting the caller to handle it. This function must create the binding itself. + +CREATE OR REPLACE FUNCTION "public"."invite_user_to_org_rbac"("email" character varying, "org_id" "uuid", "role_name" "text") RETURNS character varying + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + org record; + invited_user record; + current_record record; + current_tmp_user record; + role_id uuid; + legacy_right public.user_min_right; + invite_right public.user_min_right; + api_key_text text; + v_granted_by uuid; +BEGIN + SELECT * INTO org FROM public.orgs WHERE public.orgs.id = invite_user_to_org_rbac.org_id; + IF org IS NULL THEN + RETURN 'NO_ORG'; + END IF; + + IF NOT public.rbac_is_enabled_for_org(invite_user_to_org_rbac.org_id) THEN + RETURN 'RBAC_NOT_ENABLED'; + END IF; + + SELECT id INTO role_id + FROM public.roles r + WHERE r.name = invite_user_to_org_rbac.role_name + AND r.scope_type = public.rbac_scope_org() + AND r.is_assignable = true + LIMIT 1; + + IF role_id IS NULL THEN + RETURN 'ROLE_NOT_FOUND'; + END IF; + + SELECT public.get_apikey_header() INTO api_key_text; + + IF invite_user_to_org_rbac.role_name = public.rbac_role_org_super_admin() THEN + IF NOT public.rbac_check_permission_direct(public.rbac_perm_org_update_user_roles(), auth.uid(), invite_user_to_org_rbac.org_id, NULL, NULL, api_key_text) THEN + RETURN 'NO_RIGHTS'; + END IF; + ELSE + IF NOT public.rbac_check_permission_direct(public.rbac_perm_org_invite_user(), auth.uid(), invite_user_to_org_rbac.org_id, NULL, NULL, api_key_text) THEN + RETURN 'NO_RIGHTS'; + END IF; + END IF; + + legacy_right := public.rbac_legacy_right_for_org_role(invite_user_to_org_rbac.role_name); + invite_right := public.transform_role_to_invite(legacy_right); + v_granted_by := COALESCE(auth.uid(), (SELECT user_id FROM public.find_apikey_by_value(api_key_text) LIMIT 1)); + + SELECT public.users.id INTO invited_user FROM public.users WHERE public.users.email = invite_user_to_org_rbac.email; + + IF invited_user IS NOT NULL THEN + SELECT public.org_users.id INTO current_record + FROM public.org_users + WHERE public.org_users.user_id = invited_user.id + AND public.org_users.org_id = invite_user_to_org_rbac.org_id; + + IF current_record IS NOT NULL THEN + RETURN 'ALREADY_INVITED'; + ELSE + INSERT INTO public.org_users (user_id, org_id, user_right, rbac_role_name) + VALUES (invited_user.id, invite_user_to_org_rbac.org_id, invite_right, invite_user_to_org_rbac.role_name); + + INSERT INTO public.role_bindings ( + principal_type, principal_id, role_id, scope_type, org_id, + granted_by, granted_at, reason, is_direct + ) VALUES ( + public.rbac_principal_user(), invited_user.id, role_id, public.rbac_scope_org(), invite_user_to_org_rbac.org_id, + COALESCE(v_granted_by, invited_user.id), now(), 'Invited via invite_user_to_org_rbac', true + ) ON CONFLICT DO NOTHING; + + RETURN 'OK'; + END IF; + ELSE + SELECT * INTO current_tmp_user + FROM public.tmp_users + WHERE public.tmp_users.email = invite_user_to_org_rbac.email + AND public.tmp_users.org_id = invite_user_to_org_rbac.org_id; + + IF current_tmp_user IS NOT NULL THEN + IF current_tmp_user.cancelled_at IS NOT NULL THEN + IF current_tmp_user.cancelled_at > (CURRENT_TIMESTAMP - INTERVAL '3 hours') THEN + RETURN 'TOO_RECENT_INVITATION_CANCELATION'; + ELSE + RETURN 'NO_EMAIL'; + END IF; + ELSE + RETURN 'ALREADY_INVITED'; + END IF; + ELSE + RETURN 'NO_EMAIL'; + END IF; + END IF; +END; +$$; diff --git a/supabase/schemas/prod.sql b/supabase/schemas/prod.sql index f87d5ccba3..7166e0f786 100644 --- a/supabase/schemas/prod.sql +++ b/supabase/schemas/prod.sql @@ -12611,7 +12611,7 @@ CREATE TABLE IF NOT EXISTS "public"."orgs" ( "max_apikey_expiration_days" integer, "enforce_encrypted_bundles" boolean DEFAULT false NOT NULL, "required_encryption_key" character varying(21) DEFAULT NULL::character varying, - "use_new_rbac" boolean DEFAULT false NOT NULL, + "use_new_rbac" boolean DEFAULT true NOT NULL, "has_usage_credits" boolean DEFAULT false NOT NULL ); diff --git a/supabase/seed.sql b/supabase/seed.sql index 7287feee51..8d801e70de 100644 --- a/supabase/seed.sql +++ b/supabase/seed.sql @@ -272,22 +272,22 @@ BEGIN ALTER TABLE public.users ENABLE TRIGGER generate_org_on_user_create; ALTER TABLE public.orgs DISABLE TRIGGER generate_org_user_stripe_info_on_org_create; - INSERT INTO "public"."orgs" ("id", "created_by", "created_at", "updated_at", "logo", "name", "management_email", "customer_id") VALUES - ('22dbad8a-b885-4309-9b3b-a09f8460fb6d', 'c591b04e-cf29-4945-b9a0-776d0672061a', NOW(), NOW(), '', 'Admin org', 'admin@capgo.app', 'cus_Pa0k8TO6HVln6A'), - ('046a36ac-e03c-4590-9257-bd6c9dba9ee8', '6aa76066-55ef-4238-ade6-0b32334a4097', NOW(), NOW(), '', 'Demo org', 'test@capgo.app', 'cus_Q38uE91NP8Ufqc'), - ('34a8c55d-2d0f-4652-a43f-684c7a9403ac', '6f0d1a2e-59ed-4769-b9d7-4d9615b28fe5', NOW(), NOW(), '', 'Test2 org', 'test2@capgo.app', 'cus_Pa0f3M6UCQ8g5Q'), - ('a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d', '6f0d1a2e-59ed-4769-b9d7-4d9615b28fe5', NOW(), NOW(), '', 'Non-Owner Org', 'test2@capgo.app', 'cus_NonOwner'), - ('b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e', '7a1b2c3d-4e5f-4a6b-7c8d-9e0f1a2b3c4d', NOW(), NOW(), '', 'Stats Test Org', 'stats@capgo.app', 'cus_StatsTest'), - ('c3d4e5f6-a7b8-4c9d-8e0f-1a2b3c4d5e6f', '8b2c3d4e-5f6a-4b7c-8d9e-0f1a2b3c4d5e', NOW(), NOW(), '', 'RLS Test Org', 'rls@capgo.app', 'cus_RLSTest'), - ('d5e6f7a8-b9c0-4d1e-8f2a-3b4c5d6e7f80', '8b2c3d4e-5f6a-4b7c-8d9e-0f1a2b3c4d5e', NOW(), NOW(), '', 'RLS 2FA Test Org', 'rls@capgo.app', 'cus_2fa_rls_test_123'), - ('f6a7b8c9-d0e1-4f2a-9b3c-4d5e6f7a8b92', 'e5f6a7b8-c9d0-4e1f-8a2b-3c4d5e6f7a81', NOW(), NOW(), '', 'CLI Hashed Test Org', 'cli_hashed@capgo.app', 'cus_cli_hashed_test_123'), - ('a7b8c9d0-e1f2-4a3b-9c4d-5e6f7a8b9ca4', 'f6a7b8c9-d0e1-4f2a-9b3c-4d5e6f708193', NOW(), NOW(), '', 'Encrypted Test Org', 'encrypted@capgo.app', 'cus_encrypted_test_123'), - ('aa1b2c3d-4e5f-4a60-9b7c-1d2e3f4a5061', '9f1a2b3c-4d5e-4f60-8a7b-1c2d3e4f5061', NOW(), NOW(), '', 'Email Prefs Test Org', 'emailprefs@capgo.app', 'cus_email_prefs_test_123'), - ('b1c2d3e4-f5a6-4b70-8c9d-0e1f2a3b4c5d', '6aa76066-55ef-4238-ade6-0b32334a4097', NOW(), NOW(), '', 'Cron App Test Org', 'test@capgo.app', 'cus_cron_app_test_123'), - ('c2d3e4f5-a6b7-4c80-9d0e-1f2a3b4c5d6e', '6aa76066-55ef-4238-ade6-0b32334a4097', NOW(), NOW(), '', 'Cron Integration Test Org', 'test@capgo.app', 'cus_cron_integration_test_123'), - ('d3e4f5a6-b7c8-4d90-8e1f-2a3b4c5d6e7f', '6aa76066-55ef-4238-ade6-0b32334a4097', NOW(), NOW(), '', 'Cron Queue Test Org', 'test@capgo.app', 'cus_cron_queue_test_123'), - ('e4f5a6b7-c8d9-4ea0-9f1a-2b3c4d5e6f70', '6aa76066-55ef-4238-ade6-0b32334a4097', NOW(), NOW(), '', 'Overage Test Org', 'test@capgo.app', 'cus_overage_test_123'), - ('e5f6a7b8-c9d0-4e1f-9a2b-3c4d5e6f7a82', '6aa76066-55ef-4238-ade6-0b32334a4097', NOW(), NOW(), '', 'Private Error Test Org', 'test@capgo.app', NULL); + INSERT INTO "public"."orgs" ("id", "created_by", "created_at", "updated_at", "logo", "name", "management_email", "customer_id", "use_new_rbac") VALUES + ('22dbad8a-b885-4309-9b3b-a09f8460fb6d', 'c591b04e-cf29-4945-b9a0-776d0672061a', NOW(), NOW(), '', 'Admin org', 'admin@capgo.app', 'cus_Pa0k8TO6HVln6A', false), + ('046a36ac-e03c-4590-9257-bd6c9dba9ee8', '6aa76066-55ef-4238-ade6-0b32334a4097', NOW(), NOW(), '', 'Demo org', 'test@capgo.app', 'cus_Q38uE91NP8Ufqc', false), + ('34a8c55d-2d0f-4652-a43f-684c7a9403ac', '6f0d1a2e-59ed-4769-b9d7-4d9615b28fe5', NOW(), NOW(), '', 'Test2 org', 'test2@capgo.app', 'cus_Pa0f3M6UCQ8g5Q', false), + ('a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d', '6f0d1a2e-59ed-4769-b9d7-4d9615b28fe5', NOW(), NOW(), '', 'Non-Owner Org', 'test2@capgo.app', 'cus_NonOwner', false), + ('b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e', '7a1b2c3d-4e5f-4a6b-7c8d-9e0f1a2b3c4d', NOW(), NOW(), '', 'Stats Test Org', 'stats@capgo.app', 'cus_StatsTest', false), + ('c3d4e5f6-a7b8-4c9d-8e0f-1a2b3c4d5e6f', '8b2c3d4e-5f6a-4b7c-8d9e-0f1a2b3c4d5e', NOW(), NOW(), '', 'RLS Test Org', 'rls@capgo.app', 'cus_RLSTest', false), + ('d5e6f7a8-b9c0-4d1e-8f2a-3b4c5d6e7f80', '8b2c3d4e-5f6a-4b7c-8d9e-0f1a2b3c4d5e', NOW(), NOW(), '', 'RLS 2FA Test Org', 'rls@capgo.app', 'cus_2fa_rls_test_123', false), + ('f6a7b8c9-d0e1-4f2a-9b3c-4d5e6f7a8b92', 'e5f6a7b8-c9d0-4e1f-8a2b-3c4d5e6f7a81', NOW(), NOW(), '', 'CLI Hashed Test Org', 'cli_hashed@capgo.app', 'cus_cli_hashed_test_123', false), + ('a7b8c9d0-e1f2-4a3b-9c4d-5e6f7a8b9ca4', 'f6a7b8c9-d0e1-4f2a-9b3c-4d5e6f708193', NOW(), NOW(), '', 'Encrypted Test Org', 'encrypted@capgo.app', 'cus_encrypted_test_123', false), + ('aa1b2c3d-4e5f-4a60-9b7c-1d2e3f4a5061', '9f1a2b3c-4d5e-4f60-8a7b-1c2d3e4f5061', NOW(), NOW(), '', 'Email Prefs Test Org', 'emailprefs@capgo.app', 'cus_email_prefs_test_123', false), + ('b1c2d3e4-f5a6-4b70-8c9d-0e1f2a3b4c5d', '6aa76066-55ef-4238-ade6-0b32334a4097', NOW(), NOW(), '', 'Cron App Test Org', 'test@capgo.app', 'cus_cron_app_test_123', false), + ('c2d3e4f5-a6b7-4c80-9d0e-1f2a3b4c5d6e', '6aa76066-55ef-4238-ade6-0b32334a4097', NOW(), NOW(), '', 'Cron Integration Test Org', 'test@capgo.app', 'cus_cron_integration_test_123', false), + ('d3e4f5a6-b7c8-4d90-8e1f-2a3b4c5d6e7f', '6aa76066-55ef-4238-ade6-0b32334a4097', NOW(), NOW(), '', 'Cron Queue Test Org', 'test@capgo.app', 'cus_cron_queue_test_123', false), + ('e4f5a6b7-c8d9-4ea0-9f1a-2b3c4d5e6f70', '6aa76066-55ef-4238-ade6-0b32334a4097', NOW(), NOW(), '', 'Overage Test Org', 'test@capgo.app', 'cus_overage_test_123', false), + ('e5f6a7b8-c9d0-4e1f-9a2b-3c4d5e6f7a82', '6aa76066-55ef-4238-ade6-0b32334a4097', NOW(), NOW(), '', 'Private Error Test Org', 'test@capgo.app', NULL, false); ALTER TABLE public.orgs ENABLE TRIGGER generate_org_user_stripe_info_on_org_create; UPDATE public.orgs SET use_new_rbac = true WHERE id = '046a36ac-e03c-4590-9257-bd6c9dba9ee8'; @@ -853,7 +853,7 @@ BEGIN build_time_exceeded = EXCLUDED.build_time_exceeded, updated_at = NOW(); - INSERT INTO public.orgs (id, created_by, created_at, updated_at, logo, name, management_email, customer_id) + INSERT INTO public.orgs (id, created_by, created_at, updated_at, logo, name, management_email, customer_id, use_new_rbac) VALUES ( org_id, user_id, @@ -862,7 +862,8 @@ BEGIN '', org_name, 'test@capgo.app', - stripe_customer_id + stripe_customer_id, + false ) ON CONFLICT (id) DO UPDATE SET customer_id = EXCLUDED.customer_id, diff --git a/supabase/tests/37_test_check_min_rights_2fa_enforcement.sql b/supabase/tests/37_test_check_min_rights_2fa_enforcement.sql index e1ba803194..4b8d645f74 100644 --- a/supabase/tests/37_test_check_min_rights_2fa_enforcement.sql +++ b/supabase/tests/37_test_check_min_rights_2fa_enforcement.sql @@ -40,13 +40,13 @@ BEGIN test_unverified_2fa_user_id := tests.get_supabase_uid('test_unverified_2fa_user'); test_admin_id := tests.get_supabase_uid('test_admin'); - -- Create org WITH 2FA enforcement - INSERT INTO public.orgs (id, created_by, name, management_email, enforcing_2fa) - VALUES (org_with_2fa_enforcement_id, test_admin_id, '2FA Enforced Org', '2fa@org.com', true); + -- Create org WITH 2FA enforcement (use_new_rbac = false: preserves legacy check_min_rights coverage) + INSERT INTO public.orgs (id, created_by, name, management_email, enforcing_2fa, use_new_rbac) + VALUES (org_with_2fa_enforcement_id, test_admin_id, '2FA Enforced Org', '2fa@org.com', true, false); - -- Create org WITHOUT 2FA enforcement - INSERT INTO public.orgs (id, created_by, name, management_email, enforcing_2fa) - VALUES (org_without_2fa_enforcement_id, test_admin_id, 'No 2FA Org', 'no2fa@org.com', false); + -- Create org WITHOUT 2FA enforcement (use_new_rbac = false: preserves legacy check_min_rights coverage) + INSERT INTO public.orgs (id, created_by, name, management_email, enforcing_2fa, use_new_rbac) + VALUES (org_without_2fa_enforcement_id, test_admin_id, 'No 2FA Org', 'no2fa@org.com', false, false); -- Add members to org WITH 2FA enforcement -- Give test_2fa_user admin permission (which covers read, write, and admin checks) diff --git a/supabase/tests/40_test_password_policy_enforcement.sql b/supabase/tests/40_test_password_policy_enforcement.sql index 53732e246c..c324af48ec 100644 --- a/supabase/tests/40_test_password_policy_enforcement.sql +++ b/supabase/tests/40_test_password_policy_enforcement.sql @@ -46,24 +46,26 @@ BEGIN -- Define password policy config policy_config := '{"enabled": true, "min_length": 10, "require_uppercase": true, "require_number": true, "require_special": true}'::jsonb; - -- Create org WITH password policy enforcement - INSERT INTO public.orgs (id, created_by, name, management_email, password_policy_config) + -- Create org WITH password policy enforcement (use_new_rbac = false: preserves legacy check_min_rights coverage) + INSERT INTO public.orgs (id, created_by, name, management_email, password_policy_config, use_new_rbac) VALUES ( org_with_pwd_policy_id, test_admin_id, 'Pwd Policy Org', 'pwd@org.com', - policy_config + policy_config, + false ); - -- Create org WITHOUT password policy enforcement - INSERT INTO public.orgs (id, created_by, name, management_email, password_policy_config) + -- Create org WITHOUT password policy enforcement (use_new_rbac = false: preserves legacy check_min_rights coverage) + INSERT INTO public.orgs (id, created_by, name, management_email, password_policy_config, use_new_rbac) VALUES ( org_without_pwd_policy_id, test_admin_id, 'No Pwd Policy Org', 'nopwd@org.com', - NULL + NULL, + false ); -- Add members to org WITH password policy @@ -267,13 +269,15 @@ BEGIN test_admin_id := tests.get_supabase_uid('test_admin'); noncompliant_user_id := tests.get_supabase_uid('test_pwd_noncompliant_user'); - INSERT INTO public.orgs (id, created_by, name, management_email, password_policy_config) + -- use_new_rbac = false: preserves legacy check_min_rights coverage + INSERT INTO public.orgs (id, created_by, name, management_email, password_policy_config, use_new_rbac) VALUES ( org_disabled_policy_id, test_admin_id, 'Disabled Policy Org', 'disabled@org.com', - '{"enabled": false, "min_length": 10}'::jsonb + '{"enabled": false, "min_length": 10}'::jsonb, + false ); INSERT INTO public.org_users (org_id, user_id, user_right) diff --git a/tests/organization-api.test.ts b/tests/organization-api.test.ts index 73771d55b6..d145117bc9 100644 --- a/tests/organization-api.test.ts +++ b/tests/organization-api.test.ts @@ -28,6 +28,7 @@ beforeAll(async () => { management_email: TEST_EMAIL, created_by: USER_ID, customer_id: customerId, + use_new_rbac: false, // Explicitly legacy — this suite tests the legacy permission path }) if (error) throw error @@ -412,6 +413,8 @@ describe('[POST] /organization', () => { expect(error).toBeNull() expect(data).toBeTruthy() expect(data?.name).toBe(name) + // New orgs should default to RBAC enabled + expect(data?.use_new_rbac).toBe(true) }) it('create organization with missing name', async () => { @@ -723,6 +726,155 @@ describe('[PUT] /organization - enforce_hashed_api_keys setting', () => { }) }) +// ─── RBAC mode coverage ────────────────────────────────────────────────────── +// New orgs default to use_new_rbac = true. The suite below runs the same key +// member operations against an explicitly RBAC-enabled org so that the RBAC +// permission path (role_bindings) is exercised alongside the legacy tests above. + +const ORG_ID_RBAC = randomUUID() +const globalIdRbac = randomUUID() +const nameRbac = `RBAC Test Organization ${globalIdRbac}` +const customerIdRbac = `cus_test_rbac_${ORG_ID_RBAC}` + +describe('RBAC mode - organization member operations', () => { + beforeAll(async () => { + const { error: stripeError } = await getSupabaseClient().from('stripe_info').insert({ + customer_id: customerIdRbac, + status: 'succeeded', + product_id: 'prod_LQIregjtNduh4q', + subscription_id: `sub_${globalIdRbac}`, + trial_at: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString(), + is_good_plan: true, + }) + if (stripeError) + throw stripeError + + const { error } = await getSupabaseClient().from('orgs').insert({ + id: ORG_ID_RBAC, + name: nameRbac, + management_email: TEST_EMAIL, + created_by: USER_ID, + customer_id: customerIdRbac, + use_new_rbac: true, // Explicitly RBAC — tests the RBAC permission path + }) + if (error) + throw error + + // The generate_org_user_on_org_create trigger creates org_users(super_admin) + // and role_bindings(org_super_admin) for created_by automatically. + }) + + afterAll(async () => { + await getSupabaseClient().from('role_bindings').delete().eq('org_id', ORG_ID_RBAC) + await getSupabaseClient().from('org_users').delete().eq('org_id', ORG_ID_RBAC) + await getSupabaseClient().from('orgs').delete().eq('id', ORG_ID_RBAC) + await getSupabaseClient().from('stripe_info').delete().eq('customer_id', customerIdRbac) + }) + + it('[GET] /organization - get RBAC org by id', async () => { + const response = await fetch(`${BASE_URL}/organization?orgId=${ORG_ID_RBAC}`, { + headers, + }) + expect(response.status).toBe(200) + const type = z.object({ id: z.string(), name: z.string() }) + const safe = type.safeParse(await response.json()) + expect(safe.success).toBe(true) + expect(safe.data).toEqual({ id: ORG_ID_RBAC, name: nameRbac }) + }) + + it('[GET] /organization/members - returns members via role_bindings (RBAC path)', async () => { + const response = await fetch(`${BASE_URL}/organization/members?orgId=${ORG_ID_RBAC}`, { + headers, + }) + expect(response.status).toBe(200) + const type = z.array(z.object({ + uid: z.string(), + email: z.string(), + image_url: z.string(), + role: z.string(), + })) + const safe = type.safeParse(await response.json()) + expect(safe.success).toBe(true) + + const testUser = safe.data?.find(m => m.uid === USER_ID) + expect(testUser).toBeTruthy() + expect(testUser?.email).toBe(USER_EMAIL) + expect(testUser?.role).toBe('super_admin') + }) + + it('[PUT] /organization - update RBAC org name', async () => { + const updatedName = `RBAC Updated ${new Date().toISOString()}` + const response = await fetch(`${BASE_URL}/organization`, { + headers, + method: 'PUT', + body: JSON.stringify({ orgId: ORG_ID_RBAC, name: updatedName }), + }) + expect(response.status).toBe(200) + const type = z.object({ id: z.uuid(), data: z.any() }) + const safe = type.safeParse(await response.json()) + expect(safe.success).toBe(true) + expect(safe.data?.id).toBe(ORG_ID_RBAC) + }) + + it('[POST] /organization/members - add member in RBAC mode (sync trigger creates role_binding)', async () => { + const response = await fetch(`${BASE_URL}/organization/members`, { + headers, + method: 'POST', + body: JSON.stringify({ + orgId: ORG_ID_RBAC, + email: USER_ADMIN_EMAIL, + invite_type: 'read', + }), + }) + expect(response.status).toBe(200) + + const { data: userData } = await getSupabaseClient().from('users').select().eq('email', USER_ADMIN_EMAIL).single() + expect(userData).toBeTruthy() + + // Verify org_users entry exists + const { data: orgUser } = await getSupabaseClient().from('org_users').select().eq('org_id', ORG_ID_RBAC).eq('user_id', userData!.id).single() + expect(orgUser).toBeTruthy() + expect(orgUser?.user_right).toBe('invite_read') + + // Verify role_binding was created by sync trigger + const { data: binding } = await getSupabaseClient().from('role_bindings').select().eq('principal_type', 'user').eq('principal_id', userData!.id).eq('org_id', ORG_ID_RBAC) + expect(binding).toBeTruthy() + expect(binding!.length).toBeGreaterThan(0) + + // Cleanup + await getSupabaseClient().from('org_users').delete().eq('org_id', ORG_ID_RBAC).eq('user_id', userData!.id) + }) + + it('[DELETE] /organization/members - remove member in RBAC mode cleans up role_bindings', async () => { + const { data: userData } = await getSupabaseClient().from('users').select().eq('email', USER_ADMIN_EMAIL).single() + expect(userData).toBeTruthy() + + // Add member (sync trigger creates role_binding) + await getSupabaseClient().from('org_users').insert({ + org_id: ORG_ID_RBAC, + user_id: userData!.id, + user_right: 'read', + }) + + const { data: bindingsBefore } = await getSupabaseClient().from('role_bindings').select().eq('principal_type', 'user').eq('principal_id', userData!.id).eq('org_id', ORG_ID_RBAC) + expect(bindingsBefore!.length).toBeGreaterThan(0) + + const response = await fetch(`${BASE_URL}/organization/members?orgId=${ORG_ID_RBAC}&email=${USER_ADMIN_EMAIL}`, { + headers, + method: 'DELETE', + }) + expect(response.status).toBe(200) + + // org_users removed + const { data: orgUserAfter } = await getSupabaseClient().from('org_users').select().eq('org_id', ORG_ID_RBAC).eq('user_id', userData!.id) + expect(orgUserAfter).toHaveLength(0) + + // role_bindings also cleaned up + const { data: bindingsAfter } = await getSupabaseClient().from('role_bindings').select().eq('principal_type', 'user').eq('principal_id', userData!.id).eq('org_id', ORG_ID_RBAC) + expect(bindingsAfter).toHaveLength(0) + }) +}) + describe('Hashed API key enforcement integration', () => { it('find_apikey_by_value finds hashed key', async () => { // Create a hashed API key via API diff --git a/tests/password-policy.test.ts b/tests/password-policy.test.ts index d684692bcc..034b1a2b03 100644 --- a/tests/password-policy.test.ts +++ b/tests/password-policy.test.ts @@ -29,6 +29,7 @@ beforeAll(async () => { management_email: TEST_EMAIL, created_by: USER_ID, customer_id: customerId, + use_new_rbac: false, // Explicitly legacy — preserves legacy check_min_rights coverage }) if (error) throw error @@ -478,13 +479,14 @@ describe('password Policy Enforcement Integration', () => { is_good_plan: true, }) - // Create org with password policy enabled + // Create org with password policy enabled (legacy mode — preserves legacy check_min_rights coverage) await getSupabaseClient().from('orgs').insert({ id: orgWithPolicyId, name: orgWithPolicyName, management_email: TEST_EMAIL, created_by: USER_ID, customer_id: orgWithPolicyCustomerId, + use_new_rbac: false, password_policy_config: { enabled: true, min_length: 10, @@ -509,8 +511,8 @@ describe('password Policy Enforcement Integration', () => { await getSupabaseClient().from('stripe_info').delete().eq('customer_id', orgWithPolicyCustomerId) }) - it('check_min_rights respects password policy', async () => { - // Directly test the check_min_rights function via RPC + it('check_min_rights respects password policy (legacy mode)', async () => { + // Directly test the check_min_rights function via RPC in legacy mode const { data, error } = await getSupabaseClient().rpc('check_min_rights', { min_right: 'read', user_id: USER_ID, @@ -525,6 +527,67 @@ describe('password Policy Enforcement Integration', () => { expect(typeof data).toBe('boolean') }) + it('check_min_rights respects password policy (RBAC mode)', async () => { + // Create a dedicated RBAC org with password policy to test the RBAC path + const orgRbacId = randomUUID() + const orgRbacCustomerId = `cus_pwd_rbac_${orgRbacId}` + + const { error: stripeError } = await getSupabaseClient().from('stripe_info').insert({ + customer_id: orgRbacCustomerId, + status: 'succeeded', + product_id: 'prod_LQIregjtNduh4q', + subscription_id: `sub_rbac_${orgRbacId}`, + trial_at: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString(), + is_good_plan: true, + }) + expect(stripeError).toBeNull() + + const { error: orgError } = await getSupabaseClient().from('orgs').insert({ + id: orgRbacId, + name: `RBAC Pwd Policy Org ${orgRbacId}`, + management_email: TEST_EMAIL, + created_by: USER_ID, + customer_id: orgRbacCustomerId, + use_new_rbac: true, // RBAC path + password_policy_config: { + enabled: true, + min_length: 10, + require_uppercase: true, + require_number: true, + require_special: true, + }, + }) + expect(orgError).toBeNull() + + // org_users + role_bindings are created by triggers on org + org_users insert + const { error: orgUserError } = await getSupabaseClient().from('org_users').insert({ + org_id: orgRbacId, + user_id: USER_ID, + user_right: 'super_admin', + }) + expect(orgUserError).toBeNull() + + try { + // check_min_rights routes through RBAC path. Password policy is checked + // before the RBAC/legacy fork, so it should still be enforced. + const { data, error } = await getSupabaseClient().rpc('check_min_rights', { + min_right: 'read', + user_id: USER_ID, + org_id: orgRbacId, + app_id: '' as any, + channel_id: 0 as any, + }) + expect(error).toBeNull() + expect(typeof data).toBe('boolean') + } + finally { + await getSupabaseClient().from('role_bindings').delete().eq('org_id', orgRbacId) + await getSupabaseClient().from('org_users').delete().eq('org_id', orgRbacId) + await getSupabaseClient().from('orgs').delete().eq('id', orgRbacId) + await getSupabaseClient().from('stripe_info').delete().eq('customer_id', orgRbacCustomerId) + } + }) + it('get_orgs_v7 includes password policy fields', async () => { const { data, error } = await getSupabaseClient().rpc('get_orgs_v7', { userid: USER_ID, diff --git a/tests/private-error-cases.test.ts b/tests/private-error-cases.test.ts index d4679bf9cd..e96c690d45 100644 --- a/tests/private-error-cases.test.ts +++ b/tests/private-error-cases.test.ts @@ -25,12 +25,15 @@ beforeAll(async () => { throw stripeError // Create unique test organization (WITH a customer_id so RLS allows access) + // use_new_rbac: false — this test checks error handling (404 for missing app). + // In RBAC mode check_min_rights fails for non-existent apps before the app lookup. const { error: orgError } = await getSupabaseClient().from('orgs').insert({ id: testOrgId, name: `Test Private Error Org ${id}`, management_email: testOrgEmail, created_by: USER_ID, customer_id: testCustomerId, + use_new_rbac: false, }) if (orgError)