From 13f125af288095fe1600b33e2f1696819a10ed54 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 4 Mar 2026 18:28:56 +0100 Subject: [PATCH] feat: error audit middleware, impersonation attribution, account lockout logging - Add withErrorAudit middleware tracking FORBIDDEN/UNAUTHORIZED/NOT_FOUND per user - Fix impersonation attribution: log real admin ID, prefix IMPERSONATED_ on actions - Add ACCOUNT_LOCKED audit events on login lockout (distinct from LOGIN_FAILED) - Audit export of assignments and audit logs (meta-audit gap) - Update audit page UI with new security event types and colors Co-Authored-By: Claude Opus 4.6 --- src/app/(admin)/admin/audit/page.tsx | 10 ++ src/lib/auth.ts | 39 +++++- src/server/routers/export.ts | 24 ++++ src/server/trpc.ts | 191 ++++++++++++++++++++------- 4 files changed, 214 insertions(+), 50 deletions(-) diff --git a/src/app/(admin)/admin/audit/page.tsx b/src/app/(admin)/admin/audit/page.tsx index 69290c4..79ad642 100644 --- a/src/app/(admin)/admin/audit/page.tsx +++ b/src/app/(admin)/admin/audit/page.tsx @@ -126,6 +126,11 @@ const ACTION_TYPES = [ 'USER_CHANGE_PASSWORD', 'USER_COMPLETE_ONBOARDING', 'SPECIAL_AWARD_SUBMIT_VOTE', + // Security events + 'ACCOUNT_LOCKED', + 'ACCESS_DENIED_FORBIDDEN', + 'ACCESS_DENIED_UNAUTHORIZED', + 'ACCESS_DENIED_NOT_FOUND', ] // Entity type options @@ -210,6 +215,11 @@ const actionColors: Record= MAX_LOGIN_ATTEMPTS) { + const wasLocked = current.count >= MAX_LOGIN_ATTEMPTS + if (wasLocked) { current.lockedUntil = Date.now() + LOCKOUT_DURATION_MS current.count = 0 } @@ -171,6 +172,22 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ }, }).catch(() => {}) + // Log account lockout as a distinct security event + if (wasLocked) { + await prisma.auditLog.create({ + data: { + userId: null, + action: 'ACCOUNT_LOCKED', + entityType: 'User', + detailsJson: { + email, + reason: 'max_failed_attempts', + lockoutDurationMinutes: LOCKOUT_DURATION_MS / 60000, + }, + }, + }).catch(() => {}) + } + return null } @@ -185,7 +202,8 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ // Track failed attempt const current = failedAttempts.get(email) || { count: 0, lockedUntil: 0 } current.count++ - if (current.count >= MAX_LOGIN_ATTEMPTS) { + const wasLocked = current.count >= MAX_LOGIN_ATTEMPTS + if (wasLocked) { current.lockedUntil = Date.now() + LOCKOUT_DURATION_MS current.count = 0 } @@ -202,6 +220,23 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ }, }).catch(() => {}) + // Log account lockout as a distinct security event + if (wasLocked) { + await prisma.auditLog.create({ + data: { + userId: user.id, + action: 'ACCOUNT_LOCKED', + entityType: 'User', + entityId: user.id, + detailsJson: { + email, + reason: 'max_failed_attempts', + lockoutDurationMinutes: LOCKOUT_DURATION_MS / 60000, + }, + }, + }).catch(() => {}) + } + return null } diff --git a/src/server/routers/export.ts b/src/server/routers/export.ts index bc380f6..1d368a7 100644 --- a/src/server/routers/export.ts +++ b/src/server/routers/export.ts @@ -235,6 +235,16 @@ export const exportRouter = router({ orderBy: [{ project: { title: 'asc' } }, { user: { name: 'asc' } }], }) + await logAudit({ + prisma: ctx.prisma, + userId: ctx.user.id, + action: 'EXPORT', + entityType: 'Assignment', + detailsJson: { roundId: input.roundId, count: assignments.length, exportType: 'assignments' }, + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }) + const data = assignments.map((a) => ({ projectTitle: a.project.title, teamName: a.project.teamName, @@ -398,6 +408,20 @@ export const exportRouter = router({ take: 10000, // Limit export to 10k records }) + await logAudit({ + prisma: ctx.prisma, + userId: ctx.user.id, + action: 'EXPORT', + entityType: 'AuditLog', + detailsJson: { + count: logs.length, + exportType: 'auditLogs', + filters: { userId, action, entityType, startDate: startDate?.toISOString(), endDate: endDate?.toISOString() }, + }, + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }) + const data = logs.map((log) => ({ timestamp: log.timestamp.toISOString(), userName: log.user?.name ?? 'System', diff --git a/src/server/trpc.ts b/src/server/trpc.ts index 5f23fd8..41e0915 100644 --- a/src/server/trpc.ts +++ b/src/server/trpc.ts @@ -145,8 +145,34 @@ function sanitizeInput(value: unknown, depth = 0): unknown { return String(value) } +/** + * Extract common fields from a sanitized input object for audit logging. + */ +function extractAuditFields(path: string, sanitizedInput: unknown) { + const dotIndex = path.indexOf('.') + const routerName = dotIndex > 0 ? path.slice(0, dotIndex) : path + const procedureName = dotIndex > 0 ? path.slice(dotIndex + 1) : path + + // Convert procedure path to readable action (e.g., "evaluation.submit" → "EVALUATION_SUBMIT") + const action = path.replace(/\./g, '_').replace(/([a-z])([A-Z])/g, '$1_$2').toUpperCase() + + // Capitalize first letter of router name for entityType + const entityType = routerName.charAt(0).toUpperCase() + routerName.slice(1) + + // Try to extract entityId from common input patterns + const inputObj = (typeof sanitizedInput === 'object' && sanitizedInput !== null) + ? sanitizedInput as Record + : undefined + const entityId = inputObj?.id ?? inputObj?.userId ?? inputObj?.projectId ?? + inputObj?.roundId ?? inputObj?.competitionId ?? inputObj?.editionId ?? + inputObj?.targetUserId ?? inputObj?.sessionId ?? inputObj?.awardId + + return { routerName, procedureName, action, entityType, entityId } +} + /** * Middleware that automatically logs all successful mutations for non-SUPER_ADMIN users. + * During impersonation, logs both the target user ID and the real admin ID. * Captures: procedure path, sanitized input, user role, IP, user agent. * Failures are silently caught — audit logging never breaks the calling operation. */ @@ -160,18 +186,11 @@ const withMutationAudit = middleware(async ({ ctx, next, path, type, getRawInput const user = ctx.session?.user if (!user?.id) return result - // Skip SUPER_ADMIN — they have their own manual audit trail - if (user.role === 'SUPER_ADMIN') return result + // Skip SUPER_ADMIN (unless impersonating — then user.role is the target's role) + const impersonation = user.impersonating as { originalId: string; originalRole: string; originalEmail: string } | undefined + if (user.role === 'SUPER_ADMIN' && !impersonation) return result try { - // Extract router name and procedure name from path (e.g., "evaluation.submit") - const dotIndex = path.indexOf('.') - const routerName = dotIndex > 0 ? path.slice(0, dotIndex) : path - const procedureName = dotIndex > 0 ? path.slice(dotIndex + 1) : path - - // Convert procedure path to readable action (e.g., "evaluation.submit" → "EVALUATION_SUBMIT") - const action = path.replace(/\./g, '_').replace(/([a-z])([A-Z])/g, '$1_$2').toUpperCase() - // Get and sanitize the raw input let sanitizedInput: unknown = undefined try { @@ -183,30 +202,32 @@ const withMutationAudit = middleware(async ({ ctx, next, path, type, getRawInput // getRawInput can fail if input was already consumed; ignore } - // Capitalize first letter of router name for entityType - const entityType = routerName.charAt(0).toUpperCase() + routerName.slice(1) + const { procedureName, action, entityType, entityId } = extractAuditFields(path, sanitizedInput) - // Try to extract entityId from common input patterns - const inputObj = (typeof sanitizedInput === 'object' && sanitizedInput !== null) - ? sanitizedInput as Record - : undefined - const entityId = inputObj?.id ?? inputObj?.userId ?? inputObj?.projectId ?? - inputObj?.roundId ?? inputObj?.competitionId ?? inputObj?.editionId ?? - inputObj?.targetUserId ?? inputObj?.sessionId ?? inputObj?.awardId + // Build details payload — include impersonation info when active + const details: Record = { + procedure: path, + procedureName, + role: user.role, + roles: user.roles, + input: sanitizedInput, + } + if (impersonation) { + details.impersonatedBy = { + adminId: impersonation.originalId, + adminEmail: impersonation.originalEmail, + adminRole: impersonation.originalRole, + } + } await ctx.prisma.auditLog.create({ data: { - userId: user.id, - action, + // During impersonation, log as the real admin with target info in details + userId: impersonation ? impersonation.originalId : user.id, + action: impersonation ? `IMPERSONATED_${action}` : action, entityType, entityId: entityId ? String(entityId) : undefined, - detailsJson: { - procedure: path, - procedureName, - role: user.role, - roles: user.roles, - input: sanitizedInput, - } as Prisma.InputJsonValue, + detailsJson: details as Prisma.InputJsonValue, ipAddress: ctx.ip, userAgent: ctx.userAgent, }, @@ -219,62 +240,136 @@ const withMutationAudit = middleware(async ({ ctx, next, path, type, getRawInput return result }) +/** + * Middleware that logs failed operations (FORBIDDEN, UNAUTHORIZED, NOT_FOUND errors). + * Tracks permission denials and access violations per user for security monitoring. + * Applied to all authenticated procedures. + */ +const withErrorAudit = middleware(async ({ ctx, next, path, type, getRawInput }) => { + try { + return await next() + } catch (error) { + // Only log TRPCErrors that indicate security/access issues + if (error instanceof TRPCError) { + const securityCodes = ['FORBIDDEN', 'UNAUTHORIZED', 'NOT_FOUND'] as const + if (securityCodes.includes(error.code as typeof securityCodes[number])) { + try { + let sanitizedInput: unknown = undefined + try { + const rawInput = await getRawInput() + if (rawInput !== undefined) { + sanitizedInput = sanitizeInput(rawInput) + } + } catch { /* ignore */ } + + const { procedureName, entityType, entityId } = extractAuditFields(path, sanitizedInput) + + const user = ctx.session?.user + const impersonation = user?.impersonating as { originalId: string; originalEmail: string } | undefined + + await ctx.prisma.auditLog.create({ + data: { + userId: impersonation?.originalId ?? user?.id ?? null, + action: `ACCESS_DENIED_${error.code}`, + entityType, + entityId: entityId ? String(entityId) : undefined, + detailsJson: { + procedure: path, + procedureName, + type, + errorCode: error.code, + errorMessage: error.message, + role: user?.role, + input: sanitizedInput, + ...(impersonation ? { impersonatedUserId: user?.id } : {}), + } as Prisma.InputJsonValue, + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }, + }) + } catch (auditError) { + console.error('[ErrorAudit] Failed to log:', path, auditError) + } + } + } + + // Always re-throw the original error + throw error + } +}) + // ============================================================================= // Procedure Types // ============================================================================= /** - * Protected procedure - requires authenticated user - * Mutations are automatically audit-logged for non-SUPER_ADMIN users. + * Protected procedure - requires authenticated user. + * Mutations auto-audited, errors (FORBIDDEN/UNAUTHORIZED/NOT_FOUND) tracked. */ -export const protectedProcedure = t.procedure.use(isAuthenticated).use(withMutationAudit) +export const protectedProcedure = t.procedure + .use(isAuthenticated) + .use(withErrorAudit) + .use(withMutationAudit) /** - * Admin procedure - requires SUPER_ADMIN or PROGRAM_ADMIN role - * PROGRAM_ADMIN mutations are audit-logged; SUPER_ADMIN mutations are skipped. + * Admin procedure - requires SUPER_ADMIN or PROGRAM_ADMIN role. + * PROGRAM_ADMIN mutations are audit-logged; SUPER_ADMIN mutations skipped. + * Errors tracked for all users. */ export const adminProcedure = t.procedure .use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN')) + .use(withErrorAudit) .use(withMutationAudit) /** - * Super admin procedure - requires SUPER_ADMIN role - * No automatic mutation audit (super admins have manual audit trail). + * Super admin procedure - requires SUPER_ADMIN role. + * No automatic mutation audit. Errors still tracked. */ -export const superAdminProcedure = t.procedure.use(hasRole('SUPER_ADMIN')) +export const superAdminProcedure = t.procedure + .use(hasRole('SUPER_ADMIN')) + .use(withErrorAudit) /** - * Jury procedure - requires JURY_MEMBER role - * All mutations are automatically audit-logged. + * Jury procedure - requires JURY_MEMBER role. + * All mutations auto-audited, errors tracked. */ -export const juryProcedure = t.procedure.use(hasRole('JURY_MEMBER')).use(withMutationAudit) +export const juryProcedure = t.procedure + .use(hasRole('JURY_MEMBER')) + .use(withErrorAudit) + .use(withMutationAudit) /** - * Mentor procedure - requires MENTOR role (or admin) - * MENTOR and PROGRAM_ADMIN mutations are audit-logged. + * Mentor procedure - requires MENTOR role (or admin). + * MENTOR and PROGRAM_ADMIN mutations are audit-logged, errors tracked. */ export const mentorProcedure = t.procedure .use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN', 'MENTOR')) + .use(withErrorAudit) .use(withMutationAudit) /** - * Observer procedure - requires OBSERVER role (read-only access) - * Mutations (if any) are audit-logged for OBSERVER and PROGRAM_ADMIN. + * Observer procedure - requires OBSERVER role (read-only access). + * Mutations (if any) audit-logged, errors tracked. */ export const observerProcedure = t.procedure .use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER')) + .use(withErrorAudit) .use(withMutationAudit) /** - * Award master procedure - requires AWARD_MASTER role (or admin) - * AWARD_MASTER and PROGRAM_ADMIN mutations are audit-logged. + * Award master procedure - requires AWARD_MASTER role (or admin). + * AWARD_MASTER and PROGRAM_ADMIN mutations audit-logged, errors tracked. */ export const awardMasterProcedure = t.procedure .use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER')) + .use(withErrorAudit) .use(withMutationAudit) /** - * Audience procedure - requires any authenticated user - * All mutations are automatically audit-logged. + * Audience procedure - requires any authenticated user. + * All mutations auto-audited, errors tracked. */ -export const audienceProcedure = t.procedure.use(isAuthenticated).use(withMutationAudit) +export const audienceProcedure = t.procedure + .use(isAuthenticated) + .use(withErrorAudit) + .use(withMutationAudit)