Skip to content

Commit 7fa70e0

Browse files
fix(content): keep stored provenance consistent (#9097)
1 parent f4fd25e commit 7fa70e0

25 files changed

Lines changed: 934 additions & 380 deletions

‎apps/sim/lib/internal/file/operations.test.ts‎

Lines changed: 0 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1278,55 +1278,6 @@ describe('file manage operations', () => {
12781278
expect(mockUploadWorkspaceFile).toHaveBeenCalled()
12791279
})
12801280

1281-
it('downgrades provenance when overwriting a file owned by another user', async () => {
1282-
const existing = workspaceFile('report', 'other-user')
1283-
mockResolveWorkspaceFileReference.mockResolvedValue(existing)
1284-
mockUpdateWorkspaceFileContent.mockResolvedValue(existing)
1285-
1286-
const response = await POST(
1287-
createMockRequest(
1288-
'POST',
1289-
{
1290-
operation: 'write',
1291-
workspaceId: 'workspace-1',
1292-
fileName: 'report.txt',
1293-
content: 'secret-value',
1294-
overwrite: true,
1295-
__privateSecretProvenance: {
1296-
version: 1,
1297-
complete: true,
1298-
selections: [
1299-
{
1300-
key: 'content',
1301-
provenance: {
1302-
version: 1,
1303-
complete: true,
1304-
entries: [{ name: 'TOKEN', encryptedValue: 'encrypted-token' }],
1305-
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
1306-
},
1307-
},
1308-
],
1309-
},
1310-
},
1311-
PRIVATE_SECRET_PROVENANCE_HEADER
1312-
)
1313-
)
1314-
1315-
expect(response.status).toBe(200)
1316-
expect(mockUpdateWorkspaceFileContent).toHaveBeenCalledWith(
1317-
'workspace-1',
1318-
'report',
1319-
'user-1',
1320-
Buffer.from('secret-value'),
1321-
'text/plain',
1322-
{
1323-
version: { source: 'workflow', authorUserId: 'user-1' },
1324-
expectedUpdatedAt: CONTENT_UPDATED_AT,
1325-
secretProvenancePolicy: { mode: 'replace', provenance: { status: 'unknown' } },
1326-
}
1327-
)
1328-
})
1329-
13301281
it('atomically binds append provenance to the exact predecessor version', async () => {
13311282
const existing = workspaceFile('file-1')
13321283
mockResolveWorkspaceFileReference.mockResolvedValue(existing)

‎apps/sim/lib/internal/file/operations.ts‎

Lines changed: 4 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1412,14 +1412,6 @@ export async function executeFileManageOperation(
14121412
leafName,
14131413
})
14141414
if (existing) {
1415-
// Writing into a file someone else owns must not hand its owner an
1416-
// exact, re-resolvable secret lineage, exactly as appending does.
1417-
const overwriteProvenance =
1418-
writeProvenance?.status === 'exact' &&
1419-
writeProvenance.entries.length > 0 &&
1420-
existing.uploadedBy !== userId
1421-
? { status: 'unknown' as const }
1422-
: writeProvenance
14231415
const { file: overwritten } = await updateWorkspaceFileContent.execute({
14241416
principal,
14251417
input: {
@@ -1431,7 +1423,7 @@ export async function executeFileManageOperation(
14311423
provenanceMode: 'replace_empty',
14321424
expectedUpdatedAt: existing.contentUpdatedAt ?? undefined,
14331425
expectedRevision,
1434-
...(overwriteProvenance ? { secretProvenance: overwriteProvenance } : {}),
1426+
...(writeProvenance ? { secretProvenance: writeProvenance } : {}),
14351427
},
14361428
})
14371429

@@ -1657,14 +1649,9 @@ export async function executeFileManageOperation(
16571649
)
16581650
}
16591651
const appendedProvenance = appendedResolution.provenanceBySelection?.get('content')
1660-
const secretProvenance =
1661-
appendedProvenance?.status === 'exact' &&
1662-
appendedProvenance.entries.length > 0 &&
1663-
existing.uploadedBy !== userId
1664-
? { status: 'unknown' as const }
1665-
: appendedProvenance
1666-
? mergeWorkspaceFileSecretProvenance(existingProvenance, appendedProvenance)
1667-
: undefined
1652+
const secretProvenance = appendedProvenance
1653+
? mergeWorkspaceFileSecretProvenance(existingProvenance, appendedProvenance)
1654+
: undefined
16681655
const { content: existingBuffer } = await readWorkspaceFileContent.execute({
16691656
principal,
16701657
input: {

‎apps/sim/lib/knowledge/__integration__/connector-save-concurrency.integration.ts‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
workspace,
1616
} from '@sim/db/schema'
1717
import { deleteWorkspaceFixture } from '@sim/db/testing/workspace-fixtures'
18+
import { sha256Hex } from '@sim/security/hash'
1819
import { generateId } from '@sim/utils/id'
1920
import { and, eq, inArray, isNotNull, isNull, sql } from 'drizzle-orm'
2021
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -113,6 +114,78 @@ describe('independent connector saves in one knowledge base', () => {
113114
await db.$client.end()
114115
})
115116

117+
it('keeps retained chunk tags consistent during metadata-only connector refresh', async () => {
118+
const item = await source('retry hash')
119+
const chunkId = generateId()
120+
const refreshedTags = {
121+
tag1: 'current',
122+
tag2: null,
123+
number1: 3.5,
124+
boolean1: true,
125+
date1: new Date('2026-10-01T00:00:00.000Z'),
126+
}
127+
await db
128+
.update(document)
129+
.set({ tag1: 'old', tag2: 'cleared', chunkCount: 1 })
130+
.where(eq(document.id, item.documentId))
131+
await db.insert(embedding).values({
132+
id: chunkId,
133+
knowledgeBaseId: ids.knowledgeBaseId,
134+
documentId: item.documentId,
135+
chunkIndex: 0,
136+
chunkHash: sha256Hex('retained content'),
137+
content: 'retained content',
138+
contentLength: 16,
139+
tokenCount: 4,
140+
startOffset: 0,
141+
endOffset: 16,
142+
embedding: Array(1536).fill(0.2),
143+
tag1: 'old',
144+
tag2: 'cleared',
145+
})
146+
await persistHashOnlyUpdates(
147+
ids.knowledgeBaseId,
148+
ids.connectorId,
149+
[
150+
{
151+
existingId: item.documentId,
152+
externalId: item.extDoc.externalId,
153+
contentHash: 'refreshed-hash',
154+
sourceMetadata: { sourceUrl: null, sourceModifiedAt: null, ...refreshedTags },
155+
},
156+
],
157+
{ stillHeld: () => stillHoldsSyncLock(ids.connectorId, ids.lockId) }
158+
)
159+
expect(
160+
await db
161+
.select({
162+
tag1: document.tag1,
163+
tag2: document.tag2,
164+
number1: document.number1,
165+
boolean1: document.boolean1,
166+
date1: document.date1,
167+
count: document.chunkCount,
168+
status: document.processingStatus,
169+
})
170+
.from(document)
171+
.where(eq(document.id, item.documentId))
172+
).toEqual([{ ...refreshedTags, count: 1, status: 'completed' }])
173+
expect(
174+
await db
175+
.select({
176+
tag1: embedding.tag1,
177+
tag2: embedding.tag2,
178+
number1: embedding.number1,
179+
boolean1: embedding.boolean1,
180+
date1: embedding.date1,
181+
content: embedding.content,
182+
id: embedding.id,
183+
})
184+
.from(embedding)
185+
.where(eq(embedding.documentId, item.documentId))
186+
).toEqual([{ ...refreshedTags, content: 'retained content', id: chunkId }])
187+
})
188+
116189
async function source(kind: SaveKind) {
117190
const item = {
118191
documentId: generateId(),

‎apps/sim/lib/knowledge/__integration__/execution-archive-provenance.integration.ts‎

Lines changed: 122 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,15 @@ import {
1212
knowledgeBase,
1313
organization,
1414
outboxEvent,
15+
permissions,
1516
user,
1617
workspace,
1718
workspaceFiles,
1819
} from '@sim/db/schema'
1920
import { deleteWorkspaceFixture } from '@sim/db/testing/workspace-fixtures'
2021
import { remoteSandboxMock, remoteSandboxMockFns } from '@sim/testing/mocks/remote-sandbox.mock'
2122
import { generateId } from '@sim/utils/id'
22-
import { eq, inArray, sql } from 'drizzle-orm'
23+
import { and, eq, inArray, sql } from 'drizzle-orm'
2324
import JSZip from 'jszip'
2425
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
2526

@@ -64,7 +65,10 @@ vi.mock('@/lib/embeddings', async () => ({
6465
}))
6566

6667
import { functionExecuteBodySchema } from '@/lib/api/contracts'
67-
import { fileManageDecompressBodySchema } from '@/lib/api/contracts/tools/file'
68+
import {
69+
fileManageBodySchema,
70+
fileManageDecompressBodySchema,
71+
} from '@/lib/api/contracts/tools/file'
6872
import { observeContentDelivery } from '@/lib/core/application'
6973
import { processOutboxEventById } from '@/lib/core/outbox/service'
7074
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
@@ -990,9 +994,121 @@ describe('execution archive durable provenance', () => {
990994
expect(await isOpaqueWorkspaceFileEgressSafe(ids.workspaceId, source.identity)).toBe(true)
991995
})
992996

993-
it.each([false, true])(
994-
'refuses tracked unknown execution attachments with historical metadata (archivedOnly=%s)',
995-
async (archivedOnly) => {
997+
it.each(['append', 'write'] as const)(
998+
'preserves encrypted origins when another member performs %s',
999+
async (operation) => {
1000+
const ids = await seed()
1001+
await db
1002+
.update(permissions)
1003+
.set({ permissionType: 'write' })
1004+
.where(and(eq(permissions.userId, ids.bobId), eq(permissions.entityId, ids.workspaceId)))
1005+
const priorSecret = 'fixture-prior-origin-secret'
1006+
const previous = {
1007+
status: 'exact' as const,
1008+
entries: [
1009+
{
1010+
encryptedValue: (await encryptSecret(priorSecret)).encrypted,
1011+
sourceUserId: ids.aliceId,
1012+
sourceWorkspaceId: ids.workspaceId,
1013+
name: 'PRIOR_TOKEN',
1014+
},
1015+
],
1016+
}
1017+
const source = await uploadWorkspaceFile(
1018+
ids.workspaceId,
1019+
ids.aliceId,
1020+
Buffer.from(priorSecret),
1021+
`${generateId()}.txt`,
1022+
'text/plain',
1023+
{ secretProvenance: previous, notifyWorkspaceChange: false }
1024+
)
1025+
const response = await executeFileManageOperation(
1026+
fileManageBodySchema.parse({
1027+
operation,
1028+
workspaceId: ids.workspaceId,
1029+
fileName: source.name,
1030+
content: FIXTURE_SECRET,
1031+
...(operation === 'write' ? { overwrite: true } : {}),
1032+
[PRIVATE_SECRET_PROVENANCE_FIELD]: {
1033+
version: 1,
1034+
complete: true,
1035+
selections: [
1036+
{
1037+
key: 'content',
1038+
provenance: {
1039+
version: 1,
1040+
complete: true,
1041+
scope: { userId: ids.bobId, workspaceId: ids.workspaceId },
1042+
entries: [
1043+
{
1044+
name: 'NEW_TOKEN',
1045+
encryptedValue: (await encryptSecret(FIXTURE_SECRET)).encrypted,
1046+
},
1047+
],
1048+
},
1049+
},
1050+
],
1051+
},
1052+
}),
1053+
{
1054+
principal: createWorkspaceFileDelegatedPrincipal({
1055+
serviceId: 'executor',
1056+
subjectUserId: ids.bobId,
1057+
workspaceId: ids.workspaceId,
1058+
delegationId: generateId(),
1059+
executionId: ids.executionId,
1060+
}),
1061+
workspaceId: ids.workspaceId,
1062+
attributedUserId: ids.bobId,
1063+
fileAccessUserId: ids.bobId,
1064+
workflowId: ids.workflowId,
1065+
executionId: ids.executionId,
1066+
headers: new Headers({
1067+
[PRIVATE_SECRET_PROVENANCE_HEADER]: PRIVATE_SECRET_PROVENANCE_BUNDLE_V1,
1068+
}),
1069+
requestId: generateId(),
1070+
}
1071+
)
1072+
expect(response.status, await response.text()).toBe(200)
1073+
const record = await getWorkspaceFile(ids.workspaceId, source.id)
1074+
if (!record) throw new Error('Edited file disappeared')
1075+
const bytes = await downloadFile({ key: record.key, context: 'workspace' })
1076+
expect(bytes.toString()).toBe(
1077+
operation === 'append' ? priorSecret + FIXTURE_SECRET : FIXTURE_SECRET
1078+
)
1079+
const registry = new ResolvedSecretTraceRegistry([], {
1080+
userId: ids.bobId,
1081+
workspaceId: ids.workspaceId,
1082+
})
1083+
expect(
1084+
await importWorkspaceFileSecretProvenanceForRuntime({
1085+
workspaceId: ids.workspaceId,
1086+
identity: {
1087+
fileId: record.id,
1088+
key: record.key,
1089+
context: 'workspace',
1090+
contentUpdatedAt: record.contentUpdatedAt ?? undefined,
1091+
},
1092+
registry,
1093+
})
1094+
).toBe(true)
1095+
const shown = projectResolvedSecretModelContent(bytes.toString(), registry)
1096+
expect(shown.safe).toBe(true)
1097+
expect(JSON.stringify(shown)).not.toContain(FIXTURE_SECRET)
1098+
expect(JSON.stringify(shown)).not.toContain(priorSecret)
1099+
expect(JSON.stringify(shown)).toContain('{{NEW_TOKEN}}')
1100+
if (operation === 'append') expect(JSON.stringify(shown)).toContain('[REDACTED_SECRET]')
1101+
}
1102+
)
1103+
1104+
it.each([
1105+
['execution', false],
1106+
['execution', true],
1107+
['workspace', true],
1108+
['mothership', true],
1109+
] as const)(
1110+
'retains refusal evidence for %s attachments (archivedOnly=%s)',
1111+
async (context, archivedOnly) => {
9961112
const ids = await seed()
9971113
const file = await uploadExecutionFile(
9981114
ids,
@@ -1005,7 +1121,7 @@ describe('execution archive durable provenance', () => {
10051121
if (archivedOnly) {
10061122
await db
10071123
.update(workspaceFiles)
1008-
.set({ deletedAt: new Date() })
1124+
.set({ deletedAt: new Date(), context })
10091125
.where(eq(workspaceFiles.key, file.key))
10101126
} else {
10111127
await db.insert(workspaceFiles).values({

0 commit comments

Comments
 (0)