feat: automatic mutation audit logging for all non-super-admin users
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled

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 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 18:04:52 +01:00
parent 6c52e519e5
commit 79ac60dc1e
2 changed files with 218 additions and 32 deletions

View File

@@ -57,8 +57,9 @@ import { CsvExportDialog } from '@/components/shared/csv-export-dialog'
import { formatDate } from '@/lib/utils' import { formatDate } from '@/lib/utils'
import { cn } 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 = [ const ACTION_TYPES = [
// Manual audit actions
'CREATE', 'CREATE',
'UPDATE', 'UPDATE',
'DELETE', 'DELETE',
@@ -88,12 +89,48 @@ const ACTION_TYPES = [
'APPLY_AI_SUGGESTIONS', 'APPLY_AI_SUGGESTIONS',
'APPLY_SUGGESTIONS', 'APPLY_SUGGESTIONS',
'NOTIFY_JURORS_OF_ASSIGNMENTS', '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 // Entity type options
const ENTITY_TYPES = [ const ENTITY_TYPES = [
'User', 'User',
'Program', 'Program',
'Competition',
'Round', 'Round',
'Project', 'Project',
'Assignment', 'Assignment',
@@ -101,6 +138,21 @@ const ENTITY_TYPES = [
'EvaluationForm', 'EvaluationForm',
'ProjectFile', 'ProjectFile',
'GracePeriod', 'GracePeriod',
'Applicant',
'Application',
'Mentor',
'Live',
'LiveVoting',
'Deliberation',
'Notification',
'SpecialAward',
'File',
'Tag',
'Message',
'Settings',
'Ranking',
'Filtering',
'RoundEngine',
] ]
// Color map for action types // Color map for action types
@@ -128,8 +180,35 @@ const actionColors: Record<string, 'default' | 'destructive' | 'secondary' | 'ou
APPLY_AI_SUGGESTIONS: 'default', APPLY_AI_SUGGESTIONS: 'default',
APPLY_SUGGESTIONS: 'default', APPLY_SUGGESTIONS: 'default',
NOTIFY_JURORS_OF_ASSIGNMENTS: 'outline', NOTIFY_JURORS_OF_ASSIGNMENTS: 'outline',
IMPERSONATION_START: 'destructive',
IMPERSONATION_END: 'secondary',
// Auto-generated mutation audit actions
EVALUATION_START: 'default',
EVALUATION_SUBMIT: 'default',
EVALUATION_AUTOSAVE: 'outline',
EVALUATION_DECLARE_COI: 'secondary',
EVALUATION_ADD_COMMENT: 'outline',
APPLICANT_SAVE_SUBMISSION: 'default',
APPLICANT_DELETE_FILE: 'destructive',
APPLICANT_WITHDRAW_FROM_COMPETITION: 'destructive',
APPLICANT_INVITE_TEAM_MEMBER: 'default',
APPLICANT_REMOVE_TEAM_MEMBER: 'destructive',
APPLICATION_SUBMIT: 'default',
MENTOR_SEND_MESSAGE: 'outline',
MENTOR_CREATE_NOTE: 'default',
MENTOR_DELETE_NOTE: 'destructive',
LIVE_CAST_VOTE: 'default',
LIVE_CAST_STAGE_VOTE: 'default',
LIVE_VOTING_CAST_AUDIENCE_VOTE: 'default',
DELIBERATION_SUBMIT_VOTE: 'default',
SPECIAL_AWARD_SUBMIT_VOTE: 'default',
USER_UPDATE_PROFILE: 'secondary',
USER_SET_PASSWORD: 'outline',
USER_CHANGE_PASSWORD: 'outline',
USER_COMPLETE_ONBOARDING: 'default',
} }
export default function AuditLogPage() { export default function AuditLogPage() {
// Filter state // Filter state
const [filters, setFilters] = useState({ const [filters, setFilters] = useState({

View File

@@ -1,6 +1,7 @@
import { initTRPC, TRPCError } from '@trpc/server' import { initTRPC, TRPCError } from '@trpc/server'
import superjson from 'superjson' import superjson from 'superjson'
import { ZodError } from 'zod' import { ZodError } from 'zod'
import type { Prisma } from '@prisma/client'
import type { Context } from './context' import type { Context } from './context'
import type { UserRole } from '@prisma/client' import type { UserRole } from '@prisma/client'
@@ -93,21 +94,126 @@ const hasRole = (...roles: UserRole[]) =>
}) })
}) })
// =============================================================================
// 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<string, unknown> = {}
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
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() const result = await next()
// Log successful mutations // Only log mutations, only on success
if (result.ok && path.includes('.')) { if (type !== 'mutation' || !result.ok) return result
const [, action] = path.split('.')
const mutationActions = ['create', 'update', 'delete', 'import', 'submit', 'grant', 'revoke']
if (mutationActions.some((a) => action?.toLowerCase().includes(a))) { // Must have an authenticated user
// Audit logging would happen here const user = ctx.session?.user
// We'll implement this in the audit service 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<string, unknown>
: 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 return result
@@ -119,55 +225,56 @@ const withAuditLog = middleware(async ({ ctx, next, path }) => {
/** /**
* Protected procedure - requires authenticated user * 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 * 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( export const adminProcedure = t.procedure
hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN') .use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN'))
) .use(withMutationAudit)
/** /**
* Super admin procedure - requires SUPER_ADMIN role * 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')) export const superAdminProcedure = t.procedure.use(hasRole('SUPER_ADMIN'))
/** /**
* Jury procedure - requires JURY_MEMBER role * 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 procedure - requires MENTOR role (or admin)
* MENTOR and PROGRAM_ADMIN mutations are audit-logged.
*/ */
export const mentorProcedure = t.procedure.use( export const mentorProcedure = t.procedure
hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN', 'MENTOR') .use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN', 'MENTOR'))
) .use(withMutationAudit)
/** /**
* Observer procedure - requires OBSERVER role (read-only access) * 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( export const observerProcedure = t.procedure
hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER') .use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER'))
) .use(withMutationAudit)
/** /**
* Award master procedure - requires AWARD_MASTER role (or admin) * Award master procedure - requires AWARD_MASTER role (or admin)
* AWARD_MASTER and PROGRAM_ADMIN mutations are audit-logged.
*/ */
export const awardMasterProcedure = t.procedure.use( export const awardMasterProcedure = t.procedure
hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER') .use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER'))
) .use(withMutationAudit)
/** /**
* Audience procedure - requires any authenticated user * Audience procedure - requires any authenticated user
* All mutations are automatically audit-logged.
*/ */
export const audienceProcedure = t.procedure.use(isAuthenticated) export const audienceProcedure = t.procedure.use(isAuthenticated).use(withMutationAudit)
/**
* Protected procedure with audit logging
*/
export const auditedProcedure = t.procedure
.use(isAuthenticated)
.use(withAuditLog)