Skip to content

Commit 0374a90

Browse files
test(auth): cover minted delegation through workspace and knowledge use cases
Workspace admission requires the same chat id on the invocation and the grant, so the GitLab fixture and the new cases use the principals Copilot actually mints. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 3d21a22 commit 0374a90

2 files changed

Lines changed: 234 additions & 6 deletions

File tree

‎apps/sim/lib/knowledge/__integration__/gitlab-workspace.integration.ts‎

Lines changed: 113 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
} from '@sim/db/schema'
1818
import { deleteWorkspaceFixture } from '@sim/db/testing/workspace-fixtures'
1919
import { generateId } from '@sim/utils/id'
20+
import { compareStrings } from '@sim/utils/string'
2021
import { and, eq, inArray, isNull } from 'drizzle-orm'
2122
import { 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'
4548
import { decryptApiKey } from '@/lib/api-key/crypto'
49+
import { withWorkspaceInvocationScope } from '@/lib/core/application/workspace-invocation-scope'
4650
import { encryptSecret } from '@/lib/core/security/encryption'
4751
import {
4852
createKnowledgeAclFixtureIds,
@@ -54,6 +58,10 @@ import {
5458
} from '@/lib/knowledge/application/connectors'
5559
import { readKnowledgeDocument } from '@/lib/knowledge/application/documents'
5660
import { 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

5866
const ids = createKnowledgeAclFixtureIds()
5967
const 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+
74110
beforeAll(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+
})

‎apps/sim/lib/workspace-files/__integration__/copilot-file-versions.integration.ts‎

Lines changed: 121 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { mkdtempSync } from 'node:fs'
33
import { rm } from 'node:fs/promises'
44
import { tmpdir } from 'node:os'
55
import path from 'node:path'
6+
import { hasResourceOperationGrant, parsePrincipal, serializePrincipal } from '@sim/auth/principal'
67
import { db } from '@sim/db'
78
import {
89
auditLog,
@@ -30,6 +31,7 @@ vi.mock('@/lib/uploads/core/setup.server', () => ({
3031
},
3132
}))
3233

34+
import { copilotRequestPrincipal } from '@/lib/api/server/routes/copilot-request'
3335
import { ContentDeliveryUnobservedError } from '@/lib/core/application'
3436
import { withWorkspaceInvocationScope } from '@/lib/core/application/workspace-invocation-scope'
3537
import { encryptSecret } from '@/lib/core/security/encryption'
@@ -41,7 +43,14 @@ import { runCli } from '@/lib/mothership/agent-cli/run-cli'
4143
import { createScopedCliTransport } from '@/lib/mothership/agent-cli/scoped-transport'
4244
import { createAgentCliTransport } from '@/lib/mothership/agent-cli/transport'
4345
import { createWorkbenchFileProvenance } from '@/lib/mothership/agent-cli/workbench-file-provenance'
44-
import { createCopilotChatPrincipal } from '@/lib/mothership/auth/application-delegation'
46+
import { markCopilotProjectFileRequest } from '@/lib/mothership/application/execute-project-file-use-case'
47+
import {
48+
COPILOT_APPLICATION_DELEGATION_TTL_MS,
49+
createCopilotChatPrincipal,
50+
createCopilotResourceAdmission,
51+
createTrustedCopilotPrincipal,
52+
} from '@/lib/mothership/auth/application-delegation'
53+
import { getProjectFileMetadata, projectFileOperations } from '@/lib/projects/files/application'
4554
import {
4655
updateWorkspaceFileContent,
4756
uploadWorkspaceFile,
@@ -52,6 +61,7 @@ import {
5261
} from '@/lib/uploads/contexts/workspace/file-secret-provenance'
5362
import { WORKSPACE_FILES_DELEGATION_AUDIENCE } from '@/lib/workspace-files/application/authorization'
5463
import { downloadWorkspaceFileStream } from '@/lib/workspace-files/application/download-workspace-file'
64+
import { revertWorkspaceFileVersion } from '@/lib/workspace-files/application/file-versions'
5565
import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection'
5666
import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
5767
import '@/app/api/v2/files/[fileId]/text/route'
@@ -182,6 +192,34 @@ describe('chat-delegated file version history', () => {
182192
return new ResolvedSecretTraceRegistry([], { userId, workspaceId: fixture.workspaceId })
183193
}
184194

195+
function admittedProjectFileGrant(userId: string) {
196+
const chatId = generateId()
197+
const request = new Request('http://localhost:3000/api/v2/projects/project/files/file/metadata')
198+
markCopilotProjectFileRequest(
199+
request,
200+
{
201+
userId,
202+
chatId,
203+
toolCallId: generateId(),
204+
copilotToolExecution: true,
205+
copilotResourceAdmission: createCopilotResourceAdmission({
206+
userId,
207+
invocation: { kind: 'chat', chatId },
208+
}),
209+
},
210+
{ projectId: generateId(), fileId: generateId() }
211+
)
212+
const principal = copilotRequestPrincipal(
213+
request,
214+
projectFileOperations.readMetadata,
215+
getProjectFileMetadata
216+
)
217+
if (!principal || !hasResourceOperationGrant(principal)) {
218+
throw new Error('Project file request did not mint a resource grant')
219+
}
220+
return principal
221+
}
222+
185223
function versionRows(fileId: string) {
186224
return db
187225
.select({ version: workspaceFileVersion.version, source: workspaceFileVersion.source })
@@ -285,6 +323,88 @@ describe('chat-delegated file version history', () => {
285323
expect((await versionRows(fixture.fileId)).map((row) => row.version)).toEqual([1, 2, 3])
286324
})
287325

326+
it('admits a file revert after the minted Copilot principal is serialized and restored', async () => {
327+
const fixture = await seedVersionedFile()
328+
const chatId = generateId()
329+
const restored = parsePrincipal(
330+
serializePrincipal(
331+
createTrustedCopilotPrincipal(
332+
{
333+
userId: fixture.aliceId,
334+
workspaceId: fixture.workspaceId,
335+
chatId,
336+
delegationId: `copilot-chat:${chatId}`,
337+
},
338+
{
339+
audience: WORKSPACE_FILES_DELEGATION_AUDIENCE,
340+
ttlMs: COPILOT_APPLICATION_DELEGATION_TTL_MS,
341+
}
342+
)
343+
)
344+
)
345+
346+
const result = await withWorkspaceInvocationScope(
347+
{ workspaceId: fixture.workspaceId, organizationId: fixture.organizationId },
348+
() =>
349+
revertWorkspaceFileVersion.execute({
350+
principal: restored,
351+
input: {
352+
fileId: fixture.fileId,
353+
assertedWorkspaceId: fixture.workspaceId,
354+
version: 1,
355+
expectedCurrentVersion: 3,
356+
},
357+
})
358+
)
359+
360+
expect(result).toMatchObject({
361+
reverted: true,
362+
version: { version: 4, source: 'revert', restoredFromVersion: 1 },
363+
})
364+
expect((await versionRows(fixture.fileId)).map((row) => row.version)).toEqual([1, 2, 3, 4])
365+
await vi.waitFor(async () => {
366+
const [audit] = await db
367+
.select({ actorId: auditLog.actorId, metadata: auditLog.metadata })
368+
.from(auditLog)
369+
.where(
370+
and(
371+
eq(auditLog.workspaceId, fixture.workspaceId),
372+
eq(auditLog.resourceId, fixture.fileId)
373+
)
374+
)
375+
expect(audit).toMatchObject({
376+
actorId: fixture.aliceId,
377+
metadata: {
378+
operation: 'files.versions.revert',
379+
actor: { kind: 'delegated', serviceId: 'copilot' },
380+
},
381+
})
382+
})
383+
})
384+
385+
it('refuses a workspace file revert for a project file grant', async () => {
386+
const fixture = await seedVersionedFile()
387+
388+
await expect(
389+
withWorkspaceInvocationScope(
390+
{ workspaceId: fixture.workspaceId, organizationId: fixture.organizationId },
391+
() =>
392+
revertWorkspaceFileVersion.execute({
393+
principal: admittedProjectFileGrant(fixture.aliceId),
394+
input: {
395+
fileId: fixture.fileId,
396+
assertedWorkspaceId: fixture.workspaceId,
397+
version: 1,
398+
},
399+
})
400+
)
401+
).rejects.toMatchObject({
402+
detailCode: 'PRINCIPAL_KIND_NOT_PERMITTED',
403+
message: 'Principal kind delegated cannot perform operation files.versions.revert',
404+
})
405+
expect((await versionRows(fixture.fileId)).map((row) => row.version)).toEqual([1, 2, 3])
406+
})
407+
288408
it('keeps version delete and download direct-only', async () => {
289409
const fixture = await seedVersionedFile()
290410
const chat = chatTransport(fixture, fixture.aliceId)

0 commit comments

Comments
 (0)