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
100 changes: 84 additions & 16 deletions apps/sim/app/api/knowledge/secret-provenance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,25 @@ function createHeaderlessRequest(payload: Record<string, unknown>): NextRequest
})
}

function privateChunkPayload(
scope: { userId: string; workspaceId?: string },
entries: Array<{ name: string; encryptedValue: string }> = []
) {
return {
content: 'workflow content',
[PRIVATE_SECRET_PROVENANCE_FIELD]: {
version: 1 as const,
complete: true,
selections: [
{
key: 'chunk-content',
provenance: { version: 1 as const, complete: true, entries, scope },
},
],
},
}
}

describe('knowledge write secret provenance', () => {
it('classifies a headerless external chunk write as exact-empty', () => {
const payload = { content: 'manual content' }
Expand Down Expand Up @@ -99,22 +118,7 @@ describe('knowledge write secret provenance', () => {
})

it('tracks exact-empty provenance only when an internal write supplies a verified envelope', () => {
const bundle = {
version: 1 as const,
complete: true,
selections: [
{
key: 'chunk-content',
provenance: {
version: 1 as const,
complete: true,
entries: [],
scope: PRIVATE_PROVENANCE_SCOPE,
},
},
],
}
const payload = { content: 'workflow content', [PRIVATE_SECRET_PROVENANCE_FIELD]: bundle }
const payload = privateChunkPayload(PRIVATE_PROVENANCE_SCOPE)

const result = resolveKnowledgeWriteSecretProvenance({
request: createRequest(payload),
Expand All @@ -131,6 +135,70 @@ describe('knowledge write secret provenance', () => {
})
})

it('accepts a different provenance source user in the destination workspace', () => {
const payload = privateChunkPayload({ userId: 'workflow-owner', workspaceId: 'workspace-1' }, [
{ name: 'TOKEN', encryptedValue: 'encrypted-token' },
])

expect(
resolveKnowledgeWriteSecretProvenance({
request: createRequest(payload),
payload,
authType: AuthType.INTERNAL_JWT,
userId: 'billing-actor',
workspaceId: 'workspace-1',
selectionKeys: ['chunk-content'],
})
).toEqual({
success: true,
provenances: [
{
status: 'exact',
entries: [
{
name: 'TOKEN',
encryptedValue: 'encrypted-token',
sourceUserId: 'workflow-owner',
sourceWorkspaceId: 'workspace-1',
},
],
},
],
})
})

it('rejects provenance from another workspace', () => {
const payload = privateChunkPayload({
userId: 'workflow-owner',
workspaceId: 'workspace-2',
})
const result = resolveKnowledgeWriteSecretProvenance({
request: createRequest(payload),
payload,
authType: AuthType.INTERNAL_JWT,
userId: 'billing-actor',
workspaceId: 'workspace-1',
selectionKeys: ['chunk-content'],
})

expect(result.success).toBe(false)
if (!result.success) expect(result.response.status).toBe(400)
})

it('keeps workspace-less knowledge writes isolated to the authenticated user', () => {
const payload = privateChunkPayload({ userId: 'workflow-owner' })
const result = resolveKnowledgeWriteSecretProvenance({
request: createRequest(payload),
payload,
authType: AuthType.INTERNAL_JWT,
userId: 'billing-actor',
selectionKeys: ['chunk-content'],
})

expect(result.success).toBe(false)
if (!result.success) expect(result.response.status).toBe(400)
})

it('rejects a private provenance envelope from an external caller', () => {
const bundle = {
version: 1 as const,
Expand Down
88 changes: 88 additions & 0 deletions apps/sim/app/api/memory/secret-provenance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,30 @@ import {
resolveMemoryWriteSecretProvenance,
} from '@/app/api/memory/secret-provenance'

function privateMemoryWrite(
scope: { userId: string; workspaceId?: string },
entries: Array<{ name: string; encryptedValue: string }> = []
) {
const payload = {
[PRIVATE_SECRET_PROVENANCE_FIELD]: {
version: 1 as const,
complete: true,
selections: [
{
key: 'data',
provenance: { version: 1 as const, complete: true, entries, scope },
},
],
},
}
const request = new NextRequest('http://localhost/api/memory', {
method: 'POST',
headers: { [PRIVATE_SECRET_PROVENANCE_HEADER]: PRIVATE_SECRET_PROVENANCE_BUNDLE_V1 },
body: JSON.stringify(payload),
})
return { payload, request }
}

describe('memory write secret provenance', () => {
beforeEach(() => {
resetDbChainMock()
Expand Down Expand Up @@ -83,6 +107,70 @@ describe('memory write secret provenance', () => {
if (!result.success) expect(result.response.status).toBe(400)
})

it('accepts exact-empty provenance from the workflow owner in the actor workspace', () => {
const { payload, request } = privateMemoryWrite({
userId: 'workflow-owner',
workspaceId: 'workspace-1',
})

expect(
resolveMemoryWriteSecretProvenance({
request,
payload,
authType: AuthType.INTERNAL_JWT,
userId: 'billing-actor',
workspaceId: 'workspace-1',
})
).toEqual({ success: true, provenance: { status: 'exact', entries: [] } })
})

it('preserves the workflow owner as the source of same-workspace provenance', () => {
const { payload, request } = privateMemoryWrite(
{ userId: 'workflow-owner', workspaceId: 'workspace-1' },
[{ name: 'TOKEN', encryptedValue: 'encrypted-token' }]
)

expect(
resolveMemoryWriteSecretProvenance({
request,
payload,
authType: AuthType.INTERNAL_JWT,
userId: 'billing-actor',
workspaceId: 'workspace-1',
})
).toEqual({
success: true,
provenance: {
status: 'exact',
entries: [
{
name: 'TOKEN',
encryptedValue: 'encrypted-token',
sourceUserId: 'workflow-owner',
sourceWorkspaceId: 'workspace-1',
},
],
},
})
})

it('rejects provenance from another workspace', () => {
const { payload, request } = privateMemoryWrite({
userId: 'workflow-owner',
workspaceId: 'workspace-2',
})
const result = resolveMemoryWriteSecretProvenance({
request,
payload,
authType: AuthType.INTERNAL_JWT,
userId: 'billing-actor',
workspaceId: 'workspace-1',
})

expect(result.success).toBe(false)
if (!result.success) expect(result.response.status).toBe(400)
})

it('bounds only requested private response provenance without querying sidecars', async () => {
const request = new NextRequest('http://localhost/api/memory', {
headers: {
Expand Down
39 changes: 38 additions & 1 deletion apps/sim/app/api/table/row-secret-provenance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ describe('resolveTableWriteSecretProvenance', () => {
expect(result.success).toBe(false)
})

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

expect(result.success).toBe(true)
if (!result.success) return
expect(result.provenanceByRowKey?.['0'].columns.col_email).toMatchObject({
scope: { userId: 'someone-else', workspaceId: WORKSPACE_ID },
})
})

it('rejects a bundle whose selection comes from another workspace', () => {
const rows = [{ email: 'a@b.c' }]
const payload = {
[PRIVATE_SECRET_PROVENANCE_FIELD]: {
version: 1,
complete: true,
selections: [
{
key: tableRowSecretProvenanceSelectionKey(0, 'email'),
provenance: {
...traceProvenance(),
scope: { userId: USER_ID, workspaceId: 'another-workspace' },
},
},
],
},
}

const result = resolveTableWriteSecretProvenance({
request: createMockRequest('POST', payload, {
[PRIVATE_SECRET_PROVENANCE_HEADER]: PRIVATE_SECRET_PROVENANCE_BUNDLE_V1,
}),
payload,
authType: AuthType.INTERNAL_JWT,
userId: USER_ID,
workspaceId: WORKSPACE_ID,
targets: createTableWriteProvenanceTargets(rows, translateNames),
rowKeys: ['0'],
})

expect(result.success).toBe(false)
})
})
7 changes: 5 additions & 2 deletions apps/sim/app/api/table/row-secret-provenance.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { type NextRequest, NextResponse } from 'next/server'
import { AuthType, type AuthTypeValue } from '@/lib/auth/hybrid'
import { isPrivateSecretProvenanceScopeCompatible } from '@/lib/execution/durable-secret-provenance'
import {
inspectPrivateSecretProvenanceRequest,
isPrivateSecretProvenanceBundleV1,
Expand Down Expand Up @@ -138,8 +139,10 @@ export function resolveTableWriteSecretProvenance(options: {
const target = targetBySelectionKey.get(selection.key)
if (
!target ||
selection.provenance.scope?.userId !== options.userId ||
selection.provenance.scope?.workspaceId !== options.workspaceId
!isPrivateSecretProvenanceScopeCompatible(selection.provenance.scope, {
userId: options.userId,
workspaceId: options.workspaceId,
})
) {
return { success: false, response: invalidProvenanceResponse() }
}
Expand Down
39 changes: 36 additions & 3 deletions apps/sim/app/api/tools/file/manage/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ describe('POST /api/tools/file/manage content provenance', () => {
})
})

it('stores exact causal provenance for a trusted file write', async () => {
it('stores exact causal provenance from a different user in the actor workspace', async () => {
const response = await POST(
createMockRequest(
'POST',
Expand All @@ -211,7 +211,7 @@ describe('POST /api/tools/file/manage content provenance', () => {
version: 1,
complete: true,
entries: [{ name: 'TOKEN', encryptedValue: 'encrypted-token' }],
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
scope: { userId: 'workflow-owner', workspaceId: 'workspace-1' },
},
},
],
Expand All @@ -236,7 +236,7 @@ describe('POST /api/tools/file/manage content provenance', () => {
{
name: 'TOKEN',
encryptedValue: 'encrypted-token',
sourceUserId: 'user-1',
sourceUserId: 'workflow-owner',
sourceWorkspaceId: 'workspace-1',
},
],
Expand All @@ -245,6 +245,39 @@ describe('POST /api/tools/file/manage content provenance', () => {
)
})

it('rejects file-write provenance from another workspace', async () => {
const response = await POST(
createMockRequest(
'POST',
{
operation: 'write',
workspaceId: 'workspace-1',
fileName: 'new.txt',
content: 'secret-value',
__privateSecretProvenance: {
version: 1,
complete: true,
selections: [
{
key: 'content',
provenance: {
version: 1,
complete: true,
entries: [{ name: 'TOKEN', encryptedValue: 'encrypted-token' }],
scope: { userId: 'workflow-owner', workspaceId: 'workspace-2' },
},
},
],
},
},
PRIVATE_SECRET_PROVENANCE_HEADER
)
)

expect(response.status).toBe(400)
expect(mockUploadWorkspaceFile).not.toHaveBeenCalled()
})

it('preserves existing file-path behavior when a filename was resolved from a secret', async () => {
const response = await POST(
createMockRequest(
Expand Down
4 changes: 2 additions & 2 deletions apps/sim/app/api/tools/file/manage/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -337,13 +337,13 @@ function resolveFileMutationSecretProvenance(options: {
return { success: false, error: 'Invalid file secret provenance' }
}

const expectedScope = { userId: options.userId, workspaceId: options.workspaceId }
const destinationScope = { userId: options.userId, workspaceId: options.workspaceId }
const provenanceBySelection = new Map<string, WorkspaceFileSecretProvenance>()
for (const selectionKey of options.selectionKeys) {
const provenance = durableSecretProvenanceFromPrivateBundle(
inspection.value,
selectionKey,
expectedScope
destinationScope
)
if (
!provenance ||
Expand Down
Loading
Loading