Skip to content

Commit 1f3dc52

Browse files
authored
feat(api): audit log read endpoints for admin and enterprise (#3343)
* feat(api): audit log read endpoints for admin and enterprise * fix(api): address PR review — boolean coercion, cursor validation, detail scope * ran lint
1 parent f625482 commit 1f3dc52

File tree

8 files changed

+599
-1
lines changed

8 files changed

+599
-1
lines changed
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/**
2+
* GET /api/v1/admin/audit-logs/[id]
3+
*
4+
* Get a single audit log entry by ID.
5+
*
6+
* Response: AdminSingleResponse<AdminAuditLog>
7+
*/
8+
9+
import { db } from '@sim/db'
10+
import { auditLog } from '@sim/db/schema'
11+
import { createLogger } from '@sim/logger'
12+
import { eq } from 'drizzle-orm'
13+
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
14+
import {
15+
internalErrorResponse,
16+
notFoundResponse,
17+
singleResponse,
18+
} from '@/app/api/v1/admin/responses'
19+
import { toAdminAuditLog } from '@/app/api/v1/admin/types'
20+
21+
const logger = createLogger('AdminAuditLogDetailAPI')
22+
23+
interface RouteParams {
24+
id: string
25+
}
26+
27+
export const GET = withAdminAuthParams<RouteParams>(async (request, context) => {
28+
const { id } = await context.params
29+
30+
try {
31+
const [log] = await db.select().from(auditLog).where(eq(auditLog.id, id)).limit(1)
32+
33+
if (!log) {
34+
return notFoundResponse('AuditLog')
35+
}
36+
37+
logger.info(`Admin API: Retrieved audit log ${id}`)
38+
39+
return singleResponse(toAdminAuditLog(log))
40+
} catch (error) {
41+
logger.error('Admin API: Failed to get audit log', { error, id })
42+
return internalErrorResponse('Failed to get audit log')
43+
}
44+
})
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/**
2+
* GET /api/v1/admin/audit-logs
3+
*
4+
* List all audit logs with pagination and filtering.
5+
*
6+
* Query Parameters:
7+
* - limit: number (default: 50, max: 250)
8+
* - offset: number (default: 0)
9+
* - action: string (optional) - Filter by action (e.g., "workflow.created")
10+
* - resourceType: string (optional) - Filter by resource type (e.g., "workflow")
11+
* - resourceId: string (optional) - Filter by resource ID
12+
* - workspaceId: string (optional) - Filter by workspace ID
13+
* - actorId: string (optional) - Filter by actor user ID
14+
* - actorEmail: string (optional) - Filter by actor email
15+
* - startDate: string (optional) - ISO 8601 date, filter createdAt >= startDate
16+
* - endDate: string (optional) - ISO 8601 date, filter createdAt <= endDate
17+
*
18+
* Response: AdminListResponse<AdminAuditLog>
19+
*/
20+
21+
import { db } from '@sim/db'
22+
import { auditLog } from '@sim/db/schema'
23+
import { createLogger } from '@sim/logger'
24+
import { and, count, desc, eq, gte, lte, type SQL } from 'drizzle-orm'
25+
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
26+
import {
27+
badRequestResponse,
28+
internalErrorResponse,
29+
listResponse,
30+
} from '@/app/api/v1/admin/responses'
31+
import {
32+
type AdminAuditLog,
33+
createPaginationMeta,
34+
parsePaginationParams,
35+
toAdminAuditLog,
36+
} from '@/app/api/v1/admin/types'
37+
38+
const logger = createLogger('AdminAuditLogsAPI')
39+
40+
export const GET = withAdminAuth(async (request) => {
41+
const url = new URL(request.url)
42+
const { limit, offset } = parsePaginationParams(url)
43+
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')
52+
53+
if (startDateFilter && Number.isNaN(Date.parse(startDateFilter))) {
54+
return badRequestResponse('Invalid startDate format. Use ISO 8601.')
55+
}
56+
if (endDateFilter && Number.isNaN(Date.parse(endDateFilter))) {
57+
return badRequestResponse('Invalid endDate format. Use ISO 8601.')
58+
}
59+
60+
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)))
71+
72+
const whereClause = conditions.length > 0 ? and(...conditions) : undefined
73+
74+
const [countResult, logs] = await Promise.all([
75+
db.select({ total: count() }).from(auditLog).where(whereClause),
76+
db
77+
.select()
78+
.from(auditLog)
79+
.where(whereClause)
80+
.orderBy(desc(auditLog.createdAt))
81+
.limit(limit)
82+
.offset(offset),
83+
])
84+
85+
const total = countResult[0].total
86+
const data: AdminAuditLog[] = logs.map(toAdminAuditLog)
87+
const pagination = createPaginationMeta(total, limit, offset)
88+
89+
logger.info(`Admin API: Listed ${data.length} audit logs (total: ${total})`)
90+
91+
return listResponse(data, pagination)
92+
} catch (error) {
93+
logger.error('Admin API: Failed to list audit logs', { error })
94+
return internalErrorResponse('Failed to list audit logs')
95+
}
96+
})

apps/sim/app/api/v1/admin/types.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
*/
77

88
import type {
9+
auditLog,
910
member,
1011
organization,
1112
referralCampaigns,
@@ -694,3 +695,45 @@ export function toAdminReferralCampaign(
694695
updatedAt: dbCampaign.updatedAt.toISOString(),
695696
}
696697
}
698+
699+
// =============================================================================
700+
// Audit Log Types
701+
// =============================================================================
702+
703+
export type DbAuditLog = InferSelectModel<typeof auditLog>
704+
705+
export interface AdminAuditLog {
706+
id: string
707+
workspaceId: string | null
708+
actorId: string | null
709+
actorName: string | null
710+
actorEmail: string | null
711+
action: string
712+
resourceType: string
713+
resourceId: string | null
714+
resourceName: string | null
715+
description: string | null
716+
metadata: unknown
717+
ipAddress: string | null
718+
userAgent: string | null
719+
createdAt: string
720+
}
721+
722+
export function toAdminAuditLog(dbLog: DbAuditLog): AdminAuditLog {
723+
return {
724+
id: dbLog.id,
725+
workspaceId: dbLog.workspaceId,
726+
actorId: dbLog.actorId,
727+
actorName: dbLog.actorName,
728+
actorEmail: dbLog.actorEmail,
729+
action: dbLog.action,
730+
resourceType: dbLog.resourceType,
731+
resourceId: dbLog.resourceId,
732+
resourceName: dbLog.resourceName,
733+
description: dbLog.description,
734+
metadata: dbLog.metadata,
735+
ipAddress: dbLog.ipAddress,
736+
userAgent: dbLog.userAgent,
737+
createdAt: dbLog.createdAt.toISOString(),
738+
}
739+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/**
2+
* GET /api/v1/audit-logs/[id]
3+
*
4+
* Get a single audit log entry by ID, scoped to the authenticated user's organization.
5+
* Requires enterprise subscription and org admin/owner role.
6+
*
7+
* Scope includes logs from current org members AND logs within org workspaces
8+
* (including those from departed members or system actions with null actorId).
9+
*
10+
* Response: { data: AuditLogEntry, limits: UserLimits }
11+
*/
12+
13+
import { db } from '@sim/db'
14+
import { auditLog, workspace } from '@sim/db/schema'
15+
import { createLogger } from '@sim/logger'
16+
import { and, eq, inArray, or } from 'drizzle-orm'
17+
import { type NextRequest, NextResponse } from 'next/server'
18+
import { validateEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth'
19+
import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format'
20+
import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta'
21+
import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware'
22+
23+
const logger = createLogger('V1AuditLogDetailAPI')
24+
25+
export const revalidate = 0
26+
27+
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
28+
const requestId = crypto.randomUUID().slice(0, 8)
29+
30+
try {
31+
const rateLimit = await checkRateLimit(request, 'audit-logs')
32+
if (!rateLimit.allowed) {
33+
return createRateLimitResponse(rateLimit)
34+
}
35+
36+
const userId = rateLimit.userId!
37+
const { id } = await params
38+
39+
const authResult = await validateEnterpriseAuditAccess(userId)
40+
if (!authResult.success) {
41+
return authResult.response
42+
}
43+
44+
const { orgMemberIds } = authResult.context
45+
46+
const orgWorkspaceIds = db
47+
.select({ id: workspace.id })
48+
.from(workspace)
49+
.where(inArray(workspace.ownerId, orgMemberIds))
50+
51+
const [log] = await db
52+
.select()
53+
.from(auditLog)
54+
.where(
55+
and(
56+
eq(auditLog.id, id),
57+
or(
58+
inArray(auditLog.actorId, orgMemberIds),
59+
inArray(auditLog.workspaceId, orgWorkspaceIds)
60+
)
61+
)
62+
)
63+
.limit(1)
64+
65+
if (!log) {
66+
return NextResponse.json({ error: 'Audit log not found' }, { status: 404 })
67+
}
68+
69+
const limits = await getUserLimits(userId)
70+
const response = createApiResponse({ data: formatAuditLogEntry(log) }, limits, rateLimit)
71+
72+
return NextResponse.json(response.body, { headers: response.headers })
73+
} catch (error: unknown) {
74+
const message = error instanceof Error ? error.message : 'Unknown error'
75+
logger.error(`[${requestId}] Audit log detail fetch error`, { error: message })
76+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
77+
}
78+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
/**
2+
* Enterprise audit log authorization.
3+
*
4+
* Validates that the authenticated user is an admin/owner of an enterprise organization
5+
* and returns the organization context needed for scoped queries.
6+
*/
7+
8+
import { db } from '@sim/db'
9+
import { member, subscription } from '@sim/db/schema'
10+
import { createLogger } from '@sim/logger'
11+
import { and, eq } from 'drizzle-orm'
12+
import { NextResponse } from 'next/server'
13+
14+
const logger = createLogger('V1AuditLogsAuth')
15+
16+
export interface EnterpriseAuditContext {
17+
organizationId: string
18+
orgMemberIds: string[]
19+
}
20+
21+
type AuthResult =
22+
| { success: true; context: EnterpriseAuditContext }
23+
| { success: false; response: NextResponse }
24+
25+
/**
26+
* Validates enterprise audit log access for the given user.
27+
*
28+
* Checks:
29+
* 1. User belongs to an organization
30+
* 2. User has admin or owner role
31+
* 3. Organization has an active enterprise subscription
32+
*
33+
* Returns the organization ID and all member user IDs on success,
34+
* or an error response on failure.
35+
*/
36+
export async function validateEnterpriseAuditAccess(userId: string): Promise<AuthResult> {
37+
const [membership] = await db
38+
.select({ organizationId: member.organizationId, role: member.role })
39+
.from(member)
40+
.where(eq(member.userId, userId))
41+
.limit(1)
42+
43+
if (!membership) {
44+
return {
45+
success: false,
46+
response: NextResponse.json({ error: 'Not a member of any organization' }, { status: 403 }),
47+
}
48+
}
49+
50+
if (membership.role !== 'admin' && membership.role !== 'owner') {
51+
return {
52+
success: false,
53+
response: NextResponse.json(
54+
{ error: 'Organization admin or owner role required' },
55+
{ status: 403 }
56+
),
57+
}
58+
}
59+
60+
const [orgSub, orgMembers] = await Promise.all([
61+
db
62+
.select({ id: subscription.id })
63+
.from(subscription)
64+
.where(
65+
and(
66+
eq(subscription.referenceId, membership.organizationId),
67+
eq(subscription.plan, 'enterprise'),
68+
eq(subscription.status, 'active')
69+
)
70+
)
71+
.limit(1),
72+
db
73+
.select({ userId: member.userId })
74+
.from(member)
75+
.where(eq(member.organizationId, membership.organizationId)),
76+
])
77+
78+
if (orgSub.length === 0) {
79+
return {
80+
success: false,
81+
response: NextResponse.json(
82+
{ error: 'Active enterprise subscription required' },
83+
{ status: 403 }
84+
),
85+
}
86+
}
87+
88+
const orgMemberIds = orgMembers.map((m) => m.userId)
89+
90+
logger.info('Enterprise audit access validated', {
91+
userId,
92+
organizationId: membership.organizationId,
93+
memberCount: orgMemberIds.length,
94+
})
95+
96+
return {
97+
success: true,
98+
context: {
99+
organizationId: membership.organizationId,
100+
orgMemberIds,
101+
},
102+
}
103+
}

0 commit comments

Comments
 (0)