Skip to content

Commit a06248c

Browse files
committed
feat(audit): record the client surface on audit entries and identify CLI login requests
1 parent b26365c commit a06248c

13 files changed

Lines changed: 26681 additions & 15 deletions

File tree

packages/audit/src/log.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,18 @@ vi.mock('drizzle-orm', () => ({
1515
or: vi.fn(),
1616
sql: vi.fn(),
1717
}))
18+
const { mockGetRequestContext } = vi.hoisted(() => ({
19+
mockGetRequestContext: vi.fn(),
20+
}))
21+
1822
vi.mock('@sim/logger', () => ({
1923
createLogger: () => ({
2024
info: vi.fn(),
2125
warn: vi.fn(),
2226
error: vi.fn(),
2327
debug: vi.fn(),
2428
}),
29+
getRequestContext: mockGetRequestContext,
2530
}))
2631
vi.mock('@sim/utils/id', () => ({
2732
generateId: () => 'test-uuid-123',
@@ -181,6 +186,43 @@ describe('recordAudit', () => {
181186
)
182187
})
183188

189+
it('records the surface the request came from', async () => {
190+
mockGetRequestContext.mockReturnValueOnce({
191+
requestId: 'req-1',
192+
client: { surface: 'cli', version: '2.1.16', source: 'header' },
193+
})
194+
195+
recordAudit({
196+
workspaceId: 'ws-1',
197+
actorId: 'user-1',
198+
actorName: 'Test',
199+
actorEmail: 'test@test.com',
200+
action: AuditAction.WORKFLOW_CREATED,
201+
resourceType: AuditResourceType.WORKFLOW,
202+
})
203+
204+
await flush()
205+
206+
expect(dbChainMockFns.values).toHaveBeenCalledWith(expect.objectContaining({ surface: 'cli' }))
207+
})
208+
209+
it('records no surface outside a request', async () => {
210+
mockGetRequestContext.mockReturnValueOnce(undefined)
211+
212+
recordAudit({
213+
workspaceId: 'ws-1',
214+
actorId: 'user-1',
215+
actorName: 'Test',
216+
actorEmail: 'test@test.com',
217+
action: AuditAction.WORKFLOW_CREATED,
218+
resourceType: AuditResourceType.WORKFLOW,
219+
})
220+
221+
await flush()
222+
223+
expect(dbChainMockFns.values.mock.calls.at(-1)?.[0].surface).toBeUndefined()
224+
})
225+
184226
it('records null when x-forwarded-for is absent', async () => {
185227
const request = new Request('https://example.com', {
186228
headers: { 'x-real-ip': '10.0.0.1' },

packages/audit/src/log.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { auditLog, db, user } from '@sim/db'
2-
import { createLogger } from '@sim/logger'
2+
import { createLogger, getRequestContext } from '@sim/logger'
33
import { createClientIpResolver } from '@sim/security/ip'
44
import { generateShortId } from '@sim/utils/id'
55
import { eq } from 'drizzle-orm'
@@ -66,6 +66,10 @@ export function recordAuditBatch(entries: AuditLogParams[]): void {
6666
* insert paths so the write shape cannot drift between them. Actor fields
6767
* are taken as-is — lazy actor resolution is layered on top by
6868
* {@link recordAudit} only.
69+
*
70+
* The surface comes from the ambient request context, which the route wrapper
71+
* resolves once per request, so every entry recorded while serving a request
72+
* is attributed without each caller passing it. It is absent outside a request.
6973
*/
7074
function buildAuditRow(
7175
params: AuditLogParams,
@@ -86,6 +90,7 @@ function buildAuditRow(
8690
metadata: params.metadata ?? {},
8791
ipAddress: params.request ? clientIpResolver.resolve(params.request.headers) : undefined,
8892
userAgent: params.request?.headers.get('user-agent') ?? undefined,
93+
surface: getRequestContext()?.client?.surface,
8994
}
9095
}
9196

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ALTER TABLE "audit_log" ADD COLUMN "surface" text;

0 commit comments

Comments
 (0)