Skip to content

Commit ec3a98c

Browse files
committed
Merge remote-tracking branch 'origin/staging' into fix/member-lifecycle-bounded-tombstone
2 parents b7e6c7a + 49f56c9 commit ec3a98c

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
@@ -277,6 +277,7 @@ jobs:
277277
lib/knowledge/__integration__/member-document-lifecycle.integration.ts
278278
lib/knowledge/__integration__/slack-empty-threads.integration.ts
279279
lib/knowledge/__integration__/kb-block-search.integration.ts
280+
lib/knowledge/__integration__/unfilled-projection-source.integration.ts
280281
lib/core/outbox/service.integration.ts
281282
lib/knowledge/__integration__/connector-upload.integration.ts
282283
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)