Skip to content

Commit f2ef7d7

Browse files
waleedlatif1claude
andcommitted
feat(ee): add enterprise audit logs settings page with server-side search
Add a new audit logs page under enterprise settings that displays all actions captured via recordAudit. Includes server-side search, resource type filtering, date range selection, and cursor-based pagination. - Add internal API route (app/api/audit-logs) with session auth - Extract shared query logic (buildFilterConditions, buildOrgScopeCondition, queryAuditLogs) into app/api/v1/audit-logs/query.ts - Refactor v1 and admin audit log routes to use shared query module - Add React Query hook with useInfiniteQuery and cursor pagination - Add audit logs UI with debounced search, combobox filters, expandable rows - Gate behind requiresHosted + requiresEnterprise navigation flags - Place all enterprise audit log code in ee/audit-logs/ Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 20cc018 commit f2ef7d7

File tree

9 files changed

+647
-106
lines changed

9 files changed

+647
-106
lines changed
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { createLogger } from '@sim/logger'
2+
import { NextResponse } from 'next/server'
3+
import { getSession } from '@/lib/auth'
4+
import { validateEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth'
5+
import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format'
6+
import {
7+
buildFilterConditions,
8+
buildOrgScopeCondition,
9+
queryAuditLogs,
10+
} from '@/app/api/v1/audit-logs/query'
11+
12+
const logger = createLogger('AuditLogsAPI')
13+
14+
export const dynamic = 'force-dynamic'
15+
16+
export async function GET(request: Request) {
17+
try {
18+
const session = await getSession()
19+
if (!session?.user?.id) {
20+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
21+
}
22+
23+
const authResult = await validateEnterpriseAuditAccess(session.user.id)
24+
if (!authResult.success) {
25+
return authResult.response
26+
}
27+
28+
const { orgMemberIds } = authResult.context
29+
30+
const { searchParams } = new URL(request.url)
31+
const search = searchParams.get('search')?.trim() || undefined
32+
const startDate = searchParams.get('startDate') || undefined
33+
const endDate = searchParams.get('endDate') || undefined
34+
const includeDeparted = searchParams.get('includeDeparted') === 'true'
35+
const limit = Math.min(Math.max(Number(searchParams.get('limit')) || 50, 1), 100)
36+
const cursor = searchParams.get('cursor') || undefined
37+
38+
if (startDate && Number.isNaN(Date.parse(startDate))) {
39+
return NextResponse.json({ error: 'Invalid startDate format' }, { status: 400 })
40+
}
41+
if (endDate && Number.isNaN(Date.parse(endDate))) {
42+
return NextResponse.json({ error: 'Invalid endDate format' }, { status: 400 })
43+
}
44+
45+
const scopeCondition = await buildOrgScopeCondition(orgMemberIds, includeDeparted)
46+
const filterConditions = buildFilterConditions({
47+
action: searchParams.get('action') || undefined,
48+
resourceType: searchParams.get('resourceType') || undefined,
49+
actorId: searchParams.get('actorId') || undefined,
50+
search,
51+
startDate,
52+
endDate,
53+
})
54+
55+
const { data, nextCursor } = await queryAuditLogs(
56+
[scopeCondition, ...filterConditions],
57+
limit,
58+
cursor
59+
)
60+
61+
return NextResponse.json({
62+
success: true,
63+
data: data.map(formatAuditLogEntry),
64+
nextCursor,
65+
})
66+
} catch (error: unknown) {
67+
const message = error instanceof Error ? error.message : 'Unknown error'
68+
logger.error('Audit logs fetch error', { error: message })
69+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
70+
}
71+
}

apps/sim/app/api/v1/admin/audit-logs/route.ts

Lines changed: 16 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
import { db } from '@sim/db'
2222
import { auditLog } from '@sim/db/schema'
2323
import { createLogger } from '@sim/logger'
24-
import { and, count, desc, eq, gte, lte, type SQL } from 'drizzle-orm'
24+
import { and, count, desc } from 'drizzle-orm'
2525
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
2626
import {
2727
badRequestResponse,
@@ -34,40 +34,35 @@ import {
3434
parsePaginationParams,
3535
toAdminAuditLog,
3636
} from '@/app/api/v1/admin/types'
37+
import { buildFilterConditions } from '@/app/api/v1/audit-logs/query'
3738

3839
const logger = createLogger('AdminAuditLogsAPI')
3940

4041
export const GET = withAdminAuth(async (request) => {
4142
const url = new URL(request.url)
4243
const { limit, offset } = parsePaginationParams(url)
4344

44-
const actionFilter = url.searchParams.get('action')
45-
const resourceTypeFilter = url.searchParams.get('resourceType')
46-
const resourceIdFilter = url.searchParams.get('resourceId')
47-
const workspaceIdFilter = url.searchParams.get('workspaceId')
48-
const actorIdFilter = url.searchParams.get('actorId')
49-
const actorEmailFilter = url.searchParams.get('actorEmail')
50-
const startDateFilter = url.searchParams.get('startDate')
51-
const endDateFilter = url.searchParams.get('endDate')
45+
const startDate = url.searchParams.get('startDate') || undefined
46+
const endDate = url.searchParams.get('endDate') || undefined
5247

53-
if (startDateFilter && Number.isNaN(Date.parse(startDateFilter))) {
48+
if (startDate && Number.isNaN(Date.parse(startDate))) {
5449
return badRequestResponse('Invalid startDate format. Use ISO 8601.')
5550
}
56-
if (endDateFilter && Number.isNaN(Date.parse(endDateFilter))) {
51+
if (endDate && Number.isNaN(Date.parse(endDate))) {
5752
return badRequestResponse('Invalid endDate format. Use ISO 8601.')
5853
}
5954

6055
try {
61-
const conditions: SQL<unknown>[] = []
62-
63-
if (actionFilter) conditions.push(eq(auditLog.action, actionFilter))
64-
if (resourceTypeFilter) conditions.push(eq(auditLog.resourceType, resourceTypeFilter))
65-
if (resourceIdFilter) conditions.push(eq(auditLog.resourceId, resourceIdFilter))
66-
if (workspaceIdFilter) conditions.push(eq(auditLog.workspaceId, workspaceIdFilter))
67-
if (actorIdFilter) conditions.push(eq(auditLog.actorId, actorIdFilter))
68-
if (actorEmailFilter) conditions.push(eq(auditLog.actorEmail, actorEmailFilter))
69-
if (startDateFilter) conditions.push(gte(auditLog.createdAt, new Date(startDateFilter)))
70-
if (endDateFilter) conditions.push(lte(auditLog.createdAt, new Date(endDateFilter)))
56+
const conditions = buildFilterConditions({
57+
action: url.searchParams.get('action') || undefined,
58+
resourceType: url.searchParams.get('resourceType') || undefined,
59+
resourceId: url.searchParams.get('resourceId') || undefined,
60+
workspaceId: url.searchParams.get('workspaceId') || undefined,
61+
actorId: url.searchParams.get('actorId') || undefined,
62+
actorEmail: url.searchParams.get('actorEmail') || undefined,
63+
startDate,
64+
endDate,
65+
})
7166

7267
const whereClause = conditions.length > 0 ? and(...conditions) : undefined
7368

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { db } from '@sim/db'
2+
import { auditLog, workspace } from '@sim/db/schema'
3+
import { and, desc, eq, gte, ilike, inArray, lt, lte, or, type SQL } from 'drizzle-orm'
4+
import type { InferSelectModel } from 'drizzle-orm'
5+
6+
type DbAuditLog = InferSelectModel<typeof auditLog>
7+
8+
interface CursorData {
9+
createdAt: string
10+
id: string
11+
}
12+
13+
export function encodeCursor(data: CursorData): string {
14+
return Buffer.from(JSON.stringify(data)).toString('base64')
15+
}
16+
17+
export function decodeCursor(cursor: string): CursorData | null {
18+
try {
19+
return JSON.parse(Buffer.from(cursor, 'base64').toString())
20+
} catch {
21+
return null
22+
}
23+
}
24+
25+
export interface AuditLogFilterParams {
26+
action?: string
27+
resourceType?: string
28+
resourceId?: string
29+
workspaceId?: string
30+
actorId?: string
31+
actorEmail?: string
32+
search?: string
33+
startDate?: string
34+
endDate?: string
35+
}
36+
37+
export function buildFilterConditions(params: AuditLogFilterParams): SQL<unknown>[] {
38+
const conditions: SQL<unknown>[] = []
39+
40+
if (params.action) conditions.push(eq(auditLog.action, params.action))
41+
if (params.resourceType) conditions.push(eq(auditLog.resourceType, params.resourceType))
42+
if (params.resourceId) conditions.push(eq(auditLog.resourceId, params.resourceId))
43+
if (params.workspaceId) conditions.push(eq(auditLog.workspaceId, params.workspaceId))
44+
if (params.actorId) conditions.push(eq(auditLog.actorId, params.actorId))
45+
if (params.actorEmail) conditions.push(eq(auditLog.actorEmail, params.actorEmail))
46+
47+
if (params.search) {
48+
const searchTerm = `%${params.search}%`
49+
conditions.push(
50+
or(
51+
ilike(auditLog.action, searchTerm),
52+
ilike(auditLog.actorEmail, searchTerm),
53+
ilike(auditLog.actorName, searchTerm),
54+
ilike(auditLog.resourceName, searchTerm),
55+
ilike(auditLog.description, searchTerm)
56+
)!
57+
)
58+
}
59+
60+
if (params.startDate) conditions.push(gte(auditLog.createdAt, new Date(params.startDate)))
61+
if (params.endDate) conditions.push(lte(auditLog.createdAt, new Date(params.endDate)))
62+
63+
return conditions
64+
}
65+
66+
export async function buildOrgScopeCondition(
67+
orgMemberIds: string[],
68+
includeDeparted: boolean
69+
): Promise<SQL<unknown>> {
70+
if (!includeDeparted) {
71+
return inArray(auditLog.actorId, orgMemberIds)
72+
}
73+
74+
const orgWorkspaces = await db
75+
.select({ id: workspace.id })
76+
.from(workspace)
77+
.where(inArray(workspace.ownerId, orgMemberIds))
78+
79+
const orgWorkspaceIds = orgWorkspaces.map((w) => w.id)
80+
81+
if (orgWorkspaceIds.length > 0) {
82+
return or(
83+
inArray(auditLog.actorId, orgMemberIds),
84+
inArray(auditLog.workspaceId, orgWorkspaceIds)
85+
)!
86+
}
87+
88+
return inArray(auditLog.actorId, orgMemberIds)
89+
}
90+
91+
export function buildCursorCondition(cursor: string): SQL<unknown> | null {
92+
const cursorData = decodeCursor(cursor)
93+
if (!cursorData?.createdAt || !cursorData.id) return null
94+
95+
const cursorDate = new Date(cursorData.createdAt)
96+
if (Number.isNaN(cursorDate.getTime())) return null
97+
98+
return or(
99+
lt(auditLog.createdAt, cursorDate),
100+
and(eq(auditLog.createdAt, cursorDate), lt(auditLog.id, cursorData.id))
101+
)!
102+
}
103+
104+
interface CursorPaginatedResult {
105+
data: DbAuditLog[]
106+
nextCursor?: string
107+
}
108+
109+
export async function queryAuditLogs(
110+
conditions: SQL<unknown>[],
111+
limit: number,
112+
cursor?: string
113+
): Promise<CursorPaginatedResult> {
114+
const allConditions = [...conditions]
115+
116+
if (cursor) {
117+
const cursorCondition = buildCursorCondition(cursor)
118+
if (cursorCondition) allConditions.push(cursorCondition)
119+
}
120+
121+
const rows = await db
122+
.select()
123+
.from(auditLog)
124+
.where(allConditions.length > 0 ? and(...allConditions) : undefined)
125+
.orderBy(desc(auditLog.createdAt), desc(auditLog.id))
126+
.limit(limit + 1)
127+
128+
const hasMore = rows.length > limit
129+
const data = rows.slice(0, limit)
130+
131+
let nextCursor: string | undefined
132+
if (hasMore && data.length > 0) {
133+
const last = data[data.length - 1]
134+
nextCursor = encodeCursor({
135+
createdAt: last.createdAt.toISOString(),
136+
id: last.id,
137+
})
138+
}
139+
140+
return { data, nextCursor }
141+
}

0 commit comments

Comments
 (0)