From 71f376de237511d035c7268b4b3f862fba7ac5af Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Sat, 2 May 2026 15:59:25 +0200 Subject: [PATCH 1/3] fix(rbac): block super admin binding demotion --- .../_backend/private/role_bindings.ts | 11 ++ ...4234_prevent_last_super_admin_demotion.sql | 81 +++++++++++++ tests/private-role-bindings.test.ts | 110 ++++++++++++++++++ ...security-definer-execute-hardening.test.ts | 1 + 4 files changed, 203 insertions(+) create mode 100644 supabase/migrations/20260502134234_prevent_last_super_admin_demotion.sql diff --git a/supabase/functions/_backend/private/role_bindings.ts b/supabase/functions/_backend/private/role_bindings.ts index 9af4cfc974..3f057adab9 100644 --- a/supabase/functions/_backend/private/role_bindings.ts +++ b/supabase/functions/_backend/private/role_bindings.ts @@ -646,6 +646,17 @@ app.patch( return c.json({ error: 'Cannot assign a role with higher privileges than your own' }, 403) } + // Prevent privilege escalation: caller cannot modify a binding for a role with higher priority than their own + const [existingRole] = await drizzle + .select({ priority_rank: schema.roles.priority_rank }) + .from(schema.roles) + .where(eq(schema.roles.id, binding.role_id!)) + .limit(1) + + if (existingRole && existingRole.priority_rank > callerMaxRank) { + return c.json({ error: 'Cannot modify a binding for a role with higher privileges than your own' }, 403) + } + const [updated] = await drizzle .update(schema.role_bindings) .set({ role_id: role.id }) diff --git a/supabase/migrations/20260502134234_prevent_last_super_admin_demotion.sql b/supabase/migrations/20260502134234_prevent_last_super_admin_demotion.sql new file mode 100644 index 0000000000..ba41e2f3e5 --- /dev/null +++ b/supabase/migrations/20260502134234_prevent_last_super_admin_demotion.sql @@ -0,0 +1,81 @@ +-- Prevent role updates from bypassing the last org super_admin guard. +-- The existing delete trigger blocks deleting the final super_admin binding; +-- this companion trigger blocks demoting that final binding through role_id updates. + +CREATE OR REPLACE FUNCTION "public"."prevent_last_super_admin_binding_update"() +RETURNS TRIGGER +LANGUAGE "plpgsql" +SECURITY DEFINER +SET "search_path" TO '' +AS $$ +DECLARE + v_remaining_count integer; + v_org_exists boolean; +BEGIN + IF OLD.role_id IS NOT DISTINCT FROM NEW.role_id THEN + RETURN NEW; + END IF; + + IF OLD.scope_type != public.rbac_scope_org() THEN + RETURN NEW; + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM public.roles r + WHERE r.id = OLD.role_id + AND r.name = public.rbac_role_org_super_admin() + ) THEN + RETURN NEW; + END IF; + + IF EXISTS ( + SELECT 1 + FROM public.roles r + WHERE r.id = NEW.role_id + AND r.name = public.rbac_role_org_super_admin() + ) THEN + RETURN NEW; + END IF; + + SELECT EXISTS( + SELECT 1 + FROM public.orgs + WHERE id = OLD.org_id + ) INTO v_org_exists; + + IF NOT v_org_exists THEN + RETURN NEW; + END IF; + + PERFORM pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtext(OLD.org_id::text)); + + SELECT COUNT(*) INTO v_remaining_count + FROM public.role_bindings rb + INNER JOIN public.roles r ON rb.role_id = r.id + WHERE rb.scope_type = public.rbac_scope_org() + AND rb.org_id = OLD.org_id + AND rb.principal_type = public.rbac_principal_user() + AND r.name = public.rbac_role_org_super_admin() + AND rb.id != OLD.id; + + IF v_remaining_count < 1 THEN + RAISE EXCEPTION 'CANNOT_DEMOTE_LAST_SUPER_ADMIN_BINDING' + USING HINT = 'At least one super_admin binding must remain in the org'; + END IF; + + RETURN NEW; +END; +$$; + +ALTER FUNCTION "public"."prevent_last_super_admin_binding_update"() OWNER TO "postgres"; +REVOKE ALL ON FUNCTION "public"."prevent_last_super_admin_binding_update"() FROM PUBLIC; +REVOKE ALL ON FUNCTION "public"."prevent_last_super_admin_binding_update"() FROM "anon"; +REVOKE ALL ON FUNCTION "public"."prevent_last_super_admin_binding_update"() FROM "authenticated"; +GRANT ALL ON FUNCTION "public"."prevent_last_super_admin_binding_update"() TO "service_role"; + +DROP TRIGGER IF EXISTS "prevent_last_super_admin_update" ON "public"."role_bindings"; +CREATE TRIGGER "prevent_last_super_admin_update" + BEFORE UPDATE OF "role_id" ON "public"."role_bindings" + FOR EACH ROW + EXECUTE FUNCTION "public"."prevent_last_super_admin_binding_update"(); diff --git a/tests/private-role-bindings.test.ts b/tests/private-role-bindings.test.ts index 7532b01c27..e776e47865 100644 --- a/tests/private-role-bindings.test.ts +++ b/tests/private-role-bindings.test.ts @@ -9,6 +9,7 @@ import { getAuthHeaders, getEndpointUrl, getSupabaseClient, POSTGRES_URL, USER_I let authHeaders: Record const USE_CLOUDFLARE = env.USE_CLOUDFLARE_WORKERS === 'true' +const ADMIN_USER_ID = 'c591b04e-cf29-4945-b9a0-776d0672061a' interface RoleBindingFixture { attackerOrgId: string @@ -208,6 +209,80 @@ describe.skipIf(USE_CLOUDFLARE)('[POST] /private/role_bindings', () => { }) }) +describe.skipIf(USE_CLOUDFLARE)('[PATCH] /private/role_bindings/:binding_id', () => { + it('rejects org_admin demotion of an existing org_super_admin binding', async () => { + const id = randomUUID() + const orgId = randomUUID() + const supabase = getSupabaseClient() + + try { + const { data: roles, error: rolesError } = await supabase + .from('roles') + .select('id, name') + .in('name', ['org_super_admin', 'org_admin']) + if (rolesError) + throw rolesError + + const superAdminRoleId = roles?.find(role => role.name === 'org_super_admin')?.id + const adminRoleId = roles?.find(role => role.name === 'org_admin')?.id + if (!superAdminRoleId || !adminRoleId) + throw new Error('Expected RBAC org roles to exist') + + const { error: orgError } = await supabase.from('orgs').insert({ + id: orgId, + created_by: USER_ID_2, + name: `Role Binding Rank Org ${id}`, + management_email: `role-binding-rank-${id}@capgo.app`, + use_new_rbac: true, + }) + if (orgError) + throw orgError + + const { error: membersError } = await supabase.from('org_users').insert([ + { org_id: orgId, user_id: USER_ID, user_right: 'admin' }, + { org_id: orgId, user_id: ADMIN_USER_ID, user_right: 'super_admin' }, + ]) + if (membersError) + throw membersError + + const { data: targetBinding, error: bindingError } = await supabase + .from('role_bindings') + .select('id, role_id') + .eq('org_id', orgId) + .eq('principal_type', 'user') + .eq('principal_id', USER_ID_2) + .eq('scope_type', 'org') + .single() + if (bindingError) + throw bindingError + + expect(targetBinding.role_id).toBe(superAdminRoleId) + + const response = await fetch(getEndpointUrl(`/private/role_bindings/${targetBinding.id}`), { + method: 'PATCH', + headers: authHeaders, + body: JSON.stringify({ role_name: 'org_member' }), + }) + const data = await response.json() as { error: string } + + expect(response.status).toBe(403) + expect(data.error).toBe('Cannot modify a binding for a role with higher privileges than your own') + + const { data: unchangedBinding, error: unchangedError } = await supabase + .from('role_bindings') + .select('role_id') + .eq('id', targetBinding.id) + .single() + + expect(unchangedError).toBeNull() + expect(unchangedBinding?.role_id).toBe(superAdminRoleId) + } + finally { + await supabase.from('orgs').delete().eq('id', orgId) + } + }) +}) + describe('private role bindings helpers', () => { let pool: Pool let client: PoolClient @@ -403,4 +478,39 @@ describe('private role bindings helpers', () => { error: 'Role scope_type does not match binding scope', }) }) + + it('blocks demoting the last org_super_admin binding at the database layer', async () => { + const id = randomUUID() + const orgId = randomUUID() + + await query(` + INSERT INTO public.orgs (id, name, management_email, created_by, use_new_rbac) + VALUES ($1::uuid, $2, $3, $4::uuid, true) + `, [orgId, `Last Super Admin Demotion Org ${id}`, `last-super-admin-demotion-${id}@capgo.app`, USER_ID]) + + const bindingResult = await query(` + SELECT rb.id, member_role.id AS member_role_id + FROM public.role_bindings rb + INNER JOIN public.roles bound_role + ON bound_role.id = rb.role_id + CROSS JOIN public.roles member_role + WHERE rb.org_id = $1::uuid + AND rb.principal_type = public.rbac_principal_user() + AND rb.principal_id = $2::uuid + AND rb.scope_type = public.rbac_scope_org() + AND bound_role.name = public.rbac_role_org_super_admin() + AND member_role.name = public.rbac_role_org_member() + LIMIT 1 + `, [orgId, USER_ID]) + + expect(bindingResult.rowCount).toBe(1) + + await expect(query(` + UPDATE public.role_bindings + SET role_id = $2::uuid + WHERE id = $1::uuid + `, [bindingResult.rows[0].id, bindingResult.rows[0].member_role_id])) + .rejects + .toThrow('CANNOT_DEMOTE_LAST_SUPER_ADMIN_BINDING') + }) }) diff --git a/tests/security-definer-execute-hardening.test.ts b/tests/security-definer-execute-hardening.test.ts index 80499cf338..77a7b79815 100644 --- a/tests/security-definer-execute-hardening.test.ts +++ b/tests/security-definer-execute-hardening.test.ts @@ -30,6 +30,7 @@ const SERVICE_ONLY_PROCS = [ 'public.get_apikey()', 'public.noupdate()', 'public.prevent_last_super_admin_binding_delete()', + 'public.prevent_last_super_admin_binding_update()', 'public.resync_org_user_role_bindings(uuid, uuid)', 'public.sanitize_apps_text_fields()', 'public.sanitize_orgs_text_fields()', From 55546acffabda6f484e9b73eac59af84de4c1fab Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Sat, 2 May 2026 16:26:19 +0200 Subject: [PATCH 2/3] fix(rbac): harden role binding update guard --- .../_backend/private/role_bindings.ts | 49 +++++++++++----- tests/private-role-bindings.test.ts | 57 ++++++++++++++++--- 2 files changed, 84 insertions(+), 22 deletions(-) diff --git a/supabase/functions/_backend/private/role_bindings.ts b/supabase/functions/_backend/private/role_bindings.ts index 3f057adab9..d567af9ae5 100644 --- a/supabase/functions/_backend/private/role_bindings.ts +++ b/supabase/functions/_backend/private/role_bindings.ts @@ -646,23 +646,39 @@ app.patch( return c.json({ error: 'Cannot assign a role with higher privileges than your own' }, 403) } - // Prevent privilege escalation: caller cannot modify a binding for a role with higher priority than their own - const [existingRole] = await drizzle - .select({ priority_rank: schema.roles.priority_rank }) - .from(schema.roles) - .where(eq(schema.roles.id, binding.role_id!)) - .limit(1) - - if (existingRole && existingRole.priority_rank > callerMaxRank) { + const updateResult = await pgClient.query(` + UPDATE public.role_bindings AS rb + SET role_id = $2::uuid + FROM public.roles AS bound_role + WHERE rb.id = $1::uuid + AND rb.org_id IS NOT DISTINCT FROM $4::uuid + AND rb.app_id IS NOT DISTINCT FROM $5::uuid + AND rb.bundle_id IS NOT DISTINCT FROM $6::bigint + AND rb.channel_id IS NOT DISTINCT FROM $7::uuid + AND rb.scope_type = $8::text + AND rb.principal_type = $9::text + AND rb.principal_id = $10::uuid + AND rb.role_id = bound_role.id + AND bound_role.priority_rank <= $3::integer + RETURNING rb.* + `, [ + bindingId, + role.id, + callerMaxRank, + binding.org_id, + binding.app_id, + binding.bundle_id, + binding.channel_id, + binding.scope_type, + binding.principal_type, + binding.principal_id, + ]) + + const updated = updateResult.rows[0] + if (!updated) { return c.json({ error: 'Cannot modify a binding for a role with higher privileges than your own' }, 403) } - const [updated] = await drizzle - .update(schema.role_bindings) - .set({ role_id: role.id }) - .where(eq(schema.role_bindings.id, bindingId)) - .returning() - cloudlog({ requestId: c.get('requestId'), message: 'role_binding_updated', @@ -686,6 +702,11 @@ app.patch( bindingId, error, }) + const errorMessage = error instanceof Error ? error.message : String(error) + const errorCode = typeof error === 'object' && error !== null && 'code' in error ? (error as { code?: string }).code : undefined + if (errorMessage.includes('CANNOT_DEMOTE_LAST_SUPER_ADMIN_BINDING') || errorCode === 'CANNOT_DEMOTE_LAST_SUPER_ADMIN_BINDING') { + return c.json({ error: 'Cannot demote the last org_super_admin' }, 409) + } return c.json({ error: 'Internal server error' }, 500) } finally { diff --git a/tests/private-role-bindings.test.ts b/tests/private-role-bindings.test.ts index e776e47865..7a28d18284 100644 --- a/tests/private-role-bindings.test.ts +++ b/tests/private-role-bindings.test.ts @@ -5,11 +5,11 @@ import { Pool } from 'pg' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' import { validatePrincipalAccess, validateRoleScope } from '../supabase/functions/_backend/private/role_bindings.ts' import { getDrizzleClient } from '../supabase/functions/_backend/utils/pg.ts' -import { getAuthHeaders, getEndpointUrl, getSupabaseClient, POSTGRES_URL, USER_ID, USER_ID_2 } from './test-utils.ts' +import { getAuthHeaders, getAuthHeadersForCredentials, getEndpointUrl, getSupabaseClient, POSTGRES_URL, USER_ID, USER_ID_2, USER_PASSWORD } from './test-utils.ts' let authHeaders: Record +let user2AuthHeaders: Record const USE_CLOUDFLARE = env.USE_CLOUDFLARE_WORKERS === 'true' -const ADMIN_USER_ID = 'c591b04e-cf29-4945-b9a0-776d0672061a' interface RoleBindingFixture { attackerOrgId: string @@ -127,6 +127,7 @@ beforeAll(async () => { return authHeaders = await getAuthHeaders() + user2AuthHeaders = await getAuthHeadersForCredentials('test2@capgo.app', USER_PASSWORD) }) // /private/role_bindings is currently served by the Supabase private functions stack, not the Cloudflare API worker. @@ -219,14 +220,13 @@ describe.skipIf(USE_CLOUDFLARE)('[PATCH] /private/role_bindings/:binding_id', () const { data: roles, error: rolesError } = await supabase .from('roles') .select('id, name') - .in('name', ['org_super_admin', 'org_admin']) + .eq('name', 'org_super_admin') if (rolesError) throw rolesError - const superAdminRoleId = roles?.find(role => role.name === 'org_super_admin')?.id - const adminRoleId = roles?.find(role => role.name === 'org_admin')?.id - if (!superAdminRoleId || !adminRoleId) - throw new Error('Expected RBAC org roles to exist') + const superAdminRoleId = roles?.[0]?.id + if (!superAdminRoleId) + throw new Error('Expected RBAC org_super_admin role to exist') const { error: orgError } = await supabase.from('orgs').insert({ id: orgId, @@ -240,7 +240,6 @@ describe.skipIf(USE_CLOUDFLARE)('[PATCH] /private/role_bindings/:binding_id', () const { error: membersError } = await supabase.from('org_users').insert([ { org_id: orgId, user_id: USER_ID, user_right: 'admin' }, - { org_id: orgId, user_id: ADMIN_USER_ID, user_right: 'super_admin' }, ]) if (membersError) throw membersError @@ -281,6 +280,48 @@ describe.skipIf(USE_CLOUDFLARE)('[PATCH] /private/role_bindings/:binding_id', () await supabase.from('orgs').delete().eq('id', orgId) } }) + + it('returns a conflict when the last org_super_admin binding cannot be demoted', async () => { + const id = randomUUID() + const orgId = randomUUID() + const supabase = getSupabaseClient() + + try { + const { error: orgError } = await supabase.from('orgs').insert({ + id: orgId, + created_by: USER_ID_2, + name: `Last Super Admin API Org ${id}`, + management_email: `last-super-admin-api-${id}@capgo.app`, + use_new_rbac: true, + }) + if (orgError) + throw orgError + + const { data: targetBinding, error: bindingError } = await supabase + .from('role_bindings') + .select('id') + .eq('org_id', orgId) + .eq('principal_type', 'user') + .eq('principal_id', USER_ID_2) + .eq('scope_type', 'org') + .single() + if (bindingError) + throw bindingError + + const response = await fetch(getEndpointUrl(`/private/role_bindings/${targetBinding.id}`), { + method: 'PATCH', + headers: user2AuthHeaders, + body: JSON.stringify({ role_name: 'org_member' }), + }) + const data = await response.json() as { error: string } + + expect(response.status).toBe(409) + expect(data.error).toBe('Cannot demote the last org_super_admin') + } + finally { + await supabase.from('orgs').delete().eq('id', orgId) + } + }) }) describe('private role bindings helpers', () => { From 8563e6d17adc0ebe3fd2aa0712c0ac172fea955e Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Sat, 2 May 2026 16:44:48 +0200 Subject: [PATCH 3/3] refactor(rbac): simplify role binding update handler --- .../_backend/private/role_bindings.ts | 143 +++++++++++------- 1 file changed, 86 insertions(+), 57 deletions(-) diff --git a/supabase/functions/_backend/private/role_bindings.ts b/supabase/functions/_backend/private/role_bindings.ts index d567af9ae5..bc9a3ce151 100644 --- a/supabase/functions/_backend/private/role_bindings.ts +++ b/supabase/functions/_backend/private/role_bindings.ts @@ -37,6 +37,7 @@ interface RoleBindingBody { type ValidationResult = { ok: true, data: T } | { ok: false, status: number, error: string } type RouteValidationResult = { ok: true, data: T } | { ok: false, response: Response } type RoleBindingRecord = typeof schema.role_bindings.$inferSelect +type RoleRecord = typeof schema.roles.$inferSelect const INVALID_APIKEY_ACCESS_ERROR = 'Invalid API key or access' export const app = createHono('', version) @@ -358,6 +359,41 @@ async function loadManagedBinding( return { ok: true, data: binding } } +async function loadAssignableRoleForBinding( + c: Context, + drizzle: ReturnType, + binding: RoleBindingRecord, + roleName: string, +): Promise> { + const [role] = await drizzle + .select() + .from(schema.roles) + .where(eq(schema.roles.name, roleName)) + .limit(1) + + if (!role) { + return { ok: false, response: c.json({ error: 'Role not found' }, 404) } + } + + if (!role.is_assignable) { + return { ok: false, response: c.json({ error: 'Role is not assignable' }, 403) } + } + + const principalValidation = binding.org_id + ? await validatePrincipalAccess(drizzle, binding.principal_type as RoleBindingBody['principal_type'], binding.principal_id, binding.org_id) + : { ok: true as const, data: null } + if (!principalValidation.ok) { + return { ok: false, response: c.json({ error: principalValidation.error }, principalValidation.status as any) } + } + + const roleScopeValidation = validateRoleScope(role.scope_type, binding.scope_type) + if (!roleScopeValidation.ok) { + return { ok: false, response: c.json({ error: roleScopeValidation.error }, roleScopeValidation.status as any) } + } + + return { ok: true, data: role } +} + async function getCallerMaxPriorityRank( drizzle: ReturnType, authType: 'apikey' | 'jwt', @@ -384,6 +420,50 @@ async function getCallerMaxPriorityRank( return result[0]?.max_rank ?? 0 } +async function updateRoleBindingRole( + pgClient: ReturnType, + bindingId: string, + binding: RoleBindingRecord, + roleId: string, + callerMaxRank: number, +): Promise { + const updateResult = await pgClient.query(` + UPDATE public.role_bindings AS rb + SET role_id = $2::uuid + FROM public.roles AS bound_role + WHERE rb.id = $1::uuid + AND rb.org_id IS NOT DISTINCT FROM $4::uuid + AND rb.app_id IS NOT DISTINCT FROM $5::uuid + AND rb.bundle_id IS NOT DISTINCT FROM $6::bigint + AND rb.channel_id IS NOT DISTINCT FROM $7::uuid + AND rb.scope_type = $8::text + AND rb.principal_type = $9::text + AND rb.principal_id = $10::uuid + AND rb.role_id = bound_role.id + AND bound_role.priority_rank <= $3::integer + RETURNING rb.* + `, [ + bindingId, + roleId, + callerMaxRank, + binding.org_id, + binding.app_id, + binding.bundle_id, + binding.channel_id, + binding.scope_type, + binding.principal_type, + binding.principal_id, + ]) + + return updateResult.rows[0] ?? null +} + +function isLastSuperAdminDemotionError(error: unknown): boolean { + const errorMessage = error instanceof Error ? error.message : String(error) + const errorCode = typeof error === 'object' && error !== null && 'code' in error ? (error as { code?: string }).code : undefined + return errorMessage.includes('CANNOT_DEMOTE_LAST_SUPER_ADMIN_BINDING') || errorCode === 'CANNOT_DEMOTE_LAST_SUPER_ADMIN_BINDING' +} + // GET /private/role_bindings/:org_id - List role bindings for an org app.get('/:org_id', requireUserAuth, sValidator('param', orgIdParamSchema, invalidOrgIdHook), async (c) => { const { org_id: orgId } = c.req.valid('param') @@ -613,31 +693,10 @@ app.patch( return bindingResult.response const binding = bindingResult.data - const [role] = await drizzle - .select() - .from(schema.roles) - .where(eq(schema.roles.name, roleName)) - .limit(1) - - if (!role) { - return c.json({ error: 'Role not found' }, 404) - } - - if (!role.is_assignable) { - return c.json({ error: 'Role is not assignable' }, 403) - } - - const principalValidation = binding.org_id - ? await validatePrincipalAccess(drizzle, binding.principal_type as RoleBindingBody['principal_type'], binding.principal_id, binding.org_id) - : { ok: true as const, data: null } - if (!principalValidation.ok) { - return c.json({ error: principalValidation.error }, principalValidation.status as any) - } - - const roleScopeValidation = validateRoleScope(role.scope_type, binding.scope_type) - if (!roleScopeValidation.ok) { - return c.json({ error: roleScopeValidation.error }, roleScopeValidation.status as any) - } + const roleResult = await loadAssignableRoleForBinding(c, drizzle, binding, roleName) + if (!roleResult.ok) + return roleResult.response + const role = roleResult.data // Prevent privilege escalation: caller cannot assign a role with higher priority than their own const callerPrincipalId = auth.authType === 'apikey' ? auth.apikey!.rbac_id : auth.userId @@ -646,35 +705,7 @@ app.patch( return c.json({ error: 'Cannot assign a role with higher privileges than your own' }, 403) } - const updateResult = await pgClient.query(` - UPDATE public.role_bindings AS rb - SET role_id = $2::uuid - FROM public.roles AS bound_role - WHERE rb.id = $1::uuid - AND rb.org_id IS NOT DISTINCT FROM $4::uuid - AND rb.app_id IS NOT DISTINCT FROM $5::uuid - AND rb.bundle_id IS NOT DISTINCT FROM $6::bigint - AND rb.channel_id IS NOT DISTINCT FROM $7::uuid - AND rb.scope_type = $8::text - AND rb.principal_type = $9::text - AND rb.principal_id = $10::uuid - AND rb.role_id = bound_role.id - AND bound_role.priority_rank <= $3::integer - RETURNING rb.* - `, [ - bindingId, - role.id, - callerMaxRank, - binding.org_id, - binding.app_id, - binding.bundle_id, - binding.channel_id, - binding.scope_type, - binding.principal_type, - binding.principal_id, - ]) - - const updated = updateResult.rows[0] + const updated = await updateRoleBindingRole(pgClient, bindingId, binding, role.id, callerMaxRank) if (!updated) { return c.json({ error: 'Cannot modify a binding for a role with higher privileges than your own' }, 403) } @@ -702,9 +733,7 @@ app.patch( bindingId, error, }) - const errorMessage = error instanceof Error ? error.message : String(error) - const errorCode = typeof error === 'object' && error !== null && 'code' in error ? (error as { code?: string }).code : undefined - if (errorMessage.includes('CANNOT_DEMOTE_LAST_SUPER_ADMIN_BINDING') || errorCode === 'CANNOT_DEMOTE_LAST_SUPER_ADMIN_BINDING') { + if (isLastSuperAdminDemotionError(error)) { return c.json({ error: 'Cannot demote the last org_super_admin' }, 409) } return c.json({ error: 'Internal server error' }, 500)