From 97e7a593bed63ed3c280958d24e25b1f20ea0ba0 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 18 Oct 2026 16:14:46 -0700 Subject: [PATCH] fix(file): bind copied workspace files to their source by storage key The File operations resolved a selected workspace file's storage key through the VFS reference resolver, which only understands ids, paths, and names, so a key never resolved. Every copy, archive, or content read of a workspace file given as a file object lost its source identity: the write recorded the copy's secret provenance as unknown and later reads of the copy were refused. Picker values passed to manage_sharing failed with "File not found" the same way. Resolve the key to its current workspace file row first, then authorize that row by id under the operation's policy, and bind only when the row's current key is the key whose bytes are read. Gate on the key's storage prefix instead of the optional `context` field so a block reference without one also binds. --- apps/sim/lib/internal/file/operations.test.ts | 4 +- apps/sim/lib/internal/file/operations.ts | 18 +- .../file/write-copy-provenance.integration.ts | 262 ++++++++++++++++++ .../resolve-workspace-file-reference.ts | 28 ++ .../mocks/workspace-file-reference.mock.ts | 2 + 5 files changed, 305 insertions(+), 9 deletions(-) create mode 100644 apps/sim/lib/internal/file/write-copy-provenance.integration.ts diff --git a/apps/sim/lib/internal/file/operations.test.ts b/apps/sim/lib/internal/file/operations.test.ts index 1c476fc66c5..1217fd73abc 100644 --- a/apps/sim/lib/internal/file/operations.test.ts +++ b/apps/sim/lib/internal/file/operations.test.ts @@ -1086,7 +1086,9 @@ describe('file manage operations', () => { }) it('pins resolved file-input provenance to the captured content revision', async () => { - mockResolveWorkspaceFileReference.mockResolvedValue(workspaceFile('file-1')) + const file = workspaceFile('file-1') + mockGetFileMetadataByKey.mockResolvedValue({ ...file, context: 'workspace' }) + mockResolveWorkspaceFileReference.mockResolvedValue(file) mockGetBoundWorkspaceFileSecretProvenance.mockResolvedValue({ status: 'exact', entries: [], diff --git a/apps/sim/lib/internal/file/operations.ts b/apps/sim/lib/internal/file/operations.ts index 2fc26305e70..54bfbc5ff8a 100644 --- a/apps/sim/lib/internal/file/operations.ts +++ b/apps/sim/lib/internal/file/operations.ts @@ -94,7 +94,10 @@ import { } from '@/lib/workspace-files/application/read-workspace-file-metadata' import { downloadWorkspaceFileRecord } from '@/lib/workspace-files/application/read-workspace-file-record' import { readWorkspaceFileSecretProvenance } from '@/lib/workspace-files/application/read-workspace-file-secret-provenance' -import { resolveWorkspaceFileReference } from '@/lib/workspace-files/application/resolve-workspace-file-reference' +import { + resolveWorkspaceFileReference, + resolveWorkspaceFileStorageKey, +} from '@/lib/workspace-files/application/resolve-workspace-file-reference' import { getWorkspaceFileShares, updateWorkspaceFileShare, @@ -492,21 +495,20 @@ async function bindSelectedContentFile( }) return { file, ...source } } - if (!file.key || file.context !== 'workspace') return { file } + if (!file.key || tryInferContextFromKey(file.key) !== 'workspace') return { file } - let metadata: Awaited> + let metadata: Awaited> try { - metadata = await resolveWorkspaceFileReference({ + metadata = await resolveWorkspaceFileStorageKey({ principal, operation: fileOperations.readContent, workspaceId, - reference: file.key, + key: file.key, }) } catch (error) { if (error instanceof OrchestrationError && error.code === 'not_found') return { file } throw error } - if (!metadata) return { file } return { file, @@ -1554,11 +1556,11 @@ export async function executeFileManageOperation( resolvedFileId = record.fileId else if (typeof record.key === 'string' && record.key) { resolvedFileId = ( - await resolveWorkspaceFileReference({ + await resolveWorkspaceFileStorageKey({ principal, operation: fileOperations.updateShare, workspaceId, - reference: record.key, + key: record.key, }) ).id } diff --git a/apps/sim/lib/internal/file/write-copy-provenance.integration.ts b/apps/sim/lib/internal/file/write-copy-provenance.integration.ts new file mode 100644 index 00000000000..ff9b665630e --- /dev/null +++ b/apps/sim/lib/internal/file/write-copy-provenance.integration.ts @@ -0,0 +1,262 @@ +/** + * A File block write that copies an existing workspace file (`fileInput`) carries the source's + * durable secret provenance onto the copy. Exercises the real file operation, application + * authorization, metadata, provenance sidecars and local storage against Postgres. + */ +import { mkdtempSync } from 'node:fs' +import { rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { db } from '@sim/db' +import { + auditLog, + organization, + permissions, + user, + workspace, + workspaceFiles, +} from '@sim/db/schema' +import { deleteWorkspaceFixture } from '@sim/db/testing/workspace-fixtures' +import { generateId } from '@sim/utils/id' +import { eq, inArray } from 'drizzle-orm' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' + +const fixtureStorage = vi.hoisted(() => ({ root: '' })) +vi.mock('@/lib/uploads/core/setup.server', () => ({ + get UPLOAD_DIR_SERVER() { + return fixtureStorage.root + }, +})) + +import { fileManageBodySchema } from '@/lib/api/contracts/tools/file' +import { encryptSecret } from '@/lib/core/security/encryption' +import { executeFileManageOperation } from '@/lib/internal/file/operations' +import { + createKnowledgeAclFixtureIds, + seedKnowledgeAclFixture, +} from '@/lib/knowledge/__integration__/seed-source-access-fixture' +import { + updateWorkspaceFileContent, + uploadWorkspaceFile, +} from '@/lib/uploads/contexts/workspace/file-manager' +import { + getBoundWorkspaceFileSecretProvenance, + type WorkspaceFileSecretProvenance, +} from '@/lib/uploads/contexts/workspace/file-secret-provenance' +import { createWorkspaceFileDelegatedPrincipal } from '@/lib/workspace-files/application/delegated-principal' + +const fixtures: ReturnType[] = [] +const FIXTURE_SECRET = 'fixture-resolved-secret-not-a-live-key' + +async function seed() { + const ids = createKnowledgeAclFixtureIds() + fixtures.push(ids) + await seedKnowledgeAclFixture(ids) + return ids +} + +type Fixture = Awaited> + +async function secretEntryFrom(ownerId: string, workspaceId: string) { + return { + name: 'FIXTURE_TOKEN', + encryptedValue: (await encryptSecret(FIXTURE_SECRET)).encrypted, + sourceUserId: ownerId, + sourceWorkspaceId: workspaceId, + } +} + +async function uploadSource( + workspaceId: string, + ownerId: string, + provenance: WorkspaceFileSecretProvenance = { status: 'exact', entries: [] } +) { + return uploadWorkspaceFile( + workspaceId, + ownerId, + Buffer.from(`source bytes ${generateId()}`), + `source-${generateId()}.txt`, + 'text/plain', + { secretProvenance: provenance } + ) +} + +/** The File block's executor-delegated call, as a workflow run makes it. */ +async function runOperation(ids: Fixture, body: Record) { + return executeFileManageOperation( + fileManageBodySchema.parse({ workspaceId: ids.workspaceId, ...body }), + { + principal: createWorkspaceFileDelegatedPrincipal({ + serviceId: 'executor', + subjectUserId: ids.aliceId, + workspaceId: ids.workspaceId, + delegationId: generateId(), + }), + workspaceId: ids.workspaceId, + attributedUserId: ids.aliceId, + fileAccessUserId: ids.aliceId, + workflowId: generateId(), + headers: new Headers(), + requestId: generateId(), + } + ) +} + +/** Writes a copy of `fileInput` and returns the provenance stored for the copy, or the refusal. */ +async function copyProvenance(ids: Fixture, fileInput: Record) { + const response = await runOperation(ids, { + operation: 'write', + fileName: `copy-${generateId()}.txt`, + fileInput, + }) + const body = await response.json() + if (response.status !== 200) return { refused: response.status } + const [copy] = await db.select().from(workspaceFiles).where(eq(workspaceFiles.id, body.data.id)) + if (!copy) throw new Error('The copy has no workspace file record') + return getBoundWorkspaceFileSecretProvenance(ids.workspaceId, { + fileId: copy.id, + key: copy.key, + context: 'workspace', + contentUpdatedAt: copy.contentUpdatedAt, + }) +} + +/** A block-reference UserFile, as a File list/read output hands it on. */ +type SourceFile = Awaited> + +function blockReference(source: SourceFile) { + return { + id: source.id, + name: source.name, + url: source.url, + size: source.size, + type: source.type, + key: source.key, + context: 'workspace', + } +} + +beforeAll(() => { + fixtureStorage.root = mkdtempSync(path.join(tmpdir(), 'sim-write-copy-provenance-')) +}) + +afterAll(async () => { + try { + for (const ids of fixtures) { + await db.delete(auditLog).where(eq(auditLog.workspaceId, ids.workspaceId)) + await deleteWorkspaceFixture(db, eq(workspace.id, ids.workspaceId)) + await db.delete(organization).where(eq(organization.id, ids.organizationId)) + await db.delete(user).where(inArray(user.id, [ids.aliceId, ids.bobId])) + } + await rm(fixtureStorage.root, { recursive: true, force: true }) + } finally { + await db.$client.end() + } +}) + +describe('copying a workspace file with the File write operation', () => { + it.each<{ shape: string; toInput: (source: SourceFile) => Record }>([ + { shape: 'a block-reference file', toInput: blockReference }, + { + shape: 'a block-reference file without a context', + toInput: (source) => { + const { context: _context, ...file } = blockReference(source) + return file + }, + }, + { + shape: 'a file picker value', + toInput: (source) => ({ + name: source.name, + path: source.url, + key: source.key, + size: source.size, + type: source.type, + }), + }, + ])('keeps a clean source exact when given $shape', async ({ toInput }) => { + const ids = await seed() + const source = await uploadSource(ids.workspaceId, ids.aliceId) + + expect(await copyProvenance(ids, toInput(source))).toEqual({ status: 'exact', entries: [] }) + }) + + it("carries the caller's own recorded secrets onto the copy", async () => { + const ids = await seed() + const entry = await secretEntryFrom(ids.aliceId, ids.workspaceId) + const source = await uploadSource(ids.workspaceId, ids.aliceId, { + status: 'exact', + entries: [entry], + }) + + expect(await copyProvenance(ids, blockReference(source))).toEqual({ + status: 'exact', + entries: [entry], + }) + }) + + it("keeps a copy of another member's secret-bearing file unknown", async () => { + const ids = await seed() + const source = await uploadSource(ids.workspaceId, ids.bobId, { + status: 'exact', + entries: [await secretEntryFrom(ids.bobId, ids.workspaceId)], + }) + + expect(await copyProvenance(ids, blockReference(source))).toEqual({ status: 'unknown' }) + }) + + it('refuses to copy a superseded version key', async () => { + const ids = await seed() + const source = await uploadSource(ids.workspaceId, ids.aliceId) + await updateWorkspaceFileContent( + ids.workspaceId, + source.id, + ids.aliceId, + Buffer.from('newer bytes'), + 'text/plain', + { + version: { source: 'api', authorUserId: ids.aliceId }, + syncLiveDoc: false, + secretProvenancePolicy: { + mode: 'replace', + provenance: { status: 'exact', entries: [] }, + }, + } + ) + + expect(await copyProvenance(ids, blockReference(source))).toEqual({ refused: 404 }) + }) + + it("refuses to copy another workspace's key", async () => { + const ids = await seed() + const other = await seed() + /** Alice can read the other workspace, so only the key's tenancy makes it foreign here. */ + await db.insert(permissions).values({ + id: generateId(), + userId: ids.aliceId, + entityType: 'workspace', + entityId: other.workspaceId, + permissionType: 'admin', + }) + const foreign = await uploadSource(other.workspaceId, other.aliceId) + + expect(await copyProvenance(ids, blockReference(foreign))).toEqual({ refused: 404 }) + }) +}) + +describe('managing sharing for a file picker value', () => { + it('resolves the storage key to its workspace file', async () => { + const ids = await seed() + const source = await uploadSource(ids.workspaceId, ids.aliceId) + + const response = await runOperation(ids, { + operation: 'manage_sharing', + fileInput: { name: source.name, path: source.url, key: source.key }, + isActive: true, + }) + const body = await response.json() + + expect(response.status, JSON.stringify(body)).toBe(200) + expect(body.data.share).toMatchObject({ isActive: true }) + }) +}) diff --git a/apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.ts b/apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.ts index 38dfd293ace..31e8052e531 100644 --- a/apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.ts +++ b/apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.ts @@ -10,6 +10,7 @@ import { type WorkspaceFileLookupOptions, type WorkspaceFileRecord, } from '@/lib/uploads/contexts/workspace/file-manager' +import { getFileMetadataByKey } from '@/lib/uploads/server/metadata' import { VfsPathError } from '@/lib/vfs/path' import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' import { assertOwnedFileAccess } from '@/lib/workspace-files/application/file-context' @@ -169,3 +170,30 @@ export async function resolveWorkspaceFileReference({ }) return result.file } + +/** + * Resolve the workspace file whose current content lives at a storage key, under the same policy + * as a reference to its id. A key that is not that file's current revision in this workspace (a + * superseded version, another workspace's object, an unregistered upload) is not found. + */ +export async function resolveWorkspaceFileStorageKey({ + principal, + operation, + workspaceId, + key, +}: Pick & { + key: string +}): Promise { + const metadata = await getFileMetadataByKey(key, 'workspace') + if (!metadata || metadata.workspaceId !== workspaceId) { + throw new OrchestrationError('not_found', 'File not found') + } + const file = await resolveWorkspaceFileReference({ + principal, + operation, + workspaceId, + reference: metadata.id, + }) + if (file.key !== key) throw new OrchestrationError('not_found', 'File not found') + return file +} diff --git a/packages/testing/src/mocks/workspace-file-reference.mock.ts b/packages/testing/src/mocks/workspace-file-reference.mock.ts index 690be7b7714..44374a3ca22 100644 --- a/packages/testing/src/mocks/workspace-file-reference.mock.ts +++ b/packages/testing/src/mocks/workspace-file-reference.mock.ts @@ -15,6 +15,7 @@ import { vi } from 'vitest' export const workspaceFileReferenceMockFns = { mockResolveReferencedWorkspaceFileContext: vi.fn(), mockResolveWorkspaceFileReference: vi.fn(), + mockResolveWorkspaceFileStorageKey: vi.fn(), } /** @@ -32,4 +33,5 @@ export const workspaceFileReferenceMock = { resolveReferencedWorkspaceFileContext: workspaceFileReferenceMockFns.mockResolveReferencedWorkspaceFileContext, resolveWorkspaceFileReference: workspaceFileReferenceMockFns.mockResolveWorkspaceFileReference, + resolveWorkspaceFileStorageKey: workspaceFileReferenceMockFns.mockResolveWorkspaceFileStorageKey, }