fix(audit): H8 (residentialAccess caller-superset) + M12 (self-target guard) in updateUser

H8: enabling the residentialAccess flag grants the full residential CRUD
set, so a non-super-admin caller must now hold those leaves themselves to
grant it — closes the escalation back door around the role-superset check.
M12: an admin can no longer change their OWN isActive / roleId /
residentialAccess (self-lockout / self-escalation), mirroring the
permission-override route's self-target block.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-02 12:06:06 +02:00
parent f4fb7aae84
commit 7a7fd76081

View File

@@ -227,6 +227,25 @@ export async function updateUser(
}); });
if (!portRole) throw new NotFoundError('User not assigned to this port'); 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 // Update profile fields
const profileUpdates: Record<string, unknown> = { updatedAt: new Date() }; const profileUpdates: Record<string, unknown> = { updatedAt: new Date() };
if (data.displayName !== undefined) profileUpdates.displayName = data.displayName; if (data.displayName !== undefined) profileUpdates.displayName = data.displayName;
@@ -324,6 +343,26 @@ export async function updateUser(
data.residentialAccess !== undefined && data.residentialAccess !== undefined &&
data.residentialAccess !== portRole.residentialAccess 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<string, string[]> = {
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; portRoleUpdates.residentialAccess = data.residentialAccess;
} }
if (Object.keys(portRoleUpdates).length > 0) { if (Object.keys(portRoleUpdates).length > 0) {