audit: 33-agent comprehensive audit + critical fixes

Full team audit run, all reports verbatim in docs/AUDIT-2026-05-12.md
(5900+ lines, 30+ critical findings). Already-fixed this commit:
- permission-overrides PUT: self-target block + RolePermissions allow-list + cross-tenant guard
- /api/auth/resolve-identifier: rate-limit + synthetic miss-email kill enumeration
- admin email-change: rotates account.accountId + revokes sessions
- middleware: token-gated email confirm/cancel routes whitelisted
- NAV_CATALOG: 10 dead-link sweeps to existing /admin/<x> targets

Feature work landing same commit: optional username sign-in
(migration 0054), per-user permission overrides (0055) with three-state
matrix tabbed inside UserForm, user disable button, role + outcome +
stage label normalisation across the platform, admin email-change
with auto-notification template.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 16:52:35 +02:00
parent 660553c074
commit 4b9743a594
31 changed files with 7042 additions and 81 deletions

View File

@@ -1,7 +1,7 @@
import { and, eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { user, userProfiles, userPortRoles, roles, ports } from '@/lib/db/schema';
import { account, session, user, userProfiles, userPortRoles, roles, ports } from '@/lib/db/schema';
import { auth } from '@/lib/auth';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
@@ -247,6 +247,25 @@ export async function updateUser(
await db.update(user).set(userUpdates).where(eq(user.id, userId));
}
if (wantsEmailChange) {
const newEmailLower = data.email!.toLowerCase();
// Better Auth's credential provider authenticates by
// `account.accountId` (the email captured at sign-up), NOT by
// `user.email`. Without this update the user can't sign in with
// either address — old fails because user.email no longer matches,
// new fails because there's no account.accountId row for it.
await db
.update(account)
.set({ accountId: newEmailLower, updatedAt: new Date() })
.where(and(eq(account.userId, userId), eq(account.providerId, 'credential')));
// Revoke every active session — the admin just changed the identity
// the user authenticates with, so existing sessions are effectively
// orphaned and a security risk if the account is being rotated due
// to compromise. The user re-authenticates with the new address.
await db.delete(session).where(eq(session.userId, userId));
}
if (wantsEmailChange && previousEmail) {
// Best-effort notification — failure to send doesn't roll back the
// change because Better Auth's primary identity has already moved.