fix(security): harden user router role guards + drop self-service email change

Three high-severity issues in user router:

1. user.update accepted both `role` and `roles[]` from input but only
   guarded the singular `role`. A PROGRAM_ADMIN could pass `roles:
   ['SUPER_ADMIN']` and self-escalate. Now applies the same guards to the
   array field and uses both fields when checking the target's current
   admin tier.

2. user.updateRoles only blocked SUPER_ADMIN grants; PROGRAM_ADMIN could
   grant PROGRAM_ADMIN laterally and could pass `roles: []` against any
   existing SUPER_ADMIN to silently demote them. Now blocks PROGRAM_ADMIN
   grants and refuses to mutate any target who currently holds SUPER_ADMIN
   or PROGRAM_ADMIN unless the caller is SUPER_ADMIN.

3. user.bulkUpdateRoles had the same omission and additionally let a
   PROGRAM_ADMIN strip SUPER_ADMIN from every peer admin in one call. Now
   requires SUPER_ADMIN for any add/remove of admin-tier roles, blocks
   modifying admin targets entirely from non-super-admins, and adds a
   PROGRAM_ADMIN self-demote guard.

Plus: user.updateProfile previously let any authenticated user silently
overwrite their own email with no verification or notification — turning
any short-lived session compromise into permanent account takeover via
password reset on the new address. Email is removed from the input
schema; the profile page email field is now read-only with a "contact
an administrator" hint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-04-29 03:29:09 +02:00
parent a1c293028a
commit 89e637843a
2 changed files with 109 additions and 37 deletions

View File

@@ -106,7 +106,6 @@ export default function ProfileSettingsPage() {
const handleSaveProfile = async () => {
try {
await updateProfile.mutateAsync({
email: email || undefined,
name: name || undefined,
bio,
phoneNumber: phoneNumber || null,
@@ -229,11 +228,13 @@ export default function ProfileSettingsPage() {
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
readOnly
disabled
placeholder="you@example.com"
/>
<p className="text-xs text-muted-foreground">
This will be used for login and all notification emails.
Used for login and notifications. Contact an administrator to
change your email address.
</p>
</div>