Visitar URL original
improvement(auth): unify human delegation with entity owner grants by mzxchandra · Pull Request #9157 · simstudioai/sim · GitHub
Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
test(auth): cover minted delegation through workspace and knowledge u…
…se 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>
  • Loading branch information
mzxchandra and cursoragent committed Oct 18, 2026
commit 0374a900dd22c0b0a1470ed89d4e4eb3a6a4e9f4
118 changes: 113 additions & 5 deletions apps/sim/lib/knowledge/__integration__/gitlab-workspace.integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
} from '@sim/db/schema'
import { deleteWorkspaceFixture } from '@sim/db/testing/workspace-fixtures'
import { generateId } from '@sim/utils/id'
import { compareStrings } from '@sim/utils/string'
import { and, eq, inArray, isNull } from 'drizzle-orm'
import { afterAll, beforeAll, expect, it, vi } from 'vitest'

Expand All @@ -42,7 +43,10 @@ vi.mock('@/lib/embeddings', async () => ({
}),
}))

import { hasResourceOperationGrant } from '@sim/auth/principal'
import { copilotRequestPrincipal } from '@/lib/api/server/routes/copilot-request'
import { decryptApiKey } from '@/lib/api-key/crypto'
import { withWorkspaceInvocationScope } from '@/lib/core/application/workspace-invocation-scope'
import { encryptSecret } from '@/lib/core/security/encryption'
import {
createKnowledgeAclFixtureIds,
Expand All @@ -54,6 +58,10 @@ import {
} from '@/lib/knowledge/application/connectors'
import { readKnowledgeDocument } from '@/lib/knowledge/application/documents'
import { searchKnowledge } from '@/lib/knowledge/application/search'
import { executeCopilotKnowledgeUseCase } from '@/lib/mothership/application/execute-knowledge-use-case'
import { markCopilotProjectFileRequest } from '@/lib/mothership/application/execute-project-file-use-case'
import { createCopilotResourceAdmission } from '@/lib/mothership/auth/application-delegation'
import { getProjectFileMetadata, projectFileOperations } from '@/lib/projects/files/application'

const ids = createKnowledgeAclFixtureIds()
const sourceConfig = {
Expand All @@ -71,6 +79,34 @@ const input = {
syncIntervalMinutes: 1440,
}

function admittedProjectFileGrant(userId: string) {
const chatId = generateId()
const request = new Request('http://localhost:3000/api/v2/projects/project/files/file/metadata')
markCopilotProjectFileRequest(
request,
{
userId,
chatId,
toolCallId: generateId(),
copilotToolExecution: true,
copilotResourceAdmission: createCopilotResourceAdmission({
userId,
invocation: { kind: 'chat', chatId },
}),
},
{ projectId: generateId(), fileId: generateId() }
)
const principal = copilotRequestPrincipal(
request,
projectFileOperations.readMetadata,
getProjectFileMetadata
)
if (!principal || !hasResourceOperationGrant(principal)) {
throw new Error('Project file request did not mint a resource grant')
}
return principal
}

beforeAll(async () => {
fixture.root = mkdtempSync(path.join(tmpdir(), 'sim-gitlab-workspace-'))
await seedKnowledgeAclFixture(ids)
Expand Down Expand Up @@ -128,6 +164,7 @@ it.each([
])(
'creates, syncs, edits, and searches a workspace GitLab source through $surface using $apiKey',
async ({ surface, apiKey }) => {
const chatId = generateId()
const caller =
surface === 'application'
? principal
Expand All @@ -139,12 +176,12 @@ it.each([
audience: 'sim:knowledge',
issuedAt: new Date(),
expiresAt: new Date(Date.now() + 60000),
invocation: { kind: 'chat', chatId: generateId() } as const,
invocation: { kind: 'chat' as const, chatId },
grant: {
kind: 'entity',
owner: { entityType: 'workspace', entityId: ids.workspaceId },
restrictions: { chatId: generateId() },
} as const,
kind: 'entity' as const,
owner: { entityType: 'workspace' as const, entityId: ids.workspaceId },
restrictions: { chatId },
},
}
const { connector } = await createKnowledgeConnector.execute({
principal: caller,
Expand Down Expand Up @@ -265,3 +302,74 @@ it('still refuses workspace access on a Search index before contacting GitLab',
).rejects.toThrow('Search sources must support per-person access or source permissions')
expect(fixture.fetch.mock.calls).toHaveLength(requestCount)
})

it('creates a GitLab connector for a writer through the minted Copilot knowledge principal', async () => {
const chatId = generateId()
const { connector } = await withWorkspaceInvocationScope(
{ workspaceId: ids.workspaceId, organizationId: ids.organizationId },
() =>
executeCopilotKnowledgeUseCase(
{
userId: ids.aliceId,
workspaceId: ids.workspaceId,
chatId,
toolCallId: generateId(),
copilotToolExecution: true,
},
createKnowledgeConnector,
input
)
)

expect(JSON.stringify(connector)).not.toContain(input.apiKey)
const [stored] = await db
.select()
.from(knowledgeConnector)
.where(eq(knowledgeConnector.id, connector.id))
if (!stored) throw new Error('Connector was not stored')
const encryptedApiKey = stored.encryptedApiKey
if (!encryptedApiKey) throw new Error('Connector was stored without an API key')
expect(stored.accessMode).toBe('workspace')
expect(encryptedApiKey).not.toBe(input.apiKey)
expect((await decryptApiKey(encryptedApiKey)).decrypted).toBe(input.apiKey)
await expect
.poll(
async () => {
const [row] = await db
.select({ status: knowledgeConnector.status, error: knowledgeConnector.lastSyncError })
.from(knowledgeConnector)
.where(eq(knowledgeConnector.id, connector.id))
return row
},
{ timeout: 15000 }
)
.toEqual({ status: 'active', error: null })
}, 30000)

it('refuses knowledge connector create for a project file grant', async () => {
const connectorIds = async () => {
const rows = await db
.select({ id: knowledgeConnector.id })
.from(knowledgeConnector)
.where(eq(knowledgeConnector.knowledgeBaseId, ids.knowledgeBaseId))
return rows.map((row) => row.id).sort(compareStrings)
}
const before = await connectorIds()
const requestCount = fixture.fetch.mock.calls.length

await expect(
withWorkspaceInvocationScope(
{ workspaceId: ids.workspaceId, organizationId: ids.organizationId },
() =>
createKnowledgeConnector.execute({
principal: admittedProjectFileGrant(ids.aliceId),
input,
})
)
).rejects.toMatchObject({
detailCode: 'PRINCIPAL_KIND_NOT_PERMITTED',
message: 'Principal kind delegated cannot perform operation knowledge.connectors.create',
})
expect(fixture.fetch.mock.calls).toHaveLength(requestCount)
expect(await connectorIds()).toEqual(before)
})
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { mkdtempSync } from 'node:fs'
import { rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { hasResourceOperationGrant, parsePrincipal, serializePrincipal } from '@sim/auth/principal'
import { db } from '@sim/db'
import {
auditLog,
Expand Down Expand Up @@ -30,6 +31,7 @@ vi.mock('@/lib/uploads/core/setup.server', () => ({
},
}))

import { copilotRequestPrincipal } from '@/lib/api/server/routes/copilot-request'
import { ContentDeliveryUnobservedError } from '@/lib/core/application'
import { withWorkspaceInvocationScope } from '@/lib/core/application/workspace-invocation-scope'
import { encryptSecret } from '@/lib/core/security/encryption'
Expand All @@ -41,7 +43,14 @@ import { runCli } from '@/lib/mothership/agent-cli/run-cli'
import { createScopedCliTransport } from '@/lib/mothership/agent-cli/scoped-transport'
import { createAgentCliTransport } from '@/lib/mothership/agent-cli/transport'
import { createWorkbenchFileProvenance } from '@/lib/mothership/agent-cli/workbench-file-provenance'
import { createCopilotChatPrincipal } from '@/lib/mothership/auth/application-delegation'
import { markCopilotProjectFileRequest } from '@/lib/mothership/application/execute-project-file-use-case'
import {
COPILOT_APPLICATION_DELEGATION_TTL_MS,
createCopilotChatPrincipal,
createCopilotResourceAdmission,
createTrustedCopilotPrincipal,
} from '@/lib/mothership/auth/application-delegation'
import { getProjectFileMetadata, projectFileOperations } from '@/lib/projects/files/application'
import {
updateWorkspaceFileContent,
uploadWorkspaceFile,
Expand All @@ -52,6 +61,7 @@ import {
} from '@/lib/uploads/contexts/workspace/file-secret-provenance'
import { WORKSPACE_FILES_DELEGATION_AUDIENCE } from '@/lib/workspace-files/application/authorization'
import { downloadWorkspaceFileStream } from '@/lib/workspace-files/application/download-workspace-file'
import { revertWorkspaceFileVersion } from '@/lib/workspace-files/application/file-versions'
import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection'
import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
import '@/app/api/v2/files/[fileId]/text/route'
Expand Down Expand Up @@ -182,6 +192,34 @@ describe('chat-delegated file version history', () => {
return new ResolvedSecretTraceRegistry([], { userId, workspaceId: fixture.workspaceId })
}

function admittedProjectFileGrant(userId: string) {
const chatId = generateId()
const request = new Request('http://localhost:3000/api/v2/projects/project/files/file/metadata')
markCopilotProjectFileRequest(
request,
{
userId,
chatId,
toolCallId: generateId(),
copilotToolExecution: true,
copilotResourceAdmission: createCopilotResourceAdmission({
userId,
invocation: { kind: 'chat', chatId },
}),
},
{ projectId: generateId(), fileId: generateId() }
)
const principal = copilotRequestPrincipal(
request,
projectFileOperations.readMetadata,
getProjectFileMetadata
)
if (!principal || !hasResourceOperationGrant(principal)) {
throw new Error('Project file request did not mint a resource grant')
}
return principal
}

function versionRows(fileId: string) {
return db
.select({ version: workspaceFileVersion.version, source: workspaceFileVersion.source })
Expand Down Expand Up @@ -285,6 +323,88 @@ describe('chat-delegated file version history', () => {
expect((await versionRows(fixture.fileId)).map((row) => row.version)).toEqual([1, 2, 3])
})

it('admits a file revert after the minted Copilot principal is serialized and restored', async () => {
const fixture = await seedVersionedFile()
const chatId = generateId()
const restored = parsePrincipal(
serializePrincipal(
createTrustedCopilotPrincipal(
{
userId: fixture.aliceId,
workspaceId: fixture.workspaceId,
chatId,
delegationId: `copilot-chat:${chatId}`,
},
{
audience: WORKSPACE_FILES_DELEGATION_AUDIENCE,
ttlMs: COPILOT_APPLICATION_DELEGATION_TTL_MS,
}
)
)
)

const result = await withWorkspaceInvocationScope(
{ workspaceId: fixture.workspaceId, organizationId: fixture.organizationId },
() =>
revertWorkspaceFileVersion.execute({
principal: restored,
input: {
fileId: fixture.fileId,
assertedWorkspaceId: fixture.workspaceId,
version: 1,
expectedCurrentVersion: 3,
},
})
)

expect(result).toMatchObject({
reverted: true,
version: { version: 4, source: 'revert', restoredFromVersion: 1 },
})
expect((await versionRows(fixture.fileId)).map((row) => row.version)).toEqual([1, 2, 3, 4])
await vi.waitFor(async () => {
const [audit] = await db
.select({ actorId: auditLog.actorId, metadata: auditLog.metadata })
.from(auditLog)
.where(
and(
eq(auditLog.workspaceId, fixture.workspaceId),
eq(auditLog.resourceId, fixture.fileId)
)
)
expect(audit).toMatchObject({
actorId: fixture.aliceId,
metadata: {
operation: 'files.versions.revert',
actor: { kind: 'delegated', serviceId: 'copilot' },
},
})
})
})

it('refuses a workspace file revert for a project file grant', async () => {
const fixture = await seedVersionedFile()

await expect(
withWorkspaceInvocationScope(
{ workspaceId: fixture.workspaceId, organizationId: fixture.organizationId },
() =>
revertWorkspaceFileVersion.execute({
principal: admittedProjectFileGrant(fixture.aliceId),
input: {
fileId: fixture.fileId,
assertedWorkspaceId: fixture.workspaceId,
version: 1,
},
})
)
).rejects.toMatchObject({
detailCode: 'PRINCIPAL_KIND_NOT_PERMITTED',
message: 'Principal kind delegated cannot perform operation files.versions.revert',
})
expect((await versionRows(fixture.fileId)).map((row) => row.version)).toEqual([1, 2, 3])
})

it('keeps version delete and download direct-only', async () => {
const fixture = await seedVersionedFile()
const chat = chatTransport(fixture, fixture.aliceId)
Expand Down
Loading