Skip to content

Commit f4f2e71

Browse files
committed
address timestamp concern
1 parent c1e2f21 commit f4f2e71

5 files changed

Lines changed: 122 additions & 12 deletions

File tree

apps/sim/lib/knowledge/documents/document-processing-source.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,31 @@ describe('knowledge document processing source', () => {
183183
expect(mockGenerateEmbeddings).not.toHaveBeenCalled()
184184
})
185185

186+
it('processes a legacy document when its workspace metadata row no longer exists', async () => {
187+
mockGetFileMetadataByKeys.mockResolvedValue([])
188+
mockGetBoundWorkspaceFileSecretProvenanceByMetadata.mockResolvedValue(new Map())
189+
190+
await processDocumentAsync('knowledge-base-1', 'document-1', {
191+
filename: 'stale.pdf',
192+
fileUrl: 'https://example.com/stale.pdf',
193+
fileSize: 1,
194+
mimeType: 'text/plain',
195+
})
196+
197+
expect(mockProcessDocument).toHaveBeenCalledWith(
198+
PERSISTED_CONTEXT.fileUrl,
199+
PERSISTED_CONTEXT.filename,
200+
PERSISTED_CONTEXT.mimeType,
201+
1024,
202+
200,
203+
100,
204+
PERSISTED_CONTEXT.uploadedBy,
205+
null,
206+
undefined,
207+
undefined
208+
)
209+
})
210+
186211
it('fails before parsing an existing document when its current source is tracked unknown', async () => {
187212
mockGetFileMetadataByKeys.mockImplementation(async (_keys: string[], context: string) =>
188213
context === 'workspace' ? [{ ...SOURCE_BINDING, secretProvenanceVersion: 1 }] : []

apps/sim/lib/knowledge/documents/service.ts

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,8 @@ import { calculateCost } from '@/providers/utils'
104104
const logger = createLogger('DocumentService')
105105

106106
/**
107-
* Thrown when a knowledge-base document's `fileUrl` references an internal `kb/`
108-
* storage object that is not owned by the target knowledge base's workspace.
107+
* Thrown when a knowledge-base document's `fileUrl` references an internal
108+
* knowledge-base storage object not owned by the target knowledge base's workspace.
109109
* Routes map this to a 403.
110110
*/
111111
export class KnowledgeBaseFileOwnershipError extends Error {
@@ -117,17 +117,17 @@ export class KnowledgeBaseFileOwnershipError extends Error {
117117

118118
/**
119119
* Guard document `fileUrl`s at creation time. When a URL points at an internal
120-
* `kb/` storage object, require that the target knowledge base owns the object,
120+
* knowledge-base storage object, require that the target knowledge base owns the object,
121121
* resolved from the trusted `workspace_files` binding:
122122
*
123123
* - Workspace KB (`kbWorkspaceId` set): the binding's `workspaceId` must match.
124124
* - Personal KB (`kbWorkspaceId` null): the binding's `userId` must be the KB
125125
* owner. A key bound to another tenant is rejected; an unbound key (legacy /
126126
* never reserved) passes since it carries no cross-tenant ownership.
127127
*
128-
* External `http(s)`/`data:` URLs (ingestion sources) and non-`kb/` internal keys
128+
* External `http(s)`/`data:` URLs (ingestion sources) and other internal keys
129129
* pass through unchanged. This blocks a user from asserting ownership of another
130-
* tenant's `kb/` key via a planted `fileUrl` — including in a personal KB, which
130+
* tenant's object via a planted `fileUrl` — including in a personal KB, which
131131
* otherwise could be moved into a workspace to launder the binding. All
132132
* referenced bindings are resolved in one query (no N+1 inside the `FOR UPDATE`
133133
* window). Single-document callers pass a one-element array.
@@ -2557,8 +2557,7 @@ export async function deleteDocumentStorageFiles(
25572557
entries
25582558
.map((entry) => entry.storageKey)
25592559
.filter(
2560-
(key): key is string =>
2561-
typeof key === 'string' && (key.startsWith('kb/') || key.startsWith('knowledge-base/'))
2560+
(key): key is string => typeof key === 'string' && isKnowledgeBaseOwnedStorageKey(key)
25622561
)
25632562
),
25642563
]
@@ -2575,7 +2574,7 @@ export async function deleteDocumentStorageFiles(
25752574
return
25762575
}
25772576

2578-
if (!storageKey.startsWith('kb/') && !storageKey.startsWith('knowledge-base/')) {
2577+
if (!isKnowledgeBaseOwnedStorageKey(storageKey)) {
25792578
return
25802579
}
25812580

@@ -2598,13 +2597,20 @@ export async function deleteDocumentStorageFiles(
25982597
}
25992598

26002599
try {
2601-
await deleteFile({ key: storageKey, context: 'knowledge-base' })
2602-
await deleteFileMetadataByIdentity({
2600+
const metadataDeleted = await deleteFileMetadataByIdentity({
26032601
id: binding.id,
26042602
key: binding.key,
26052603
context: binding.context,
26062604
contentUpdatedAt: binding.contentUpdatedAt,
26072605
})
2606+
if (!metadataDeleted) {
2607+
logger.warn(`[${requestId}] Skipping storage delete: ownership binding changed`, {
2608+
documentId: doc.id,
2609+
storageKey,
2610+
})
2611+
return
2612+
}
2613+
await deleteFile({ key: storageKey, context: 'knowledge-base' })
26082614
} catch (error) {
26092615
logger.warn(`[${requestId}] Failed to delete document storage file`, {
26102616
documentId: doc.id,

apps/sim/lib/knowledge/documents/workspace-source-provenance.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,4 +292,64 @@ describe('knowledge workspace source provenance', () => {
292292
expect(mockDeleteFile).not.toHaveBeenCalled()
293293
expect(mockDeleteFileMetadataByIdentity).not.toHaveBeenCalled()
294294
})
295+
296+
it.each(['kb', 'knowledge-base'])(
297+
'deletes a trusted %s object only after its metadata identity is claimed',
298+
async (keyPrefix) => {
299+
const storageKey = `${keyPrefix}/owned.pdf`
300+
const fileUrl = `/api/files/serve/${encodeURIComponent(storageKey)}?context=knowledge-base`
301+
const binding = {
302+
...SOURCE_BINDING,
303+
id: `binding-${keyPrefix}`,
304+
key: storageKey,
305+
context: 'knowledge-base',
306+
}
307+
mockGetFileMetadataByKeys.mockImplementation(async (_keys: string[], context: string) =>
308+
context === 'knowledge-base' ? [binding] : []
309+
)
310+
mockDeleteFileMetadataByIdentity.mockResolvedValue(true)
311+
312+
await deleteDocumentStorageFiles(
313+
[{ id: 'document-1', fileUrl, workspaceId: WORKSPACE_ID }],
314+
'request-1'
315+
)
316+
317+
expect(mockDeleteFileMetadataByIdentity).toHaveBeenCalledWith({
318+
id: binding.id,
319+
key: storageKey,
320+
context: 'knowledge-base',
321+
contentUpdatedAt: binding.contentUpdatedAt,
322+
})
323+
expect(mockDeleteFile).toHaveBeenCalledWith({
324+
key: storageKey,
325+
context: 'knowledge-base',
326+
})
327+
expect(mockDeleteFileMetadataByIdentity.mock.invocationCallOrder[0]).toBeLessThan(
328+
mockDeleteFile.mock.invocationCallOrder[0]
329+
)
330+
}
331+
)
332+
333+
it('keeps the object when its metadata identity changed before deletion', async () => {
334+
const storageKey = 'kb/changed.pdf'
335+
const fileUrl = `/api/files/serve/${encodeURIComponent(storageKey)}?context=knowledge-base`
336+
const binding = {
337+
...SOURCE_BINDING,
338+
id: 'changed-binding',
339+
key: storageKey,
340+
context: 'knowledge-base',
341+
}
342+
mockGetFileMetadataByKeys.mockImplementation(async (_keys: string[], context: string) =>
343+
context === 'knowledge-base' ? [binding] : []
344+
)
345+
mockDeleteFileMetadataByIdentity.mockResolvedValue(false)
346+
347+
await deleteDocumentStorageFiles(
348+
[{ id: 'document-1', fileUrl, workspaceId: WORKSPACE_ID }],
349+
'request-1'
350+
)
351+
352+
expect(mockDeleteFileMetadataByIdentity).toHaveBeenCalledOnce()
353+
expect(mockDeleteFile).not.toHaveBeenCalled()
354+
})
295355
})

apps/sim/lib/uploads/server/metadata.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@
33
*/
44
import { workspaceFiles } from '@sim/db/schema'
55
import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing'
6+
import type { SQL } from 'drizzle-orm'
7+
import { PgDialect } from 'drizzle-orm/pg-core'
68
import { beforeEach, describe, expect, it, vi } from 'vitest'
9+
10+
vi.unmock('@sim/db/schema')
11+
vi.unmock('drizzle-orm')
12+
713
import {
814
ActiveFileMetadataKeyConflictError,
915
deleteFileMetadataByIdentity,
@@ -28,6 +34,12 @@ describe('deleteFileMetadataByIdentity', () => {
2834
dbChainMockFns.returning.mockResolvedValueOnce([{ id: identity.id }])
2935

3036
await expect(deleteFileMetadataByIdentity(identity)).resolves.toBe(true)
37+
const predicate = dbChainMockFns.where.mock.calls[0]?.[0] as SQL
38+
const query = new PgDialect().sqlToQuery(predicate)
39+
expect(query.sql).toContain(
40+
`date_trunc('milliseconds', "workspace_files"."content_updated_at")`
41+
)
42+
expect(query.params).toContain(identity.contentUpdatedAt)
3143

3244
dbChainMockFns.returning.mockResolvedValueOnce([])
3345
await expect(deleteFileMetadataByIdentity(identity)).resolves.toBe(false)

apps/sim/lib/uploads/server/metadata.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -353,7 +353,11 @@ export async function deleteFileMetadata(key: string): Promise<boolean> {
353353
return true
354354
}
355355

356-
/** Soft-deletes only the exact active metadata version previously authorized by a caller. */
356+
/**
357+
* Soft-deletes only the active metadata version previously authorized by a caller.
358+
* Postgres timestamps are compared at JavaScript `Date` precision because a selected
359+
* microsecond timestamp has already been rounded to milliseconds at this boundary.
360+
*/
357361
export async function deleteFileMetadataByIdentity(identity: {
358362
id: string
359363
key: string
@@ -368,7 +372,10 @@ export async function deleteFileMetadataByIdentity(identity: {
368372
eq(workspaceFiles.id, identity.id),
369373
eq(workspaceFiles.key, identity.key),
370374
eq(workspaceFiles.context, identity.context),
371-
eq(workspaceFiles.contentUpdatedAt, identity.contentUpdatedAt),
375+
eq(
376+
sql<Date>`date_trunc('milliseconds', ${workspaceFiles.contentUpdatedAt})`,
377+
identity.contentUpdatedAt
378+
),
372379
isNull(workspaceFiles.deletedAt)
373380
)
374381
)

0 commit comments

Comments
 (0)