Skip to content

Commit f7ef6ed

Browse files
authored
fix(files): refuse delegated version reads no provenance observer records (#8843)
1 parent 0809d27 commit f7ef6ed

5 files changed

Lines changed: 215 additions & 21 deletions

File tree

‎apps/sim/lib/workspace-files/__integration__/copilot-file-versions.integration.ts‎

Lines changed: 175 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,24 @@ import { rm } from 'node:fs/promises'
44
import { tmpdir } from 'node:os'
55
import path from 'node:path'
66
import { db } from '@sim/db'
7-
import { auditLog, organization, user, workspace, workspaceFileVersion } from '@sim/db/schema'
7+
import {
8+
auditLog,
9+
organization,
10+
permissionGroup,
11+
permissionGroupMember,
12+
permissionGroupWorkspace,
13+
permissions,
14+
user,
15+
workspace,
16+
workspaceFileVersion,
17+
} from '@sim/db/schema'
18+
import { envFlagsMock } from '@sim/testing/mocks/env-flags.mock'
819
import { generateId } from '@sim/utils/id'
920
import { and, asc, eq, inArray } from 'drizzle-orm'
1021
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
1122

23+
vi.mock('@/lib/core/config/env-flags', () => ({ ...envFlagsMock, isAccessControlEnabled: true }))
24+
1225
const fixtureStorage = vi.hoisted(() => ({ root: '' }))
1326
vi.mock('@/lib/uploads/core/setup.server', () => ({
1427
get UPLOAD_DIR_SERVER() {
@@ -28,6 +41,7 @@ import {
2841
updateWorkspaceFileContent,
2942
uploadWorkspaceFile,
3043
} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
44+
import type { WorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance'
3145
import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection'
3246
import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
3347
import '@/app/api/v2/files/[fileId]/versions/route'
@@ -49,7 +63,11 @@ describe('chat-delegated file version history', () => {
4963
fixtureStorage.root = mkdtempSync(path.join(tmpdir(), 'sim-chat-file-versions-'))
5064
})
5165

66+
const extraWorkspaceIds: string[] = []
67+
5268
afterAll(async () => {
69+
if (extraWorkspaceIds.length > 0)
70+
await db.delete(workspace).where(inArray(workspace.id, extraWorkspaceIds))
5371
for (const ids of fixtures) {
5472
await db.delete(auditLog).where(eq(auditLog.workspaceId, ids.workspaceId))
5573
await db.delete(workspace).where(eq(workspace.id, ids.workspaceId))
@@ -60,7 +78,7 @@ describe('chat-delegated file version history', () => {
6078
})
6179

6280
/** Version 2 holds a named secret; version 3 replaced it with public text. */
63-
async function seedVersionedFile() {
81+
async function seedVersionedFile(historicalProvenance?: WorkspaceFileSecretProvenance) {
6482
const ids = createKnowledgeAclFixtureIds()
6583
fixtures.push(ids)
6684
await seedKnowledgeAclFixture(ids)
@@ -84,7 +102,7 @@ describe('chat-delegated file version history', () => {
84102
],
85103
} as const
86104
for (const [content, provenance] of [
87-
[`token=${SECRET}`, secretProvenance],
105+
[`token=${SECRET}`, historicalProvenance ?? secretProvenance],
88106
['public replacement', { status: 'exact', entries: [] }],
89107
] as const) {
90108
await updateWorkspaceFileContent(
@@ -102,25 +120,39 @@ describe('chat-delegated file version history', () => {
102120
return { ...ids, fileId: file.id }
103121
}
104122

105-
/** Chat's composed CLI transport: the provenance-observing read layer over in-process admission. */
123+
/**
124+
* Chat's composed CLI transport: the provenance-observing read layer over in-process admission.
125+
* `layers` drops the outer layers to prove the inner ones hold on their own.
126+
*/
106127
function chatTransport(
107128
fixture: { workspaceId: string; organizationId: string },
108129
userId: string,
109-
registry?: ResolvedSecretTraceRegistry
130+
registry?: ResolvedSecretTraceRegistry,
131+
layers: { observer?: boolean; invocationScope?: boolean } = {}
110132
) {
111133
const invocation = { userId, workspaceId: fixture.workspaceId, chatId: generateId() }
112-
const transport = createFileReadTransport({
113-
endpoint: ORIGIN,
114-
transport: createScopedCliTransport(ORIGIN, invocation),
115-
userId,
116-
invocation,
117-
...(registry ? { registry } : {}),
118-
})
134+
const scoped = createScopedCliTransport(ORIGIN, invocation)
135+
const transport =
136+
layers.observer === false
137+
? scoped
138+
: createFileReadTransport({
139+
endpoint: ORIGIN,
140+
transport: scoped,
141+
userId,
142+
invocation,
143+
...(registry ? { registry } : {}),
144+
})
119145
return (url: string, init?: RequestInit) =>
120-
withWorkspaceInvocationScope(
121-
{ workspaceId: fixture.workspaceId, organizationId: fixture.organizationId },
122-
() => transport(`${ORIGIN}${url}`, init)
123-
)
146+
layers.invocationScope === false
147+
? transport(`${ORIGIN}${url}`, init)
148+
: withWorkspaceInvocationScope(
149+
{ workspaceId: fixture.workspaceId, organizationId: fixture.organizationId },
150+
() => transport(`${ORIGIN}${url}`, init)
151+
)
152+
}
153+
154+
function registryFor(fixture: { workspaceId: string }, userId: string) {
155+
return new ResolvedSecretTraceRegistry([], { userId, workspaceId: fixture.workspaceId })
124156
}
125157

126158
function versionRows(fileId: string) {
@@ -243,4 +275,131 @@ describe('chat-delegated file version history', () => {
243275

244276
expect((await versionRows(fixture.fileId)).map((row) => row.version)).toEqual([1, 2, 3])
245277
})
278+
279+
it('refuses a delegated version read that no delivery observer records', async () => {
280+
const fixture = await seedVersionedFile()
281+
282+
const response = await chatTransport(fixture, fixture.bobId, undefined, { observer: false })(
283+
`/api/v2/files/${fixture.fileId}/versions/2/text?workspaceId=${fixture.workspaceId}`
284+
)
285+
286+
expect(response.status).toBe(503)
287+
const body = await response.text()
288+
expect(body).not.toContain(SECRET)
289+
expect(JSON.parse(body)).toMatchObject({ error: { code: 'SERVICE_UNAVAILABLE' } })
290+
})
291+
292+
it("refuses a file in another of the user's workspaces without the invocation scope", async () => {
293+
const fixture = await seedVersionedFile()
294+
const otherWorkspaceId = generateId()
295+
extraWorkspaceIds.push(otherWorkspaceId)
296+
await db.insert(workspace).values({
297+
id: otherWorkspaceId,
298+
organizationId: fixture.organizationId,
299+
name: 'Second workspace',
300+
ownerId: fixture.aliceId,
301+
billedAccountUserId: fixture.aliceId,
302+
})
303+
await db.insert(permissions).values({
304+
id: generateId(),
305+
userId: fixture.aliceId,
306+
entityType: 'workspace',
307+
entityId: otherWorkspaceId,
308+
permissionType: 'admin',
309+
})
310+
const chat = chatTransport(
311+
{ workspaceId: otherWorkspaceId, organizationId: fixture.organizationId },
312+
fixture.aliceId,
313+
undefined,
314+
{ invocationScope: false }
315+
)
316+
317+
for (const assertedWorkspaceId of [fixture.workspaceId, otherWorkspaceId]) {
318+
const list = await chat(
319+
`/api/v2/files/${fixture.fileId}/versions?workspaceId=${assertedWorkspaceId}`
320+
)
321+
expect(list.status).toBe(404)
322+
expect(await list.json()).toMatchObject({ error: { code: 'NOT_FOUND' } })
323+
}
324+
const revert = await chat(`/api/v2/files/${fixture.fileId}/versions/1/revert`, {
325+
method: 'POST',
326+
headers: { 'content-type': 'application/json' },
327+
body: JSON.stringify({ workspaceId: fixture.workspaceId }),
328+
})
329+
expect(revert.status).toBe(404)
330+
expect((await versionRows(fixture.fileId)).map((row) => row.version)).toEqual([1, 2, 3])
331+
})
332+
333+
it('refuses a member whose permission group withholds the Files module', async () => {
334+
const fixture = await seedVersionedFile()
335+
const groupId = generateId()
336+
await db.insert(permissionGroup).values({
337+
id: groupId,
338+
organizationId: fixture.organizationId,
339+
name: 'No files',
340+
createdBy: fixture.aliceId,
341+
config: { hideFilesTab: true },
342+
})
343+
await db.insert(permissionGroupWorkspace).values({
344+
id: generateId(),
345+
permissionGroupId: groupId,
346+
workspaceId: fixture.workspaceId,
347+
organizationId: fixture.organizationId,
348+
})
349+
await db.insert(permissionGroupMember).values({
350+
id: generateId(),
351+
permissionGroupId: groupId,
352+
organizationId: fixture.organizationId,
353+
userId: fixture.bobId,
354+
})
355+
356+
const response = await chatTransport(
357+
fixture,
358+
fixture.bobId
359+
)(`/api/v2/files/${fixture.fileId}/versions?workspaceId=${fixture.workspaceId}`)
360+
361+
expect(response.status).toBe(403)
362+
expect(await response.json()).toMatchObject({
363+
error: { code: 'FORBIDDEN', details: { code: 'PERMISSION_GROUP_CAPABILITY_BLOCKED' } },
364+
})
365+
})
366+
367+
it('withholds a historical version whose secret provenance is unknown', async () => {
368+
const fixture = await seedVersionedFile({ status: 'unknown' })
369+
370+
const response = await chatTransport(
371+
fixture,
372+
fixture.bobId,
373+
registryFor(fixture, fixture.bobId)
374+
)(`/api/v2/files/${fixture.fileId}/versions/2/text?workspaceId=${fixture.workspaceId}`)
375+
376+
expect(response.status).toBe(503)
377+
expect(await response.text()).not.toContain(SECRET)
378+
})
379+
380+
it('keeps a secret redacted after Chat reverts the file to the version holding it', async () => {
381+
const fixture = await seedVersionedFile()
382+
const reverted = await chatTransport(fixture, fixture.aliceId)(
383+
`/api/v2/files/${fixture.fileId}/versions/2/revert`,
384+
{
385+
method: 'POST',
386+
headers: { 'content-type': 'application/json' },
387+
body: JSON.stringify({ workspaceId: fixture.workspaceId }),
388+
}
389+
)
390+
expect(reverted.status).toBe(200)
391+
const url = `/api/v2/files/${fixture.fileId}/text?workspaceId=${fixture.workspaceId}`
392+
const registry = registryFor(fixture, fixture.bobId)
393+
394+
const response = await chatTransport(fixture, fixture.bobId, registry)(url)
395+
expect(response.status).toBe(200)
396+
const projected = projectResolvedSecretModelContent(await response.text(), registry)
397+
if (!projected.safe) throw new Error('Reverted file text was withheld')
398+
expect(projected.value).not.toContain(SECRET)
399+
expect(projected.value).toContain('token=[REDACTED_SECRET]')
400+
401+
const untracked = await chatTransport(fixture, fixture.bobId)(url)
402+
expect(untracked.status).toBe(503)
403+
expect(await untracked.text()).not.toContain(SECRET)
404+
})
246405
})

‎apps/sim/lib/workspace-files/api/route-policies.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,12 @@ import {
66
} from '@/lib/api/server/routes'
77
import { ArchiveError, statusForArchiveError } from '@/lib/uploads/archive'
88
import { WORKSPACE_FILES_DELEGATION_AUDIENCE } from '@/lib/workspace-files/application/authorization'
9-
import { v2CaughtOrchestrationError, v2ErrorForOrchestration } from '@/app/api/v2/lib/response'
9+
import { WorkspaceFileDeliveryUnobservedError } from '@/lib/workspace-files/application/file-delivery-observer'
10+
import {
11+
v2CaughtOrchestrationError,
12+
v2Error,
13+
v2ErrorForOrchestration,
14+
} from '@/app/api/v2/lib/response'
1015

1116
export const internalSessionOrExecutorAuth = createInternalSessionOrExecutorAuth({
1217
audience: WORKSPACE_FILES_DELEGATION_AUDIENCE,
@@ -29,6 +34,12 @@ export const v2FileErrorPolicies = {
2934
default: v2OrchestrationErrorPolicy,
3035
concealResourceAuthorization: createV2ResourceConcealmentPolicy({
3136
notFoundMessage: 'File not found',
37+
render(error) {
38+
if (error instanceof WorkspaceFileDeliveryUnobservedError) {
39+
return v2Error('SERVICE_UNAVAILABLE', error.message)
40+
}
41+
return v2CaughtOrchestrationError(error)
42+
},
3243
}) satisfies V2ErrorPolicy,
3344
/**
3445
* Resource-ID upload controls conceal the *authorization* failure as absence,

‎apps/sim/lib/workspace-files/application/file-delivery-observer.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { AsyncLocalStorage } from 'node:async_hooks'
2+
import type { Principal } from '@sim/auth/principal'
23
import type { WorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance'
34

45
const observer = new AsyncLocalStorage<
@@ -20,3 +21,22 @@ export async function reportWorkspaceFileDelivery(
2021
): Promise<void> {
2122
await observer.getStore()?.(provenance)
2223
}
24+
25+
/** A delegated read reached stored bytes with no observer to record their secret provenance. */
26+
export class WorkspaceFileDeliveryUnobservedError extends Error {
27+
constructor() {
28+
super('File read provenance is unavailable. Retry the read.')
29+
this.name = 'WorkspaceFileDeliveryUnobservedError'
30+
}
31+
}
32+
33+
/**
34+
* Refuses a delegated caller before any bytes load unless a delivery observer is installed, so a
35+
* secret in the content cannot reach a model without its provenance being recorded, whatever
36+
* transport composed the call.
37+
*/
38+
export function requireDelegatedWorkspaceFileDeliveryObserver(principal: Principal): void {
39+
if (principal.kind === 'delegated' && !hasWorkspaceFileDeliveryObserver()) {
40+
throw new WorkspaceFileDeliveryUnobservedError()
41+
}
42+
}

‎apps/sim/lib/workspace-files/application/file-versions.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
import {
3838
hasWorkspaceFileDeliveryObserver,
3939
reportWorkspaceFileDelivery,
40+
requireDelegatedWorkspaceFileDeliveryObserver,
4041
} from '@/lib/workspace-files/application/file-delivery-observer'
4142
import { parseWorkspaceFileRevision } from '@/lib/workspace-files/application/file-revision'
4243
import { resolveWorkspaceFileVersionWrite } from '@/lib/workspace-files/application/file-version-write'
@@ -219,6 +220,7 @@ export const readWorkspaceFileVersionText = defineAuthorizedWorkspaceFileUseCase
219220
principal,
220221
request,
221222
}): Promise<ReadWorkspaceFileVersionTextResult> {
223+
requireDelegatedWorkspaceFileDeliveryObserver(principal)
222224
const file = await loadActiveFile(context)
223225
const version = await loadVersion(file, input.version)
224226
const fileAtVersion = recordAtVersion(file, version)

‎apps/sim/lib/workspace-files/application/operations.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,12 @@ const HUMAN_FILE_TOOL_PRINCIPAL_POLICY = {
2525
delegatedServices: ['copilot', 'executor'],
2626
} as const
2727
/**
28-
* Chat may list, read and revert versions as the delegating user: a version text read reports the
29-
* snapshot's secret provenance to Chat's delivery observer like a current-file read, and a revert
30-
* writes a new, undoable version. Deleting a version purges history irreversibly and downloading
31-
* one hands out raw bytes, so both admit direct callers only.
28+
* Chat may list, read and revert versions as the delegating user: a version text read refuses a
29+
* delegated caller unless a delivery observer records the snapshot's secret provenance, and a
30+
* revert writes a new, undoable version that reinstates that provenance. Deleting a version purges
31+
* history irreversibly, so it admits direct callers only. Download stays direct-only too, but it
32+
* is not a hard boundary for a writer: reverting to a version and downloading the current file
33+
* reaches the same bytes, with provenance tracked at each step.
3234
*/
3335
const VERSION_HISTORY_PRINCIPAL_POLICY = ALL_COPILOT_PRINCIPAL_POLICY
3436
const DIRECT_PRINCIPAL_POLICY = {

0 commit comments

Comments
 (0)