Skip to content

Commit 1387345

Browse files
committed
fix(provenance): close durable writer and reader gaps
1 parent d482526 commit 1387345

29 files changed

Lines changed: 2454 additions & 167 deletions

.github/workflows/test-build.yml

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ permissions:
99

1010
jobs:
1111
oauth-postgres:
12-
name: OAuth and SCIM PostgreSQL (${{ matrix.provision }})
12+
name: PostgreSQL integration (${{ matrix.provision }})
1313
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-latest' }}
1414
timeout-minutes: 15
1515
strategy:
@@ -144,6 +144,16 @@ jobs:
144144
if-no-files-found: ignore
145145
retention-days: 7
146146

147+
- name: Verify durable provenance bindings and concurrent memory writes
148+
working-directory: apps/sim
149+
env:
150+
TABLE_PROVENANCE_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_auth_scim
151+
MEMORY_PROVENANCE_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_auth_scim
152+
run: >-
153+
bunx vitest run
154+
lib/table/rows/secret-provenance.postgres.test.ts
155+
lib/memory/message-provenance.postgres.test.ts
156+
147157
test-build:
148158
name: Lint and Test
149159
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-latest' }}

apps/docs/openapi-v2-knowledge.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1317,7 +1317,7 @@
13171317
"post": {
13181318
"operationId": "searchKnowledge",
13191319
"summary": "Search Knowledge",
1320-
"description": "Search one or more knowledge bases with semantic vector retrieval, optional hybrid full-text retrieval, and structured tag filters. Every result names the `knowledgeBaseId` it came from. A request body over 2 MiB is a `413`.\n\nOAuth scope: `api:read`.",
1320+
"description": "Search one or more knowledge bases with semantic vector retrieval, optional hybrid full-text retrieval, and structured tag filters. Every result names the `knowledgeBaseId` it came from. A request body over 2 MiB is a `413`. Reranking returns `409` when the stored results cannot pass secret-provenance enforcement.\n\nOAuth scope: `api:read`.",
13211321
"x-sim-operation": "knowledge.search",
13221322
"x-oauth-scope": "api:read",
13231323
"tags": ["Knowledge Bases"],
@@ -1369,6 +1369,9 @@
13691369
"404": {
13701370
"$ref": "#/components/responses/NotFound"
13711371
},
1372+
"409": {
1373+
"$ref": "#/components/responses/Conflict"
1374+
},
13721375
"413": {
13731376
"$ref": "#/components/responses/PayloadTooLarge"
13741377
},
Lines changed: 347 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,347 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const mocks = vi.hoisted(() => ({
8+
resolveWorkspace: vi.fn(),
9+
resolvePermission: vi.fn(),
10+
getKnowledgeBase: vi.fn(),
11+
resolveBilling: vi.fn(),
12+
checkUsage: vi.fn(),
13+
checkActorUsage: vi.fn(),
14+
generateEmbedding: vi.fn(),
15+
executeSearch: vi.fn(),
16+
getDocumentMetadata: vi.fn(),
17+
getTagDefinitions: vi.fn(),
18+
recordEmbeddingUsage: vi.fn(),
19+
}))
20+
21+
vi.mock('@sim/platform-authz/workspace', () => ({
22+
permissionSatisfies: (actual: string | null, required: string) => {
23+
const rank = { read: 1, write: 2, admin: 3 } as const
24+
return (
25+
actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank]
26+
)
27+
},
28+
resolveEffectiveWorkspacePermission: mocks.resolvePermission,
29+
}))
30+
31+
vi.mock('@/lib/billing/core/billing-attribution', () => ({
32+
resolveBillingAttribution: mocks.resolveBilling,
33+
resolveSystemBillingAttribution: mocks.resolveBilling,
34+
checkAttributedUsageLimits: mocks.checkUsage,
35+
}))
36+
37+
/** Retrieval defaults are the flag's concern; here the flag is off so the search stays as configured. */
38+
vi.mock('@/lib/knowledge/access/availability', () => ({
39+
isKnowledgeMemberAccessAvailable: async () => false,
40+
}))
41+
42+
vi.mock('@/lib/billing/calculations/usage-monitor', () => ({
43+
checkActorUsageLimits: mocks.checkActorUsage,
44+
}))
45+
46+
vi.mock('@/lib/knowledge/application/contexts', () => ({
47+
resolveKnowledgeWorkspaceContext: mocks.resolveWorkspace,
48+
}))
49+
50+
vi.mock('@/lib/knowledge/service', () => ({
51+
getKnowledgeBaseById: mocks.getKnowledgeBase,
52+
}))
53+
54+
vi.mock('@/lib/knowledge/embeddings', () => ({
55+
generateSearchEmbedding: mocks.generateEmbedding,
56+
recordSearchEmbeddingUsage: mocks.recordEmbeddingUsage,
57+
}))
58+
59+
vi.mock('@/lib/knowledge/search/queries', () => ({
60+
generateSearchEmbedding: mocks.generateEmbedding,
61+
executeKnowledgeSearch: mocks.executeSearch,
62+
getDocumentMetadataByIds: mocks.getDocumentMetadata,
63+
}))
64+
65+
vi.mock('@/lib/knowledge/tags/service', () => ({
66+
getDocumentTagDefinitions: mocks.getTagDefinitions,
67+
}))
68+
69+
vi.mock('@/lib/knowledge/tags/utils', () => ({
70+
buildUndefinedTagsError: (tags: string[]) => `Undefined tags: ${tags.join(', ')}`,
71+
validateTagValue: () => null,
72+
}))
73+
74+
import { searchKnowledge } from '@/lib/knowledge/application/search'
75+
76+
const workspace = {
77+
workspaceId: 'workspace-1',
78+
workspaceOrganizationId: null,
79+
allowPersonalApiKeys: true,
80+
billedAccountUserId: 'billing-owner-1',
81+
}
82+
83+
const knowledgeBase = {
84+
id: 'knowledge-1',
85+
userId: 'user-1',
86+
name: 'Docs',
87+
workspaceId: 'workspace-1',
88+
embeddingModel: 'text-embedding-3-small',
89+
embeddingDimension: 1536,
90+
}
91+
92+
import { document, embedding } from '@sim/db/schema'
93+
import { sha256Hex } from '@sim/security/hash'
94+
import {
95+
queueTableRows,
96+
resetDbChainMock,
97+
V2_OPERATION_RATE_LIMIT_ALLOWED,
98+
V2_PREAUTH_RATE_LIMIT_ALLOWED,
99+
v2ApiKeyAuthModuleMock,
100+
v2RateLimiterModuleMock,
101+
v2RouteMocks,
102+
} from '@sim/testing'
103+
import { NextRequest } from 'next/server'
104+
import { env } from '@/lib/core/config/env'
105+
import {
106+
isDurableSecretProvenanceEnforced,
107+
resetDurableSecretProvenanceEnforcementCache,
108+
} from '@/lib/execution/durable-secret-provenance-enforcement'
109+
import { createKnowledgeDocumentSourceValue } from '@/lib/knowledge/secret-provenance'
110+
import { POST } from '@/app/api/v2/knowledge/search/route'
111+
import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
112+
113+
const provider = vi.hoisted(() => ({ fetch: vi.fn(), decrypt: vi.fn() }))
114+
vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock)
115+
vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock)
116+
vi.mock('@/lib/api-key/byok', () => ({ getBYOKKey: async () => null }))
117+
vi.mock('@/lib/core/security/encryption', () => ({ decryptSecret: provider.decrypt }))
118+
119+
const SECRET = 'synthetic-audit-secret-7b88a2'
120+
const CONTENT = `Stored knowledge contains ${SECRET} in this synthetic fixture.`
121+
const HASH = sha256Hex(CONTENT)
122+
const PRINCIPAL = { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' } as const
123+
const requestInput = {
124+
workspaceId: 'workspace-1',
125+
knowledgeBaseIds: ['knowledge-1'],
126+
query: 'find the fixture',
127+
topK: 1,
128+
rerankerEnabled: true,
129+
rerankerModel: 'rerank-v4.0-fast',
130+
}
131+
const source = createKnowledgeDocumentSourceValue({
132+
filename: 'synthetic.txt',
133+
fileUrl: 'https://example.invalid/synthetic.txt',
134+
})
135+
const row = {
136+
id: 'embedding-1',
137+
documentId: 'document-1',
138+
knowledgeBaseId: 'knowledge-1',
139+
content: CONTENT,
140+
chunkIndex: 0,
141+
distance: 0.2,
142+
tag1: null,
143+
tag2: null,
144+
tag3: null,
145+
tag4: null,
146+
tag5: null,
147+
tag6: null,
148+
tag7: null,
149+
number1: null,
150+
number2: null,
151+
number3: null,
152+
number4: null,
153+
number5: null,
154+
date1: null,
155+
date2: null,
156+
boolean1: null,
157+
boolean2: null,
158+
boolean3: null,
159+
}
160+
161+
function seedSidecar(status: 'exact' | 'unknown' | 'legacy' | 'missing' | 'stale' | 'malformed') {
162+
queueTableRows(embedding, [
163+
{
164+
...row,
165+
secretProvenanceVersion: status === 'legacy' ? null : 1,
166+
chunkHash: HASH,
167+
provenanceContentHash: status === 'stale' ? 'old-hash' : HASH,
168+
status: status === 'missing' ? null : status === 'unknown' ? 'unknown' : 'exact',
169+
entries:
170+
status === 'malformed'
171+
? [{ encryptedValue: 123 }]
172+
: status === 'exact'
173+
? [
174+
{
175+
name: 'TOKEN',
176+
encryptedValue: 'synthetic-encrypted-token',
177+
sourceUserId: 'user-1',
178+
sourceWorkspaceId: 'workspace-1',
179+
},
180+
]
181+
: [],
182+
},
183+
])
184+
queueTableRows(document, [
185+
{
186+
id: 'document-1',
187+
...source,
188+
secretProvenanceVersion: null,
189+
provenanceSourceHash: null,
190+
status: null,
191+
entries: null,
192+
},
193+
])
194+
}
195+
196+
function providerPayload() {
197+
expect(provider.fetch).toHaveBeenCalledTimes(1)
198+
expect(provider.fetch.mock.calls[0][0]).toBe('https://api.cohere.com/v2/rerank')
199+
return JSON.parse(provider.fetch.mock.calls[0][1].body)
200+
}
201+
202+
function enforceKnowledge(enforced: boolean) {
203+
env.DURABLE_SECRET_PROVENANCE_ENFORCED_SURFACES = enforced ? 'all' : ''
204+
resetDurableSecretProvenanceEnforcementCache()
205+
expect(isDurableSecretProvenanceEnforced('knowledge')).toBe(enforced)
206+
}
207+
208+
async function requestSearch(overrides: Partial<typeof requestInput> = {}) {
209+
return POST(
210+
new NextRequest('http://localhost/api/v2/knowledge/search', {
211+
method: 'POST',
212+
headers: { 'content-type': 'application/json', 'x-api-key': 'synthetic-key' },
213+
body: JSON.stringify({ ...requestInput, ...overrides }),
214+
})
215+
)
216+
}
217+
218+
beforeEach(() => {
219+
vi.clearAllMocks()
220+
resetDbChainMock()
221+
enforceKnowledge(true)
222+
env.COHERE_API_KEY = 'synthetic-cohere-key'
223+
provider.decrypt.mockResolvedValue({ decrypted: SECRET })
224+
provider.fetch.mockResolvedValue(
225+
new Response(JSON.stringify({ results: [{ index: 0, relevance_score: 0.9 }] }), {
226+
status: 200,
227+
headers: { 'Content-Type': 'application/json' },
228+
})
229+
)
230+
vi.stubGlobal('fetch', provider.fetch)
231+
mocks.resolveWorkspace.mockResolvedValue(workspace)
232+
mocks.resolvePermission.mockResolvedValue('read')
233+
mocks.getKnowledgeBase.mockResolvedValue(knowledgeBase)
234+
mocks.resolveBilling.mockResolvedValue({ actorUserId: 'user-1', workspaceId: 'workspace-1' })
235+
mocks.checkUsage.mockResolvedValue({ isExceeded: false })
236+
mocks.checkActorUsage.mockResolvedValue({ isExceeded: false })
237+
mocks.generateEmbedding.mockResolvedValue({ embedding: [0.1], isBYOK: false })
238+
mocks.executeSearch.mockResolvedValue([row])
239+
mocks.getDocumentMetadata.mockResolvedValue({
240+
'document-1': { filename: 'synthetic.txt', sourceUrl: null },
241+
})
242+
mocks.getTagDefinitions.mockResolvedValue([])
243+
v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED)
244+
v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED)
245+
v2RouteMocks.authenticate.mockResolvedValue({
246+
principal: PRINCIPAL,
247+
rateLimitSubjectIds: ['api-key:key-1', 'workspace:workspace-1'],
248+
rateLimitSubscription: null,
249+
keyType: 'personal',
250+
})
251+
})
252+
253+
/** The route, use case, sidecar binding/import, registry, projection and provider request builder are real. */
254+
describe('Knowledge search provenance through the V2 route and reranker HTTP boundary', () => {
255+
it.each([false, true])(
256+
'redacts current known-secret chunks with enforcement=%s',
257+
async (enforced) => {
258+
enforceKnowledge(enforced)
259+
seedSidecar('exact')
260+
const response = await requestSearch()
261+
const body = await response.json()
262+
expect(response.status).toBe(200)
263+
expect(body.data.rerankerStatus).toBe('applied')
264+
expect(providerPayload().documents).toEqual([CONTENT.replace(SECRET, '{{TOKEN}}')])
265+
expect(body.data.results[0].content).toBe(CONTENT)
266+
expect(provider.decrypt).toHaveBeenCalledWith('synthetic-encrypted-token')
267+
expect(mocks.generateEmbedding).toHaveBeenCalledWith(
268+
requestInput.query,
269+
expect.anything(),
270+
'workspace-1'
271+
)
272+
}
273+
)
274+
275+
it('does not assign a billing owner secret name to a workspace-key caller', async () => {
276+
seedSidecar('exact')
277+
v2RouteMocks.authenticate.mockResolvedValue({
278+
principal: { kind: 'workspace_api_key', workspaceId: 'workspace-1', keyId: 'key-1' },
279+
rateLimitSubjectIds: ['api-key:key-1', 'workspace:workspace-1'],
280+
rateLimitSubscription: null,
281+
keyType: 'workspace',
282+
})
283+
const response = await requestSearch()
284+
expect(response.status).toBe(200)
285+
expect(providerPayload().documents).toEqual([CONTENT.replace(SECRET, '[REDACTED_SECRET]')])
286+
})
287+
288+
it('keeps a trusted incoming registry for existing internal and tool callers', async () => {
289+
seedSidecar('exact')
290+
const registry = new ResolvedSecretTraceRegistry([], {
291+
userId: 'user-1',
292+
workspaceId: 'workspace-1',
293+
})
294+
const result = await searchKnowledge.execute({
295+
principal: PRINCIPAL,
296+
input: { ...requestInput, resultSecretRegistry: registry },
297+
})
298+
expect(result.rerankerStatus).toBe('applied')
299+
expect(result.resultSecretRegistry).toBe(registry)
300+
expect(providerPayload().documents).toEqual([CONTENT.replace(SECRET, '{{TOKEN}}')])
301+
})
302+
303+
it.each(['unknown', 'missing', 'stale', 'malformed'] as const)(
304+
'refuses %s tracked provenance before provider HTTP when enforcement is enabled',
305+
async (status) => {
306+
seedSidecar(status)
307+
const response = await requestSearch()
308+
expect(response.status).toBe(409)
309+
expect(await response.json()).toMatchObject({
310+
error: { code: 'CONFLICT', message: 'Knowledge result secret provenance is unavailable' },
311+
})
312+
expect(provider.fetch).not.toHaveBeenCalled()
313+
}
314+
)
315+
316+
it.each(['unknown', 'missing', 'stale', 'malformed'] as const)(
317+
'preserves existing flag-off compatibility for %s sidecars',
318+
async (status) => {
319+
enforceKnowledge(false)
320+
seedSidecar(status)
321+
const response = await requestSearch()
322+
expect(response.status).toBe(200)
323+
expect(providerPayload().documents).toEqual([CONTENT])
324+
}
325+
)
326+
327+
it.each([false, true])(
328+
'keeps pre-tracking NULL rows readable with enforcement=%s',
329+
async (enforced) => {
330+
enforceKnowledge(enforced)
331+
seedSidecar('legacy')
332+
const response = await requestSearch()
333+
expect(response.status).toBe(200)
334+
expect(providerPayload().documents).toEqual([CONTENT])
335+
}
336+
)
337+
338+
it('does not subject a raw public read without reranking to durable-model enforcement', async () => {
339+
seedSidecar('unknown')
340+
const response = await requestSearch({ rerankerEnabled: false })
341+
const body = await response.json()
342+
expect(response.status).toBe(200)
343+
expect(body.data.results[0].content).toBe(CONTENT)
344+
expect(provider.fetch).not.toHaveBeenCalled()
345+
expect(provider.decrypt).not.toHaveBeenCalled()
346+
})
347+
})

0 commit comments

Comments
 (0)