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');
|
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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user