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
24 changes: 6 additions & 18 deletions apps/sim/app/api/v1/audit-logs/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,12 @@ import { v1LogsMetaMock, v1LogsMetaMockFns } from '@sim/testing/mocks/v1-logs-me
import { v1MiddlewareMock, v1MiddlewareMockFns } from '@sim/testing/mocks/v1-middleware.mock'
import { beforeEach, describe, expect, it, vi } from 'vitest'

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

vi.mock('@/app/api/v1/middleware', () => v1MiddlewareMock)

Expand All @@ -28,8 +21,6 @@ vi.mock('@/app/api/v1/audit-logs/auth', () => ({
}))

vi.mock('@/lib/audit-logs/query', () => ({
buildFilterConditions: mockBuildFilterConditions,
buildOrgScopeCondition: mockBuildOrgScopeCondition,
getOrgWorkspaceIds: mockGetOrgWorkspaceIds,
queryAuditLogs: mockQueryAuditLogs,
}))
Expand All @@ -48,7 +39,6 @@ v1LogsMetaMockFns.mockCreateApiResponse.mockImplementation((body: unknown) => ({
const ORG_ID = 'org-1'
const MEMBER_IDS = ['admin-1', 'member-1']
const ORG_WORKSPACE_IDS = ['ws-org-1', 'ws-org-2']
const SCOPE_SENTINEL = { type: 'org-scope-sentinel' }

function makeRequest(query: string) {
return createMockRequest('GET', undefined, {}, `http://localhost:3000/api/v1/audit-logs${query}`)
Expand All @@ -63,8 +53,6 @@ describe('GET /api/v1/audit-logs', () => {
context: { organizationId: ORG_ID, orgMemberIds: MEMBER_IDS },
})
mockGetOrgWorkspaceIds.mockResolvedValue(ORG_WORKSPACE_IDS)
mockBuildOrgScopeCondition.mockReturnValue(SCOPE_SENTINEL)
mockBuildFilterConditions.mockReturnValue([])
mockQueryAuditLogs.mockResolvedValue({ data: [], nextCursor: undefined })
})

Expand Down
34 changes: 11 additions & 23 deletions apps/sim/app/api/v1/audit-logs/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,7 @@ import { generateId } from '@sim/utils/id'
import { type NextRequest, NextResponse } from 'next/server'
import { v1ListAuditLogsContract } from '@/lib/api/contracts/v1/audit-logs'
import { parseRequest } from '@/lib/api/server'
import {
buildFilterConditions,
buildOrgScopeCondition,
getOrgWorkspaceIds,
queryAuditLogs,
} from '@/lib/audit-logs/query'
import { getOrgWorkspaceIds, queryAuditLogs } from '@/lib/audit-logs/query'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { validateV1EnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth'
import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format'
Expand Down Expand Up @@ -99,24 +94,17 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
)
}

const scopeCondition = buildOrgScopeCondition({
organizationId,
orgWorkspaceIds,
orgMemberIds,
includeDeparted: params.includeDeparted,
})
const filterConditions = buildFilterConditions({
action: params.action,
resourceType: params.resourceType,
resourceId: params.resourceId,
workspaceId: params.workspaceId,
actorId: params.actorId,
startDate: params.startDate,
endDate: params.endDate,
})

const { data, nextCursor } = await queryAuditLogs(
[scopeCondition, ...filterConditions],
{ organizationId, orgWorkspaceIds, orgMemberIds, includeDeparted: params.includeDeparted },
{
action: params.action,
resourceType: params.resourceType,
resourceId: params.resourceId,
workspaceId: params.workspaceId,
actorId: params.actorId,
startDate: params.startDate,
endDate: params.endDate,
},
params.limit,
params.cursor
)
Expand Down
11 changes: 5 additions & 6 deletions apps/sim/lib/audit-logs/application/export-audit-logs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,7 @@ import { auditLogOperations } from '@/lib/audit-logs/application/operations'
import { AUDIT_LOG_CSV_HEADER, toAuditLogCsvRow } from '@/lib/audit-logs/csv'
import {
type AuditLogFilterParams,
buildFilterConditions,
buildOrgScopeCondition,
type AuditLogListScope,
getOrgWorkspaceIds,
queryAuditLogs,
} from '@/lib/audit-logs/query'
Expand Down Expand Up @@ -37,13 +36,12 @@ export const exportAuditLogs = defineAuthorizedAuditLogUseCase({
) {
throw new OrchestrationError('validation', 'actorId is not a member of your organization')
}
const scope = buildOrgScopeCondition({
const scope: AuditLogListScope = {
organizationId: context.organizationId,
orgWorkspaceIds: workspaceIds,
orgMemberIds: context.orgMemberIds,
includeDeparted: input.includeDeparted,
})
const conditions = [scope, ...buildFilterConditions(input.filters)]
}
const lines = [AUDIT_LOG_CSV_HEADER]
let rowCount = 0
let cursor: string | undefined
Expand All @@ -52,7 +50,8 @@ export const exportAuditLogs = defineAuthorizedAuditLogUseCase({
pages: while (rowCount < EXPORT_MAX_ROWS && bytes < EXPORT_MAX_BYTES) {
request?.signal?.throwIfAborted()
const page = await queryAuditLogs(
conditions,
scope,
input.filters,
Math.min(EXPORT_PAGE_ROWS, EXPORT_MAX_ROWS - rowCount),
cursor,
EXPORT_MAX_BYTES
Expand Down
27 changes: 14 additions & 13 deletions apps/sim/lib/audit-logs/application/list-audit-logs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ import { defineAuthorizedAuditLogUseCase } from '@/lib/audit-logs/application/au
import { auditLogOperations } from '@/lib/audit-logs/application/operations'
import {
type AuditLogFilterParams,
buildFilterConditions,
buildOrgScopeCondition,
decodeAuditLogCursor,
getOrgWorkspaceIds,
queryAuditLogs,
Expand Down Expand Up @@ -32,22 +30,25 @@ export const listAuditLogs = defineAuthorizedAuditLogUseCase({
if (input.filters.workspaceId && !orgWorkspaceIds.includes(input.filters.workspaceId)) {
throw new OrchestrationError('validation', 'workspaceId does not belong to your organization')
}
const scopeCondition = buildOrgScopeCondition({
organizationId: context.organizationId,
orgWorkspaceIds,
orgMemberIds: context.orgMemberIds,
includeDeparted: input.includeDeparted,
})
if (
context.workspaceId &&
input.filters.workspaceId &&
context.workspaceId !== input.filters.workspaceId
)
throw new OrchestrationError('not_found', 'Audit logs not found in the selected workspace')
const filterConditions = buildFilterConditions({
...input.filters,
...(context.workspaceId ? { workspaceId: context.workspaceId } : {}),
})
return queryAuditLogs([scopeCondition, ...filterConditions], input.limit, input.cursor)
return queryAuditLogs(
{
organizationId: context.organizationId,
orgWorkspaceIds,
orgMemberIds: context.orgMemberIds,
includeDeparted: input.includeDeparted,
},
{
...input.filters,
...(context.workspaceId ? { workspaceId: context.workspaceId } : {}),
},
input.limit,
input.cursor
)
},
})
10 changes: 6 additions & 4 deletions apps/sim/lib/audit-logs/application/use-cases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ const hoisted = vi.hoisted(() => ({
resolveDefaultOrganization: vi.fn(),
getOrgWorkspaceIds: vi.fn(),
buildOrgScopeCondition: vi.fn(),
buildFilterConditions: vi.fn(),
decodeAuditLogCursor: vi.fn(),
queryAuditLogs: vi.fn(),
isCapabilityWithheldForUser: vi.fn(),
Expand All @@ -50,7 +49,6 @@ vi.mock('@/lib/audit-logs/authorization', () => ({
vi.mock('@/lib/audit-logs/query', () => ({
getOrgWorkspaceIds: hoisted.getOrgWorkspaceIds,
buildOrgScopeCondition: hoisted.buildOrgScopeCondition,
buildFilterConditions: hoisted.buildFilterConditions,
decodeAuditLogCursor: hoisted.decodeAuditLogCursor,
queryAuditLogs: hoisted.queryAuditLogs,
}))
Expand Down Expand Up @@ -96,7 +94,6 @@ describe('audit-log application use cases', () => {
})
mocks.getOrgWorkspaceIds.mockResolvedValue(['workspace-1'])
mocks.buildOrgScopeCondition.mockReturnValue({ type: 'scope' })
mocks.buildFilterConditions.mockReturnValue([])
mocks.decodeAuditLogCursor.mockReturnValue({
createdAt: '2026-01-01T00:00:00.000Z',
id: 'audit-1',
Expand All @@ -111,7 +108,12 @@ describe('audit-log application use cases', () => {
await listAuditLogs.execute({ principal, input: { ...listInput, organizationId: undefined } })
expect(mocks.resolveAccess).toHaveBeenCalledWith('admin-1', 'organization-1')
expect(mocks.resolveDefaultOrganization).not.toHaveBeenCalled()
expect(mocks.buildFilterConditions).toHaveBeenCalledWith({ workspaceId: 'workspace-1' })
expect(mocks.queryAuditLogs).toHaveBeenCalledWith(
expect.objectContaining({ organizationId: 'organization-1' }),
{ workspaceId: 'workspace-1' },
50,
undefined
)
mocks.resolveAccess.mockResolvedValueOnce({
success: false,
code: 'FORBIDDEN',
Expand Down
148 changes: 148 additions & 0 deletions apps/sim/lib/audit-logs/list-plans.integration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
/**
* The plans an organization's audit-log listing must get, proven on the exact SQL `queryAuditLogs`
* sends. Narrowed by nothing but a time range, each workspace is read by a keyset scan of
* `audit_log_workspace_created_at_id_idx` that a page limit stops, never by collecting and sorting
* the whole scope. Any other filter keeps the single scope predicate, so its plan is the one the
* filter's own index drives.
*/
import { db } from '@sim/db'
import { auditLog, user, workspace } from '@sim/db/schema'
import { explainCaptured, type PlanNode } from '@sim/db/testing/explain'
import { assertDisposableTestDatabaseUrl } from '@sim/db/testing/test-infrastructure'
import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures'
import { generateId } from '@sim/utils/id'
import { eq, inArray, like, sql } from 'drizzle-orm'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import {
type AuditLogFilterParams,
type AuditLogListScope,
queryAuditLogs,
} from '@/lib/audit-logs/query'

const organizationId = generateId()
const memberId = generateId()
const departedId = generateId()
const workspaceId = generateId()
const idPrefix = `audit_plan_${generateId()}_`
const workspaceIndex = 'audit_log_workspace_created_at_id_idx'
const orgLevelIndex = 'audit_log_org_level_created_at_id_idx'

const cursor = Buffer.from(
JSON.stringify({ createdAt: '2026-01-01T03:00:00.000Z', id: `${idPrefix}~` })
).toString('base64')

const listingPlan = (
filters: AuditLogFilterParams,
options: { includeDeparted?: boolean; cursor?: string } = {}
) => {
const scope: AuditLogListScope = {
organizationId,
orgWorkspaceIds: [workspaceId],
orgMemberIds: [memberId],
includeDeparted: options.includeDeparted ?? false,
}
return explainCaptured(db, () => queryAuditLogs(scope, filters, 50, options.cursor))
}

/** The workspace part's keyset scans, each directly under the limit that stops it after a page. */
const limitedWorkspaceScans = (nodes: PlanNode[]) =>
nodes
.filter((node) => node['Node Type'] === 'Limit')
.map((node) => node.Plans?.[0])
.filter((child) => child?.['Index Name'] === workspaceIndex)

const KEYSET_CASES: [
string,
AuditLogFilterParams,
{ includeDeparted?: boolean; cursor?: string },
][] = [
['no filter, current members', {}, {}],
['no filter, departed members included', {}, { includeDeparted: true }],
['no filter, after a cursor', {}, { cursor }],
['a start date', { startDate: '2026-01-01T01:00:00.000Z' }, {}],
[
'a date range, after a cursor',
{ startDate: '2026-01-01T01:00:00.000Z', endDate: '2026-01-01T04:00:00.000Z' },
{ cursor },
],
]

const NARROWED_CASES: [string, AuditLogFilterParams][] = [
['an action', { action: 'organization.updated' }],
[
'an action and a start date',
{ action: 'organization.updated', startDate: '2026-01-01T01:00:00.000Z' },
],
['a resource type', { resourceType: 'workflow' }],
['a resource', { resourceType: 'workflow', resourceId: 'resource-7' }],
['an actor', { actorId: memberId }],
['a workspace', { workspaceId }],
['a search', { search: 'fixture' }],
]

describe('organization audit-log listing plans in PostgreSQL', () => {
beforeAll(async () => {
assertDisposableTestDatabaseUrl(process.env.DATABASE_URL ?? '')
await db.insert(user).values(
[memberId, departedId].map((id) => ({
id,
name: 'Audit plan fixture',
email: `${id}@audit-plan.test`,
emailVerified: true,
createdAt: new Date(),
updatedAt: new Date(),
}))
)
await insertWorkspaceFixture(db, {
id: workspaceId,
name: 'Audit plan fixture',
ownerId: memberId,
billedAccountUserId: memberId,
})
// A workspace with a long history written mostly by a departed member, and workspace-less
// rows of many other organizations, so collecting the scope and sorting it costs far more than
// a keyset scan that stops at a page, even one that passes over departed rows to fill it.
await db.execute(sql`
INSERT INTO ${auditLog} (id, workspace_id, actor_id, action, resource_type, resource_id,
metadata, created_at)
SELECT ${idPrefix} || n, CASE WHEN n % 4 = 1 THEN ${workspaceId} END,
CASE WHEN n % 20 = 1 OR n % 2 = 0 THEN ${memberId} WHEN n % 4 = 1 THEN ${departedId} END,
'organization.updated',
CASE WHEN n % 997 = 0 THEN 'organization' ELSE 'workflow' END,
CASE WHEN n % 997 = 0 THEN ${organizationId} ELSE 'resource-' || n END,
jsonb_build_object('organizationId',
CASE WHEN n % 50 = 0 THEN ${organizationId} ELSE 'other-organization-' || (n % 400) END),
timestamp '2026-01-01' + n * interval '1 second'
FROM generate_series(1, 20000) AS n`)
await db.execute(sql`VACUUM ANALYZE ${auditLog}`)
})

afterAll(async () => {
await db.delete(auditLog).where(like(auditLog.id, `${idPrefix}%`))
await deleteWorkspaceFixture(db, eq(workspace.id, workspaceId))
await db.delete(user).where(inArray(user.id, [memberId, departedId]))
})

it.each(KEYSET_CASES)(
'reads each workspace newest first through its keyset index and stops after a page (%s)',
async (_label, filters, options) => {
const nodes = await listingPlan(filters, options)
const [scan] = limitedWorkspaceScans(nodes)
expect(scan).toMatchObject({ 'Node Type': 'Index Scan', 'Scan Direction': 'Backward' })
expect(scan?.['Index Cond']).toMatch(/workspace_id = org_workspace\.workspace_id/)
if (filters.startDate) expect(scan?.['Index Cond']).toMatch(/date_trunc\(.*\) >= /)
if (filters.endDate) expect(scan?.['Index Cond']).toMatch(/date_trunc\(.*\) <= /)
if (options.cursor) expect(scan?.['Index Cond']).toMatch(/ROW\(date_trunc/)
expect(nodes.filter((node) => node['Index Name'] === orgLevelIndex)).toHaveLength(1)
}
)

it.each(NARROWED_CASES)(
'keeps the single scope predicate when the listing is narrowed by %s',
async (_label, filters) => {
const nodes = await listingPlan(filters)
expect(nodes.filter((node) => node['Node Type'] === 'Function Scan')).toEqual([])
expect(nodes.filter((node) => /org_workspace/.test(node['Index Cond'] ?? ''))).toEqual([])
}
)
})
Loading
Loading