Skip to content

Commit 8a2f26b

Browse files
committed
fix(audit): persist provider acknowledgements and enforce export bounds
1 parent 2cf565a commit 8a2f26b

20 files changed

Lines changed: 424 additions & 105 deletions

‎apps/sim/app/api/audit-logs/export/route.ts‎

Lines changed: 2 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -7,24 +7,7 @@ import {
77
} from '@/lib/api/server/routes'
88
import { exportAuditLogs } from '@/lib/audit-logs/application/export-audit-logs'
99
import { auditLogOperations } from '@/lib/audit-logs/application/operations'
10-
import { formatCsvValue, toCsvRow } from '@/lib/core/utils/csv'
11-
12-
const CSV_HEADER = toCsvRow([
13-
'Date',
14-
'Action',
15-
'Resource Type',
16-
'Resource Name',
17-
'Actor',
18-
'Description',
19-
'Event ID',
20-
'Workspace ID',
21-
'Resource ID',
22-
'Actor ID',
23-
'IP Address',
24-
'User Agent',
25-
'Surface',
26-
'Metadata',
27-
])
10+
import { AUDIT_LOG_CSV_HEADER, toAuditLogCsvRow } from '@/lib/audit-logs/csv'
2811

2912
export const GET = defineInternalBinaryRoute({
3013
contract: exportAuditLogsContract,
@@ -38,29 +21,7 @@ export const GET = defineInternalBinaryRoute({
3821
},
3922
useCase: exportAuditLogs,
4023
present: ({ rows, truncated }) => ({
41-
body: [
42-
CSV_HEADER,
43-
...rows.map((row) =>
44-
toCsvRow(
45-
[
46-
row.createdAt,
47-
row.action,
48-
row.resourceType,
49-
row.resourceName,
50-
row.actorEmail || row.actorName || 'System',
51-
row.description,
52-
row.id,
53-
row.workspaceId,
54-
row.resourceId,
55-
row.actorId,
56-
row.ipAddress,
57-
row.userAgent,
58-
row.surface,
59-
row.metadata,
60-
].map((value) => formatCsvValue(value))
61-
)
62-
),
63-
].join('\n'),
24+
body: [AUDIT_LOG_CSV_HEADER, ...rows.map(toAuditLogCsvRow)].join('\n'),
6425
contentType: 'text/csv; charset=utf-8',
6526
contentDisposition: `attachment; filename="audit-logs-${new Date().toISOString().slice(0, 10)}.csv"`,
6627
headers: { 'Cache-Control': 'no-store', 'X-Export-Truncated': truncated ? '1' : '0' },

‎apps/sim/lib/audit-logs/application/export-audit-logs.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { AuditAction, AuditResourceType } from '@sim/audit'
22
import { defineAuthorizedAuditLogUseCase } from '@/lib/audit-logs/application/authorized-audit-log-use-case'
33
import { auditLogOperations } from '@/lib/audit-logs/application/operations'
4+
import { AUDIT_LOG_CSV_HEADER, toAuditLogCsvRow } from '@/lib/audit-logs/csv'
45
import {
56
type AuditLogFilterParams,
67
buildFilterConditions,
@@ -45,7 +46,7 @@ export const exportAuditLogs = defineAuthorizedAuditLogUseCase({
4546
const conditions = [scope, ...buildFilterConditions(input.filters)]
4647
const rows: Awaited<ReturnType<typeof queryAuditLogs>>['data'] = []
4748
let cursor: string | undefined
48-
let bytes = 0
49+
let bytes = Buffer.byteLength(AUDIT_LOG_CSV_HEADER, 'utf8')
4950
let truncated = false
5051
pages: while (rows.length < EXPORT_MAX_ROWS) {
5152
request?.signal?.throwIfAborted()
@@ -55,7 +56,7 @@ export const exportAuditLogs = defineAuthorizedAuditLogUseCase({
5556
cursor
5657
)
5758
for (const row of page.data) {
58-
bytes += Buffer.byteLength(JSON.stringify(row), 'utf8')
59+
bytes += Buffer.byteLength(toAuditLogCsvRow(row), 'utf8') + 1
5960
if (bytes > EXPORT_MAX_BYTES) {
6061
truncated = true
6162
break pages

‎apps/sim/lib/audit-logs/csv.ts‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import type { queryAuditLogs } from '@/lib/audit-logs/query'
2+
import { formatCsvValue, toCsvRow } from '@/lib/core/utils/csv'
3+
4+
export const AUDIT_LOG_CSV_HEADER = toCsvRow([
5+
'Date',
6+
'Action',
7+
'Resource Type',
8+
'Resource Name',
9+
'Actor',
10+
'Description',
11+
'Event ID',
12+
'Workspace ID',
13+
'Resource ID',
14+
'Actor ID',
15+
'IP Address',
16+
'User Agent',
17+
'Surface',
18+
'Metadata',
19+
])
20+
21+
/** Formats the exported row identically for byte admission and the download response. */
22+
export function toAuditLogCsvRow(row: Awaited<ReturnType<typeof queryAuditLogs>>['data'][number]) {
23+
return toCsvRow(
24+
[
25+
row.createdAt,
26+
row.action,
27+
row.resourceType,
28+
row.resourceName,
29+
row.actorEmail || row.actorName || 'System',
30+
row.description,
31+
row.id,
32+
row.workspaceId,
33+
row.resourceId,
34+
row.actorId,
35+
row.ipAddress,
36+
row.userAgent,
37+
row.surface,
38+
row.metadata,
39+
].map((value) => formatCsvValue(value))
40+
)
41+
}

‎apps/sim/lib/audit-logs/export.integration.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,4 +117,31 @@ describe('Organization audit CSV export in PostgreSQL', () => {
117117
)
118118
expect(response.status).toBe(400)
119119
})
120+
121+
it('bounds encoded CSV bytes and marks quote-heavy metadata as truncated', async () => {
122+
const { db, schema, eq } = runtime
123+
await db
124+
.update(schema.auditLog)
125+
.set({
126+
metadata: { organizationId, fixture: '"'.repeat(24_000_000) },
127+
})
128+
.where(eq(schema.auditLog.id, eventId))
129+
try {
130+
const response = await runtime.GET(
131+
new NextRequest(
132+
`http://localhost:3000/api/audit-logs/export?organizationId=${organizationId}&includeDeparted=true&actorId=${departedId}`
133+
)
134+
)
135+
expect(response.status).toBe(200)
136+
const csv = await response.arrayBuffer()
137+
expect(csv.byteLength).toBeLessThanOrEqual(64 * 1024 * 1024)
138+
expect(response.headers.get('x-export-truncated')).toBe('1')
139+
expect(Buffer.from(csv).toString('utf8')).not.toContain(eventId)
140+
} finally {
141+
await db
142+
.update(schema.auditLog)
143+
.set({ metadata: { organizationId, changedFields: ['name'] } })
144+
.where(eq(schema.auditLog.id, eventId))
145+
}
146+
})
120147
})

‎apps/sim/lib/auth/lifecycle-audit.integration.ts‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,48 @@ describe('authentication audit history in PostgreSQL', () => {
224224
expect((await history()).some((event) => event.action === 'auth.login_succeeded')).toBe(false)
225225
})
226226

227+
it.each([
228+
{ path: '/sign-in/email', action: 'auth.login_failed' },
229+
{ path: '/request-password-reset', action: 'password.reset_requested' },
230+
])('omits ambiguous legacy email provenance for $action', async ({ path, action }) => {
231+
const collisionId = generateId()
232+
additionalUserIds.push(collisionId)
233+
await db.insert(user).values({
234+
id: collisionId,
235+
name: 'Legacy authentication fixture',
236+
email: email.toUpperCase(),
237+
emailVerified: true,
238+
createdAt: new Date(),
239+
updatedAt: new Date(),
240+
})
241+
await db
242+
.insert(member)
243+
.values({ id: generateId(), userId: collisionId, organizationId, role: 'member' })
244+
const agent = `Authentication collision fixture ${generateId()}`
245+
const attempt = request(path, {
246+
email,
247+
password: 'incorrect-fixture-password',
248+
redirectTo: 'http://localhost:3000/reset-password',
249+
})
250+
attempt.headers.set('user-agent', agent)
251+
try {
252+
const response = await auth.handler(attempt)
253+
expect(response.status).toBe(path === '/sign-in/email' ? 401 : 200)
254+
const events = () => db.select().from(auditLog).where(eq(auditLog.userAgent, agent))
255+
await expect
256+
.poll(async () => (await events()).filter((event) => event.action === action).length)
257+
.toBe(1)
258+
const event = (await events()).find((event) => event.action === action)
259+
if (!event) throw new Error('Authentication audit event missing')
260+
expect(event.actorId).toBeNull()
261+
expect(event.resourceId).toBeNull()
262+
expect(event.metadata).not.toHaveProperty('targetUserId')
263+
expect(event.metadata).not.toHaveProperty('organizationId')
264+
} finally {
265+
await db.delete(auditLog).where(eq(auditLog.userAgent, agent))
266+
}
267+
})
268+
227269
it('attributes a password-reset request to its unauthenticated requester and the target organization', async () => {
228270
const response = await resetPassword(
229271
createMockRequest({

‎apps/sim/lib/auth/lifecycle-audit.ts‎

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -65,18 +65,23 @@ function isFailureRedirect(location: string | null | undefined): boolean {
6565
async function loadSubject(input: { userId?: string; email?: unknown }) {
6666
const email = typeof input.email === 'string' ? input.email.trim().toLowerCase() : undefined
6767
if (!input.userId && !email) return undefined
68-
const [subject] = await db
68+
const subjects = await db
6969
.select({
7070
id: user.id,
7171
name: user.name,
7272
email: user.email,
73-
organizationId: member.organizationId,
7473
})
7574
.from(user)
76-
.leftJoin(member, eq(member.userId, user.id))
7775
.where(input.userId ? eq(user.id, input.userId) : eq(foldedEmail(user.email), email ?? ''))
76+
.limit(2)
77+
if (subjects.length !== 1) return undefined
78+
const subject = subjects[0]
79+
const [membership] = await db
80+
.select({ organizationId: member.organizationId })
81+
.from(member)
82+
.where(eq(member.userId, subject.id))
7883
.limit(1)
79-
return subject
84+
return { ...subject, organizationId: membership?.organizationId }
8085
}
8186

8287
/** Records final authentication outcomes without persisting request bodies or bearer credentials. */
@@ -178,21 +183,21 @@ export async function recordPasswordResetAudit(input: {
178183
}): Promise<void> {
179184
try {
180185
const subject = await loadSubject(input)
181-
if (!subject) return
182186
const completed = input.kind === 'completed'
187+
if (completed && !subject) return
183188
recordAudit({
184-
actorId: completed ? subject.id : null,
185-
actorName: completed ? subject.name : 'Unauthenticated requester',
186-
actorEmail: completed ? subject.email : null,
189+
actorId: completed && subject ? subject.id : null,
190+
actorName: completed && subject ? subject.name : 'Unauthenticated requester',
191+
actorEmail: completed && subject ? subject.email : null,
187192
action: completed ? AuditAction.PASSWORD_RESET : AuditAction.PASSWORD_RESET_REQUESTED,
188193
resourceType: AuditResourceType.PASSWORD,
189-
resourceId: subject.id,
190-
resourceName: subject.email,
194+
resourceId: subject?.id,
195+
resourceName: subject?.email,
191196
description: completed ? 'Password reset completed' : 'Password reset requested',
192197
metadata: {
193198
outcome: 'success',
194-
...(subject.organizationId ? { organizationId: subject.organizationId } : {}),
195-
...(!completed ? { targetUserId: subject.id } : {}),
199+
...(subject?.organizationId ? { organizationId: subject.organizationId } : {}),
200+
...(!completed && subject ? { targetUserId: subject.id } : {}),
196201
},
197202
request: input.request,
198203
})

‎apps/sim/lib/core/network/fixtures/gateway-runtime.fixture.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,18 @@ try {
3535
})
3636
if (pinned.headers.get('x-via-proxy') !== 'yes') throw new Error('Pinned fetch bypassed proxy')
3737
if ((await pinned.text()) !== 'tls reached') throw new Error('Pinned fetch response mismatch')
38+
const target = '/exports/../raw/event%2Fpart.ndjson?x-id=PutObject&token=%2B'
39+
const signed = await secureFetchWithPinnedIP(`https://origin.invalid${target}`, '1.1.1.1', {
40+
profile: 'selfHostedService',
41+
requestTarget: target,
42+
method: 'PUT',
43+
body: '{"id":"event"}\n',
44+
timeout: 10_000,
45+
})
46+
if (signed.headers.get('x-via-proxy') !== 'yes') throw new Error('Signed fetch bypassed proxy')
47+
if (signed.headers.get('x-request-target') !== target) throw new Error('Signed target changed')
48+
if ((await signed.text()) !== '{"id":"event"}\n')
49+
throw new Error('Signed fetch response mismatch')
3850
})
3951

4052
let rejected = false

‎apps/sim/lib/core/network/gateway.server.test.ts‎

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,14 @@ const admissions: Array<{
4343
}> = []
4444
let originRequests = 0
4545
let receivedHeaders: Record<string, unknown> = {}
46+
let receivedTarget = ''
4647
const proxiedPorts = new Set<number>()
4748
const secureOrigin = httpsServer(async (req, res) => {
4849
res.setHeader('x-via-proxy', proxiedPorts.has(req.socket.remotePort ?? 0) ? 'yes' : 'no')
4950
originRequests++
5051
receivedHeaders = req.headers
52+
receivedTarget = req.url ?? ''
53+
res.setHeader('x-request-target', receivedTarget)
5154
if (req.url === '/redirect') {
5255
res.writeHead(302, { location: '/done' })
5356
res.end()
@@ -56,7 +59,7 @@ const secureOrigin = httpsServer(async (req, res) => {
5659
if (req.url === '/wait') return
5760
const chunks: Buffer[] = []
5861
for await (const chunk of req) chunks.push(Buffer.from(chunk))
59-
res.end(req.method === 'POST' ? Buffer.concat(chunks) : 'tls reached')
62+
res.end(req.method === 'POST' || req.method === 'PUT' ? Buffer.concat(chunks) : 'tls reached')
6063
})
6164
const proxy = httpsServer()
6265
proxy.on('connect', (req, socket, head) => {
@@ -150,6 +153,28 @@ const url = () => 'https://origin.invalid'
150153
const options = { profile: 'selfHostedService' as const }
151154

152155
describe('organization gateways over real TLS CONNECT sockets', () => {
156+
it('preserves signed request targets and origin authentication through the gateway', async () => {
157+
const target = '/exports/../raw/audit_logs/event%2Fpart.ndjson?x-id=PutObject&token=%2B'
158+
const response = await runWithOutboundOrganization('org_a', () =>
159+
secureFetchWithPinnedIP(`${url()}${target}`, '1.1.1.1', {
160+
...options,
161+
requestTarget: target,
162+
method: 'PUT',
163+
body: '{"id":"event"}\n',
164+
headers: {
165+
authorization: 'AWS4-HMAC-SHA256 signed-request',
166+
'Proxy-Authorization': 'must-not-reach-origin',
167+
},
168+
})
169+
)
170+
expect(await response.text()).toBe('{"id":"event"}\n')
171+
expect(response.headers.get('x-via-proxy')).toBe('yes')
172+
expect(receivedTarget).toBe(target)
173+
expect(receivedHeaders.authorization).toBe('AWS4-HMAC-SHA256 signed-request')
174+
expect(receivedHeaders['proxy-authorization']).toBeUndefined()
175+
expect(admissions.at(-1)?.destination).toBe('1.1.1.1:443')
176+
})
177+
153178
it('keeps an upgraded tunnel usable after five minutes', async () => {
154179
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
155180
let tunnel: Socket | undefined
@@ -312,7 +337,7 @@ describe('organization gateways over real TLS CONNECT sockets', () => {
312337
},
313338
}
314339
)
315-
expect(admissions.length).toBe(start + 3)
340+
expect(admissions.length).toBe(start + 4)
316341
expect(
317342
admissions.slice(start).every((entry) => entry.token === `Bearer ${'a'.repeat(48)}`)
318343
).toBe(true)

‎apps/sim/lib/core/network/transport.server.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,16 +79,27 @@ type OutboundRequestOptions = Omit<
7979
> & {
8080
headers?: Record<string, string>
8181
dispatcher: Dispatcher
82+
requestTarget?: string
8283
}
8384

8485
/**
8586
* The shared HTTP wire transport. Import the installed package explicitly: Bun's bare
8687
* undici shim ignores dispatchers. Proxy credentials never become destination headers.
8788
*/
8889
export function requestWithOutboundDispatcher(url: string, options: OutboundRequestOptions) {
90+
const { requestTarget, dispatcher, ...requestOptions } = options
8991
const headers = { ...options.headers }
9092
for (const name of Object.keys(headers)) {
9193
if (name.toLowerCase() === 'proxy-authorization') delete headers[name]
9294
}
93-
return request(url, { ...options, headers })
95+
if (requestTarget !== undefined) {
96+
return dispatcher.request({
97+
...requestOptions,
98+
origin: new URL(url).origin,
99+
path: requestTarget,
100+
method: requestOptions.method ?? 'GET',
101+
headers,
102+
})
103+
}
104+
return request(url, { ...requestOptions, dispatcher, headers })
94105
}

0 commit comments

Comments
 (0)