Skip to content

Commit 49f56c9

Browse files
authored
fix(knowledge): read an unfilled keyword candidate's source from its document (#8176)
* fix(knowledge): read an unfilled keyword candidate's source from its document The Tin keyword page took each candidate's source from its projection row. A row the source and ACL fill has not reached carries a NULL source, so a chunk from a source that needs a live reader proof was never recognized as one: the proof was not resolved, hydration ran without the caller's grants, and the document was hidden from a member who can read it. Until the keyword projection is filled, a page's unfilled rows now take their source from the document, one primary-key read per row of the page after its limit. The excluded-sources filter asks the document the same way, as the vector leg already does, so a denied source's unfilled rows stop taking slots on a rebuilt page. The filled path's statement is unchanged. * test(knowledge): assert the ACL write's fan-out, not unfilled rows the trigger now skips The projection trigger no longer writes an ACL onto chunks the backfill has not filled, so the ACL write tests can no longer see an unchanged write through those rows. They now count the document writes that fire the fan-out trigger, and expect a changed ACL on filled chunks only. * fix(knowledge): read the document only for unfilled rows in the keyword source exclusion
1 parent b44596f commit 49f56c9

4 files changed

Lines changed: 435 additions & 10 deletions

File tree

.github/workflows/test-build.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,7 @@ jobs:
276276
lib/knowledge/__integration__/member-scope-renewal.integration.ts
277277
lib/knowledge/__integration__/slack-empty-threads.integration.ts
278278
lib/knowledge/__integration__/kb-block-search.integration.ts
279+
lib/knowledge/__integration__/unfilled-projection-source.integration.ts
279280
lib/core/outbox/service.integration.ts
280281
lib/knowledge/__integration__/connector-upload.integration.ts
281282
lib/uploads/contexts/organization-logo/application.integration.ts
Lines changed: 380 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,380 @@
1+
/**
2+
* A search candidate's source decides whether the caller's live source proof is resolved before
3+
* its content is read. These fixtures put a GitHub installation source's chunks on projection rows
4+
* the source and ACL fill has not reached (`acl` and `connector_id` NULL), and check that such a
5+
* chunk still reaches a member who holds the installation grant, stays hidden from one who does
6+
* not, and is left out of a page once its source is known to be denied.
7+
*/
8+
import { createHash } from 'node:crypto'
9+
import { db } from '@sim/db'
10+
import {
11+
credential,
12+
credentialGroup,
13+
credentialGroupEnrollment,
14+
document,
15+
embedding,
16+
embeddingKeywordTin,
17+
embeddingSearch,
18+
knowledgeConnector,
19+
knowledgeConnectorMember,
20+
knowledgeDocumentObservation,
21+
organization,
22+
user,
23+
workspace,
24+
} from '@sim/db/schema'
25+
import { generateId } from '@sim/utils/id'
26+
import { isRecordLike } from '@sim/utils/object'
27+
import { eq, inArray, sql } from 'drizzle-orm'
28+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
29+
30+
vi.mock('@/lib/knowledge/search/tin-keyword', () => ({
31+
resolveTinKeywordQuery: async () => 'fixture',
32+
}))
33+
34+
import {
35+
createKnowledgeAclFixtureIds,
36+
seedKnowledgeAclFixture,
37+
} from '@/lib/knowledge/__integration__/seed-source-access-fixture'
38+
import type { SearchAccessPlan } from '@/lib/knowledge/access/predicate'
39+
import type {
40+
GitHubInstallationReadGrant,
41+
KnowledgeAccessProvider,
42+
UserAccessScope,
43+
} from '@/lib/knowledge/access/types'
44+
import {
45+
executeKeywordSearch,
46+
forgetProjectionFilled,
47+
handleVectorOnlySearch,
48+
liveSourceAccessFor,
49+
} from '@/lib/knowledge/search/queries'
50+
import { GITHUB_INSTALLATION_PROVIDER_ID } from '@/lib/oauth/github-installation-types'
51+
52+
const ids = createKnowledgeAclFixtureIds()
53+
const connectorId = generateId()
54+
const contentCredentialId = generateId()
55+
const groupId = generateId()
56+
const optionId = generateId()
57+
const documentId = generateId()
58+
const embeddingId = generateId()
59+
const repositoryId = '4242'
60+
const members = {
61+
alice: { id: generateId(), subject: 'alice-gh', credentialId: generateId() },
62+
bob: { id: generateId(), subject: 'bob-gh', credentialId: generateId() },
63+
}
64+
const userIdOf = (who: 'alice' | 'bob') => (who === 'alice' ? ids.aliceId : ids.bobId)
65+
const subjectToken = (subject: string) => `s:github-repositories:-:${subject}`
66+
const queryVector = {
67+
vector: JSON.stringify([1, ...Array<number>(1535).fill(0)]),
68+
dimensions: 1536 as const,
69+
model: 'text-embedding-3-small',
70+
}
71+
72+
/** The shims stand in for the Tin extension, which the test database does not carry. */
73+
let createdTinShims = false
74+
75+
const scopeFor = (who: 'alice' | 'bob'): UserAccessScope => ({
76+
kind: 'user',
77+
userId: userIdOf(who),
78+
tokens: [
79+
'pub',
80+
subjectToken(members[who].subject),
81+
`u:${userIdOf(who)}@fixture.test`,
82+
'ws',
83+
].sort(),
84+
})
85+
86+
const planFor = (who: 'alice' | 'bob'): SearchAccessPlan => ({
87+
connectors: {
88+
workspace: [],
89+
admin: [],
90+
members: [connectorId],
91+
liveProofRequired: [connectorId],
92+
},
93+
observers: { confirmed: [{ id: members[who].id, connectorId }], observed: [] },
94+
memberSources: [connectorId],
95+
connectorTypes: new Map([[connectorId, 'github']]),
96+
uploads: false,
97+
})
98+
99+
/** Alice's reader credential backs a real installation grant; Bob holds none. */
100+
const aliceGrant: GitHubInstallationReadGrant = {
101+
connectorId,
102+
contentCredentialId,
103+
readerCredentialId: members.alice.credentialId,
104+
readerSubjectToken: subjectToken(members.alice.subject),
105+
repositoryId,
106+
}
107+
108+
function searchInputs(who: 'alice' | 'bob') {
109+
const access = scopeFor(who)
110+
const accessPlan = planFor(who)
111+
const granted = who === 'alice' ? { ...access, githubInstallationGrants: [aliceGrant] } : access
112+
const accessProvider: KnowledgeAccessProvider = {
113+
get: async () => access,
114+
getForConnectors: async () => granted,
115+
getForDocuments: async () => granted,
116+
liveSourceConnectorCondition: async () => null,
117+
}
118+
return {
119+
knowledgeBaseIds: [ids.knowledgeBaseId],
120+
topK: 5,
121+
access,
122+
accessProvider,
123+
accessPlan,
124+
liveSourceAccess: liveSourceAccessFor(access, accessPlan, accessProvider),
125+
queryVector,
126+
}
127+
}
128+
129+
const keywordIds = async (who: 'alice' | 'bob') =>
130+
(
131+
await executeKeywordSearch({
132+
...searchInputs(who),
133+
query: 'fixture',
134+
permitted: { kind: 'unbounded', broad: false },
135+
searchIndexOnly: true,
136+
})
137+
).map((row) => row.id)
138+
139+
const vectorIds = async (who: 'alice' | 'bob') =>
140+
(
141+
await handleVectorOnlySearch({
142+
...searchInputs(who),
143+
distanceThreshold: 2,
144+
permitted: { kind: 'unbounded', broad: true },
145+
})
146+
).map((row) => row.id)
147+
148+
async function setProjection(state: 'filled' | 'unfilled') {
149+
for (const table of [embeddingSearch, embeddingKeywordTin]) {
150+
await db
151+
.update(table)
152+
.set(
153+
state === 'filled'
154+
? {
155+
connectorId,
156+
acl: [subjectToken(members.alice.subject), subjectToken(members.bob.subject)].sort(),
157+
}
158+
: { connectorId: null, acl: null }
159+
)
160+
.where(eq(table.id, embeddingId))
161+
}
162+
forgetProjectionFilled()
163+
}
164+
165+
beforeAll(async () => {
166+
await seedKnowledgeAclFixture(ids)
167+
const now = new Date()
168+
await db
169+
.update(user)
170+
.set({ emailVerified: true })
171+
.where(inArray(user.id, [ids.aliceId, ids.bobId]))
172+
await db.insert(credential).values({
173+
id: contentCredentialId,
174+
workspaceId: ids.workspaceId,
175+
type: 'service_account',
176+
displayName: 'Fixture GitHub installation',
177+
createdBy: ids.aliceId,
178+
providerId: GITHUB_INSTALLATION_PROVIDER_ID,
179+
})
180+
await db.insert(credentialGroup).values({
181+
id: groupId,
182+
workspaceId: ids.workspaceId,
183+
publicId: generateId(),
184+
name: 'GitHub readers',
185+
options: [
186+
{
187+
id: optionId,
188+
provider: 'github-repositories',
189+
label: 'GitHub fixture',
190+
authorizationAppId: 'fixture-app',
191+
requiredScopes: ['repo'],
192+
scopeVersion: 1,
193+
required: false,
194+
status: 'active',
195+
},
196+
],
197+
} as typeof credentialGroup.$inferInsert)
198+
for (const who of ['alice', 'bob'] as const) {
199+
const [enrollment] = await db
200+
.insert(credentialGroupEnrollment)
201+
.values({
202+
id: generateId(),
203+
credentialGroupId: groupId,
204+
userId: userIdOf(who),
205+
email: `${userIdOf(who)}@fixture.test`,
206+
status: 'completed',
207+
invitationTokenHash: createHash('sha256').update(generateId()).digest('hex'),
208+
invitationExpiresAt: new Date(Date.now() + 60 * 60 * 1000),
209+
invitedAt: now,
210+
})
211+
.returning({ id: credentialGroupEnrollment.id })
212+
await db.insert(credential).values({
213+
id: members[who].credentialId,
214+
workspaceId: ids.workspaceId,
215+
type: 'managed_oauth',
216+
displayName: 'Fixture GitHub reader',
217+
providerId: 'github-repositories',
218+
authorizationAppId: 'fixture-app',
219+
credentialGroupEnrollmentId: enrollment!.id,
220+
credentialGroupOptionId: optionId,
221+
managedOauthScopeVersion: 1,
222+
providerSubjectId: members[who].subject,
223+
providerTenantId: '',
224+
managedOauthStatus: 'active',
225+
grantedScopes: ['repo'],
226+
encryptedOauthTokenSet: 'fixture-not-an-oauth-token',
227+
grantedAt: now,
228+
createdBy: userIdOf(who),
229+
})
230+
}
231+
await db.insert(knowledgeConnector).values({
232+
id: connectorId,
233+
knowledgeBaseId: ids.knowledgeBaseId,
234+
connectorType: 'github',
235+
sourceConfig: { githubRepositoryId: repositoryId },
236+
accessMode: 'members',
237+
status: 'active',
238+
credentialId: contentCredentialId,
239+
credentialGroupId: groupId,
240+
credentialGroupOptionId: optionId,
241+
})
242+
await db.insert(knowledgeConnectorMember).values(
243+
(['alice', 'bob'] as const).map((who) => ({
244+
id: members[who].id,
245+
workspaceId: ids.workspaceId,
246+
connectorId,
247+
credentialId: members[who].credentialId,
248+
subjectToken: subjectToken(members[who].subject),
249+
status: 'active',
250+
memberSyncedThrough: now,
251+
}))
252+
)
253+
await db.insert(document).values({
254+
id: documentId,
255+
connectorId,
256+
knowledgeBaseId: ids.knowledgeBaseId,
257+
externalId: 'fixture-file',
258+
filename: 'readme.md',
259+
fileUrl: 'https://fixture.test/readme',
260+
fileSize: 12,
261+
mimeType: 'text/plain',
262+
processingStatus: 'completed',
263+
acl: [subjectToken(members.alice.subject), subjectToken(members.bob.subject)].sort(),
264+
})
265+
await db.insert(knowledgeDocumentObservation).values(
266+
(['alice', 'bob'] as const).map((who) => ({
267+
documentId,
268+
memberId: members[who].id,
269+
lastSeenAt: now,
270+
runId: generateId(),
271+
}))
272+
)
273+
await db.insert(embedding).values({
274+
id: embeddingId,
275+
documentId,
276+
knowledgeBaseId: ids.knowledgeBaseId,
277+
chunkIndex: 0,
278+
chunkHash: 'fixture-hash',
279+
content: 'fixture readme',
280+
contentLength: 14,
281+
tokenCount: 2,
282+
startOffset: 0,
283+
endOffset: 14,
284+
embeddingModel: 'text-embedding-3-small',
285+
embedding: [1, ...Array<number>(1535).fill(0)],
286+
})
287+
await db.insert(embeddingKeywordTin).values({
288+
id: embeddingId,
289+
knowledgeBaseId: ids.knowledgeBaseId,
290+
documentId,
291+
enabled: true,
292+
content: 'fixture readme',
293+
})
294+
const [tin] = await db.execute<{ present: boolean }>(
295+
sql`SELECT to_regnamespace('tin') IS NOT NULL AS present`
296+
)
297+
if (!tin?.present) {
298+
createdTinShims = true
299+
await db.execute(
300+
sql.raw(`CREATE SCHEMA tin;
301+
CREATE FUNCTION tin.full_score(tid) RETURNS double precision LANGUAGE sql IMMUTABLE AS 'SELECT 1.0::float8';
302+
CREATE FUNCTION knowledge_tin_base_token(text) RETURNS text LANGUAGE sql IMMUTABLE AS $$SELECT 'kb'$$;
303+
CREATE FUNCTION tin_fixture_match(text, text) RETURNS boolean LANGUAGE sql IMMUTABLE AS 'SELECT true';
304+
CREATE OPERATOR ==> (LEFTARG = text, RIGHTARG = text, FUNCTION = tin_fixture_match);`)
305+
)
306+
}
307+
})
308+
309+
afterAll(async () => {
310+
if (createdTinShims) {
311+
await db.execute(
312+
sql.raw(`DROP OPERATOR IF EXISTS ==> (text, text);
313+
DROP FUNCTION IF EXISTS tin_fixture_match(text, text);
314+
DROP FUNCTION IF EXISTS knowledge_tin_base_token(text);
315+
DROP SCHEMA IF EXISTS tin CASCADE;`)
316+
)
317+
}
318+
await db.delete(embeddingKeywordTin).where(eq(embeddingKeywordTin.id, embeddingId))
319+
await db.delete(workspace).where(eq(workspace.id, ids.workspaceId))
320+
await db.delete(credentialGroup).where(eq(credentialGroup.id, groupId))
321+
await db.delete(organization).where(eq(organization.id, ids.organizationId))
322+
await db.delete(user).where(inArray(user.id, [ids.aliceId, ids.bobId]))
323+
forgetProjectionFilled()
324+
})
325+
326+
describe('a chunk whose projection row the fill has not reached', () => {
327+
beforeEach(() => setProjection('unfilled'))
328+
329+
it('reaches the member holding the installation grant through the keyword ranking', async () => {
330+
expect(await keywordIds('alice')).toEqual([embeddingId])
331+
})
332+
333+
it('reaches the member holding the installation grant through the vector ranking', async () => {
334+
expect(await vectorIds('alice')).toEqual([embeddingId])
335+
})
336+
337+
it('stays hidden from a member without the grant', async () => {
338+
expect(await keywordIds('bob')).toEqual([])
339+
expect(await vectorIds('bob')).toEqual([])
340+
})
341+
342+
describe('once its source is known to be denied', () => {
343+
/** Each Tin ranking statement's page of candidates, in the order the search read them. */
344+
const pages: Array<{ candidates: unknown[] }> = []
345+
beforeEach(() => {
346+
pages.length = 0
347+
const execute = db.execute.bind(db)
348+
vi.spyOn(db, 'execute').mockImplementation((async (query: Parameters<typeof execute>[0]) => {
349+
const rows = await execute(query)
350+
const [row] = Array.from(rows)
351+
if (isRecordLike(row) && 'ranked' in row && Array.isArray(row.candidates))
352+
pages.push({ candidates: row.candidates })
353+
return rows
354+
}) as typeof db.execute)
355+
})
356+
afterEach(() => vi.restoreAllMocks())
357+
358+
it('carries the source read from its document and is left out of the rebuilt keyword page', async () => {
359+
expect(await keywordIds('bob')).toEqual([])
360+
expect(pages.length).toBeGreaterThanOrEqual(2)
361+
expect(pages[0]!.candidates).toEqual([{ id: embeddingId, documentId, connectorId }])
362+
expect(pages.at(-1)!.candidates).toEqual([])
363+
})
364+
})
365+
})
366+
367+
describe('a chunk whose projection row is filled', () => {
368+
beforeEach(() => setProjection('filled'))
369+
370+
it('ranks on the row as before and is read only by the member holding the grant', async () => {
371+
const [{ unfilled }] = await db.execute<{ unfilled: boolean }>(
372+
sql`SELECT EXISTS (SELECT 1 FROM ${embeddingKeywordTin} WHERE ${embeddingKeywordTin.acl} IS NULL) AS unfilled`
373+
)
374+
expect(unfilled).toBe(false)
375+
expect(await keywordIds('alice')).toEqual([embeddingId])
376+
expect(await keywordIds('bob')).toEqual([])
377+
expect(await vectorIds('alice')).toEqual([embeddingId])
378+
expect(await vectorIds('bob')).toEqual([])
379+
})
380+
})

0 commit comments

Comments
 (0)