Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions apps/sim/app/api/audit-logs/route.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
/**
* @vitest-environment node
*/
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const mocks = vi.hoisted(() => ({
getSession: vi.fn(),
execute: vi.fn(),
}))

vi.mock('@/lib/auth', () => ({ getSession: mocks.getSession }))

vi.mock('@/lib/audit-logs/application/list-audit-logs', () => ({
listAuditLogs: { operation: { id: 'audit_logs.list' }, execute: mocks.execute },
}))

import { OrchestrationError } from '@/lib/core/orchestration/types'
import { GET } from '@/app/api/audit-logs/route'

const log = {
id: 'audit-1',
workspaceId: 'workspace-1',
actorId: 'admin-1',
actorName: 'Ada',
actorEmail: 'ada@example.com',
action: 'workspace.updated',
resourceType: 'workspace',
resourceId: 'workspace-1',
resourceName: 'Engineering',
description: null,
metadata: {},
createdAt: new Date('2026-08-01T00:00:00Z'),
}

describe('GET /api/audit-logs', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.getSession.mockResolvedValue({
user: { id: 'admin-1' },
session: { id: 'session-1' },
})
mocks.execute.mockResolvedValue({ data: [log], nextCursor: 'next-1' })
})

it('authenticates before parsing the organization query', async () => {
const response = await GET(new NextRequest('http://localhost:3000/api/audit-logs'))

expect(response.status).toBe(400)
expect(mocks.getSession).toHaveBeenCalled()
expect(mocks.execute).not.toHaveBeenCalled()
})

it('keeps the internal envelope while sharing the application operation', async () => {
const request = new NextRequest(
'http://localhost:3000/api/audit-logs?organizationId=organization-1'
)
const response = await GET(request)

expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({
success: true,
data: [{ id: 'audit-1', actorId: 'admin-1' }],
nextCursor: 'next-1',
})
expect(mocks.execute).toHaveBeenCalledWith({
principal: { kind: 'session', userId: 'admin-1', sessionId: 'session-1' },
input: expect.objectContaining({ organizationId: 'organization-1' }),
request,
})
})

it('preserves internal typed error presentation', async () => {
mocks.execute.mockRejectedValueOnce(new OrchestrationError('forbidden', 'Admin required'))

const response = await GET(
new NextRequest('http://localhost:3000/api/audit-logs?organizationId=organization-1')
)

expect(response.status).toBe(403)
expect(await response.json()).toEqual({ error: 'Admin required' })
})
})
127 changes: 36 additions & 91 deletions apps/sim/app/api/audit-logs/route.ts
Original file line numberDiff line numberDiff line change
@@ -1,97 +1,42 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { listAuditLogsContract } from '@/lib/api/contracts/audit-logs'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { validateEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth'
import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format'
import {
buildFilterConditions,
buildOrgScopeCondition,
getOrgWorkspaceIds,
queryAuditLogs,
} from '@/app/api/v1/audit-logs/query'

const logger = createLogger('AuditLogsAPI')
defineInternalJsonRoute,
internalPlainOrchestrationErrorPolicy,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import { listAuditLogs } from '@/lib/audit-logs/application/list-audit-logs'
import { auditLogOperations } from '@/lib/audit-logs/application/operations'
import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format'

export const dynamic = 'force-dynamic'

export const GET = withRouteHandler(async (request: NextRequest) => {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}

const parsed = await parseRequest(
listAuditLogsContract,
request,
{},
{
validationErrorResponse: (error) =>
NextResponse.json(
{ error: getValidationErrorMessage(error, 'Invalid query parameters') },
{ status: 400 }
),
}
)
if (!parsed.success) return parsed.response

const authResult = await validateEnterpriseAuditAccess(
session.user.id,
parsed.data.query.organizationId
)
if (!authResult.success) {
return authResult.response
}

const { organizationId, orgMemberIds } = authResult.context

const {
organizationId: _targetOrganizationId,
search,
action,
resourceType,
actorId,
startDate,
endDate,
includeDeparted,
limit,
cursor,
} = parsed.data.query

const orgWorkspaceIds = await getOrgWorkspaceIds(organizationId)
const scopeCondition = buildOrgScopeCondition({
organizationId,
orgWorkspaceIds,
orgMemberIds,
includeDeparted,
})
const filterConditions = buildFilterConditions({
action,
resourceType,
actorId,
search,
startDate,
endDate,
})

const { data, nextCursor } = await queryAuditLogs(
[scopeCondition, ...filterConditions],
limit,
cursor
)

return NextResponse.json({
success: true,
data: data.map(formatAuditLogEntry),
nextCursor,
})
} catch (error: unknown) {
const message = getErrorMessage(error, 'Unknown error')
logger.error('Audit logs fetch error', { error: message })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
export const GET = defineInternalJsonRoute({
contract: listAuditLogsContract,
auth: internalSessionAuth,
operation: auditLogOperations.list,
rateLimit: internalRateLimits.none({
reason: 'Existing authenticated audit-log settings read has no request-rate policy',
}),
errorPolicy: internalPlainOrchestrationErrorPolicy,
mapInput: ({ query }) => ({
organizationId: query.organizationId,
includeDeparted: query.includeDeparted,
filters: {
Comment thread
TheodoreSpeaks marked this conversation as resolved.
search: query.search,
action: query.action,
resourceType: query.resourceType,
actorId: query.actorId,
startDate: query.startDate,
endDate: query.endDate,
},
limit: query.limit,
cursor: query.cursor,
}),
useCase: listAuditLogs,
present: ({ data, nextCursor }) => ({
success: true,
data: data.map(formatAuditLogEntry),
nextCursor,
}),
})
54 changes: 16 additions & 38 deletions apps/sim/app/api/v2/audit-logs/[id]/route.ts
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
import { db } from '@sim/db'
import { auditLog } from '@sim/db/schema'
import { and, eq } from 'drizzle-orm'
import { v2GetAuditLogContract } from '@/lib/api/contracts/v2/audit-logs'
import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler'
import { resolveEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth'
import { buildOrgScopeCondition, getOrgWorkspaceIds } from '@/app/api/v1/audit-logs/query'
import {
defineV2JsonRoute,
v2ApiKeyAuth,
v2OrchestrationErrorPolicy,
v2RateLimits,
} from '@/lib/api/server/routes'
import { getAuditLog } from '@/lib/audit-logs/application/get-audit-log'
import { auditLogOperations } from '@/lib/audit-logs/application/operations'
import { formatV2AuditLogEntry } from '@/app/api/v2/audit-logs/format'
import { v2Data, v2Error } from '@/app/api/v2/lib/response'

export const revalidate = 0

Expand All@@ -17,36 +18,13 @@ export const revalidate = 0
* organization. Audit logs are personal-key-only because a workspace-scoped
* key must never expand into organization-wide visibility.
*/
export const GET = withPublicApiRouteHandler({
export const GET = defineV2JsonRoute({
contract: v2GetAuditLogContract,
rateLimitEndpoint: 'audit-logs',
handler: async ({ input, auth: { userId, rateLimit } }) => {
if (rateLimit.keyType !== 'personal') {
return v2Error('FORBIDDEN', 'Audit logs require a personal API key')
}

const authResult = await resolveEnterpriseAuditAccess(userId, input.query.organizationId)
if (!authResult.success) return v2Error('FORBIDDEN', authResult.message)

const { id } = input.params
const { organizationId, orgMemberIds } = authResult.context

const orgWorkspaceIds = await getOrgWorkspaceIds(organizationId)
const scopeCondition = buildOrgScopeCondition({
organizationId,
orgWorkspaceIds,
orgMemberIds,
includeDeparted: true,
})

const [log] = await db
.select()
.from(auditLog)
.where(and(eq(auditLog.id, id), scopeCondition))
.limit(1)

if (!log) return v2Error('NOT_FOUND', 'Audit log not found')

return v2Data(formatV2AuditLogEntry(log), { rateLimit })
},
auth: v2ApiKeyAuth,
operation: auditLogOperations.readDetail,
rateLimit: v2RateLimits.publicApi,
errorPolicy: v2OrchestrationErrorPolicy,
mapInput: ({ params, query }) => ({ id: params.id, organizationId: query.organizationId }),
useCase: getAuditLog,
present: ({ log }) => ({ data: formatV2AuditLogEntry(log) }),
})
Loading
Loading