diff --git a/src/lib/services/users.service.ts b/src/lib/services/users.service.ts index ff6885b8..84d672e0 100644 --- a/src/lib/services/users.service.ts +++ b/src/lib/services/users.service.ts @@ -227,6 +227,25 @@ export async function updateUser( }); if (!portRole) throw new NotFoundError('User not assigned to this port'); + // Self-target guard (audit M12): an admin must not change their OWN + // privileged fields here — self-deactivation (lockout) or self-escalation + // of role / residentialAccess. Mirrors the permission-override route's + // self-target block. Non-privileged profile edits (name/phone) stay allowed. + if (userId === meta.userId) { + if (data.isActive !== undefined && data.isActive !== profile.isActive) { + throw new ForbiddenError('You cannot change your own active status.'); + } + if (data.roleId !== undefined && data.roleId !== portRole.roleId) { + throw new ForbiddenError('You cannot change your own role.'); + } + if ( + data.residentialAccess !== undefined && + data.residentialAccess !== portRole.residentialAccess + ) { + throw new ForbiddenError('You cannot change your own residential access.'); + } + } + // Update profile fields const profileUpdates: Record = { updatedAt: new Date() }; if (data.displayName !== undefined) profileUpdates.displayName = data.displayName; @@ -324,6 +343,26 @@ export async function updateUser( data.residentialAccess !== undefined && data.residentialAccess !== portRole.residentialAccess ) { + // Caller-superset (audit H8): enabling residentialAccess grants the full + // residential CRUD set (see the resolver in helpers.ts). A caller who + // doesn't hold those leaves themselves must not be able to grant them via + // this flag — the role path is already superset-checked above, and the + // flag must not be an escalation back door. Super admins bypass. + if (data.residentialAccess === true && callerPermissions && !callerIsSuperAdmin) { + const grantedByFlag: Record = { + residential_clients: ['view', 'create', 'edit', 'delete'], + residential_interests: ['view', 'create', 'edit', 'delete', 'change_stage'], + }; + for (const [resource, actions] of Object.entries(grantedByFlag)) { + for (const action of actions) { + if (callerPermissions[resource]?.[action] !== true) { + throw new ForbiddenError( + `You don't hold ${resource}.${action} yourself, so you can't grant residential access.`, + ); + } + } + } + } portRoleUpdates.residentialAccess = data.residentialAccess; } if (Object.keys(portRoleUpdates).length > 0) {