Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
304 changes: 304 additions & 0 deletions supabase/migrations/20260302185011_fix_rbac_check_effective_user.sql
Original file line number Diff line number Diff line change
@@ -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;
Comment thread
Dalanir marked this conversation as resolved.

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;
Comment thread
Dalanir marked this conversation as resolved.
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;
$$;
2 changes: 1 addition & 1 deletion supabase/schemas/prod.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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
);

Expand Down
37 changes: 19 additions & 18 deletions supabase/seed.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
12 changes: 6 additions & 6 deletions supabase/tests/37_test_check_min_rights_2fa_enforcement.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading