diff --git a/apps/sim/lib/workspace-files/__integration__/copilot-file-versions.integration.ts b/apps/sim/lib/workspace-files/__integration__/copilot-file-versions.integration.ts index 3788c33cc3b..13d00d79666 100644 --- a/apps/sim/lib/workspace-files/__integration__/copilot-file-versions.integration.ts +++ b/apps/sim/lib/workspace-files/__integration__/copilot-file-versions.integration.ts @@ -4,11 +4,24 @@ import { rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import path from 'node:path' import { db } from '@sim/db' -import { auditLog, organization, user, workspace, workspaceFileVersion } from '@sim/db/schema' +import { + auditLog, + organization, + permissionGroup, + permissionGroupMember, + permissionGroupWorkspace, + permissions, + user, + workspace, + workspaceFileVersion, +} from '@sim/db/schema' +import { envFlagsMock } from '@sim/testing/mocks/env-flags.mock' import { generateId } from '@sim/utils/id' import { and, asc, eq, inArray } from 'drizzle-orm' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +vi.mock('@/lib/core/config/env-flags', () => ({ ...envFlagsMock, isAccessControlEnabled: true })) + const fixtureStorage = vi.hoisted(() => ({ root: '' })) vi.mock('@/lib/uploads/core/setup.server', () => ({ get UPLOAD_DIR_SERVER() { @@ -28,6 +41,7 @@ import { updateWorkspaceFileContent, uploadWorkspaceFile, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import type { WorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' import '@/app/api/v2/files/[fileId]/versions/route' @@ -49,7 +63,11 @@ describe('chat-delegated file version history', () => { fixtureStorage.root = mkdtempSync(path.join(tmpdir(), 'sim-chat-file-versions-')) }) + const extraWorkspaceIds: string[] = [] + afterAll(async () => { + if (extraWorkspaceIds.length > 0) + await db.delete(workspace).where(inArray(workspace.id, extraWorkspaceIds)) for (const ids of fixtures) { await db.delete(auditLog).where(eq(auditLog.workspaceId, ids.workspaceId)) await db.delete(workspace).where(eq(workspace.id, ids.workspaceId)) @@ -60,7 +78,7 @@ describe('chat-delegated file version history', () => { }) /** Version 2 holds a named secret; version 3 replaced it with public text. */ - async function seedVersionedFile() { + async function seedVersionedFile(historicalProvenance?: WorkspaceFileSecretProvenance) { const ids = createKnowledgeAclFixtureIds() fixtures.push(ids) await seedKnowledgeAclFixture(ids) @@ -84,7 +102,7 @@ describe('chat-delegated file version history', () => { ], } as const for (const [content, provenance] of [ - [`token=${SECRET}`, secretProvenance], + [`token=${SECRET}`, historicalProvenance ?? secretProvenance], ['public replacement', { status: 'exact', entries: [] }], ] as const) { await updateWorkspaceFileContent( @@ -102,25 +120,39 @@ describe('chat-delegated file version history', () => { return { ...ids, fileId: file.id } } - /** Chat's composed CLI transport: the provenance-observing read layer over in-process admission. */ + /** + * Chat's composed CLI transport: the provenance-observing read layer over in-process admission. + * `layers` drops the outer layers to prove the inner ones hold on their own. + */ function chatTransport( fixture: { workspaceId: string; organizationId: string }, userId: string, - registry?: ResolvedSecretTraceRegistry + registry?: ResolvedSecretTraceRegistry, + layers: { observer?: boolean; invocationScope?: boolean } = {} ) { const invocation = { userId, workspaceId: fixture.workspaceId, chatId: generateId() } - const transport = createFileReadTransport({ - endpoint: ORIGIN, - transport: createScopedCliTransport(ORIGIN, invocation), - userId, - invocation, - ...(registry ? { registry } : {}), - }) + const scoped = createScopedCliTransport(ORIGIN, invocation) + const transport = + layers.observer === false + ? scoped + : createFileReadTransport({ + endpoint: ORIGIN, + transport: scoped, + userId, + invocation, + ...(registry ? { registry } : {}), + }) return (url: string, init?: RequestInit) => - withWorkspaceInvocationScope( - { workspaceId: fixture.workspaceId, organizationId: fixture.organizationId }, - () => transport(`${ORIGIN}${url}`, init) - ) + layers.invocationScope === false + ? transport(`${ORIGIN}${url}`, init) + : withWorkspaceInvocationScope( + { workspaceId: fixture.workspaceId, organizationId: fixture.organizationId }, + () => transport(`${ORIGIN}${url}`, init) + ) + } + + function registryFor(fixture: { workspaceId: string }, userId: string) { + return new ResolvedSecretTraceRegistry([], { userId, workspaceId: fixture.workspaceId }) } function versionRows(fileId: string) { @@ -243,4 +275,131 @@ describe('chat-delegated file version history', () => { expect((await versionRows(fixture.fileId)).map((row) => row.version)).toEqual([1, 2, 3]) }) + + it('refuses a delegated version read that no delivery observer records', async () => { + const fixture = await seedVersionedFile() + + const response = await chatTransport(fixture, fixture.bobId, undefined, { observer: false })( + `/api/v2/files/${fixture.fileId}/versions/2/text?workspaceId=${fixture.workspaceId}` + ) + + expect(response.status).toBe(503) + const body = await response.text() + expect(body).not.toContain(SECRET) + expect(JSON.parse(body)).toMatchObject({ error: { code: 'SERVICE_UNAVAILABLE' } }) + }) + + it("refuses a file in another of the user's workspaces without the invocation scope", async () => { + const fixture = await seedVersionedFile() + const otherWorkspaceId = generateId() + extraWorkspaceIds.push(otherWorkspaceId) + await db.insert(workspace).values({ + id: otherWorkspaceId, + organizationId: fixture.organizationId, + name: 'Second workspace', + ownerId: fixture.aliceId, + billedAccountUserId: fixture.aliceId, + }) + await db.insert(permissions).values({ + id: generateId(), + userId: fixture.aliceId, + entityType: 'workspace', + entityId: otherWorkspaceId, + permissionType: 'admin', + }) + const chat = chatTransport( + { workspaceId: otherWorkspaceId, organizationId: fixture.organizationId }, + fixture.aliceId, + undefined, + { invocationScope: false } + ) + + for (const assertedWorkspaceId of [fixture.workspaceId, otherWorkspaceId]) { + const list = await chat( + `/api/v2/files/${fixture.fileId}/versions?workspaceId=${assertedWorkspaceId}` + ) + expect(list.status).toBe(404) + expect(await list.json()).toMatchObject({ error: { code: 'NOT_FOUND' } }) + } + const revert = await chat(`/api/v2/files/${fixture.fileId}/versions/1/revert`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ workspaceId: fixture.workspaceId }), + }) + expect(revert.status).toBe(404) + expect((await versionRows(fixture.fileId)).map((row) => row.version)).toEqual([1, 2, 3]) + }) + + it('refuses a member whose permission group withholds the Files module', async () => { + const fixture = await seedVersionedFile() + const groupId = generateId() + await db.insert(permissionGroup).values({ + id: groupId, + organizationId: fixture.organizationId, + name: 'No files', + createdBy: fixture.aliceId, + config: { hideFilesTab: true }, + }) + await db.insert(permissionGroupWorkspace).values({ + id: generateId(), + permissionGroupId: groupId, + workspaceId: fixture.workspaceId, + organizationId: fixture.organizationId, + }) + await db.insert(permissionGroupMember).values({ + id: generateId(), + permissionGroupId: groupId, + organizationId: fixture.organizationId, + userId: fixture.bobId, + }) + + const response = await chatTransport( + fixture, + fixture.bobId + )(`/api/v2/files/${fixture.fileId}/versions?workspaceId=${fixture.workspaceId}`) + + expect(response.status).toBe(403) + expect(await response.json()).toMatchObject({ + error: { code: 'FORBIDDEN', details: { code: 'PERMISSION_GROUP_CAPABILITY_BLOCKED' } }, + }) + }) + + it('withholds a historical version whose secret provenance is unknown', async () => { + const fixture = await seedVersionedFile({ status: 'unknown' }) + + const response = await chatTransport( + fixture, + fixture.bobId, + registryFor(fixture, fixture.bobId) + )(`/api/v2/files/${fixture.fileId}/versions/2/text?workspaceId=${fixture.workspaceId}`) + + expect(response.status).toBe(503) + expect(await response.text()).not.toContain(SECRET) + }) + + it('keeps a secret redacted after Chat reverts the file to the version holding it', async () => { + const fixture = await seedVersionedFile() + const reverted = await chatTransport(fixture, fixture.aliceId)( + `/api/v2/files/${fixture.fileId}/versions/2/revert`, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ workspaceId: fixture.workspaceId }), + } + ) + expect(reverted.status).toBe(200) + const url = `/api/v2/files/${fixture.fileId}/text?workspaceId=${fixture.workspaceId}` + const registry = registryFor(fixture, fixture.bobId) + + const response = await chatTransport(fixture, fixture.bobId, registry)(url) + expect(response.status).toBe(200) + const projected = projectResolvedSecretModelContent(await response.text(), registry) + if (!projected.safe) throw new Error('Reverted file text was withheld') + expect(projected.value).not.toContain(SECRET) + expect(projected.value).toContain('token=[REDACTED_SECRET]') + + const untracked = await chatTransport(fixture, fixture.bobId)(url) + expect(untracked.status).toBe(503) + expect(await untracked.text()).not.toContain(SECRET) + }) }) diff --git a/apps/sim/lib/workspace-files/api/route-policies.ts b/apps/sim/lib/workspace-files/api/route-policies.ts index c8b62bd6020..031cdba674a 100644 --- a/apps/sim/lib/workspace-files/api/route-policies.ts +++ b/apps/sim/lib/workspace-files/api/route-policies.ts @@ -6,7 +6,12 @@ import { } from '@/lib/api/server/routes' import { ArchiveError, statusForArchiveError } from '@/lib/uploads/archive' import { WORKSPACE_FILES_DELEGATION_AUDIENCE } from '@/lib/workspace-files/application/authorization' -import { v2CaughtOrchestrationError, v2ErrorForOrchestration } from '@/app/api/v2/lib/response' +import { WorkspaceFileDeliveryUnobservedError } from '@/lib/workspace-files/application/file-delivery-observer' +import { + v2CaughtOrchestrationError, + v2Error, + v2ErrorForOrchestration, +} from '@/app/api/v2/lib/response' export const internalSessionOrExecutorAuth = createInternalSessionOrExecutorAuth({ audience: WORKSPACE_FILES_DELEGATION_AUDIENCE, @@ -29,6 +34,12 @@ export const v2FileErrorPolicies = { default: v2OrchestrationErrorPolicy, concealResourceAuthorization: createV2ResourceConcealmentPolicy({ notFoundMessage: 'File not found', + render(error) { + if (error instanceof WorkspaceFileDeliveryUnobservedError) { + return v2Error('SERVICE_UNAVAILABLE', error.message) + } + return v2CaughtOrchestrationError(error) + }, }) satisfies V2ErrorPolicy, /** * Resource-ID upload controls conceal the *authorization* failure as absence, diff --git a/apps/sim/lib/workspace-files/application/file-delivery-observer.ts b/apps/sim/lib/workspace-files/application/file-delivery-observer.ts index f60ada49066..b6796f242a2 100644 --- a/apps/sim/lib/workspace-files/application/file-delivery-observer.ts +++ b/apps/sim/lib/workspace-files/application/file-delivery-observer.ts @@ -1,4 +1,5 @@ import { AsyncLocalStorage } from 'node:async_hooks' +import type { Principal } from '@sim/auth/principal' import type { WorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' const observer = new AsyncLocalStorage< @@ -20,3 +21,22 @@ export async function reportWorkspaceFileDelivery( ): Promise { await observer.getStore()?.(provenance) } + +/** A delegated read reached stored bytes with no observer to record their secret provenance. */ +export class WorkspaceFileDeliveryUnobservedError extends Error { + constructor() { + super('File read provenance is unavailable. Retry the read.') + this.name = 'WorkspaceFileDeliveryUnobservedError' + } +} + +/** + * Refuses a delegated caller before any bytes load unless a delivery observer is installed, so a + * secret in the content cannot reach a model without its provenance being recorded, whatever + * transport composed the call. + */ +export function requireDelegatedWorkspaceFileDeliveryObserver(principal: Principal): void { + if (principal.kind === 'delegated' && !hasWorkspaceFileDeliveryObserver()) { + throw new WorkspaceFileDeliveryUnobservedError() + } +} diff --git a/apps/sim/lib/workspace-files/application/file-versions.ts b/apps/sim/lib/workspace-files/application/file-versions.ts index 4dc23ce42a8..5a083ff55fb 100644 --- a/apps/sim/lib/workspace-files/application/file-versions.ts +++ b/apps/sim/lib/workspace-files/application/file-versions.ts @@ -37,6 +37,7 @@ import { import { hasWorkspaceFileDeliveryObserver, reportWorkspaceFileDelivery, + requireDelegatedWorkspaceFileDeliveryObserver, } from '@/lib/workspace-files/application/file-delivery-observer' import { parseWorkspaceFileRevision } from '@/lib/workspace-files/application/file-revision' import { resolveWorkspaceFileVersionWrite } from '@/lib/workspace-files/application/file-version-write' @@ -219,6 +220,7 @@ export const readWorkspaceFileVersionText = defineAuthorizedWorkspaceFileUseCase principal, request, }): Promise { + requireDelegatedWorkspaceFileDeliveryObserver(principal) const file = await loadActiveFile(context) const version = await loadVersion(file, input.version) const fileAtVersion = recordAtVersion(file, version) diff --git a/apps/sim/lib/workspace-files/application/operations.ts b/apps/sim/lib/workspace-files/application/operations.ts index ba103eb7937..e0d7417886b 100644 --- a/apps/sim/lib/workspace-files/application/operations.ts +++ b/apps/sim/lib/workspace-files/application/operations.ts @@ -25,10 +25,12 @@ const HUMAN_FILE_TOOL_PRINCIPAL_POLICY = { delegatedServices: ['copilot', 'executor'], } as const /** - * Chat may list, read and revert versions as the delegating user: a version text read reports the - * snapshot's secret provenance to Chat's delivery observer like a current-file read, and a revert - * writes a new, undoable version. Deleting a version purges history irreversibly and downloading - * one hands out raw bytes, so both admit direct callers only. + * Chat may list, read and revert versions as the delegating user: a version text read refuses a + * delegated caller unless a delivery observer records the snapshot's secret provenance, and a + * revert writes a new, undoable version that reinstates that provenance. Deleting a version purges + * history irreversibly, so it admits direct callers only. Download stays direct-only too, but it + * is not a hard boundary for a writer: reverting to a version and downloading the current file + * reaches the same bytes, with provenance tracked at each step. */ const VERSION_HISTORY_PRINCIPAL_POLICY = ALL_COPILOT_PRINCIPAL_POLICY const DIRECT_PRINCIPAL_POLICY = {