Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/sim/lib/internal/file/operations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
Expand Down
18 changes: 10 additions & 8 deletions apps/sim/lib/internal/file/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<ReturnType<typeof resolveWorkspaceFileReference>>
let metadata: Awaited<ReturnType<typeof resolveWorkspaceFileStorageKey>>
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,
Expand Down Expand Up @@ -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
}
Expand Down
262 changes: 262 additions & 0 deletions apps/sim/lib/internal/file/write-copy-provenance.integration.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createKnowledgeAclFixtureIds>[] = []
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<ReturnType<typeof seed>>

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<string, unknown>) {
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<string, unknown>) {
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<ReturnType<typeof uploadSource>>

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<string, unknown> }>([
{ 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 })
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<ResolveWorkspaceFileReferenceInput, 'principal' | 'operation' | 'workspaceId'> & {
key: string
}): Promise<WorkspaceFileRecord> {
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
}
2 changes: 2 additions & 0 deletions packages/testing/src/mocks/workspace-file-reference.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { vi } from 'vitest'
export const workspaceFileReferenceMockFns = {
mockResolveReferencedWorkspaceFileContext: vi.fn(),
mockResolveWorkspaceFileReference: vi.fn(),
mockResolveWorkspaceFileStorageKey: vi.fn(),
}

/**
Expand All @@ -32,4 +33,5 @@ export const workspaceFileReferenceMock = {
resolveReferencedWorkspaceFileContext:
workspaceFileReferenceMockFns.mockResolveReferencedWorkspaceFileContext,
resolveWorkspaceFileReference: workspaceFileReferenceMockFns.mockResolveWorkspaceFileReference,
resolveWorkspaceFileStorageKey: workspaceFileReferenceMockFns.mockResolveWorkspaceFileStorageKey,
}
Loading