Skip to content

Commit 10f5c42

Browse files
committed
fix(search): scope GitHub access checks to requested results
1 parent 8efb1dd commit 10f5c42

29 files changed

Lines changed: 1580 additions & 430 deletions

apps/sim/lib/integrations/credential-display.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ const EXPECTED_COVERAGE: Record<string, string[]> = {
4949
'calcom-service-account': ['cal-com'],
5050
'claude-platform-service-account': [],
5151
'clickup-service-account': ['clickup'],
52+
'github-app-installation': ['github'],
5253
'google-service-account': [
5354
'gmail',
5455
'google-bigquery',

apps/sim/lib/knowledge/__integration__/github-member.integration.ts

Lines changed: 176 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -72,20 +72,27 @@ import {
7272
seedKnowledgeAclFixture,
7373
seedKnowledgeMemberFixture,
7474
} from '@/lib/knowledge/__integration__/seed-source-access-fixture'
75+
import { GITHUB_READ_SOURCE_TIMEOUT_MS } from '@/lib/knowledge/access/github-installation'
76+
import { createKnowledgeAccessProvider } from '@/lib/knowledge/access/scope'
7577
import { subjectToken } from '@/lib/knowledge/access/tokens'
7678
import { KnowledgeDocumentNotReadyError } from '@/lib/knowledge/application/chunk-errors'
7779
import { listKnowledgeChunks } from '@/lib/knowledge/application/chunks'
7880
import { readKnowledgeDocument } from '@/lib/knowledge/application/documents'
81+
import { readIndexedKnowledgeDocument } from '@/lib/knowledge/application/read-indexed-document'
7982
import { searchKnowledge } from '@/lib/knowledge/application/search'
83+
import { readSearchSourceOverview } from '@/lib/knowledge/application/search-source-overview'
8084
import { listSearchSources } from '@/lib/knowledge/application/search-sources'
8185
import { grantKnowledgeConnectorCredentialAccess } from '@/lib/knowledge/connectors/member-access'
8286
import { executeMemberSync } from '@/lib/knowledge/connectors/member-sync-engine'
8387
import {
8488
MEMBER_SUSPENDED_PURGE_DAYS,
8589
MEMBER_TOMBSTONE_PURGE_DAYS,
8690
} from '@/lib/knowledge/connectors/sync-limits'
91+
import { getDocuments } from '@/lib/knowledge/documents/service'
92+
import { getTagUsageStats } from '@/lib/knowledge/tags/service'
8793
import { deleteFile } from '@/lib/uploads/core/storage-service'
8894
import { downloadFileFromUrl } from '@/lib/uploads/utils/file-utils.server'
95+
import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
8996

9097
const redisUrl = process.env.KNOWLEDGE_ACL_TEST_REDIS_URL
9198
if (redisUrl) {
@@ -105,6 +112,7 @@ interface RepositoryFixture {
105112
id: number
106113
public: boolean
107114
installed: boolean
115+
stallRef: boolean
108116
readers: Set<string>
109117
defaultBranch: string
110118
files: Map<string, string>
@@ -133,6 +141,7 @@ describe('fixture-backed GitHub member search in PostgreSQL', () => {
133141
const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 })
134142
let organizationSource = false
135143
let installationSuspended = false
144+
let referenceObserved: ((repository: string) => void) | undefined
136145
const installation = () => ({
137146
id: 42,
138147
app_id: 1,
@@ -164,6 +173,7 @@ describe('fixture-backed GitHub member search in PostgreSQL', () => {
164173
id: 9001 + repositories.size,
165174
public: false,
166175
installed: true,
176+
stallRef: false,
167177
readers: new Set(readers),
168178
defaultBranch: 'trunk',
169179
files: new Map([
@@ -240,30 +250,33 @@ describe('fixture-backed GitHub member search in PostgreSQL', () => {
240250
if (url.pathname === '/app/installations/42/access_tokens') {
241251
expect(request.method).toBe('POST')
242252
const body = await request.json()
243-
expect(body).toEqual({
253+
expect(body).toMatchObject({
244254
permissions: { contents: 'read', metadata: 'read' },
245-
repository_ids: [9001],
246255
})
256+
expect(body.repository_ids).toHaveLength(1)
257+
const repositoryId = body.repository_ids[0]
258+
expect(
259+
[...repositories.values()].some(
260+
(repository) => repository.id === repositoryId && repository.installed
261+
)
262+
).toBe(true)
247263
return Response.json({
248-
token: 'ghs_fixture_installation',
264+
token: `ghs_fixture_installation_${repositoryId}`,
249265
expires_at: new Date(Date.now() + 60 * 60_000).toISOString(),
250266
permissions: body.permissions,
251-
repositories: [{ id: 9001 }],
267+
repositories: [{ id: repositoryId }],
252268
})
253269
}
254270
expect(request.method).toBe('GET')
255-
if (
256-
url.pathname === '/repos/fixture/shared/installation' &&
257-
!repositories.get('shared')?.installed
258-
)
271+
const repositoryInstallation = url.pathname.match(/^\/repos\/fixture\/([^/]+)\/installation$/)
272+
if (repositoryInstallation && !repositories.get(repositoryInstallation[1])?.installed)
259273
return Response.json({ message: 'Not Found' }, { status: 404 })
260-
expect(['/app/installations/42', '/repos/fixture/shared/installation']).toContain(
261-
url.pathname
262-
)
274+
expect(url.pathname === '/app/installations/42' || Boolean(repositoryInstallation)).toBe(true)
263275
return Response.json(installation())
264276
}
265277
if (request.method !== 'GET') throw new Error(`Unexpected GitHub method: ${request.method}`)
266-
const installationToken = bearer === 'ghs_fixture_installation'
278+
const installationRepository = bearer.match(/^ghs_fixture_installation_(\d+)$/)?.[1]
279+
const installationToken = Boolean(installationRepository)
267280
const member = enrolled.members.find((candidate) =>
268281
[tokenFor(candidate.userId), `${tokenFor(candidate.userId)}_refreshed`].some(
269282
(token) => request.headers.get('authorization') === `Bearer ${token}`
@@ -295,6 +308,7 @@ describe('fixture-backed GitHub member search in PostgreSQL', () => {
295308
if (!match) throw new Error(`Unexpected GitHub endpoint: ${url.pathname}`)
296309
const source = repositories.get(match[1])
297310
if (!source) throw new Error('Unexpected GitHub repository')
311+
if (installationToken) expect(installationRepository).toBe(String(source.id))
298312
if (source.throttledReaders.has(actingId))
299313
return Response.json(
300314
{ message: 'You have exceeded a secondary rate limit.' },
@@ -317,6 +331,13 @@ describe('fixture-backed GitHub member search in PostgreSQL', () => {
317331
default_branch: source.defaultBranch,
318332
})
319333
if (match[2].startsWith('/git/ref/heads/')) {
334+
referenceObserved?.(match[1])
335+
if (source.stallRef)
336+
return new Promise<Response>((_resolve, reject) => {
337+
request.signal.addEventListener('abort', () => reject(request.signal.reason), {
338+
once: true,
339+
})
340+
})
320341
const ref = decodeURIComponent(match[2].slice('/git/ref/heads/'.length))
321342
return ref === source.defaultBranch
322343
? Response.json({ ref: `refs/heads/${ref}`, object: { type: 'commit', sha: shaFor(ref) } })
@@ -399,6 +420,7 @@ describe('fixture-backed GitHub member search in PostgreSQL', () => {
399420
refreshedUsers.clear()
400421
organizationSource = false
401422
installationSuspended = false
423+
referenceObserved = undefined
402424
oauthStateKey = undefined
403425
oauthVerification = undefined
404426
Object.assign(env, {
@@ -585,7 +607,7 @@ describe('fixture-backed GitHub member search in PostgreSQL', () => {
585607
.orderBy(document.externalId)
586608
}
587609

588-
async function search(principal: Principal) {
610+
async function search(principal: Principal, searchMode: 'hybrid' | 'vector' = 'hybrid') {
589611
const result = await searchKnowledge.execute({
590612
principal,
591613
input: {
@@ -594,7 +616,7 @@ describe('fixture-backed GitHub member search in PostgreSQL', () => {
594616
: { workspaceId: ids.workspaceId }),
595617
knowledgeBaseIds: [ids.knowledgeBaseId],
596618
query: 'Orion',
597-
searchMode: 'hybrid',
619+
searchMode,
598620
topK: 20,
599621
},
600622
})
@@ -697,11 +719,79 @@ describe('fixture-backed GitHub member search in PostgreSQL', () => {
697719
actorUserId: ids.aliceId,
698720
organizationId: ids.organizationId,
699721
})
722+
const unrelatedSources = Array.from({ length: 105 }, () => generateId())
723+
await db.insert(knowledgeConnector).values(
724+
unrelatedSources.map((id) => ({
725+
id,
726+
knowledgeBaseId: ids.knowledgeBaseId,
727+
connectorType: 'github',
728+
accessMode: 'members',
729+
credentialId: installationCredentialId,
730+
credentialGroupId: enrolled.groupId,
731+
credentialGroupOptionId: enrolled.optionId,
732+
sourceConfig: { repository: 'fixture/shared', githubRepositoryId: '9001' },
733+
}))
734+
)
735+
await db.insert(knowledgeConnectorMember).values(
736+
unrelatedSources.map((connectorId) => ({
737+
id: generateId(),
738+
organizationId: ids.organizationId,
739+
connectorId,
740+
credentialId: enrolled.members[0].credentialId,
741+
subjectToken: enrolled.members[0].subjectToken,
742+
}))
743+
)
700744
const result = await sync()
701745
expect(result.error).toBeUndefined()
702746
expect(result.docsHydratedOnce).toBe(1)
703747
const [indexed] = await rows()
704748
expect(indexed).toBeDefined()
749+
const provider = (userId: string) =>
750+
createKnowledgeAccessProvider(actor(userId), {
751+
organizationId: ids.organizationId,
752+
knowledgeBaseIds: [ids.knowledgeBaseId],
753+
})
754+
const page = (userId: string, offset = 0) =>
755+
getDocuments(
756+
ids.knowledgeBaseId,
757+
{ limit: 1, offset, sortBy: 'filename', sortOrder: 'asc' },
758+
'github-candidate-regression',
759+
provider(userId)
760+
)
761+
expect(await page(ids.aliceId)).toMatchObject({
762+
documents: [{ id: indexed.id }],
763+
pagination: { total: 1 },
764+
})
765+
expect(
766+
(
767+
await readSearchSourceOverview.execute({
768+
principal: actor(ids.aliceId),
769+
input: { organizationId: ids.organizationId },
770+
})
771+
).hasSearchableDocuments
772+
).toBe(true)
773+
await db.update(document).set({ tag1: 'fixture' }).where(eq(document.id, indexed.id))
774+
await db.update(embedding).set({ tag1: 'fixture' }).where(eq(embedding.documentId, indexed.id))
775+
expect(
776+
await getTagUsageStats(ids.knowledgeBaseId, provider(ids.aliceId), 'github-tag-regression')
777+
).toEqual(
778+
expect.arrayContaining([
779+
expect.objectContaining({ tagSlot: 'tag1', documentCount: 1, chunkCount: 1 }),
780+
])
781+
)
782+
expect(
783+
(
784+
await readIndexedKnowledgeDocument.execute({
785+
principal: actor(ids.aliceId),
786+
input: {
787+
organizationId: ids.organizationId,
788+
target: { kind: 'url', url: indexed.sourceUrl! },
789+
limit: 1,
790+
resultSecretRegistry: new ResolvedSecretTraceRegistry(),
791+
},
792+
})
793+
).documentId
794+
).toBe(indexed.id)
705795
expect(
706796
requests.filter((entry) => entry.path.includes('/git/blobs/')).map((entry) => entry.userId)
707797
).toEqual(['installation'])
@@ -710,6 +800,7 @@ describe('fixture-backed GitHub member search in PostgreSQL', () => {
710800
await assertAccess(actor(ids.bobId), indexed, true)
711801
const source = repositories.get('shared')!
712802
source.readers.delete(ids.bobId)
803+
expect(await page(ids.bobId)).toMatchObject({ documents: [], pagination: { total: 0 } })
713804
expect(await search(actor(ids.bobId))).toEqual([])
714805
await assertAccess(actor(ids.bobId), indexed, false)
715806
expect(await search(actor(ids.aliceId))).toEqual([indexed.id])
@@ -729,6 +820,77 @@ describe('fixture-backed GitHub member search in PostgreSQL', () => {
729820
expect(await search(actor(ids.aliceId))).toEqual([])
730821
installationSuspended = false
731822
expect(await search(actor(ids.aliceId))).toEqual([indexed.id])
823+
const slowRepository = repository('slow')
824+
const slowSourceId = generateId()
825+
await db.insert(knowledgeConnector).values({
826+
id: slowSourceId,
827+
knowledgeBaseId: ids.knowledgeBaseId,
828+
connectorType: 'github',
829+
accessMode: 'members',
830+
credentialId: installationCredentialId,
831+
credentialGroupId: enrolled.groupId,
832+
credentialGroupOptionId: enrolled.optionId,
833+
sourceConfig: { repository: 'fixture/slow', githubRepositoryId: String(slowRepository.id) },
834+
})
835+
expect((await sync(slowSourceId)).error).toBeUndefined()
836+
const [slowDocument] = await rows(slowSourceId)
837+
expect(slowDocument).toBeDefined()
838+
const deniedRepository = repository('denied-paging', [ids.aliceId])
839+
const deniedSourceId = generateId()
840+
await db.insert(knowledgeConnector).values({
841+
id: deniedSourceId,
842+
knowledgeBaseId: ids.knowledgeBaseId,
843+
connectorType: 'github',
844+
accessMode: 'members',
845+
credentialId: installationCredentialId,
846+
credentialGroupId: enrolled.groupId,
847+
credentialGroupOptionId: enrolled.optionId,
848+
sourceConfig: {
849+
repository: 'fixture/denied-paging',
850+
githubRepositoryId: String(deniedRepository.id),
851+
},
852+
})
853+
expect((await sync(deniedSourceId)).error).toBeUndefined()
854+
const [deniedDocument] = await rows(deniedSourceId)
855+
deniedRepository.readers.delete(ids.aliceId)
856+
for (const [id, filename] of [
857+
[indexed.id, 'alpha'],
858+
[deniedDocument.id, 'beta'],
859+
[slowDocument.id, 'gamma'],
860+
])
861+
await db.update(document).set({ filename }).where(eq(document.id, id))
862+
expect(await page(ids.aliceId, 1)).toMatchObject({
863+
documents: [{ id: slowDocument.id }],
864+
pagination: { total: 2, offset: 1 },
865+
})
866+
slowRepository.stallRef = true
867+
const sourceTimers: AbortController[] = []
868+
const nativeTimeout = AbortSignal.timeout.bind(AbortSignal)
869+
const timerSpy = vi.spyOn(AbortSignal, 'timeout').mockImplementation((duration) => {
870+
if (duration !== GITHUB_READ_SOURCE_TIMEOUT_MS) return nativeTimeout(duration)
871+
const controller = new AbortController()
872+
sourceTimers.push(controller)
873+
return controller.signal
874+
})
875+
try {
876+
const observed = new Set<string>()
877+
const candidatesStarted = new Promise<void>((resolve) => {
878+
referenceObserved = (name) => {
879+
observed.add(name)
880+
if (observed.has('shared') && observed.has('slow')) resolve()
881+
}
882+
})
883+
const pending = search(actor(ids.aliceId), 'vector')
884+
await candidatesStarted
885+
/** Complete the fast response's microtasks before expiring the stalled candidate. */
886+
for (let turn = 0; turn < 20; turn++) await Promise.resolve()
887+
for (const timer of sourceTimers) timer.abort(new Error('fixture source timeout'))
888+
expect(await pending).toEqual([indexed.id])
889+
} finally {
890+
timerSpy.mockRestore()
891+
referenceObserved = undefined
892+
slowRepository.stallRef = false
893+
}
732894
await db
733895
.delete(member)
734896
.where(and(eq(member.organizationId, ids.organizationId), eq(member.userId, ids.bobId)))

0 commit comments

Comments
 (0)