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-fnsCode 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#
- Create the AuditLog model in your Prisma schema
- Use the AuditLogger service for manual logging
- Register the Prisma middleware for automatic logging
- Wrap API routes with the auditedRoute helper
- 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
Related Patterns#
- Secrets Management - Track secret access
- Monitoring - Application observability
- Soft Delete - Keep deleted data for audit
- RBAC - Control who can view audit logs