From 79ac60dc1e78c68f000a838c77384df56dbd4d04 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 4 Mar 2026 18:04:52 +0100 Subject: [PATCH] feat: automatic mutation audit logging for all non-super-admin users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement withMutationAudit middleware in tRPC that automatically logs every successful mutation for non-SUPER_ADMIN users. Captures procedure path, sanitized input (passwords/tokens redacted), user role, IP, and user agent. Applied to all procedure types except superAdminProcedure. - Input sanitization: strips sensitive fields, truncates long strings (500 chars), limits array size (20 items), caps nesting depth (4) - Entity ID auto-extraction from common input patterns (id, userId, projectId, roundId, etc.) - Action names derived from procedure path (e.g., evaluation.submit becomes EVALUATION_SUBMIT) - Audit page updated with new action types and entity types for filtering auto-generated entries - Failures silently caught — audit logging never breaks operations Co-Authored-By: Claude Opus 4.6 --- src/app/(admin)/admin/audit/page.tsx | 81 ++++++++++++- src/server/trpc.ts | 169 ++++++++++++++++++++++----- 2 files changed, 218 insertions(+), 32 deletions(-) diff --git a/src/app/(admin)/admin/audit/page.tsx b/src/app/(admin)/admin/audit/page.tsx index a08009e..8815db1 100644 --- a/src/app/(admin)/admin/audit/page.tsx +++ b/src/app/(admin)/admin/audit/page.tsx @@ -57,8 +57,9 @@ import { CsvExportDialog } from '@/components/shared/csv-export-dialog' import { formatDate } from '@/lib/utils' import { cn } from '@/lib/utils' -// Action type options +// Action type options (manual audit actions + auto-generated mutation audit actions) const ACTION_TYPES = [ + // Manual audit actions 'CREATE', 'UPDATE', 'DELETE', @@ -88,12 +89,48 @@ const ACTION_TYPES = [ 'APPLY_AI_SUGGESTIONS', 'APPLY_SUGGESTIONS', 'NOTIFY_JURORS_OF_ASSIGNMENTS', + 'IMPERSONATION_START', + 'IMPERSONATION_END', + // Auto-generated mutation audit actions (non-super-admin) + 'EVALUATION_START', + 'EVALUATION_SUBMIT', + 'EVALUATION_AUTOSAVE', + 'EVALUATION_DECLARE_COI', + 'EVALUATION_ADD_COMMENT', + 'APPLICANT_SAVE_SUBMISSION', + 'APPLICANT_SAVE_FILE_METADATA', + 'APPLICANT_DELETE_FILE', + 'APPLICANT_REQUEST_MENTORING', + 'APPLICANT_WITHDRAW_FROM_COMPETITION', + 'APPLICANT_INVITE_TEAM_MEMBER', + 'APPLICANT_REMOVE_TEAM_MEMBER', + 'APPLICANT_SEND_MENTOR_MESSAGE', + 'APPLICATION_SUBMIT', + 'APPLICATION_SAVE_DRAFT', + 'APPLICATION_SUBMIT_DRAFT', + 'MENTOR_SEND_MESSAGE', + 'MENTOR_CREATE_NOTE', + 'MENTOR_DELETE_NOTE', + 'MENTOR_COMPLETE_MILESTONE', + 'LIVE_CAST_VOTE', + 'LIVE_CAST_STAGE_VOTE', + 'LIVE_VOTING_VOTE', + 'LIVE_VOTING_CAST_AUDIENCE_VOTE', + 'DELIBERATION_SUBMIT_VOTE', + 'NOTIFICATION_MARK_AS_READ', + 'NOTIFICATION_MARK_ALL_AS_READ', + 'USER_UPDATE_PROFILE', + 'USER_SET_PASSWORD', + 'USER_CHANGE_PASSWORD', + 'USER_COMPLETE_ONBOARDING', + 'SPECIAL_AWARD_SUBMIT_VOTE', ] // Entity type options const ENTITY_TYPES = [ 'User', 'Program', + 'Competition', 'Round', 'Project', 'Assignment', @@ -101,6 +138,21 @@ const ENTITY_TYPES = [ 'EvaluationForm', 'ProjectFile', 'GracePeriod', + 'Applicant', + 'Application', + 'Mentor', + 'Live', + 'LiveVoting', + 'Deliberation', + 'Notification', + 'SpecialAward', + 'File', + 'Tag', + 'Message', + 'Settings', + 'Ranking', + 'Filtering', + 'RoundEngine', ] // Color map for action types @@ -128,8 +180,35 @@ const actionColors: Record }) }) +// ============================================================================= +// Mutation Audit Logging +// ============================================================================= + +/** Fields that must never appear in audit log input snapshots. */ +const SENSITIVE_KEYS = new Set([ + 'password', 'passwordHash', 'currentPassword', 'newPassword', 'confirmPassword', + 'token', 'secret', 'apiKey', 'accessKey', 'secretKey', + 'creditCard', 'cvv', 'ssn', +]) + +/** Max depth / size for serialized input to avoid bloating the audit table. */ +const MAX_INPUT_DEPTH = 4 +const MAX_STRING_LENGTH = 500 +const MAX_ARRAY_LENGTH = 20 + /** - * Middleware for audit logging + * Recursively sanitize an input object for safe storage in audit logs. + * - Strips sensitive fields (passwords, tokens, secrets) + * - Truncates long strings and arrays + * - Limits nesting depth */ -const withAuditLog = middleware(async ({ ctx, next, path }) => { +function sanitizeInput(value: unknown, depth = 0): unknown { + if (depth > MAX_INPUT_DEPTH) return '[nested]' + if (value === null || value === undefined) return value + if (typeof value === 'boolean' || typeof value === 'number') return value + if (typeof value === 'string') { + return value.length > MAX_STRING_LENGTH + ? value.slice(0, MAX_STRING_LENGTH) + '...' + : value + } + if (value instanceof Date) return value.toISOString() + if (Array.isArray(value)) { + const truncated = value.slice(0, MAX_ARRAY_LENGTH).map(v => sanitizeInput(v, depth + 1)) + if (value.length > MAX_ARRAY_LENGTH) truncated.push(`[+${value.length - MAX_ARRAY_LENGTH} more]`) + return truncated + } + if (typeof value === 'object') { + const result: Record = {} + for (const [key, val] of Object.entries(value as Record)) { + if (SENSITIVE_KEYS.has(key)) { + result[key] = '[REDACTED]' + } else { + result[key] = sanitizeInput(val, depth + 1) + } + } + return result + } + return String(value) +} + +/** + * Middleware that automatically logs all successful mutations for non-SUPER_ADMIN users. + * Captures: procedure path, sanitized input, user role, IP, user agent. + * Failures are silently caught — audit logging never breaks the calling operation. + */ +const withMutationAudit = middleware(async ({ ctx, next, path, type, getRawInput }) => { const result = await next() - // Log successful mutations - if (result.ok && path.includes('.')) { - const [, action] = path.split('.') - const mutationActions = ['create', 'update', 'delete', 'import', 'submit', 'grant', 'revoke'] + // Only log mutations, only on success + if (type !== 'mutation' || !result.ok) return result - if (mutationActions.some((a) => action?.toLowerCase().includes(a))) { - // Audit logging would happen here - // We'll implement this in the audit service + // Must have an authenticated user + 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 + + 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 { + const rawInput = await getRawInput() + if (rawInput !== undefined) { + sanitizedInput = sanitizeInput(rawInput) + } + } catch { + // 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) + + // 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 + + await ctx.prisma.auditLog.create({ + data: { + userId: user.id, + action, + entityType, + entityId: entityId ? String(entityId) : undefined, + detailsJson: { + procedure: path, + procedureName, + role: user.role, + roles: user.roles, + input: sanitizedInput, + } as Prisma.InputJsonValue, + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }, + }) + } catch (error) { + // Never break the calling operation on audit failure + console.error('[MutationAudit] Failed to log:', path, error) } return result @@ -119,55 +225,56 @@ const withAuditLog = middleware(async ({ ctx, next, path }) => { /** * Protected procedure - requires authenticated user + * Mutations are automatically audit-logged for non-SUPER_ADMIN users. */ -export const protectedProcedure = t.procedure.use(isAuthenticated) +export const protectedProcedure = t.procedure.use(isAuthenticated).use(withMutationAudit) /** * Admin procedure - requires SUPER_ADMIN or PROGRAM_ADMIN role + * PROGRAM_ADMIN mutations are audit-logged; SUPER_ADMIN mutations are skipped. */ -export const adminProcedure = t.procedure.use( - hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN') -) +export const adminProcedure = t.procedure + .use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN')) + .use(withMutationAudit) /** * Super admin procedure - requires SUPER_ADMIN role + * No automatic mutation audit (super admins have manual audit trail). */ export const superAdminProcedure = t.procedure.use(hasRole('SUPER_ADMIN')) /** * Jury procedure - requires JURY_MEMBER role + * All mutations are automatically audit-logged. */ -export const juryProcedure = t.procedure.use(hasRole('JURY_MEMBER')) +export const juryProcedure = t.procedure.use(hasRole('JURY_MEMBER')).use(withMutationAudit) /** * Mentor procedure - requires MENTOR role (or admin) + * MENTOR and PROGRAM_ADMIN mutations are audit-logged. */ -export const mentorProcedure = t.procedure.use( - hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN', 'MENTOR') -) +export const mentorProcedure = t.procedure + .use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN', 'MENTOR')) + .use(withMutationAudit) /** * Observer procedure - requires OBSERVER role (read-only access) + * Mutations (if any) are audit-logged for OBSERVER and PROGRAM_ADMIN. */ -export const observerProcedure = t.procedure.use( - hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER') -) +export const observerProcedure = t.procedure + .use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER')) + .use(withMutationAudit) /** * Award master procedure - requires AWARD_MASTER role (or admin) + * AWARD_MASTER and PROGRAM_ADMIN mutations are audit-logged. */ -export const awardMasterProcedure = t.procedure.use( - hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER') -) +export const awardMasterProcedure = t.procedure + .use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER')) + .use(withMutationAudit) /** * Audience procedure - requires any authenticated user + * All mutations are automatically audit-logged. */ -export const audienceProcedure = t.procedure.use(isAuthenticated) - -/** - * Protected procedure with audit logging - */ -export const auditedProcedure = t.procedure - .use(isAuthenticated) - .use(withAuditLog) +export const audienceProcedure = t.procedure.use(isAuthenticated).use(withMutationAudit)