Skip to content

Commit 65a04f1

Browse files
fix(pii): preserve authorized access to restored large values (#7706)
1 parent 57c1c7c commit 65a04f1

4 files changed

Lines changed: 261 additions & 3 deletions

File tree

apps/sim/executor/execution/block-executor.test.ts

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import { loggerMock } from '@sim/testing'
55
import { beforeEach, describe, expect, it, vi } from 'vitest'
66
import { clearLargeValueCacheForTests } from '@/lib/execution/payloads/cache'
7+
import { createLargeArrayManifest } from '@/lib/execution/payloads/large-array-manifest'
78
import { isLargeArrayManifest } from '@/lib/execution/payloads/large-array-manifest-metadata'
89
import { isLargeValueRef } from '@/lib/execution/payloads/large-value-ref'
910
import { projectTraceSpansForSecrets } from '@/lib/logs/execution/trace-secret-projection'
@@ -24,8 +25,10 @@ const blockExecutorBaseLogger =
2425
loggerMock.createLogger.mock.results[blockExecutorLoggerCallIndex]?.value
2526
if (!blockExecutorBaseLogger) throw new Error('BlockExecutor logger mock was not initialized')
2627

27-
const { mockUploadFile } = vi.hoisted(() => ({
28+
const { mockUploadFile, mockDownloadFile, mockMaskBatch } = vi.hoisted(() => ({
2829
mockUploadFile: vi.fn(),
30+
mockDownloadFile: vi.fn(),
31+
mockMaskBatch: vi.fn(),
2932
}))
3033

3134
vi.mock('@/ee/access-control/utils/permission-check', () => ({
@@ -35,9 +38,14 @@ vi.mock('@/ee/access-control/utils/permission-check', () => ({
3538
vi.mock('@/lib/uploads', () => ({
3639
StorageService: {
3740
uploadFile: mockUploadFile,
41+
downloadFile: mockDownloadFile,
3842
},
3943
}))
4044

45+
vi.mock('@/lib/guardrails/mask-client', () => ({
46+
maskPIIBatchViaHttp: mockMaskBatch,
47+
}))
48+
4149
vi.mock('@/lib/logs/execution/pii-redaction', async (importOriginal) => {
4250
const actual = await importOriginal<typeof import('@/lib/logs/execution/pii-redaction')>()
4351
return {
@@ -94,6 +102,55 @@ describe('BlockExecutor', () => {
94102
mockUploadFile.mockImplementation(async ({ customKey }) => ({ key: customKey }))
95103
})
96104

105+
it('redacts an authorized prior-execution manifest returned by a block under the current execution', async () => {
106+
const items = [{ email: 'alice@example.com', count: 7 }]
107+
const manifest = await createLargeArrayManifest(items, {
108+
workspaceId: 'workspace-1',
109+
workflowId: 'workflow-1',
110+
executionId: 'source-execution',
111+
})
112+
clearLargeValueCacheForTests()
113+
mockUploadFile.mockClear()
114+
mockDownloadFile.mockResolvedValue(Buffer.from(JSON.stringify(items)))
115+
mockMaskBatch.mockImplementation(async (texts: string[]) =>
116+
texts.map((text) => text.replaceAll('alice@example.com', '<EMAIL_ADDRESS>'))
117+
)
118+
const block = createBlock()
119+
const workflow: SerializedWorkflow = {
120+
version: '1',
121+
blocks: [block],
122+
connections: [],
123+
loops: {},
124+
parallels: {},
125+
}
126+
const state = new ExecutionState()
127+
const resolver = new VariableResolver(workflow, {}, state)
128+
const handler: BlockHandler = {
129+
canHandle: () => true,
130+
execute: async () => ({ result: manifest }),
131+
}
132+
const executor = new BlockExecutor([handler], resolver, {}, state)
133+
const ctx = createContext(state)
134+
ctx.largeValueExecutionIds = ['source-execution']
135+
ctx.piiBlockOutputRedaction = { enabled: true, entityTypes: ['EMAIL_ADDRESS'], language: 'en' }
136+
137+
await executor.execute(ctx, createNode(block), block)
138+
139+
expect(state.getBlockOutput(block.id)?.result).toMatchObject({
140+
preview: [{ email: '<EMAIL_ADDRESS>', count: 7 }],
141+
chunks: [{ ref: { executionId: 'execution-1' } }],
142+
})
143+
expect(mockDownloadFile).toHaveBeenCalledWith(
144+
expect.objectContaining({ key: manifest.chunks[0].ref.key })
145+
)
146+
expect(mockUploadFile).toHaveBeenCalledWith(
147+
expect.objectContaining({
148+
customKey: expect.stringContaining('execution/workspace-1/workflow-1/execution-1/'),
149+
file: Buffer.from(JSON.stringify([{ email: '<EMAIL_ADDRESS>', count: 7 }])),
150+
})
151+
)
152+
})
153+
97154
it('persists function output arrays as manifests in execution state', async () => {
98155
const block = createBlock()
99156
const workflow: SerializedWorkflow = {

apps/sim/executor/execution/block-executor.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,9 @@ export class BlockExecutor {
358358
workspaceId: blockCtx.workspaceId,
359359
workflowId: blockCtx.workflowId,
360360
executionId: blockCtx.executionId,
361+
largeValueExecutionIds: blockCtx.largeValueExecutionIds,
362+
largeValueKeys: blockCtx.largeValueKeys,
363+
allowLargeValueWorkflowScope: blockCtx.allowLargeValueWorkflowScope,
361364
userId: blockCtx.userId,
362365
},
363366
})

apps/sim/lib/workflows/executor/execution-core.test.ts

Lines changed: 194 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,14 @@ import {
88
workflowsUtilsMock,
99
workflowsUtilsMockFns,
1010
} from '@sim/testing'
11-
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
11+
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
12+
import * as retention from '@/lib/billing/retention'
13+
import { clearLargeValueCacheForTests } from '@/lib/execution/payloads/cache'
14+
import type { LargeArrayManifest } from '@/lib/execution/payloads/large-array-manifest'
15+
import type { LargeValueRef } from '@/lib/execution/payloads/large-value-ref'
16+
import type { LoggingSession } from '@/lib/logs/execution/logging-session'
17+
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
18+
import type { SerializableExecutionState } from '@/executor/execution/types'
1219

1320
const {
1421
mergeSubblockStateWithValuesMock,
@@ -32,6 +39,9 @@ const {
3239
projectDisplayContentMock,
3340
projectDiagnosticErrorMock,
3441
decryptSecretMock,
42+
downloadFileMock,
43+
uploadFileMock,
44+
maskBatchMock,
3545
} = vi.hoisted(() => ({
3646
mergeSubblockStateWithValuesMock: vi.fn(),
3747
safeStartMock: vi.fn(),
@@ -54,6 +64,9 @@ const {
5464
projectDisplayContentMock: vi.fn(),
5565
projectDiagnosticErrorMock: vi.fn(),
5666
decryptSecretMock: vi.fn(),
67+
downloadFileMock: vi.fn(),
68+
uploadFileMock: vi.fn(),
69+
maskBatchMock: vi.fn(),
5770
}))
5871

5972
const getPersonalAndWorkspaceEnvMock = environmentUtilsMockFns.mockGetPersonalAndWorkspaceEnv
@@ -67,6 +80,19 @@ const loadWorkflowDeploymentVersionStateMock =
6780
workflowsPersistenceUtilsMockFns.mockLoadWorkflowDeploymentVersionState
6881
const updateWorkflowRunCountsMock = workflowsUtilsMockFns.mockUpdateWorkflowRunCounts
6982

83+
vi.mock('@/lib/uploads', () => ({
84+
StorageService: { downloadFile: downloadFileMock, uploadFile: uploadFileMock },
85+
}))
86+
87+
vi.mock('@/lib/guardrails/mask-client', () => ({
88+
maskPIIBatchViaHttp: maskBatchMock,
89+
}))
90+
91+
vi.mock('@/lib/execution/payloads/large-value-metadata', () => ({
92+
registerLargeValueOwner: vi.fn().mockResolvedValue(true),
93+
addLargeValueReference: vi.fn().mockResolvedValue(undefined),
94+
}))
95+
7096
vi.mock('@/lib/execution/cancellation', () => ({
7197
clearExecutionCancellation: clearExecutionCancellationMock,
7298
}))
@@ -120,7 +146,7 @@ import {
120146
executeWorkflowCore,
121147
FINALIZED_EXECUTION_ID_TTL_MS,
122148
wasExecutionFinalizedByCore,
123-
} from './execution-core'
149+
} from '@/lib/workflows/executor/execution-core'
124150

125151
const executionCoreLoggerCallIndex = loggerMock.createLogger.mock.calls.findIndex(
126152
([name]) => name === 'ExecutionCore'
@@ -993,6 +1019,172 @@ describe('executeWorkflowCore terminal finalization sequencing', () => {
9931019
expect(loadWorkflowDeploymentVersionStateMock).not.toHaveBeenCalled()
9941020
})
9951021

1022+
describe('PII redaction of restored large values', () => {
1023+
const sourceItems = [{ email: 'alice@example.com', count: 7 }]
1024+
const maskedItems = [{ email: '<EMAIL_ADDRESS>', count: 7 }]
1025+
const sourceBytes = Buffer.from(JSON.stringify(sourceItems))
1026+
1027+
function createManifest(
1028+
workspaceId = 'workspace-1',
1029+
workflowId = 'workflow-1',
1030+
executionId = 'source-execution'
1031+
): LargeArrayManifest {
1032+
const ref: LargeValueRef = {
1033+
__simLargeValueRef: true,
1034+
version: 1,
1035+
id: 'lv_123456789012',
1036+
kind: 'array',
1037+
size: sourceBytes.length,
1038+
executionId,
1039+
key: `execution/${workspaceId}/${workflowId}/${executionId}/large-value-lv_123456789012.json`,
1040+
}
1041+
return {
1042+
__simLargeArrayManifest: true,
1043+
version: 2,
1044+
kind: 'array',
1045+
totalCount: 1,
1046+
chunkCount: 1,
1047+
byteSize: sourceBytes.length,
1048+
chunks: [{ ref, count: 1, byteSize: sourceBytes.length }],
1049+
preview: sourceItems,
1050+
}
1051+
}
1052+
1053+
function createRestoredState(manifest: LargeArrayManifest): SerializableExecutionState {
1054+
return {
1055+
blockStates: { previous: { output: { result: manifest } } },
1056+
executedBlocks: ['previous'],
1057+
blockLogs: [],
1058+
decisions: { router: {}, condition: {} },
1059+
completedLoops: [],
1060+
activeExecutionPath: [],
1061+
trustedLargeValueAccess: { executionIds: [], largeValueKeys: [], fileKeys: [] },
1062+
}
1063+
}
1064+
1065+
function createPiiSnapshot(state?: SerializableExecutionState, input: unknown = {}) {
1066+
const base = createSnapshot()
1067+
return new ExecutionSnapshot(
1068+
{ ...base.metadata, resumeFromSnapshot: state !== undefined },
1069+
base.workflow,
1070+
input,
1071+
{},
1072+
[],
1073+
state
1074+
)
1075+
}
1076+
1077+
beforeEach(() => {
1078+
clearLargeValueCacheForTests()
1079+
vi.spyOn(retention, 'resolveEffectivePiiRedaction').mockReturnValue({
1080+
...retention.DEFAULT_PII_REDACTION,
1081+
input: {
1082+
enabled: true,
1083+
entityTypes: ['EMAIL_ADDRESS'],
1084+
language: 'en',
1085+
customPatterns: [],
1086+
},
1087+
blockOutputs: {
1088+
enabled: true,
1089+
entityTypes: ['EMAIL_ADDRESS'],
1090+
language: 'en',
1091+
customPatterns: [],
1092+
},
1093+
})
1094+
downloadFileMock.mockResolvedValue(sourceBytes)
1095+
uploadFileMock.mockImplementation(async ({ customKey }: { customKey: string }) => ({
1096+
key: customKey,
1097+
}))
1098+
maskBatchMock.mockImplementation(async (texts: string[]) =>
1099+
texts.map((text) => text.replaceAll('alice@example.com', '<EMAIL_ADDRESS>'))
1100+
)
1101+
executorExecuteMock.mockResolvedValue({
1102+
success: true,
1103+
status: 'completed',
1104+
output: { done: true },
1105+
logs: [],
1106+
metadata: { duration: 1, startTime: 'start', endTime: 'end' },
1107+
})
1108+
})
1109+
1110+
afterEach(() => {
1111+
vi.restoreAllMocks()
1112+
clearLargeValueCacheForTests()
1113+
})
1114+
1115+
it.each(['source execution', 'trusted key', 'resume', 'input'] as const)(
1116+
'masks cached manifest content with %s access and stores it under the new execution',
1117+
async (mode) => {
1118+
const manifest = createManifest()
1119+
const state = createRestoredState(manifest)
1120+
if (mode === 'trusted key')
1121+
state.trustedLargeValueAccess!.largeValueKeys = [manifest.chunks[0].ref.key!]
1122+
const snapshot = createPiiSnapshot(
1123+
mode === 'resume' ? state : undefined,
1124+
mode === 'input' ? { result: manifest } : {}
1125+
)
1126+
if (mode === 'input') snapshot.metadata.largeValueExecutionIds = ['source-execution']
1127+
const result = await executeWorkflowCore({
1128+
snapshot,
1129+
callbacks: {},
1130+
loggingSession: loggingSession as unknown as LoggingSession,
1131+
...(mode === 'resume' || mode === 'input'
1132+
? {}
1133+
: {
1134+
runFromBlock: {
1135+
startBlockId: 'start-block',
1136+
sourceSnapshot: state,
1137+
sourceExecutionId:
1138+
mode === 'trusted key' ? 'intermediate-execution' : 'source-execution',
1139+
},
1140+
}),
1141+
})
1142+
await loggingSession.setPostExecutionPromise.mock.calls[0][0]
1143+
expect(result.success).toBe(true)
1144+
expect(executorExecuteMock).toHaveBeenCalledOnce()
1145+
expect(downloadFileMock).toHaveBeenCalledWith(
1146+
expect.objectContaining({ key: manifest.chunks[0].ref.key, maxBytes: 64 * 1024 * 1024 })
1147+
)
1148+
expect(uploadFileMock).toHaveBeenCalledWith(
1149+
expect.objectContaining({
1150+
customKey: expect.stringContaining('execution/workspace-1/workflow-1/execution-1/'),
1151+
file: Buffer.from(JSON.stringify(maskedItems)),
1152+
})
1153+
)
1154+
if (mode !== 'input')
1155+
expect(state.blockStates.previous.output).toMatchObject({
1156+
result: { preview: maskedItems },
1157+
})
1158+
}
1159+
)
1160+
1161+
it.each([
1162+
['another workspace', 'workspace-2', 'workflow-1', 'source-execution'],
1163+
['another workflow', 'workspace-1', 'workflow-2', 'source-execution'],
1164+
['an unauthorized execution', 'workspace-1', 'workflow-1', 'unrelated-execution'],
1165+
])(
1166+
'refuses cached manifest content from %s before reading storage',
1167+
async (_, workspaceId, workflowId, executionId) => {
1168+
const state = createRestoredState(createManifest(workspaceId, workflowId, executionId))
1169+
await expect(
1170+
executeWorkflowCore({
1171+
snapshot: createPiiSnapshot(),
1172+
callbacks: {},
1173+
loggingSession: loggingSession as unknown as LoggingSession,
1174+
runFromBlock: {
1175+
startBlockId: 'start-block',
1176+
sourceExecutionId: 'source-execution',
1177+
sourceSnapshot: state,
1178+
},
1179+
})
1180+
).rejects.toThrow('Large execution value is not available in this execution.')
1181+
expect(downloadFileMock).not.toHaveBeenCalled()
1182+
expect(uploadFileMock).not.toHaveBeenCalled()
1183+
expect(executorExecuteMock).not.toHaveBeenCalled()
1184+
}
1185+
)
1186+
})
1187+
9961188
it('marks inherited client run-from-block provenance incomplete', async () => {
9971189
executorExecuteMock.mockResolvedValue({
9981190
success: true,

apps/sim/lib/workflows/executor/execution-core.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -888,6 +888,9 @@ async function executeWorkflowCoreImpl(
888888
workspaceId: providedWorkspaceId,
889889
workflowId,
890890
executionId,
891+
largeValueExecutionIds,
892+
largeValueKeys,
893+
allowLargeValueWorkflowScope,
891894
userId: userId ?? undefined,
892895
},
893896
})
@@ -918,6 +921,9 @@ async function executeWorkflowCoreImpl(
918921
workspaceId: providedWorkspaceId,
919922
workflowId,
920923
executionId,
924+
largeValueExecutionIds,
925+
largeValueKeys,
926+
allowLargeValueWorkflowScope,
921927
userId: userId ?? undefined,
922928
},
923929
}

0 commit comments

Comments
 (0)