|
| 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