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, }