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
124 changes: 124 additions & 0 deletions apps/sim/lib/execution/payloads/file-secret-provenance.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/** @vitest-environment node */
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { metadata, readWorkspaceFile } = vi.hoisted(() => ({
metadata: vi.fn(),
readWorkspaceFile: vi.fn(),
}))

vi.mock('@/lib/uploads/server/metadata', () => ({ getFileMetadataByKey: metadata }))
vi.mock('@/lib/workspace-files/application/read-workspace-file-content-by-key', () => ({
readWorkspaceFileRecordByKey: { execute: readWorkspaceFile },
}))

import { resolveStoredFileProvenanceSource } from '@/lib/execution/payloads/file-secret-provenance'

const context = {
principal: { kind: 'session', userId: 'reader', sessionId: 'session' } as const,
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
}
const file = {
key: 'execution/workspace-1/workflow-1/execution-1/unique/archive.zip',
context: 'execution' as const,
}
const revision = new Date('2026-09-11T00:00:00.000Z')
const record = {
id: 'canonical-file',
key: file.key,
context: 'execution',
workspaceId: context.workspaceId,
userId: 'writer',
contentUpdatedAt: revision,
}

describe('stored file provenance source', () => {
beforeEach(() => {
vi.clearAllMocks()
metadata.mockResolvedValue(record)
readWorkspaceFile.mockResolvedValue({ file: {} })
})

it('uses the canonical execution file identity and revision', async () => {
expect(await resolveStoredFileProvenanceSource(file, context)).toEqual({
identity: {
fileId: record.id,
key: record.key,
context: 'execution',
contentUpdatedAt: revision,
},
ownerUserId: 'writer',
})
expect(metadata).toHaveBeenCalledWith(file.key, undefined, { includeDeleted: true })
})

it.each([
{ workspaceId: 'foreign-workspace' },
{ workflowId: 'foreign-workflow' },
{ executionId: 'foreign-execution' },
])('refuses an out-of-scope file before metadata lookup: %j', async (scope) => {
await expect(resolveStoredFileProvenanceSource(file, { ...context, ...scope })).rejects.toThrow(
'File not found'
)
expect(metadata).not.toHaveBeenCalled()
})

it('accepts a causally inherited file key in the same workflow', async () => {
await expect(
resolveStoredFileProvenanceSource(file, {
...context,
executionId: 'resumed-execution',
fileKeys: [file.key],
})
).resolves.toMatchObject({ identity: { fileId: 'canonical-file' } })
})

it('does not let a file key allowlist cross workspaces', async () => {
await expect(
resolveStoredFileProvenanceSource(file, {
...context,
workspaceId: 'foreign-workspace',
fileKeys: [file.key],
})
).rejects.toThrow('File not found')
expect(metadata).not.toHaveBeenCalled()
})

it('rejects a forged context before metadata lookup', async () => {
await expect(
resolveStoredFileProvenanceSource({ ...file, context: 'workspace' }, context)
).rejects.toThrow('File context does not match its storage key')
expect(metadata).not.toHaveBeenCalled()
})

it.each([
{ workspaceId: 'foreign-workspace' },
{ context: 'workspace' },
{ context: 'knowledge-base' },
])('rejects mismatched canonical metadata: %j', async (changes) => {
metadata.mockResolvedValue({ ...record, ...changes })
await expect(resolveStoredFileProvenanceSource(file, context)).rejects.toThrow('File not found')
})

it('preserves a missing legacy record as absence', async () => {
metadata.mockResolvedValue(null)
await expect(resolveStoredFileProvenanceSource(file, context)).resolves.toBeUndefined()
})

it('does not turn metadata lookup failures into legacy absence', async () => {
metadata.mockRejectedValue(new Error('database unavailable'))
await expect(resolveStoredFileProvenanceSource(file, context)).rejects.toThrow(
'database unavailable'
)
})

it('requires the workspace file use case before resolving a workspace source', async () => {
const workspaceFile = { key: 'workspace/workspace-1/file.txt', context: 'workspace' as const }
readWorkspaceFile.mockRejectedValue(new Error('Workspace access denied'))
await expect(resolveStoredFileProvenanceSource(workspaceFile, context)).rejects.toThrow(
'Workspace access denied'
)
expect(metadata).not.toHaveBeenCalled()
})
})
61 changes: 61 additions & 0 deletions apps/sim/lib/execution/payloads/file-secret-provenance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import type { Principal } from '@sim/auth/principal'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import {
assertUserFileContentAccess,
ExecutionFileAccessError,
type ExecutionMaterializationContext,
} from '@/lib/execution/payloads/materialization.server'
import type { WorkspaceFileSecretProvenanceIdentity } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance'
import { getFileMetadataByKey } from '@/lib/uploads/server/metadata'
import { inferContextFromKey } from '@/lib/uploads/utils/file-utils'
import type { UserFile } from '@/executor/types'

export interface StoredFileProvenanceSource {
identity: WorkspaceFileSecretProvenanceIdentity
ownerUserId: string
}

/**
* Binds an authorized stored-file read to its canonical content revision. Execution callers pass
* the same trusted run capability used to read the bytes; a file object's id is never an identity.
* Files predating metadata registration retain the caller's existing absence policy.
*/
export async function resolveStoredFileProvenanceSource(
file: Pick<UserFile, 'key' | 'context'>,
context: ExecutionMaterializationContext & { principal: Principal; workspaceId: string }
): Promise<StoredFileProvenanceSource | undefined> {
if (!file.key) return undefined
try {
await assertUserFileContentAccess(file, context)
} catch (error) {
if (error instanceof ExecutionFileAccessError) {
throw new OrchestrationError('not_found', 'File not found')
}
throw error
}
const storageContext = inferContextFromKey(file.key)
if (storageContext !== 'workspace' && storageContext !== 'execution') return undefined

const metadata = await getFileMetadataByKey(file.key, undefined, { includeDeleted: true })
if (!metadata) return undefined
if (
(metadata.context !== 'workspace' &&
metadata.context !== 'mothership' &&
metadata.context !== 'execution') ||
metadata.workspaceId !== context.workspaceId ||
(storageContext === 'execution'
? metadata.context !== 'execution'
: metadata.context !== 'workspace' && metadata.context !== 'mothership')
) {
throw new OrchestrationError('not_found', 'File not found')
}
return {
identity: {
fileId: metadata.id,
key: metadata.key,
context: metadata.context,
contentUpdatedAt: metadata.contentUpdatedAt,
},
ownerUserId: metadata.userId,
}
}
33 changes: 32 additions & 1 deletion apps/sim/lib/execution/payloads/materialization.server.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/**
* @vitest-environment node
*/
import { resetDbChainMock } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { mockDownloadServableFileFromStorage, mockReadWorkspaceFileByKey, mockVerifyFileAccess } =
Expand All @@ -22,7 +23,10 @@ vi.mock('@/lib/workspace-files/application/read-workspace-file-content-by-key',
readWorkspaceFileRecordByKey: { execute: mockReadWorkspaceFileByKey },
}))

import { readUserFileContent } from '@/lib/execution/payloads/materialization.server'
import {
readUserFileContent,
readUserFileContentWithContributors,
} from '@/lib/execution/payloads/materialization.server'
import type { UserFile } from '@/executor/types'

const PDF_SOURCE = Buffer.from('from reportlab.pdfgen import canvas')
Expand All @@ -40,6 +44,7 @@ const generatedPdf: UserFile = {
describe('readUserFileContent', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
generatedPdf.size = PDF_SOURCE.length
mockVerifyFileAccess.mockResolvedValue(true)
mockReadWorkspaceFileByKey.mockResolvedValue({ file: { id: 'file-1' } })
Expand All @@ -49,6 +54,32 @@ describe('readUserFileContent', () => {
})
})

it('returns rendered contributor identities for the consuming boundary to classify', async () => {
const identity = {
fileId: 'image',
key: 'workspace/workspace-1/image.png',
context: 'workspace' as const,
contentUpdatedAt: new Date('2026-01-01T00:00:00Z'),
}
const html = '<img src="data:image/png;base64,aGlkZGVuLXNlY3JldA==">'
mockDownloadServableFileFromStorage.mockResolvedValue({
buffer: Buffer.from(html),
contentType: 'text/html',
contributingFiles: [identity],
})

await expect(
readUserFileContentWithContributors(
{ ...generatedPdf, name: 'page', type: 'text/x-sim-page' },
{ userId: 'user-1', encoding: 'text' }
)
).resolves.toEqual({
content: html,
contributingFiles: [identity],
renderedContributingFiles: [identity],
})
})

it('returns the compiled artifact instead of the stored generation source', async () => {
const content = await readUserFileContent(generatedPdf, {
userId: 'user-1',
Expand Down
45 changes: 39 additions & 6 deletions apps/sim/lib/execution/payloads/materialization.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ export interface ReadUserFileContentOptions extends ExecutionMaterializationCont
export interface ReadUserFileContentResult {
content: string
contributingFiles?: readonly WorkspaceFileSecretProvenanceIdentity[]
/** Subset transformed by the renderer; consumers apply their own admission policy. */
renderedContributingFiles?: readonly WorkspaceFileSecretProvenanceIdentity[]
}

function getLogger(options: ExecutionMaterializationContext): Logger {
Expand Down Expand Up @@ -215,10 +217,17 @@ function getExecutionKeyParts(key: string):
}
}

export class ExecutionFileAccessError extends Error {
constructor() {
super('File is not available in this execution.')
this.name = 'ExecutionFileAccessError'
}
}

function assertExecutionFileScope(key: string, options: ExecutionMaterializationContext): void {
const parts = getExecutionKeyParts(key)
if (!parts) {
throw new Error('File is not available in this execution.')
throw new ExecutionFileAccessError()
}

const allowedExecutionIds = new Set([
Expand All @@ -232,11 +241,11 @@ function assertExecutionFileScope(key: string, options: ExecutionMaterialization
options.workflowId === parts.workflowId

if (options.workspaceId && parts.workspaceId !== options.workspaceId) {
throw new Error('File is not available in this execution.')
throw new ExecutionFileAccessError()
}

if (options.workflowId && parts.workflowId !== options.workflowId) {
throw new Error('File is not available in this execution.')
throw new ExecutionFileAccessError()
}

if (allowedFileKeys.has(key)) {
Expand All @@ -247,7 +256,7 @@ function assertExecutionFileScope(key: string, options: ExecutionMaterialization
!options.executionId ||
(!allowedExecutionIds.has(parts.executionId) && !workflowScopeAllowed)
) {
throw new Error('File is not available in this execution.')
throw new ExecutionFileAccessError()
}
}

Expand Down Expand Up @@ -344,7 +353,26 @@ export async function readUserFileContentWithContributors(
throw new Error('Expected a file object with metadata.')
}

await assertUserFileContentAccess(file, options)
let sourceIdentity: WorkspaceFileSecretProvenanceIdentity | undefined
const storageContext = file.key ? inferContextFromKey(file.key) : undefined
if (
(storageContext === 'execution' || storageContext === 'workspace') &&
options.principal &&
options.workspaceId
) {
const { resolveStoredFileProvenanceSource } = await import(
'@/lib/execution/payloads/file-secret-provenance'
)
sourceIdentity = (
await resolveStoredFileProvenanceSource(file, {
...options,
principal: options.principal,
workspaceId: options.workspaceId,
})
)?.identity
} else {
await assertUserFileContentAccess(file, options)
}

const maxSourceBytes = options.maxSourceBytes ?? MAX_FUNCTION_FILE_BYTES
if (Number.isFinite(file.size) && file.size > maxSourceBytes) {
Expand All @@ -357,6 +385,7 @@ export async function readUserFileContentWithContributors(

let buffer: Buffer | null = null
let contributingFiles: readonly WorkspaceFileSecretProvenanceIdentity[] | undefined
let renderedContributingFiles: readonly WorkspaceFileSecretProvenanceIdentity[] | undefined
const log = getLogger(options)
const requestId = options.requestId ?? 'unknown'

Expand All @@ -365,7 +394,10 @@ export async function readUserFileContentWithContributors(
maxBytes: maxSourceBytes,
})
buffer = servable.buffer
contributingFiles = servable.contributingFiles
renderedContributingFiles = servable.contributingFiles
contributingFiles = sourceIdentity
? [sourceIdentity, ...(servable.contributingFiles ?? [])]
: servable.contributingFiles
} catch (error) {
if (isPayloadSizeLimitError(error)) {
if (isGeneratedDocumentSourceType(file.type) && error.observedBytes !== undefined) {
Expand Down Expand Up @@ -402,6 +434,7 @@ export async function readUserFileContentWithContributors(
return {
content: options.encoding === 'base64' ? bufferToBase64(selected) : selected.toString('utf8'),
...(contributingFiles && contributingFiles.length > 0 ? { contributingFiles } : {}),
...(renderedContributingFiles?.length ? { renderedContributingFiles } : {}),
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ vi.mock('@sim/platform-authz/workspace', () => ({

import { FUNCTION_EXECUTION_DELEGATION_AUDIENCE } from '@/lib/function-execution/application/authorization'
import { executeFunction } from '@/lib/function-execution/application/execute-function'
import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'

const principal: WorkflowExecutionDelegatedPrincipal = {
kind: 'delegated',
Expand Down Expand Up @@ -154,4 +155,28 @@ describe('executeFunction', () => {
expect(mocks.loadWorkspace).not.toHaveBeenCalled()
expect(mocks.executeRequest).not.toHaveBeenCalled()
})

it('passes trusted registry state outside the parsed Function wire body', async () => {
const registry = new ResolvedSecretTraceRegistry([], {
userId: 'workspace-owner',
workspaceId: 'workspace-1',
})
await executeFunction.execute({
principal,
input: {
workspaceId: 'workspace-1',
body: {
code: 'return 1',
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
},
headers: new Headers(),
resolvedSecretTraceRegistry: registry,
},
})

expect(mocks.executeRequest.mock.calls[0][2].resolvedSecretTraceRegistry).toBe(registry)
expect(mocks.executeRequest.mock.calls[0][1]).not.toHaveProperty('resolvedSecretTraceRegistry')
})
})
Loading
Loading