@@ -17,6 +17,7 @@ import {
1717} from '@sim/db/schema'
1818import { deleteWorkspaceFixture } from '@sim/db/testing/workspace-fixtures'
1919import { generateId } from '@sim/utils/id'
20+ import { compareStrings } from '@sim/utils/string'
2021import { and , eq , inArray , isNull } from 'drizzle-orm'
2122import { afterAll , beforeAll , expect , it , vi } from 'vitest'
2223
@@ -42,7 +43,10 @@ vi.mock('@/lib/embeddings', async () => ({
4243 } ) ,
4344} ) )
4445
46+ import { hasResourceOperationGrant } from '@sim/auth/principal'
47+ import { copilotRequestPrincipal } from '@/lib/api/server/routes/copilot-request'
4548import { decryptApiKey } from '@/lib/api-key/crypto'
49+ import { withWorkspaceInvocationScope } from '@/lib/core/application/workspace-invocation-scope'
4650import { encryptSecret } from '@/lib/core/security/encryption'
4751import {
4852 createKnowledgeAclFixtureIds ,
@@ -54,6 +58,10 @@ import {
5458} from '@/lib/knowledge/application/connectors'
5559import { readKnowledgeDocument } from '@/lib/knowledge/application/documents'
5660import { searchKnowledge } from '@/lib/knowledge/application/search'
61+ import { executeCopilotKnowledgeUseCase } from '@/lib/mothership/application/execute-knowledge-use-case'
62+ import { markCopilotProjectFileRequest } from '@/lib/mothership/application/execute-project-file-use-case'
63+ import { createCopilotResourceAdmission } from '@/lib/mothership/auth/application-delegation'
64+ import { getProjectFileMetadata , projectFileOperations } from '@/lib/projects/files/application'
5765
5866const ids = createKnowledgeAclFixtureIds ( )
5967const sourceConfig = {
@@ -71,6 +79,34 @@ const input = {
7179 syncIntervalMinutes : 1440 ,
7280}
7381
82+ function admittedProjectFileGrant ( userId : string ) {
83+ const chatId = generateId ( )
84+ const request = new Request ( 'http://localhost:3000/api/v2/projects/project/files/file/metadata' )
85+ markCopilotProjectFileRequest (
86+ request ,
87+ {
88+ userId,
89+ chatId,
90+ toolCallId : generateId ( ) ,
91+ copilotToolExecution : true ,
92+ copilotResourceAdmission : createCopilotResourceAdmission ( {
93+ userId,
94+ invocation : { kind : 'chat' , chatId } ,
95+ } ) ,
96+ } ,
97+ { projectId : generateId ( ) , fileId : generateId ( ) }
98+ )
99+ const principal = copilotRequestPrincipal (
100+ request ,
101+ projectFileOperations . readMetadata ,
102+ getProjectFileMetadata
103+ )
104+ if ( ! principal || ! hasResourceOperationGrant ( principal ) ) {
105+ throw new Error ( 'Project file request did not mint a resource grant' )
106+ }
107+ return principal
108+ }
109+
74110beforeAll ( async ( ) => {
75111 fixture . root = mkdtempSync ( path . join ( tmpdir ( ) , 'sim-gitlab-workspace-' ) )
76112 await seedKnowledgeAclFixture ( ids )
@@ -128,6 +164,7 @@ it.each([
128164] ) (
129165 'creates, syncs, edits, and searches a workspace GitLab source through $surface using $apiKey' ,
130166 async ( { surface, apiKey } ) => {
167+ const chatId = generateId ( )
131168 const caller =
132169 surface === 'application'
133170 ? principal
@@ -139,12 +176,12 @@ it.each([
139176 audience : 'sim:knowledge' ,
140177 issuedAt : new Date ( ) ,
141178 expiresAt : new Date ( Date . now ( ) + 60000 ) ,
142- invocation : { kind : 'chat' , chatId : generateId ( ) } as const ,
179+ invocation : { kind : 'chat' as const , chatId } ,
143180 grant : {
144- kind : 'entity' ,
145- owner : { entityType : 'workspace' , entityId : ids . workspaceId } ,
146- restrictions : { chatId : generateId ( ) } ,
147- } as const ,
181+ kind : 'entity' as const ,
182+ owner : { entityType : 'workspace' as const , entityId : ids . workspaceId } ,
183+ restrictions : { chatId } ,
184+ } ,
148185 }
149186 const { connector } = await createKnowledgeConnector . execute ( {
150187 principal : caller ,
@@ -265,3 +302,74 @@ it('still refuses workspace access on a Search index before contacting GitLab',
265302 ) . rejects . toThrow ( 'Search sources must support per-person access or source permissions' )
266303 expect ( fixture . fetch . mock . calls ) . toHaveLength ( requestCount )
267304} )
305+
306+ it ( 'creates a GitLab connector for a writer through the minted Copilot knowledge principal' , async ( ) => {
307+ const chatId = generateId ( )
308+ const { connector } = await withWorkspaceInvocationScope (
309+ { workspaceId : ids . workspaceId , organizationId : ids . organizationId } ,
310+ ( ) =>
311+ executeCopilotKnowledgeUseCase (
312+ {
313+ userId : ids . aliceId ,
314+ workspaceId : ids . workspaceId ,
315+ chatId,
316+ toolCallId : generateId ( ) ,
317+ copilotToolExecution : true ,
318+ } ,
319+ createKnowledgeConnector ,
320+ input
321+ )
322+ )
323+
324+ expect ( JSON . stringify ( connector ) ) . not . toContain ( input . apiKey )
325+ const [ stored ] = await db
326+ . select ( )
327+ . from ( knowledgeConnector )
328+ . where ( eq ( knowledgeConnector . id , connector . id ) )
329+ if ( ! stored ) throw new Error ( 'Connector was not stored' )
330+ const encryptedApiKey = stored . encryptedApiKey
331+ if ( ! encryptedApiKey ) throw new Error ( 'Connector was stored without an API key' )
332+ expect ( stored . accessMode ) . toBe ( 'workspace' )
333+ expect ( encryptedApiKey ) . not . toBe ( input . apiKey )
334+ expect ( ( await decryptApiKey ( encryptedApiKey ) ) . decrypted ) . toBe ( input . apiKey )
335+ await expect
336+ . poll (
337+ async ( ) => {
338+ const [ row ] = await db
339+ . select ( { status : knowledgeConnector . status , error : knowledgeConnector . lastSyncError } )
340+ . from ( knowledgeConnector )
341+ . where ( eq ( knowledgeConnector . id , connector . id ) )
342+ return row
343+ } ,
344+ { timeout : 15000 }
345+ )
346+ . toEqual ( { status : 'active' , error : null } )
347+ } , 30000 )
348+
349+ it ( 'refuses knowledge connector create for a project file grant' , async ( ) => {
350+ const connectorIds = async ( ) => {
351+ const rows = await db
352+ . select ( { id : knowledgeConnector . id } )
353+ . from ( knowledgeConnector )
354+ . where ( eq ( knowledgeConnector . knowledgeBaseId , ids . knowledgeBaseId ) )
355+ return rows . map ( ( row ) => row . id ) . sort ( compareStrings )
356+ }
357+ const before = await connectorIds ( )
358+ const requestCount = fixture . fetch . mock . calls . length
359+
360+ await expect (
361+ withWorkspaceInvocationScope (
362+ { workspaceId : ids . workspaceId , organizationId : ids . organizationId } ,
363+ ( ) =>
364+ createKnowledgeConnector . execute ( {
365+ principal : admittedProjectFileGrant ( ids . aliceId ) ,
366+ input,
367+ } )
368+ )
369+ ) . rejects . toMatchObject ( {
370+ detailCode : 'PRINCIPAL_KIND_NOT_PERMITTED' ,
371+ message : 'Principal kind delegated cannot perform operation knowledge.connectors.create' ,
372+ } )
373+ expect ( fixture . fetch . mock . calls ) . toHaveLength ( requestCount )
374+ expect ( await connectorIds ( ) ) . toEqual ( before )
375+ } )
0 commit comments