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:
@@ -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<string, unknown> = { 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<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;
|
||||
}
|
||||
if (Object.keys(portRoleUpdates).length > 0) {
|
||||
|
||||
Reference in New Issue
Block a user