Skip to content

Commit 0a2d89c

Browse files
committed
feat(audit-log): add persistent audit log system with comprehensive route instrumentation
1 parent eab01e0 commit 0a2d89c

File tree

84 files changed

+12929
-79
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

84 files changed

+12929
-79
lines changed

apps/sim/app/api/auth/oauth/disconnect/route.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
*
44
* @vitest-environment node
55
*/
6-
import { createMockLogger, createMockRequest } from '@sim/testing'
6+
import { auditMock, createMockLogger, createMockRequest } from '@sim/testing'
77
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
88

99
describe('OAuth Disconnect API Route', () => {
@@ -67,6 +67,8 @@ describe('OAuth Disconnect API Route', () => {
6767
vi.doMock('@/lib/webhooks/utils.server', () => ({
6868
syncAllWebhooksForCredentialSet: mockSyncAllWebhooksForCredentialSet,
6969
}))
70+
71+
vi.doMock('@/lib/audit/log', () => auditMock)
7072
})
7173

7274
afterEach(() => {

apps/sim/app/api/auth/oauth/disconnect/route.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
44
import { and, eq, like, or } from 'drizzle-orm'
55
import { type NextRequest, NextResponse } from 'next/server'
66
import { z } from 'zod'
7+
import { recordAudit } from '@/lib/audit/log'
78
import { getSession } from '@/lib/auth'
89
import { generateRequestId } from '@/lib/core/utils/request'
910
import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
@@ -118,6 +119,20 @@ export async function POST(request: NextRequest) {
118119
}
119120
}
120121

122+
recordAudit({
123+
workspaceId: '',
124+
actorId: session.user.id,
125+
action: 'oauth.disconnected',
126+
resourceType: 'oauth',
127+
resourceId: providerId ?? provider,
128+
actorName: session.user.name ?? undefined,
129+
actorEmail: session.user.email ?? undefined,
130+
resourceName: provider,
131+
description: `Disconnected OAuth provider: ${provider}`,
132+
metadata: { provider, providerId },
133+
request,
134+
})
135+
121136
return NextResponse.json({ success: true }, { status: 200 })
122137
} catch (error) {
123138
logger.error(`[${requestId}] Error disconnecting OAuth provider`, error)

apps/sim/app/api/chat/manage/[id]/route.test.ts

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@
33
*
44
* @vitest-environment node
55
*/
6-
import { loggerMock } from '@sim/testing'
6+
import { auditMock, loggerMock } from '@sim/testing'
77
import { NextRequest } from 'next/server'
88
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
99

10+
vi.mock('@/lib/audit/log', () => auditMock)
11+
1012
vi.mock('@/lib/core/config/feature-flags', () => ({
1113
isDev: true,
1214
isHosted: false,
@@ -48,7 +50,14 @@ describe('Chat Edit API Route', () => {
4850
}))
4951

5052
vi.doMock('@sim/db/schema', () => ({
51-
chat: { id: 'id', identifier: 'identifier', userId: 'userId' },
53+
chat: {
54+
id: 'id',
55+
identifier: 'identifier',
56+
userId: 'userId',
57+
workflowId: 'workflowId',
58+
title: 'title',
59+
},
60+
workflow: { id: 'id', workspaceId: 'workspaceId' },
5261
}))
5362

5463
// Mock logger - use loggerMock from @sim/testing
@@ -217,7 +226,7 @@ describe('Chat Edit API Route', () => {
217226
}
218227

219228
mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat })
220-
mockLimit.mockResolvedValueOnce([]) // No identifier conflict
229+
mockLimit.mockResolvedValueOnce([{ workspaceId: 'workspace-123' }])
221230

222231
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
223232
method: 'PATCH',
@@ -312,7 +321,7 @@ describe('Chat Edit API Route', () => {
312321
}
313322

314323
mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat })
315-
mockLimit.mockResolvedValueOnce([])
324+
mockLimit.mockResolvedValueOnce([{ workspaceId: 'workspace-123' }])
316325

317326
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
318327
method: 'PATCH',
@@ -372,7 +381,8 @@ describe('Chat Edit API Route', () => {
372381
}))
373382

374383
mockCheckChatAccess.mockResolvedValue({ hasAccess: true })
375-
mockWhere.mockResolvedValue(undefined)
384+
mockLimit.mockResolvedValueOnce([{ workflowId: 'workflow-123', title: 'Test Chat' }])
385+
mockLimit.mockResolvedValueOnce([{ workspaceId: 'workspace-123' }])
376386

377387
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
378388
method: 'DELETE',
@@ -394,7 +404,8 @@ describe('Chat Edit API Route', () => {
394404
}))
395405

396406
mockCheckChatAccess.mockResolvedValue({ hasAccess: true })
397-
mockWhere.mockResolvedValue(undefined)
407+
mockLimit.mockResolvedValueOnce([{ workflowId: 'workflow-123', title: 'Test Chat' }])
408+
mockLimit.mockResolvedValueOnce([{ workspaceId: 'workspace-123' }])
398409

399410
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
400411
method: 'DELETE',

apps/sim/app/api/chat/manage/[id]/route.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { db } from '@sim/db'
2-
import { chat } from '@sim/db/schema'
2+
import { chat, workflow } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { eq } from 'drizzle-orm'
55
import type { NextRequest } from 'next/server'
66
import { z } from 'zod'
7+
import { recordAudit } from '@/lib/audit/log'
78
import { getSession } from '@/lib/auth'
89
import { isDev } from '@/lib/core/config/feature-flags'
910
import { encryptSecret } from '@/lib/core/security/encryption'
@@ -217,6 +218,25 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
217218

218219
logger.info(`Chat "${chatId}" updated successfully`)
219220

221+
const [workflowRecord] = await db
222+
.select({ workspaceId: workflow.workspaceId })
223+
.from(workflow)
224+
.where(eq(workflow.id, existingChat[0].workflowId))
225+
.limit(1)
226+
227+
recordAudit({
228+
workspaceId: workflowRecord?.workspaceId || '',
229+
actorId: session.user.id,
230+
actorName: session.user.name,
231+
actorEmail: session.user.email,
232+
action: 'chat.updated',
233+
resourceType: 'chat',
234+
resourceId: chatId,
235+
resourceName: title || existingChat[0].title,
236+
description: `Updated chat deployment "${title || existingChat[0].title}"`,
237+
request,
238+
})
239+
220240
return createSuccessResponse({
221241
id: chatId,
222242
chatUrl,
@@ -258,10 +278,37 @@ export async function DELETE(
258278
return createErrorResponse('Chat not found or access denied', 404)
259279
}
260280

281+
const [chatRecord] = await db
282+
.select({ workflowId: chat.workflowId, title: chat.title })
283+
.from(chat)
284+
.where(eq(chat.id, chatId))
285+
.limit(1)
286+
287+
const [workflowRecord] = chatRecord
288+
? await db
289+
.select({ workspaceId: workflow.workspaceId })
290+
.from(workflow)
291+
.where(eq(workflow.id, chatRecord.workflowId))
292+
.limit(1)
293+
: [undefined]
294+
261295
await db.delete(chat).where(eq(chat.id, chatId))
262296

263297
logger.info(`Chat "${chatId}" deleted successfully`)
264298

299+
recordAudit({
300+
workspaceId: workflowRecord?.workspaceId || '',
301+
actorId: session.user.id,
302+
actorName: session.user.name,
303+
actorEmail: session.user.email,
304+
action: 'chat.deleted',
305+
resourceType: 'chat',
306+
resourceId: chatId,
307+
resourceName: chatRecord?.title || chatId,
308+
description: `Deleted chat deployment "${chatRecord?.title || chatId}"`,
309+
request: _request,
310+
})
311+
265312
return createSuccessResponse({
266313
message: 'Chat deployment deleted successfully',
267314
})

apps/sim/app/api/chat/route.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import { NextRequest } from 'next/server'
21
/**
32
* Tests for chat API route
43
*
54
* @vitest-environment node
65
*/
6+
import { auditMock } from '@sim/testing'
7+
import { NextRequest } from 'next/server'
78
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
89

910
describe('Chat API Route', () => {
@@ -30,6 +31,8 @@ describe('Chat API Route', () => {
3031
mockInsert.mockReturnValue({ values: mockValues })
3132
mockValues.mockReturnValue({ returning: mockReturning })
3233

34+
vi.doMock('@/lib/audit/log', () => auditMock)
35+
3336
vi.doMock('@sim/db', () => ({
3437
db: {
3538
select: mockSelect,

apps/sim/app/api/chat/route.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { eq } from 'drizzle-orm'
55
import type { NextRequest } from 'next/server'
66
import { v4 as uuidv4 } from 'uuid'
77
import { z } from 'zod'
8+
import { recordAudit } from '@/lib/audit/log'
89
import { getSession } from '@/lib/auth'
910
import { isDev } from '@/lib/core/config/feature-flags'
1011
import { encryptSecret } from '@/lib/core/security/encryption'
@@ -42,7 +43,7 @@ const chatSchema = z.object({
4243
.default([]),
4344
})
4445

45-
export async function GET(request: NextRequest) {
46+
export async function GET(_request: NextRequest) {
4647
try {
4748
const session = await getSession()
4849

@@ -224,6 +225,20 @@ export async function POST(request: NextRequest) {
224225
// Silently fail
225226
}
226227

228+
recordAudit({
229+
workspaceId: workflowRecord.workspaceId || '',
230+
actorId: session.user.id,
231+
actorName: session.user.name,
232+
actorEmail: session.user.email,
233+
action: 'chat.deployed',
234+
resourceType: 'chat',
235+
resourceId: id,
236+
resourceName: title,
237+
description: `Deployed chat "${title}"`,
238+
metadata: { workflowId, identifier, authType },
239+
request,
240+
})
241+
227242
return createSuccessResponse({
228243
id,
229244
chatUrl,

apps/sim/app/api/credential-sets/[id]/invite/route.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { and, eq } from 'drizzle-orm'
55
import { type NextRequest, NextResponse } from 'next/server'
66
import { z } from 'zod'
77
import { getEmailSubject, renderPollingGroupInvitationEmail } from '@/components/emails'
8+
import { recordAudit } from '@/lib/audit/log'
89
import { getSession } from '@/lib/auth'
910
import { hasCredentialSetsAccess } from '@/lib/billing'
1011
import { getBaseUrl } from '@/lib/core/utils/urls'
@@ -175,6 +176,19 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
175176
emailSent: !!email,
176177
})
177178

179+
recordAudit({
180+
workspaceId: result.set.organizationId,
181+
actorId: session.user.id,
182+
action: 'credential_set_invitation.created',
183+
resourceType: 'credential_set',
184+
resourceId: id,
185+
actorName: session.user.name ?? undefined,
186+
actorEmail: session.user.email ?? undefined,
187+
resourceName: result.set.name,
188+
description: `Created invitation for credential set "${result.set.name}"${email ? ` to ${email}` : ''}`,
189+
request: req,
190+
})
191+
178192
return NextResponse.json({
179193
invitation: {
180194
...invitation,
@@ -235,6 +249,19 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
235249
)
236250
)
237251

252+
recordAudit({
253+
workspaceId: result.set.organizationId,
254+
actorId: session.user.id,
255+
action: 'credential_set_invitation.revoked',
256+
resourceType: 'credential_set',
257+
resourceId: id,
258+
actorName: session.user.name ?? undefined,
259+
actorEmail: session.user.email ?? undefined,
260+
resourceName: result.set.name,
261+
description: `Revoked invitation "${invitationId}" for credential set "${result.set.name}"`,
262+
request: req,
263+
})
264+
238265
return NextResponse.json({ success: true })
239266
} catch (error) {
240267
logger.error('Error cancelling invitation', error)

apps/sim/app/api/credential-sets/[id]/members/route.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { account, credentialSet, credentialSetMember, member, user } from '@sim/
33
import { createLogger } from '@sim/logger'
44
import { and, eq, inArray } from 'drizzle-orm'
55
import { type NextRequest, NextResponse } from 'next/server'
6+
import { recordAudit } from '@/lib/audit/log'
67
import { getSession } from '@/lib/auth'
78
import { hasCredentialSetsAccess } from '@/lib/billing'
89
import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
@@ -177,6 +178,18 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
177178
userId: session.user.id,
178179
})
179180

181+
recordAudit({
182+
workspaceId: result.set.organizationId,
183+
actorId: session.user.id,
184+
action: 'credential_set_member.removed',
185+
resourceType: 'credential_set',
186+
resourceId: id,
187+
actorName: session.user.name ?? undefined,
188+
actorEmail: session.user.email ?? undefined,
189+
description: `Removed member "${memberId}" from credential set "${id}"`,
190+
request: req,
191+
})
192+
180193
return NextResponse.json({ success: true })
181194
} catch (error) {
182195
logger.error('Error removing member from credential set', error)

apps/sim/app/api/credential-sets/[id]/route.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
44
import { and, eq } from 'drizzle-orm'
55
import { type NextRequest, NextResponse } from 'next/server'
66
import { z } from 'zod'
7+
import { recordAudit } from '@/lib/audit/log'
78
import { getSession } from '@/lib/auth'
89
import { hasCredentialSetsAccess } from '@/lib/billing'
910

@@ -131,6 +132,19 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id:
131132

132133
const [updated] = await db.select().from(credentialSet).where(eq(credentialSet.id, id)).limit(1)
133134

135+
recordAudit({
136+
workspaceId: result.set.organizationId,
137+
actorId: session.user.id,
138+
action: 'credential_set.updated',
139+
resourceType: 'credential_set',
140+
resourceId: id,
141+
actorName: session.user.name ?? undefined,
142+
actorEmail: session.user.email ?? undefined,
143+
resourceName: updated?.name ?? result.set.name,
144+
description: `Updated credential set "${updated?.name ?? result.set.name}"`,
145+
request: req,
146+
})
147+
134148
return NextResponse.json({ credentialSet: updated })
135149
} catch (error) {
136150
if (error instanceof z.ZodError) {
@@ -175,6 +189,19 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
175189

176190
logger.info('Deleted credential set', { credentialSetId: id, userId: session.user.id })
177191

192+
recordAudit({
193+
workspaceId: result.set.organizationId,
194+
actorId: session.user.id,
195+
action: 'credential_set.deleted',
196+
resourceType: 'credential_set',
197+
resourceId: id,
198+
actorName: session.user.name ?? undefined,
199+
actorEmail: session.user.email ?? undefined,
200+
resourceName: result.set.name,
201+
description: `Deleted credential set "${result.set.name}"`,
202+
request: req,
203+
})
204+
178205
return NextResponse.json({ success: true })
179206
} catch (error) {
180207
logger.error('Error deleting credential set', error)

0 commit comments

Comments
 (0)