Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
fa8abc0
fix(billing): apportion per-row credit costs so they sum to the page …
waleedlatif1 Jul 3, 2026
06a4c14
fix(billing): dim stale credit usage rows while a new period loads
waleedlatif1 Jul 3, 2026
eb26a01
fix(billing): show "<1 credit" for rows apportioned to 0
waleedlatif1 Jul 3, 2026
fa73ea2
fix(audit-logs): fix broken Custom range picker, trim time-range presets
waleedlatif1 Jul 3, 2026
3c37391
feat(billing): dedicated Credit usage page with date-range filter and…
waleedlatif1 Jul 3, 2026
762b34d
refactor(billing): move workflow-name enrichment into getUserUsageLog…
waleedlatif1 Jul 3, 2026
46beb8a
fix(billing): drop Dollar cost from the CSV export, strip inline comm…
waleedlatif1 Jul 3, 2026
2871234
fix(billing): export honors partial custom date range, surfaces trunc…
waleedlatif1 Jul 3, 2026
d1dcf6c
fix(billing): remove the export's arbitrary row cap, fix a cursor pag…
waleedlatif1 Jul 3, 2026
7f97f31
perf(billing): skip the redundant cursor lookup when the caller alrea…
waleedlatif1 Jul 4, 2026
02c5f39
fix(billing): apportion credits over the whole filtered set, not per …
waleedlatif1 Jul 4, 2026
b91d2b7
fix(billing): make custom-range startDate/endDate nullable, not '' de…
waleedlatif1 Jul 4, 2026
55f2bd5
feat(audit-logs): add CSV export, matching the Credit usage page pattern
waleedlatif1 Jul 4, 2026
bd67ebd
fix(billing): skip wasted credit apportionment on the summary fetch, …
waleedlatif1 Jul 4, 2026
447c404
fix(billing): deterministic apportionment order, block audit export d…
waleedlatif1 Jul 4, 2026
ecf5505
fix(billing): distinguish a failed summary fetch from zero usage
waleedlatif1 Jul 4, 2026
17a5a73
fix(billing): gate the credit-usage page server-side for enterprise a…
waleedlatif1 Jul 4, 2026
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
169 changes: 169 additions & 0 deletions apps/sim/app/api/audit-logs/export/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
/**
* @vitest-environment node
*/
import { createMockRequest } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const {
mockGetSession,
mockValidateEnterpriseAuditAccess,
mockBuildOrgScopeCondition,
mockGetOrgWorkspaceIds,
mockQueryAuditLogs,
mockBuildFilterConditions,
} = vi.hoisted(() => ({
mockGetSession: vi.fn(),
mockValidateEnterpriseAuditAccess: vi.fn(),
mockBuildOrgScopeCondition: vi.fn(),
mockGetOrgWorkspaceIds: vi.fn(),
mockQueryAuditLogs: vi.fn(),
mockBuildFilterConditions: vi.fn(),
}))

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

vi.mock('@/app/api/v1/audit-logs/auth', () => ({
validateEnterpriseAuditAccess: mockValidateEnterpriseAuditAccess,
}))

vi.mock('@/app/api/v1/audit-logs/query', () => ({
buildFilterConditions: mockBuildFilterConditions,
buildOrgScopeCondition: mockBuildOrgScopeCondition,
getOrgWorkspaceIds: mockGetOrgWorkspaceIds,
queryAuditLogs: mockQueryAuditLogs,
}))

import { GET } from '@/app/api/audit-logs/export/route'

const ORG_ID = 'org-1'
const MEMBER_IDS = ['admin-1']
const SCOPE_SENTINEL = { type: 'org-scope-sentinel' }

function makeRequest(query = '') {
return createMockRequest(
'GET',
undefined,
{},
`http://localhost:3000/api/audit-logs/export${query}`
)
}

function auditLog(overrides: Partial<Record<string, unknown>> = {}) {
return {
id: 'log-1',
workspaceId: null,
actorId: 'admin-1',
actorName: 'Ada Lovelace',
actorEmail: 'ada@example.com',
action: 'workflow.created',
resourceType: 'workflow',
resourceId: 'wf-1',
resourceName: 'My workflow',
description: null,
metadata: null,
createdAt: new Date('2026-07-01T00:00:00.000Z'),
...overrides,
}
}

describe('GET /api/audit-logs/export', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetSession.mockResolvedValue({ user: { id: 'admin-1' } })
mockValidateEnterpriseAuditAccess.mockResolvedValue({
success: true,
context: { organizationId: ORG_ID, orgMemberIds: MEMBER_IDS },
})
mockGetOrgWorkspaceIds.mockResolvedValue([])
mockBuildOrgScopeCondition.mockReturnValue(SCOPE_SENTINEL)
mockBuildFilterConditions.mockReturnValue([])
mockQueryAuditLogs.mockResolvedValue({ data: [], nextCursor: undefined })
})

it('returns 401 when unauthenticated', async () => {
mockGetSession.mockResolvedValue(null)

const response = await GET(makeRequest())

expect(response.status).toBe(401)
})

it('returns the enterprise-access-check response when access is denied', async () => {
mockValidateEnterpriseAuditAccess.mockResolvedValue({
success: false,
response: new Response(
JSON.stringify({ error: 'Organization admin or owner role required' }),
{
status: 403,
}
),
})

const response = await GET(makeRequest())

expect(response.status).toBe(403)
expect(mockQueryAuditLogs).not.toHaveBeenCalled()
})

it('returns a CSV with the header row and one line per log', async () => {
mockQueryAuditLogs.mockResolvedValueOnce({ data: [auditLog()], nextCursor: undefined })

const response = await GET(makeRequest())
const csv = await response.text()
const [header, row] = csv.split('\n')

expect(response.headers.get('Content-Type')).toBe('text/csv; charset=utf-8')
expect(response.headers.get('Content-Disposition')).toContain('attachment; filename=')
expect(response.headers.get('X-Export-Truncated')).toBe('0')
expect(header).toBe('Date,Action,Resource Type,Resource Name,Actor,Description')
expect(row).toBe(
'2026-07-01T00:00:00.000Z,workflow.created,workflow,My workflow,ada@example.com,'
)
})

it('falls back to actorName, then "System", when actorEmail is absent', async () => {
mockQueryAuditLogs.mockResolvedValueOnce({
data: [auditLog({ actorEmail: null, actorName: 'Ada Lovelace' })],
nextCursor: undefined,
})

const response = await GET(makeRequest())
const csv = await response.text()

expect(csv).toContain('Ada Lovelace')
})

it('paginates through queryAuditLogs until there is no nextCursor', async () => {
mockQueryAuditLogs
.mockResolvedValueOnce({
data: [auditLog({ id: 'log-1' })],
nextCursor: 'cursor-1',
})
.mockResolvedValueOnce({
data: [auditLog({ id: 'log-2' })],
nextCursor: undefined,
})

const response = await GET(makeRequest())
const csv = await response.text()

expect(mockQueryAuditLogs).toHaveBeenCalledTimes(2)
expect(mockQueryAuditLogs).toHaveBeenNthCalledWith(
2,
expect.anything(),
expect.any(Number),
'cursor-1'
)
expect(csv.split('\n')).toHaveLength(3)
})

it('rejects an actorId that is not a current org member', async () => {
const response = await GET(makeRequest('?actorId=outsider-1'))

expect(response.status).toBe(400)
expect(mockQueryAuditLogs).not.toHaveBeenCalled()
})
})
151 changes: 151 additions & 0 deletions apps/sim/app/api/audit-logs/export/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { exportAuditLogsContract } 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 { formatCsvValue, toCsvRow } from '@/lib/table/export-format'
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('AuditLogsExportAPI')

/**
* Circuit breaker, not a UX boundary — an organization's audit trail can
* genuinely grow large over time, unlike a single user's credit ledger, so
* this is sized for "a reasonable audit review window" rather than "should
* never happen." Hitting it truncates (signaled via X-Export-Truncated), it
* doesn't error.
*/
const EXPORT_SAFETY_CAP = 10000
const EXPORT_PAGE_SIZE = 1000

const CSV_HEADER = toCsvRow([
'Date',
'Action',
'Resource Type',
'Resource Name',
'Actor',
'Description',
])

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

const authResult = await validateEnterpriseAuditAccess(session.user.id)
if (!authResult.success) {
return authResult.response
}

const { organizationId, orgMemberIds } = authResult.context

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

const { search, action, resourceType, actorId, startDate, endDate, includeDeparted } =
parsed.data.query

if (actorId && !orgMemberIds.includes(actorId)) {
return NextResponse.json(
{ error: 'actorId is not a member of your organization' },
{ status: 400 }
)
}

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

const rows: ReturnType<typeof formatAuditLogEntry>[] = []
let cursor: string | undefined
let truncated = false
while (rows.length < EXPORT_SAFETY_CAP) {
const page = await queryAuditLogs(
conditions,
Math.min(EXPORT_PAGE_SIZE, EXPORT_SAFETY_CAP - rows.length),
cursor
)
rows.push(...page.data.map(formatAuditLogEntry))
if (!page.nextCursor) break
truncated = rows.length >= EXPORT_SAFETY_CAP
cursor = page.nextCursor
}

if (truncated) {
logger.warn('Audit log export truncated at safety cap', {
userId: session.user.id,
organizationId,
cap: EXPORT_SAFETY_CAP,
})
}

const csvLines = rows.map((log) =>
toCsvRow([
formatCsvValue(log.createdAt),
formatCsvValue(log.action),
formatCsvValue(log.resourceType),
formatCsvValue(log.resourceName),
formatCsvValue(log.actorEmail || log.actorName || 'System'),
formatCsvValue(log.description),
])
)

const csv = [CSV_HEADER, ...csvLines].join('\n')
const filename = `audit-logs-${new Date().toISOString().slice(0, 10)}.csv`

logger.info('Exported audit logs', {
userId: session.user.id,
organizationId,
rowCount: rows.length,
})

return new NextResponse(csv, {
status: 200,
headers: {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': `attachment; filename="${filename}"`,
'Cache-Control': 'no-cache',
'X-Export-Truncated': truncated ? '1' : '0',
},
})
} catch (error: unknown) {
const message = getErrorMessage(error, 'Unknown error')
logger.error('Audit logs export error', { error: message })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
Loading
Loading