diff --git a/src/app/api/v1/admin/users/[id]/route.ts b/src/app/api/v1/admin/users/[id]/route.ts index 01f9bea0..6902e9ae 100644 --- a/src/app/api/v1/admin/users/[id]/route.ts +++ b/src/app/api/v1/admin/users/[id]/route.ts @@ -21,12 +21,19 @@ export const PATCH = withAuth( withPermission('admin', 'manage_users', async (req, ctx, params) => { try { const body = await parseBody(req, updateUserSchema); - const data = await updateUser(params.id!, ctx.portId, body, { - userId: ctx.userId, - portId: ctx.portId, - ipAddress: ctx.ipAddress, - userAgent: ctx.userAgent, - }); + const data = await updateUser( + params.id!, + ctx.portId, + body, + { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }, + ctx.permissions as Record> | null, + ctx.isSuperAdmin, + ); return NextResponse.json({ data }); } catch (error) { return errorResponse(error); diff --git a/src/lib/services/users.service.ts b/src/lib/services/users.service.ts index c440ae11..940c20b1 100644 --- a/src/lib/services/users.service.ts +++ b/src/lib/services/users.service.ts @@ -4,7 +4,7 @@ import { db } from '@/lib/db'; import { account, session, user, userProfiles, userPortRoles, roles, ports } from '@/lib/db/schema'; import { auth } from '@/lib/auth'; import { createAuditLog, type AuditMeta } from '@/lib/audit'; -import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors'; +import { ConflictError, ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors'; import { emitToRoom } from '@/lib/socket/server'; import { sendEmail } from '@/lib/email'; import { adminEmailChangeEmail } from '@/lib/email/templates/admin-email-change'; @@ -207,6 +207,15 @@ export async function updateUser( portId: string, data: UpdateUserInput, meta: AuditMeta, + /** + * Caller's effective permission map at call time. Used to enforce the + * caller-superset rule on role reassignment so a port admin holding + * only `admin.manage_users` can't promote a peer to a role that grants + * permissions the caller doesn't themselves hold (authz-auditor C-1 + * parallel path). Super admins bypass via `callerIsSuperAdmin`. + */ + callerPermissions?: Record> | null, + callerIsSuperAdmin?: boolean, ) { const profile = await db.query.userProfiles.findFirst({ where: eq(userProfiles.userId, userId), @@ -287,6 +296,27 @@ export async function updateUser( where: eq(roles.id, data.roleId), }); if (!newRole) throw new ValidationError('Invalid role ID'); + + // Caller-superset check (authz-auditor C-1 parallel path): refuse to + // assign a role whose effective permission set contains any leaf + // the caller doesn't hold. Super admins bypass. When + // `callerPermissions` isn't passed (legacy callers / system jobs) + // we skip the check — but every interactive API route should pass + // ctx.permissions + ctx.isSuperAdmin through. + if (callerPermissions && !callerIsSuperAdmin) { + const newRolePerms = newRole.permissions as Record>; + for (const [resource, actions] of Object.entries(newRolePerms ?? {})) { + for (const [action, value] of Object.entries(actions)) { + if (value !== true) continue; + if (callerPermissions[resource]?.[action] !== true) { + throw new ForbiddenError( + `You don't hold ${resource}.${action} yourself, so you can't assign a role that grants it.`, + ); + } + } + } + } + portRoleUpdates.roleId = data.roleId; portRoleUpdates.assignedBy = meta.userId; }