Skip to content

Commit fc0e119

Browse files
refactor(tables): move the audit log out of the table service
`lib/table/service.ts` wrote its own audit rows, so whether an operation was audited depended on which function a caller reached for rather than on a user having performed it. That is what let v1 and v2 audit a no-op delete, and what made `deleteTable`'s optional `actingUserId` double as an audit opt-out flag. Worse, most sites fell back to `actingUserId ?? createdBy`, so an unattributed call was logged against the table's *creator*. The copilot `mv` path passed no actor at all: renaming someone else's table recorded them as the renamer. Audit now lives in the orchestration functions — performDeleteTable, performRenameTable, performMoveTableToFolder, performUpdateTableLocks — and the services just write. Internal callers (folder cascade, import rollback) keep calling the service and are silent by construction rather than by remembering to omit an argument. Two services now return what the audit needs: `deleteTable` reports whether it actually archived a row, so a repeat delete logs nothing; `updateTableLocks` returns the before/after locks, since only the locked write can observe the transition its description names. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent f3dd1fe commit fc0e119

9 files changed

Lines changed: 329 additions & 149 deletions

File tree

apps/sim/app/api/table/[tableId]/route.test.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,13 @@ vi.mock('@/lib/table', () => ({
3333
updateTableLocks: mockUpdateTableLocks,
3434
TableConflictError: class extends Error {},
3535
}))
36+
vi.mock('@/lib/table/service', () => ({
37+
deleteTable: mockDeleteTable,
38+
getTableById: mockGetTableById,
39+
moveTableToFolder: mockMoveTableToFolder,
40+
renameTable: mockRenameTable,
41+
updateTableLocks: mockUpdateTableLocks,
42+
}))
3643
vi.mock('@/lib/table/billing', () => ({ getWorkspaceTableLimits: mockGetLimits }))
3744
vi.mock('@/lib/folders/queries', () => ({ findActiveFolder: mockFindActiveFolder }))
3845
vi.mock('@/lib/core/config/feature-flags', () => ({ isFeatureEnabled: vi.fn() }))
@@ -77,6 +84,13 @@ const routeContext = { params: Promise.resolve({ tableId: 'tbl_1' }) }
7784
describe('PATCH /api/table/[tableId] folder moves', () => {
7885
beforeEach(() => {
7986
vi.clearAllMocks()
87+
mockMoveTableToFolder.mockResolvedValue({ name: 'Table' })
88+
mockRenameTable.mockResolvedValue({ id: 'tbl_1', name: 'Table' })
89+
mockDeleteTable.mockResolvedValue({ archived: { name: 'Table', workspaceId: 'workspace-1' } })
90+
mockUpdateTableLocks.mockResolvedValue({
91+
table: { ...TABLE, locks: {} },
92+
previousLocks: {},
93+
})
8094
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
8195
success: true,
8296
userId: 'user-1',
@@ -99,8 +113,7 @@ describe('PATCH /api/table/[tableId] folder moves', () => {
99113
'tbl_1',
100114
'workspace-1',
101115
'folder-1',
102-
expect.any(String),
103-
'user-1'
116+
expect.any(String)
104117
)
105118
})
106119

@@ -118,8 +131,7 @@ describe('PATCH /api/table/[tableId] folder moves', () => {
118131
'tbl_1',
119132
'workspace-1',
120133
null,
121-
expect.any(String),
122-
'user-1'
134+
expect.any(String)
123135
)
124136
})
125137

apps/sim/app/api/table/[tableId]/route.ts

Lines changed: 46 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,14 @@ import { statusForOrchestrationError } from '@/lib/core/orchestration/types'
99
import { generateRequestId } from '@/lib/core/utils/request'
1010
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1111
import { findActiveFolder } from '@/lib/folders/queries'
12-
import {
13-
getTableById,
14-
moveTableToFolder,
15-
renameTable,
16-
TableConflictError,
17-
type TableSchema,
18-
updateTableLocks,
19-
} from '@/lib/table'
12+
import { getTableById, TableConflictError, type TableSchema } from '@/lib/table'
2013
import { getWorkspaceTableLimits } from '@/lib/table/billing'
21-
import { performDeleteTable } from '@/lib/table/orchestration'
14+
import {
15+
performDeleteTable,
16+
performMoveTableToFolder,
17+
performRenameTable,
18+
performUpdateTableLocks,
19+
} from '@/lib/table/orchestration'
2220
import { TABLE_LOCK_FLAGS, TABLE_LOCK_KINDS } from '@/lib/table/types'
2321
import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils'
2422
import {
@@ -180,11 +178,34 @@ export const PATCH = withRouteHandler(
180178
{ status: 403 }
181179
)
182180
}
183-
await updateTableLocks(tableId, validated.locks, authResult.userId, requestId, request)
181+
const lockOutcome = await performUpdateTableLocks({
182+
tableId,
183+
partial: validated.locks,
184+
userId: authResult.userId,
185+
requestId,
186+
request,
187+
})
188+
if (!lockOutcome.success) {
189+
return NextResponse.json(
190+
{ error: lockOutcome.error ?? 'Failed to update table locks' },
191+
{ status: statusForOrchestrationError(lockOutcome.errorCode) }
192+
)
193+
}
184194
}
185195

186196
if (validated.name !== undefined) {
187-
await renameTable(tableId, validated.name, requestId, authResult.userId)
197+
const renameOutcome = await performRenameTable({
198+
table,
199+
newName: validated.name,
200+
userId: authResult.userId,
201+
requestId,
202+
})
203+
if (!renameOutcome.success) {
204+
return NextResponse.json(
205+
{ error: renameOutcome.error ?? 'Failed to rename table' },
206+
{ status: statusForOrchestrationError(renameOutcome.errorCode) }
207+
)
208+
}
188209
}
189210

190211
if (validated.folderId !== undefined) {
@@ -196,21 +217,21 @@ export const PATCH = withRouteHandler(
196217
) {
197218
return NextResponse.json({ error: 'Folder not found in this workspace' }, { status: 404 })
198219
}
199-
try {
200-
await moveTableToFolder(
201-
tableId,
202-
table.workspaceId,
203-
validated.folderId,
204-
requestId,
205-
authResult.userId
220+
// The move re-asserts workspace and active state, so a miss means the table was
221+
// archived between `checkAccess` and the write. That is a 404, not a server fault.
222+
const moveOutcome = await performMoveTableToFolder({
223+
table,
224+
folderId: validated.folderId,
225+
userId: authResult.userId,
226+
requestId,
227+
})
228+
if (!moveOutcome.success) {
229+
return NextResponse.json(
230+
{
231+
error: moveOutcome.errorCode === 'not_found' ? 'Table not found' : moveOutcome.error,
232+
},
233+
{ status: statusForOrchestrationError(moveOutcome.errorCode) }
206234
)
207-
} catch (moveError) {
208-
// The move re-asserts workspace and active state, so a miss means the table was
209-
// archived between `checkAccess` and the write. That is a 404, not a server fault.
210-
if (moveError instanceof Error && moveError.message.endsWith('not found')) {
211-
return NextResponse.json({ error: 'Table not found' }, { status: 404 })
212-
}
213-
throw moveError
214235
}
215236
}
216237

apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ import {
2222
getKnowledgeBases,
2323
updateKnowledgeBase,
2424
} from '@/lib/knowledge/service'
25-
import { deleteTable, listTables, renameTable } from '@/lib/table/service'
25+
import { performDeleteTable, performRenameTable } from '@/lib/table/orchestration'
26+
import { listTables } from '@/lib/table/service'
2627
import {
2728
ensureWorkspaceFileFolderPath,
2829
findWorkspaceFileFolderIdByPath,
@@ -759,11 +760,19 @@ async function renameFlatResource(
759760
return { success: false, error: `Table not found at ${sources[0]}` }
760761
}
761762
assertMutationNotAborted(context)
762-
const renamed = await renameTable(match.id, newName, generateRequestId())
763+
const renameOutcome = await performRenameTable({
764+
table: match,
765+
newName,
766+
userId: context.userId,
767+
requestId: generateRequestId(),
768+
})
769+
if (!renameOutcome.success) {
770+
return { success: false, error: renameOutcome.error ?? 'Failed to rename table' }
771+
}
763772
return buildResult(verb, [
764773
{
765774
from: sources[0],
766-
to: `tables/${normalizeVfsSegment(renamed.name)}`,
775+
to: `tables/${normalizeVfsSegment(newName)}`,
767776
kind,
768777
id: match.id,
769778
},
@@ -1018,7 +1027,14 @@ async function removeTablePath(
10181027
)
10191028
if (!match) return { from: path, kind: 'table', error: `Table not found at ${path}` }
10201029

1021-
await deleteTable(match.id, generateRequestId(), context.userId)
1030+
const outcome = await performDeleteTable({
1031+
table: match,
1032+
userId: context.userId,
1033+
requestId: generateRequestId(),
1034+
})
1035+
if (!outcome.success) {
1036+
return { from: path, kind: 'table', error: outcome.error ?? 'Failed to archive table' }
1037+
}
10221038
logger.info('Archived table via rm', { tableId: match.id, workspaceId })
10231039
return { from: path, kind: 'table', id: match.id }
10241040
}

apps/sim/lib/copilot/tools/server/table/user-table.ts

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,11 @@ import { markTableDeleteFailed, runTableDelete } from '@/lib/table/delete-runner
4040
import { runTableImport, type TableImportPayload } from '@/lib/table/import-runner'
4141
import { markTableJobRunning, releaseJobClaim } from '@/lib/table/jobs/service'
4242
import { assertRowDelete, assertRowUpdate, patchColumnIds } from '@/lib/table/mutation-locks'
43-
import { performUpdateTableColumn } from '@/lib/table/orchestration'
43+
import {
44+
performDeleteTable,
45+
performRenameTable,
46+
performUpdateTableColumn,
47+
} from '@/lib/table/orchestration'
4448
import { predicateToFilter } from '@/lib/table/query-builder/converters'
4549
import { validatePredicate, validateSortSpec } from '@/lib/table/query-builder/validate'
4650
import { assertCursorSortBinding, decodeCursor } from '@/lib/table/rows/cursor'
@@ -59,7 +63,7 @@ import {
5963
} from '@/lib/table/rows/service'
6064
import { normalizeSelectOptionsInput } from '@/lib/table/select-options'
6165
import { predicateToStorage } from '@/lib/table/select-values'
62-
import { createTable, deleteTable, getTableById, renameTable } from '@/lib/table/service'
66+
import { createTable, deleteTable, getTableById } from '@/lib/table/service'
6367
import type {
6468
ColumnDefinition,
6569
Filter,
@@ -490,7 +494,14 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
490494

491495
const requestId = generateId().slice(0, 8)
492496
assertNotAborted()
493-
await deleteTable(tableId, requestId, context.userId)
497+
const deleteOutcome = await performDeleteTable({
498+
table,
499+
userId: context.userId,
500+
requestId,
501+
})
502+
if (!deleteOutcome.success) {
503+
return { success: false, message: deleteOutcome.error ?? 'Failed to delete table' }
504+
}
494505
captureServerEvent(
495506
context.userId,
496507
'table_deleted',
@@ -1703,12 +1714,20 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
17031714

17041715
const requestId = generateId().slice(0, 8)
17051716
assertNotAborted()
1706-
const renamed = await renameTable(args.tableId, newName, requestId, context.userId)
1717+
const renameOutcome = await performRenameTable({
1718+
table,
1719+
newName,
1720+
userId: context.userId,
1721+
requestId,
1722+
})
1723+
if (!renameOutcome.success) {
1724+
return { success: false, message: renameOutcome.error ?? 'Failed to rename table' }
1725+
}
17071726

17081727
return {
17091728
success: true,
1710-
message: `Renamed table to "${renamed.name}"`,
1711-
data: { table: { id: renamed.id, name: renamed.name } },
1729+
message: `Renamed table to "${newName}"`,
1730+
data: { table: { id: args.tableId, name: newName } },
17121731
}
17131732
}
17141733

apps/sim/lib/folders/config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,7 @@ async function archiveTableChildren(context: CascadeChildrenContext): Promise<nu
304304
const ids = await selectChildIds(FOLDER_RESOURCES.table, context, 'active')
305305

306306
for (const id of ids) {
307-
await deleteTable(id, `folder-cascade-${context.folderIds[0]}`, undefined, {
307+
await deleteTable(id, `folder-cascade-${context.folderIds[0]}`, {
308308
archivedAt: context.timestamp,
309309
})
310310
}
Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
11
export { performUpdateTableColumn } from './columns'
22
export { performRestoreTable } from './restore'
3-
export { performDeleteTable, performDeleteTableRow } from './tables'
3+
export {
4+
performDeleteTable,
5+
performDeleteTableRow,
6+
performMoveTableToFolder,
7+
performRenameTable,
8+
performUpdateTableLocks,
9+
} from './tables'

apps/sim/lib/table/orchestration/tables.test.ts

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,27 @@
44
import { beforeEach, describe, expect, it, vi } from 'vitest'
55
import type { TableDefinition } from '@/lib/table/types'
66

7-
const { mockDeleteTable, mockDeleteRow, mockCaptureServerEvent } = vi.hoisted(() => ({
8-
mockDeleteTable: vi.fn(),
9-
mockDeleteRow: vi.fn(),
10-
mockCaptureServerEvent: vi.fn(),
7+
const { mockDeleteTable, mockDeleteRow, mockCaptureServerEvent, mockRecordAudit } = vi.hoisted(
8+
() => ({
9+
mockDeleteTable: vi.fn(),
10+
mockDeleteRow: vi.fn(),
11+
mockCaptureServerEvent: vi.fn(),
12+
mockRecordAudit: vi.fn(),
13+
})
14+
)
15+
16+
vi.mock('@sim/audit', () => ({
17+
AuditAction: { TABLE_DELETED: 'table.deleted', TABLE_UPDATED: 'table.updated' },
18+
AuditResourceType: { TABLE: 'table' },
19+
recordAudit: mockRecordAudit,
1120
}))
1221

13-
vi.mock('@/lib/table/service', () => ({ deleteTable: mockDeleteTable }))
22+
vi.mock('@/lib/table/service', () => ({
23+
deleteTable: mockDeleteTable,
24+
moveTableToFolder: vi.fn(),
25+
renameTable: vi.fn(),
26+
updateTableLocks: vi.fn(),
27+
}))
1428
vi.mock('@/lib/table/rows/service', () => ({ deleteRow: mockDeleteRow }))
1529
vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mockCaptureServerEvent }))
1630

@@ -22,16 +36,18 @@ const TABLE = { id: 'table-1', name: 'Tasks', workspaceId: 'ws-1' } as unknown a
2236
describe('performDeleteTable', () => {
2337
beforeEach(() => vi.clearAllMocks())
2438

25-
it('hands the actor to the service so it owns the audit', async () => {
26-
// deleteTable audits only when a row was actually archived AND an actor is
27-
// given. Callers that omitted the actor and audited themselves emitted
28-
// TABLE_DELETED for a no-op delete of an already-archived table.
29-
mockDeleteTable.mockResolvedValue(undefined)
39+
it('audits a genuine archive against the acting user', async () => {
40+
mockDeleteTable.mockResolvedValue({ archived: { name: 'Tasks', workspaceId: 'ws-1' } })
3041

3142
const result = await performDeleteTable({ table: TABLE, userId: 'user-1', requestId: 'req-1' })
3243

3344
expect(result.success).toBe(true)
34-
expect(mockDeleteTable).toHaveBeenCalledWith('table-1', 'req-1', 'user-1')
45+
// The service no longer takes an actor — auditing follows from a user
46+
// performing the operation, not from which function the caller reached for.
47+
expect(mockDeleteTable).toHaveBeenCalledWith('table-1', 'req-1')
48+
expect(mockRecordAudit).toHaveBeenCalledWith(
49+
expect.objectContaining({ actorId: 'user-1', resourceId: 'table-1' })
50+
)
3551
expect(mockCaptureServerEvent).toHaveBeenCalledWith(
3652
'user-1',
3753
'table_deleted',
@@ -40,6 +56,15 @@ describe('performDeleteTable', () => {
4056
)
4157
})
4258

59+
it('does not audit a repeat delete of an already-archived table', async () => {
60+
mockDeleteTable.mockResolvedValue({ archived: null })
61+
62+
const result = await performDeleteTable({ table: TABLE, userId: 'user-1' })
63+
64+
expect(result.success).toBe(true)
65+
expect(mockRecordAudit).not.toHaveBeenCalled()
66+
})
67+
4368
it('classifies a delete lock as locked and emits no telemetry', async () => {
4469
mockDeleteTable.mockRejectedValue(new TableLockedError('delete'))
4570

0 commit comments

Comments
 (0)