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
121 changes: 91 additions & 30 deletions supabase/functions/_backend/private/role_bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ interface RoleBindingBody {
type ValidationResult<T> = { ok: true, data: T } | { ok: false, status: number, error: string }
type RouteValidationResult<T> = { 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)
Expand Down Expand Up @@ -358,6 +359,41 @@ async function loadManagedBinding(
return { ok: true, data: binding }
}

async function loadAssignableRoleForBinding(
c: Context<MiddlewareKeyVariables>,
drizzle: ReturnType<typeof getDrizzleClient>,
binding: RoleBindingRecord,
roleName: string,
): Promise<RouteValidationResult<RoleRecord>> {
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<typeof getDrizzleClient>,
authType: 'apikey' | 'jwt',
Expand All @@ -384,6 +420,50 @@ async function getCallerMaxPriorityRank(
return result[0]?.max_rank ?? 0
}

async function updateRoleBindingRole(
pgClient: ReturnType<typeof getPgClient>,
bindingId: string,
binding: RoleBindingRecord,
roleId: string,
callerMaxRank: number,
): Promise<RoleBindingRecord | null> {
const updateResult = await pgClient.query<RoleBindingRecord>(`
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')
Expand Down Expand Up @@ -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
Expand All @@ -646,11 +705,10 @@ app.patch(
return c.json({ error: 'Cannot assign 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()
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)
}

cloudlog({
requestId: c.get('requestId'),
Expand All @@ -675,6 +733,9 @@ app.patch(
bindingId,
error,
})
if (isLastSuperAdminDemotionError(error)) {
return c.json({ error: 'Cannot demote the last org_super_admin' }, 409)
}
return c.json({ error: 'Internal server error' }, 500)
}
finally {
Expand Down
Original file line number Diff line number Diff line change
@@ -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"();
Loading
Loading