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()}`