Skip to content

Commit 6ab405d

Browse files
committed
fix(execution): separate provenance source from actor
1 parent 5972e8b commit 6ab405d

9 files changed

Lines changed: 368 additions & 27 deletions

File tree

apps/sim/app/api/knowledge/secret-provenance.test.ts

Lines changed: 84 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,25 @@ function createHeaderlessRequest(payload: Record<string, unknown>): NextRequest
3838
})
3939
}
4040

41+
function privateChunkPayload(
42+
scope: { userId: string; workspaceId?: string },
43+
entries: Array<{ name: string; encryptedValue: string }> = []
44+
) {
45+
return {
46+
content: 'workflow content',
47+
[PRIVATE_SECRET_PROVENANCE_FIELD]: {
48+
version: 1 as const,
49+
complete: true,
50+
selections: [
51+
{
52+
key: 'chunk-content',
53+
provenance: { version: 1 as const, complete: true, entries, scope },
54+
},
55+
],
56+
},
57+
}
58+
}
59+
4160
describe('knowledge write secret provenance', () => {
4261
it('classifies a headerless external chunk write as exact-empty', () => {
4362
const payload = { content: 'manual content' }
@@ -99,22 +118,7 @@ describe('knowledge write secret provenance', () => {
99118
})
100119

101120
it('tracks exact-empty provenance only when an internal write supplies a verified envelope', () => {
102-
const bundle = {
103-
version: 1 as const,
104-
complete: true,
105-
selections: [
106-
{
107-
key: 'chunk-content',
108-
provenance: {
109-
version: 1 as const,
110-
complete: true,
111-
entries: [],
112-
scope: PRIVATE_PROVENANCE_SCOPE,
113-
},
114-
},
115-
],
116-
}
117-
const payload = { content: 'workflow content', [PRIVATE_SECRET_PROVENANCE_FIELD]: bundle }
121+
const payload = privateChunkPayload(PRIVATE_PROVENANCE_SCOPE)
118122

119123
const result = resolveKnowledgeWriteSecretProvenance({
120124
request: createRequest(payload),
@@ -131,6 +135,70 @@ describe('knowledge write secret provenance', () => {
131135
})
132136
})
133137

138+
it('accepts a different provenance source user in the destination workspace', () => {
139+
const payload = privateChunkPayload({ userId: 'workflow-owner', workspaceId: 'workspace-1' }, [
140+
{ name: 'TOKEN', encryptedValue: 'encrypted-token' },
141+
])
142+
143+
expect(
144+
resolveKnowledgeWriteSecretProvenance({
145+
request: createRequest(payload),
146+
payload,
147+
authType: AuthType.INTERNAL_JWT,
148+
userId: 'billing-actor',
149+
workspaceId: 'workspace-1',
150+
selectionKeys: ['chunk-content'],
151+
})
152+
).toEqual({
153+
success: true,
154+
provenances: [
155+
{
156+
status: 'exact',
157+
entries: [
158+
{
159+
name: 'TOKEN',
160+
encryptedValue: 'encrypted-token',
161+
sourceUserId: 'workflow-owner',
162+
sourceWorkspaceId: 'workspace-1',
163+
},
164+
],
165+
},
166+
],
167+
})
168+
})
169+
170+
it('rejects provenance from another workspace', () => {
171+
const payload = privateChunkPayload({
172+
userId: 'workflow-owner',
173+
workspaceId: 'workspace-2',
174+
})
175+
const result = resolveKnowledgeWriteSecretProvenance({
176+
request: createRequest(payload),
177+
payload,
178+
authType: AuthType.INTERNAL_JWT,
179+
userId: 'billing-actor',
180+
workspaceId: 'workspace-1',
181+
selectionKeys: ['chunk-content'],
182+
})
183+
184+
expect(result.success).toBe(false)
185+
if (!result.success) expect(result.response.status).toBe(400)
186+
})
187+
188+
it('keeps workspace-less knowledge writes isolated to the authenticated user', () => {
189+
const payload = privateChunkPayload({ userId: 'workflow-owner' })
190+
const result = resolveKnowledgeWriteSecretProvenance({
191+
request: createRequest(payload),
192+
payload,
193+
authType: AuthType.INTERNAL_JWT,
194+
userId: 'billing-actor',
195+
selectionKeys: ['chunk-content'],
196+
})
197+
198+
expect(result.success).toBe(false)
199+
if (!result.success) expect(result.response.status).toBe(400)
200+
})
201+
134202
it('rejects a private provenance envelope from an external caller', () => {
135203
const bundle = {
136204
version: 1 as const,

apps/sim/app/api/memory/secret-provenance.test.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,30 @@ import {
2020
resolveMemoryWriteSecretProvenance,
2121
} from '@/app/api/memory/secret-provenance'
2222

23+
function privateMemoryWrite(
24+
scope: { userId: string; workspaceId?: string },
25+
entries: Array<{ name: string; encryptedValue: string }> = []
26+
) {
27+
const payload = {
28+
[PRIVATE_SECRET_PROVENANCE_FIELD]: {
29+
version: 1 as const,
30+
complete: true,
31+
selections: [
32+
{
33+
key: 'data',
34+
provenance: { version: 1 as const, complete: true, entries, scope },
35+
},
36+
],
37+
},
38+
}
39+
const request = new NextRequest('http://localhost/api/memory', {
40+
method: 'POST',
41+
headers: { [PRIVATE_SECRET_PROVENANCE_HEADER]: PRIVATE_SECRET_PROVENANCE_BUNDLE_V1 },
42+
body: JSON.stringify(payload),
43+
})
44+
return { payload, request }
45+
}
46+
2347
describe('memory write secret provenance', () => {
2448
beforeEach(() => {
2549
resetDbChainMock()
@@ -83,6 +107,70 @@ describe('memory write secret provenance', () => {
83107
if (!result.success) expect(result.response.status).toBe(400)
84108
})
85109

110+
it('accepts exact-empty provenance from the workflow owner in the actor workspace', () => {
111+
const { payload, request } = privateMemoryWrite({
112+
userId: 'workflow-owner',
113+
workspaceId: 'workspace-1',
114+
})
115+
116+
expect(
117+
resolveMemoryWriteSecretProvenance({
118+
request,
119+
payload,
120+
authType: AuthType.INTERNAL_JWT,
121+
userId: 'billing-actor',
122+
workspaceId: 'workspace-1',
123+
})
124+
).toEqual({ success: true, provenance: { status: 'exact', entries: [] } })
125+
})
126+
127+
it('preserves the workflow owner as the source of same-workspace provenance', () => {
128+
const { payload, request } = privateMemoryWrite(
129+
{ userId: 'workflow-owner', workspaceId: 'workspace-1' },
130+
[{ name: 'TOKEN', encryptedValue: 'encrypted-token' }]
131+
)
132+
133+
expect(
134+
resolveMemoryWriteSecretProvenance({
135+
request,
136+
payload,
137+
authType: AuthType.INTERNAL_JWT,
138+
userId: 'billing-actor',
139+
workspaceId: 'workspace-1',
140+
})
141+
).toEqual({
142+
success: true,
143+
provenance: {
144+
status: 'exact',
145+
entries: [
146+
{
147+
name: 'TOKEN',
148+
encryptedValue: 'encrypted-token',
149+
sourceUserId: 'workflow-owner',
150+
sourceWorkspaceId: 'workspace-1',
151+
},
152+
],
153+
},
154+
})
155+
})
156+
157+
it('rejects provenance from another workspace', () => {
158+
const { payload, request } = privateMemoryWrite({
159+
userId: 'workflow-owner',
160+
workspaceId: 'workspace-2',
161+
})
162+
const result = resolveMemoryWriteSecretProvenance({
163+
request,
164+
payload,
165+
authType: AuthType.INTERNAL_JWT,
166+
userId: 'billing-actor',
167+
workspaceId: 'workspace-1',
168+
})
169+
170+
expect(result.success).toBe(false)
171+
if (!result.success) expect(result.response.status).toBe(400)
172+
})
173+
86174
it('bounds only requested private response provenance without querying sidecars', async () => {
87175
const request = new NextRequest('http://localhost/api/memory', {
88176
headers: {

apps/sim/app/api/table/row-secret-provenance.test.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ describe('resolveTableWriteSecretProvenance', () => {
188188
expect(result.success).toBe(false)
189189
})
190190

191-
it('rejects a bundle whose selection scope does not match the caller', () => {
191+
it('accepts a different source user in the authorized destination workspace', () => {
192192
const rows = [{ email: 'a@b.c' }]
193193
const payload = {
194194
[PRIVATE_SECRET_PROVENANCE_FIELD]: {
@@ -218,6 +218,43 @@ describe('resolveTableWriteSecretProvenance', () => {
218218
rowKeys: ['0'],
219219
})
220220

221+
expect(result.success).toBe(true)
222+
if (!result.success) return
223+
expect(result.provenanceByRowKey?.['0'].columns.col_email).toMatchObject({
224+
scope: { userId: 'someone-else', workspaceId: WORKSPACE_ID },
225+
})
226+
})
227+
228+
it('rejects a bundle whose selection comes from another workspace', () => {
229+
const rows = [{ email: 'a@b.c' }]
230+
const payload = {
231+
[PRIVATE_SECRET_PROVENANCE_FIELD]: {
232+
version: 1,
233+
complete: true,
234+
selections: [
235+
{
236+
key: tableRowSecretProvenanceSelectionKey(0, 'email'),
237+
provenance: {
238+
...traceProvenance(),
239+
scope: { userId: USER_ID, workspaceId: 'another-workspace' },
240+
},
241+
},
242+
],
243+
},
244+
}
245+
246+
const result = resolveTableWriteSecretProvenance({
247+
request: createMockRequest('POST', payload, {
248+
[PRIVATE_SECRET_PROVENANCE_HEADER]: PRIVATE_SECRET_PROVENANCE_BUNDLE_V1,
249+
}),
250+
payload,
251+
authType: AuthType.INTERNAL_JWT,
252+
userId: USER_ID,
253+
workspaceId: WORKSPACE_ID,
254+
targets: createTableWriteProvenanceTargets(rows, translateNames),
255+
rowKeys: ['0'],
256+
})
257+
221258
expect(result.success).toBe(false)
222259
})
223260
})

apps/sim/app/api/table/row-secret-provenance.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { type NextRequest, NextResponse } from 'next/server'
22
import { AuthType, type AuthTypeValue } from '@/lib/auth/hybrid'
3+
import { isPrivateSecretProvenanceScopeCompatible } from '@/lib/execution/durable-secret-provenance'
34
import {
45
inspectPrivateSecretProvenanceRequest,
56
isPrivateSecretProvenanceBundleV1,
@@ -138,8 +139,10 @@ export function resolveTableWriteSecretProvenance(options: {
138139
const target = targetBySelectionKey.get(selection.key)
139140
if (
140141
!target ||
141-
selection.provenance.scope?.userId !== options.userId ||
142-
selection.provenance.scope?.workspaceId !== options.workspaceId
142+
!isPrivateSecretProvenanceScopeCompatible(selection.provenance.scope, {
143+
userId: options.userId,
144+
workspaceId: options.workspaceId,
145+
})
143146
) {
144147
return { success: false, response: invalidProvenanceResponse() }
145148
}

apps/sim/app/api/tools/file/manage/route.test.ts

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ describe('POST /api/tools/file/manage content provenance', () => {
192192
})
193193
})
194194

195-
it('stores exact causal provenance for a trusted file write', async () => {
195+
it('stores exact causal provenance from a different user in the actor workspace', async () => {
196196
const response = await POST(
197197
createMockRequest(
198198
'POST',
@@ -211,7 +211,7 @@ describe('POST /api/tools/file/manage content provenance', () => {
211211
version: 1,
212212
complete: true,
213213
entries: [{ name: 'TOKEN', encryptedValue: 'encrypted-token' }],
214-
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
214+
scope: { userId: 'workflow-owner', workspaceId: 'workspace-1' },
215215
},
216216
},
217217
],
@@ -236,7 +236,7 @@ describe('POST /api/tools/file/manage content provenance', () => {
236236
{
237237
name: 'TOKEN',
238238
encryptedValue: 'encrypted-token',
239-
sourceUserId: 'user-1',
239+
sourceUserId: 'workflow-owner',
240240
sourceWorkspaceId: 'workspace-1',
241241
},
242242
],
@@ -245,6 +245,39 @@ describe('POST /api/tools/file/manage content provenance', () => {
245245
)
246246
})
247247

248+
it('rejects file-write provenance from another workspace', async () => {
249+
const response = await POST(
250+
createMockRequest(
251+
'POST',
252+
{
253+
operation: 'write',
254+
workspaceId: 'workspace-1',
255+
fileName: 'new.txt',
256+
content: 'secret-value',
257+
__privateSecretProvenance: {
258+
version: 1,
259+
complete: true,
260+
selections: [
261+
{
262+
key: 'content',
263+
provenance: {
264+
version: 1,
265+
complete: true,
266+
entries: [{ name: 'TOKEN', encryptedValue: 'encrypted-token' }],
267+
scope: { userId: 'workflow-owner', workspaceId: 'workspace-2' },
268+
},
269+
},
270+
],
271+
},
272+
},
273+
PRIVATE_SECRET_PROVENANCE_HEADER
274+
)
275+
)
276+
277+
expect(response.status).toBe(400)
278+
expect(mockUploadWorkspaceFile).not.toHaveBeenCalled()
279+
})
280+
248281
it('preserves existing file-path behavior when a filename was resolved from a secret', async () => {
249282
const response = await POST(
250283
createMockRequest(

apps/sim/app/api/tools/file/manage/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -337,13 +337,13 @@ function resolveFileMutationSecretProvenance(options: {
337337
return { success: false, error: 'Invalid file secret provenance' }
338338
}
339339

340-
const expectedScope = { userId: options.userId, workspaceId: options.workspaceId }
340+
const destinationScope = { userId: options.userId, workspaceId: options.workspaceId }
341341
const provenanceBySelection = new Map<string, WorkspaceFileSecretProvenance>()
342342
for (const selectionKey of options.selectionKeys) {
343343
const provenance = durableSecretProvenanceFromPrivateBundle(
344344
inspection.value,
345345
selectionKey,
346-
expectedScope
346+
destinationScope
347347
)
348348
if (
349349
!provenance ||

0 commit comments

Comments
 (0)