From a89bfaebc3bd71337c1a05c6c575a8d71e5eb518 Mon Sep 17 00:00:00 2001 From: Jordan Lorho Date: Wed, 29 Apr 2026 16:00:22 +0200 Subject: [PATCH 1/3] feat(db): enable RBAC for all existing organizations --- supabase/migrations/20260429135552_enable_rbac_all_orgs.sql | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 supabase/migrations/20260429135552_enable_rbac_all_orgs.sql diff --git a/supabase/migrations/20260429135552_enable_rbac_all_orgs.sql b/supabase/migrations/20260429135552_enable_rbac_all_orgs.sql new file mode 100644 index 0000000000..81de4adfec --- /dev/null +++ b/supabase/migrations/20260429135552_enable_rbac_all_orgs.sql @@ -0,0 +1,4 @@ +-- Enable RBAC for all existing organizations +UPDATE "public"."orgs" +SET "use_new_rbac" = true +WHERE "use_new_rbac" = false; From add5d3cfa34efb5c5fb0737311dc032b3b28ba82 Mon Sep 17 00:00:00 2001 From: Jordan Lorho Date: Wed, 29 Apr 2026 16:16:40 +0200 Subject: [PATCH 2/3] fix(db): use rbac_enable_for_org() to properly backfill role_bindings Address CodeRabbit review: - Replace raw UPDATE with rbac_enable_for_org() loop to migrate org_users to role_bindings before enabling the flag - Add rollback documentation in migration header --- .../20260429135552_enable_rbac_all_orgs.sql | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/supabase/migrations/20260429135552_enable_rbac_all_orgs.sql b/supabase/migrations/20260429135552_enable_rbac_all_orgs.sql index 81de4adfec..8ba2dff1f6 100644 --- a/supabase/migrations/20260429135552_enable_rbac_all_orgs.sql +++ b/supabase/migrations/20260429135552_enable_rbac_all_orgs.sql @@ -1,4 +1,19 @@ --- Enable RBAC for all existing organizations -UPDATE "public"."orgs" -SET "use_new_rbac" = true -WHERE "use_new_rbac" = false; +-- Enable RBAC for all existing organizations. +-- Uses rbac_enable_for_org() to properly backfill role_bindings from org_users +-- before flipping the use_new_rbac flag. +-- +-- Rollback (if critical issues are discovered): +-- UPDATE "public"."orgs" SET "use_new_rbac" = false WHERE "use_new_rbac" = true; +-- Note: role_bindings created by this migration will remain but become unused +-- when the flag is false. They do not need to be deleted for a safe rollback. +DO $$ +DECLARE + v_org_id uuid; + v_result jsonb; +BEGIN + FOR v_org_id IN + SELECT id FROM "public"."orgs" WHERE "use_new_rbac" = false + LOOP + v_result := "public"."rbac_enable_for_org"(v_org_id, NULL); + END LOOP; +END $$; From b55b3259316f62c21790d492efac34f7e046245c Mon Sep 17 00:00:00 2001 From: Jordan Lorho Date: Fri, 1 May 2026 18:36:12 +0200 Subject: [PATCH 3/3] fix(rbac): allow org_admin to manage user roles in legacy permission path org.update_user_roles was mapped to super_admin in the legacy fallback (rbac_legacy_right_for_permission), preventing org_admin from deleting members. This was inconsistent with RBAC where org_admin explicitly has this permission. The priority_rank guard still prevents org_admin from deleting org_super_admin bindings. --- supabase/functions/_backend/utils/rbac.ts | 2 +- ...admin_update_user_roles_legacy_mapping.sql | 82 +++++++++++++++++++ tests/organization-api.test.ts | 75 +++++++++++++++++ 3 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 supabase/migrations/20260501161128_fix_org_admin_update_user_roles_legacy_mapping.sql diff --git a/supabase/functions/_backend/utils/rbac.ts b/supabase/functions/_backend/utils/rbac.ts index 9d88f7a4d1..c7f8250dea 100644 --- a/supabase/functions/_backend/utils/rbac.ts +++ b/supabase/functions/_backend/utils/rbac.ts @@ -112,7 +112,7 @@ const PERMISSION_TO_LEGACY_RIGHT: Record admin (was super_admin) +-- The priority_rank guard in the application layer still prevents +-- org_admin (rank 90) from deleting org_super_admin (rank 95) bindings. + +CREATE OR REPLACE FUNCTION "public"."rbac_legacy_right_for_permission"("p_permission_key" "text") RETURNS "public"."user_min_right" + LANGUAGE "plpgsql" IMMUTABLE + SET "search_path" TO '' + AS $$ +BEGIN + -- Map permissions to their legacy equivalents + -- This mapping should match PERMISSION_TO_LEGACY_RIGHT in utils/rbac.ts + CASE p_permission_key + -- Read permissions -> public.rbac_right_read() + WHEN public.rbac_perm_org_read() THEN RETURN public.rbac_right_read(); + WHEN public.rbac_perm_org_read_members() THEN RETURN public.rbac_right_read(); + WHEN public.rbac_perm_app_read() THEN RETURN public.rbac_right_read(); + WHEN public.rbac_perm_app_read_bundles() THEN RETURN public.rbac_right_read(); + WHEN public.rbac_perm_app_read_channels() THEN RETURN public.rbac_right_read(); + WHEN public.rbac_perm_app_read_logs() THEN RETURN public.rbac_right_read(); + WHEN public.rbac_perm_app_read_devices() THEN RETURN public.rbac_right_read(); + WHEN public.rbac_perm_channel_read() THEN RETURN public.rbac_right_read(); + WHEN public.rbac_perm_channel_read_history() THEN RETURN public.rbac_right_read(); + WHEN public.rbac_perm_channel_read_forced_devices() THEN RETURN public.rbac_right_read(); + + -- Upload permissions -> public.rbac_right_upload() + WHEN public.rbac_perm_app_upload_bundle() THEN RETURN public.rbac_right_upload(); + + -- Write permissions -> public.rbac_right_write() + WHEN public.rbac_perm_app_update_settings() THEN RETURN public.rbac_right_write(); + WHEN public.rbac_perm_app_create_channel() THEN RETURN public.rbac_right_write(); + WHEN public.rbac_perm_app_manage_devices() THEN RETURN public.rbac_right_write(); + WHEN public.rbac_perm_app_build_native() THEN RETURN public.rbac_right_write(); + WHEN public.rbac_perm_channel_update_settings() THEN RETURN public.rbac_right_write(); + WHEN public.rbac_perm_channel_promote_bundle() THEN RETURN public.rbac_right_write(); + WHEN public.rbac_perm_channel_rollback_bundle() THEN RETURN public.rbac_right_write(); + WHEN public.rbac_perm_channel_manage_forced_devices() THEN RETURN public.rbac_right_write(); + WHEN public.rbac_perm_org_create_app() THEN RETURN public.rbac_right_write(); + + -- Admin permissions -> public.rbac_right_admin() + WHEN public.rbac_perm_org_update_settings() THEN RETURN public.rbac_right_admin(); + WHEN public.rbac_perm_org_invite_user() THEN RETURN public.rbac_right_admin(); + WHEN public.rbac_perm_org_read_billing() THEN RETURN public.rbac_right_admin(); + WHEN public.rbac_perm_org_read_invoices() THEN RETURN public.rbac_right_admin(); + WHEN public.rbac_perm_org_read_audit() THEN RETURN public.rbac_right_admin(); + WHEN public.rbac_perm_app_delete() THEN RETURN public.rbac_right_admin(); + WHEN public.rbac_perm_app_read_audit() THEN RETURN public.rbac_right_admin(); + WHEN public.rbac_perm_bundle_delete() THEN RETURN public.rbac_right_admin(); + WHEN public.rbac_perm_channel_delete() THEN RETURN public.rbac_right_admin(); + WHEN public.rbac_perm_channel_read_audit() THEN RETURN public.rbac_right_admin(); + WHEN public.rbac_perm_org_update_user_roles() THEN RETURN public.rbac_right_admin(); + + -- Super admin permissions -> public.rbac_right_super_admin() + WHEN public.rbac_perm_org_update_billing() THEN RETURN public.rbac_right_super_admin(); + WHEN public.rbac_perm_org_read_billing_audit() THEN RETURN public.rbac_right_super_admin(); + WHEN public.rbac_perm_org_delete() THEN RETURN public.rbac_right_super_admin(); + WHEN public.rbac_perm_app_transfer() THEN RETURN public.rbac_right_super_admin(); + WHEN public.rbac_perm_platform_impersonate_user() THEN RETURN public.rbac_right_super_admin(); + WHEN public.rbac_perm_platform_manage_orgs_any() THEN RETURN public.rbac_right_super_admin(); + WHEN public.rbac_perm_platform_manage_apps_any() THEN RETURN public.rbac_right_super_admin(); + WHEN public.rbac_perm_platform_manage_channels_any() THEN RETURN public.rbac_right_super_admin(); + WHEN public.rbac_perm_platform_run_maintenance_jobs() THEN RETURN public.rbac_right_super_admin(); + WHEN public.rbac_perm_platform_delete_orphan_users() THEN RETURN public.rbac_right_super_admin(); + WHEN public.rbac_perm_platform_read_all_audit() THEN RETURN public.rbac_right_super_admin(); + WHEN public.rbac_perm_platform_db_break_glass() THEN RETURN public.rbac_right_super_admin(); + + ELSE RETURN NULL; -- Unknown permission + END CASE; +END; +$$; + +ALTER FUNCTION "public"."rbac_legacy_right_for_permission"("p_permission_key" "text") OWNER TO "postgres"; +REVOKE ALL ON FUNCTION "public"."rbac_legacy_right_for_permission"("p_permission_key" "text") FROM PUBLIC; +GRANT EXECUTE ON FUNCTION "public"."rbac_legacy_right_for_permission"("p_permission_key" "text") TO "service_role"; +GRANT EXECUTE ON FUNCTION "public"."rbac_legacy_right_for_permission"("p_permission_key" "text") TO "authenticated"; +GRANT EXECUTE ON FUNCTION "public"."rbac_legacy_right_for_permission"("p_permission_key" "text") TO "anon"; diff --git a/tests/organization-api.test.ts b/tests/organization-api.test.ts index fb34fad5cf..f956ec487c 100644 --- a/tests/organization-api.test.ts +++ b/tests/organization-api.test.ts @@ -949,6 +949,81 @@ describe('[DELETE] /organization/members', () => { }) }) +describe('[DELETE] /organization/members - org_admin can delete members', () => { + const adminDeleteOrgId = randomUUID() + const adminDeleteGlobalId = randomUUID() + const adminDeleteCustomerId = `cus_test_admin_del_${adminDeleteOrgId}` + + beforeAll(async () => { + const { error: stripeError } = await getSupabaseClient().from('stripe_info').insert({ + customer_id: adminDeleteCustomerId, + status: 'succeeded', + product_id: 'prod_LQIregjtNduh4q', + subscription_id: `sub_${adminDeleteGlobalId}`, + trial_at: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString(), + is_good_plan: true, + }) + if (stripeError) + throw stripeError + + // Create org with legacy RBAC path (use_new_rbac: false) to test the fixed legacy mapping + const { error: orgError } = await getSupabaseClient().from('orgs').insert({ + id: adminDeleteOrgId, + name: `Admin Delete Test Org ${adminDeleteGlobalId}`, + management_email: TEST_EMAIL, + created_by: USER_ID, + customer_id: adminDeleteCustomerId, + website: 'https://admin-delete-test.example/', + use_new_rbac: false, + }) + if (orgError) + throw orgError + + // Add USER_ID as admin (NOT super_admin) — this is the key scenario under test + const { error: orgUserError } = await getSupabaseClient().from('org_users').insert({ + org_id: adminDeleteOrgId, + user_id: USER_ID, + user_right: 'admin', + }) + if (orgUserError) + throw orgUserError + }) + + afterAll(async () => { + await getSupabaseClient().from('org_users').delete().eq('org_id', adminDeleteOrgId) + await getSupabaseClient().from('orgs').delete().eq('id', adminDeleteOrgId) + await getSupabaseClient().from('stripe_info').delete().eq('customer_id', adminDeleteCustomerId) + }) + + it.concurrent('org_admin can delete a read member', async () => { + // Add USER_ADMIN as a read member to the org + const { data: userData, error: userError } = await getSupabaseClient().from('users').select().eq('email', USER_ADMIN_EMAIL).single() + expect(userError).toBeNull() + expect(userData).toBeTruthy() + + const { error: addError } = await getSupabaseClient().from('org_users').insert({ + org_id: adminDeleteOrgId, + user_id: userData!.id, + user_right: 'read', + }) + expect(addError).toBeNull() + + // USER_ID (admin) deletes USER_ADMIN (read) — should succeed + const response = await fetch(`${BASE_URL}/organization/members?orgId=${adminDeleteOrgId}&email=${USER_ADMIN_EMAIL}`, { + headers, + method: 'DELETE', + }) + expect(response.status).toBe(200) + const responseData = await response.json() as { status: string } + expect(responseData.status).toBe('ok') + + // Verify the member was actually removed + const { data, error: orgUserError } = await getSupabaseClient().from('org_users').select().eq('org_id', adminDeleteOrgId).eq('user_id', userData!.id).single() + expect(orgUserError).toBeTruthy() + expect(data).toBeNull() + }) +}) + describe('[POST] /organization', () => { it.concurrent('create organization', async () => { const name = `Created Organization ${new Date().toISOString()}`