Audit Logging

Patterns for tracking user actions and system events for compliance and security.

Overview#

Audit logging creates a record of who did what and when. This pattern covers:

  • Audit log schema design
  • Logger service implementation
  • Automatic logging with Prisma middleware
  • API route auditing
  • Audit log viewer

Prerequisites#

npm install date-fns

Code Example#

Audit Log Schema#

1// prisma/schema.prisma 2model AuditLog { 3 id String @id @default(cuid()) 4 timestamp DateTime @default(now()) 5 userId String? 6 user User? @relation(fields: [userId], references: [id]) 7 action String // e.g., "user.create", "post.delete" 8 entityType String // e.g., "User", "Post" 9 entityId String? 10 oldValues Json? 11 newValues Json? 12 metadata Json? // IP, user agent, etc. 13 status String @default("success") // success, failure 14 errorMessage String? 15 16 @@index([userId]) 17 @@index([entityType, entityId]) 18 @@index([action]) 19 @@index([timestamp]) 20}

Audit Logger Service#

1// lib/audit/logger.ts 2import { prisma } from '@/lib/db' 3import { headers } from 'next/headers' 4 5interface AuditContext { 6 userId?: string 7 ip?: string 8 userAgent?: string 9} 10 11interface AuditEntry { 12 action: string 13 entityType: string 14 entityId?: string 15 oldValues?: Record<string, any> 16 newValues?: Record<string, any> 17 metadata?: Record<string, any> 18 status?: 'success' | 'failure' 19 errorMessage?: string 20} 21 22export class AuditLogger { 23 private context: AuditContext 24 25 constructor(context?: AuditContext) { 26 this.context = context ?? {} 27 } 28 29 static async fromRequest(): Promise<AuditLogger> { 30 const headersList = await headers() 31 return new AuditLogger({ 32 ip: headersList.get('x-forwarded-for') ?? 33 headersList.get('x-real-ip') ?? 34 undefined, 35 userAgent: headersList.get('user-agent') ?? undefined 36 }) 37 } 38 39 setUser(userId: string): this { 40 this.context.userId = userId 41 return this 42 } 43 44 async log(entry: AuditEntry): Promise<void> { 45 await prisma.auditLog.create({ 46 data: { 47 userId: this.context.userId, 48 action: entry.action, 49 entityType: entry.entityType, 50 entityId: entry.entityId, 51 oldValues: entry.oldValues, 52 newValues: entry.newValues, 53 status: entry.status ?? 'success', 54 errorMessage: entry.errorMessage, 55 metadata: { 56 ...entry.metadata, 57 ip: this.context.ip, 58 userAgent: this.context.userAgent 59 } 60 } 61 }) 62 } 63 64 async logSuccess(entry: Omit<AuditEntry, 'status'>): Promise<void> { 65 await this.log({ ...entry, status: 'success' }) 66 } 67 68 async logFailure( 69 entry: Omit<AuditEntry, 'status'>, 70 error?: Error 71 ): Promise<void> { 72 await this.log({ 73 ...entry, 74 status: 'failure', 75 errorMessage: error?.message 76 }) 77 } 78} 79 80// Singleton for simple usage 81export const auditLog = new AuditLogger()

Audit Wrapper Function#

1// lib/audit/decorator.ts 2import { AuditLogger } from './logger' 3 4interface AuditOptions { 5 action: string 6 entityType: string 7 getEntityId?: (result: any) => string 8 getOldValues?: () => Promise<Record<string, any>> 9} 10 11export function withAudit<T extends (...args: any[]) => Promise<any>>( 12 fn: T, 13 options: AuditOptions, 14 getUserId: () => string | undefined 15): T { 16 return (async (...args: Parameters<T>) => { 17 const logger = await AuditLogger.fromRequest() 18 const userId = getUserId() 19 20 if (userId) { 21 logger.setUser(userId) 22 } 23 24 const oldValues = options.getOldValues 25 ? await options.getOldValues() 26 : undefined 27 28 try { 29 const result = await fn(...args) 30 31 await logger.logSuccess({ 32 action: options.action, 33 entityType: options.entityType, 34 entityId: options.getEntityId?.(result), 35 oldValues, 36 newValues: result 37 }) 38 39 return result 40 } catch (error) { 41 await logger.logFailure( 42 { 43 action: options.action, 44 entityType: options.entityType, 45 oldValues 46 }, 47 error as Error 48 ) 49 throw error 50 } 51 }) as T 52} 53 54// Usage 55const createUserWithAudit = withAudit( 56 async (data: { email: string; name: string }) => { 57 return prisma.user.create({ data }) 58 }, 59 { 60 action: 'user.create', 61 entityType: 'User', 62 getEntityId: (user) => user.id 63 }, 64 () => getCurrentUserId() 65)

Prisma Middleware for Automatic Auditing#

1// lib/audit/prisma-middleware.ts 2import { Prisma } from '@prisma/client' 3import { prisma } from '@/lib/db' 4 5const AUDITED_MODELS = ['User', 'Post', 'Team', 'Subscription'] 6const AUDITED_ACTIONS = ['create', 'update', 'delete'] 7 8export const auditMiddleware: Prisma.Middleware = async (params, next) => { 9 const { model, action, args } = params 10 11 if (!model || !AUDITED_MODELS.includes(model)) { 12 return next(params) 13 } 14 15 if (!AUDITED_ACTIONS.includes(action)) { 16 return next(params) 17 } 18 19 // Get old values for update/delete 20 let oldValues: any = null 21 if ((action === 'update' || action === 'delete') && args.where) { 22 oldValues = await (prisma as any)[model.toLowerCase()].findUnique({ 23 where: args.where 24 }) 25 } 26 27 try { 28 const result = await next(params) 29 30 // Log successful operation 31 await prisma.auditLog.create({ 32 data: { 33 action: `${model.toLowerCase()}.${action}`, 34 entityType: model, 35 entityId: result?.id ?? args.where?.id, 36 oldValues: oldValues ? JSON.parse(JSON.stringify(oldValues)) : null, 37 newValues: action !== 'delete' 38 ? JSON.parse(JSON.stringify(result)) 39 : null, 40 status: 'success' 41 } 42 }) 43 44 return result 45 } catch (error) { 46 // Log failed operation 47 await prisma.auditLog.create({ 48 data: { 49 action: `${model.toLowerCase()}.${action}`, 50 entityType: model, 51 entityId: args.where?.id, 52 oldValues: oldValues ? JSON.parse(JSON.stringify(oldValues)) : null, 53 status: 'failure', 54 errorMessage: error instanceof Error ? error.message : 'Unknown error' 55 } 56 }) 57 58 throw error 59 } 60} 61 62// Register middleware 63// prisma.$use(auditMiddleware)

API Route Auditing#

1// lib/audit/route-wrapper.ts 2import { NextRequest, NextResponse } from 'next/server' 3import { AuditLogger } from './logger' 4import { auth } from '@/auth' 5 6type Handler = (req: NextRequest, context?: any) => Promise<NextResponse> 7 8interface AuditRouteOptions { 9 action: string 10 entityType: string 11 getEntityId?: (req: NextRequest, response: any) => string | undefined 12} 13 14export function auditedRoute( 15 handler: Handler, 16 options: AuditRouteOptions 17): Handler { 18 return async (req: NextRequest, context?: any) => { 19 const logger = await AuditLogger.fromRequest() 20 const session = await auth() 21 22 if (session?.user?.id) { 23 logger.setUser(session.user.id) 24 } 25 26 const startTime = Date.now() 27 28 try { 29 const response = await handler(req, context) 30 const responseData = await response.clone().json().catch(() => null) 31 32 await logger.logSuccess({ 33 action: options.action, 34 entityType: options.entityType, 35 entityId: options.getEntityId?.(req, responseData), 36 metadata: { 37 method: req.method, 38 path: req.nextUrl.pathname, 39 statusCode: response.status, 40 duration: Date.now() - startTime 41 } 42 }) 43 44 return response 45 } catch (error) { 46 await logger.logFailure( 47 { 48 action: options.action, 49 entityType: options.entityType, 50 metadata: { 51 method: req.method, 52 path: req.nextUrl.pathname, 53 duration: Date.now() - startTime 54 } 55 }, 56 error as Error 57 ) 58 59 throw error 60 } 61 } 62} 63 64// Usage in app/api/users/[id]/route.ts 65export const DELETE = auditedRoute( 66 async (req, { params }) => { 67 const user = await prisma.user.delete({ 68 where: { id: params.id } 69 }) 70 return NextResponse.json({ success: true }) 71 }, 72 { 73 action: 'user.delete', 74 entityType: 'User', 75 getEntityId: (req, _) => req.nextUrl.pathname.split('/').pop() 76 } 77)

Audit Log Viewer#

1// app/admin/audit/page.tsx 2import { prisma } from '@/lib/db' 3import { formatDistanceToNow } from 'date-fns' 4 5interface AuditLogFilters { 6 userId?: string 7 action?: string 8 entityType?: string 9 startDate?: Date 10 endDate?: Date 11} 12 13async function getAuditLogs(filters: AuditLogFilters, page = 1, limit = 50) { 14 const where: any = {} 15 16 if (filters.userId) where.userId = filters.userId 17 if (filters.action) where.action = { contains: filters.action } 18 if (filters.entityType) where.entityType = filters.entityType 19 if (filters.startDate || filters.endDate) { 20 where.timestamp = {} 21 if (filters.startDate) where.timestamp.gte = filters.startDate 22 if (filters.endDate) where.timestamp.lte = filters.endDate 23 } 24 25 const [logs, total] = await Promise.all([ 26 prisma.auditLog.findMany({ 27 where, 28 include: { user: { select: { name: true, email: true } } }, 29 orderBy: { timestamp: 'desc' }, 30 skip: (page - 1) * limit, 31 take: limit 32 }), 33 prisma.auditLog.count({ where }) 34 ]) 35 36 return { logs, total, pages: Math.ceil(total / limit) } 37} 38 39export default async function AuditLogPage({ 40 searchParams 41}: { 42 searchParams: { page?: string } 43}) { 44 const page = parseInt(searchParams.page ?? '1') 45 const { logs, total, pages } = await getAuditLogs({}, page) 46 47 return ( 48 <div className="container py-8"> 49 <h1 className="text-2xl font-bold mb-6">Audit Logs</h1> 50 51 <table className="w-full"> 52 <thead> 53 <tr className="border-b"> 54 <th className="text-left py-2">Time</th> 55 <th className="text-left py-2">User</th> 56 <th className="text-left py-2">Action</th> 57 <th className="text-left py-2">Entity</th> 58 <th className="text-left py-2">Status</th> 59 </tr> 60 </thead> 61 <tbody> 62 {logs.map(log => ( 63 <tr key={log.id} className="border-b"> 64 <td className="py-2 text-sm text-gray-600"> 65 {formatDistanceToNow(log.timestamp, { addSuffix: true })} 66 </td> 67 <td className="py-2"> 68 {log.user?.name ?? log.user?.email ?? 'System'} 69 </td> 70 <td className="py-2"> 71 <code className="text-sm bg-gray-100 px-1 rounded"> 72 {log.action} 73 </code> 74 </td> 75 <td className="py-2"> 76 {log.entityType} 77 {log.entityId && ( 78 <span className="text-gray-500 text-sm ml-1"> 79 #{log.entityId.slice(0, 8)} 80 </span> 81 )} 82 </td> 83 <td className="py-2"> 84 <span 85 className={`px-2 py-0.5 rounded text-xs ${ 86 log.status === 'success' 87 ? 'bg-green-100 text-green-800' 88 : 'bg-red-100 text-red-800' 89 }`} 90 > 91 {log.status} 92 </span> 93 </td> 94 </tr> 95 ))} 96 </tbody> 97 </table> 98 99 <div className="mt-4 text-sm text-gray-600"> 100 Showing {logs.length} of {total} logs 101 </div> 102 </div> 103 ) 104}

Usage Instructions#

  1. Create the AuditLog model in your Prisma schema
  2. Use the AuditLogger service for manual logging
  3. Register the Prisma middleware for automatic logging
  4. Wrap API routes with the auditedRoute helper
  5. Build an admin interface to view and search logs

Best Practices#

  • Log immutably - Never modify or delete audit logs
  • Include context - Log IP, user agent, and timestamps
  • Store old values - Capture state before changes
  • Use consistent actions - Follow a naming convention like entity.action
  • Index wisely - Add indexes for common query patterns
  • Retention policies - Archive old logs to reduce storage costs
  • Secure access - Restrict audit log viewing to admins only