diff --git a/apps/sim/lib/api/server/routes/copilot-refused-routes.json b/apps/sim/lib/api/server/routes/copilot-refused-routes.json index 60a9555df36..79c4856280f 100644 --- a/apps/sim/lib/api/server/routes/copilot-refused-routes.json +++ b/apps/sim/lib/api/server/routes/copilot-refused-routes.json @@ -19,16 +19,6 @@ "path": "/api/v2/credentials/[credentialId]/members/[userId]", "operation": "credentials.members.remove" }, - { - "method": "GET", - "path": "/api/v2/files/[fileId]/versions", - "operation": "files.versions.list" - }, - { - "method": "GET", - "path": "/api/v2/files/[fileId]/versions/[version]", - "operation": "files.versions.read" - }, { "method": "DELETE", "path": "/api/v2/files/[fileId]/versions/[version]", @@ -39,16 +29,6 @@ "path": "/api/v2/files/[fileId]/versions/[version]/content", "operation": "files.versions.download" }, - { - "method": "POST", - "path": "/api/v2/files/[fileId]/versions/[version]/revert", - "operation": "files.versions.revert" - }, - { - "method": "GET", - "path": "/api/v2/files/[fileId]/versions/[version]/text", - "operation": "files.versions.read_content" - }, { "method": "GET", "path": "/api/v2/meta", @@ -288,10 +268,5 @@ "method": "GET", "path": "/api/v2/workspaces/[workspaceId]/access-requests/discovery", "operation": "access_requests.discover" - }, - { - "method": "GET", - "path": "/api/v2/workspaces/[workspaceId]/permission-config", - "operation": "permission_groups.read_user_config" } ] diff --git a/apps/sim/lib/api/server/routes/copilot-route-inventory.test.ts b/apps/sim/lib/api/server/routes/copilot-route-inventory.test.ts index a8d47e99076..0d02d41f43a 100644 --- a/apps/sim/lib/api/server/routes/copilot-route-inventory.test.ts +++ b/apps/sim/lib/api/server/routes/copilot-route-inventory.test.ts @@ -103,7 +103,10 @@ it('inventories private operation admission without executing use cases', async expect(inventory.length).toBeGreaterThan(200) expect(inventory.find((route) => route.path === '/api/v2/chat')?.operation.id).toBe('chat.send') const audiences = inventory.map((route) => copilotRouteAudience(route.operation, route.useCase)) - /** Public organization and version-history operations require a direct caller. */ + /** + * Operations that require a direct caller. Chat reaches organization administration through + * `settings organization`; version delete and download stay direct-only on purpose. + */ const refused = inventory .filter((_, index) => !audiences[index]) .map(({ method, path, operation }) => ({ method, path, operation: operation.id })) diff --git a/apps/sim/lib/permission-groups/__integration__/copilot-permission-config.integration.ts b/apps/sim/lib/permission-groups/__integration__/copilot-permission-config.integration.ts new file mode 100644 index 00000000000..6c69b851fc9 --- /dev/null +++ b/apps/sim/lib/permission-groups/__integration__/copilot-permission-config.integration.ts @@ -0,0 +1,181 @@ +/** Chat's in-process CLI reading the delegating member's own permission-group restrictions. */ + +import { db } from '@sim/db' +import { + member, + organization, + permissionGroup, + permissionGroupMember, + permissionGroupWorkspace, + permissions, + user, + workspace, +} from '@sim/db/schema' +import { envFlagsMock } from '@sim/testing/mocks/env-flags.mock' +import { generateId } from '@sim/utils/id' +import { eq, inArray } from 'drizzle-orm' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/core/config/env-flags', () => ({ ...envFlagsMock, isAccessControlEnabled: true })) + +import { withWorkspaceInvocationScope } from '@/lib/core/application/workspace-invocation-scope' +import { createScopedCliTransport } from '@/lib/mothership/agent-cli/scoped-transport' +import '@/app/api/v2/workspaces/[workspaceId]/permission-config/route' + +const ORIGIN = 'http://localhost:3000' +const organizationId = generateId() +const workspaceId = generateId() +const otherWorkspaceId = generateId() +const restrictedId = generateId() +const defaultMemberId = generateId() +const outsiderId = generateId() +const restrictedGroupId = generateId() +const defaultGroupId = generateId() + +function readConfigFromChat(userId: string, chatWorkspaceId = workspaceId) { + const transport = createScopedCliTransport(ORIGIN, { + userId, + workspaceId: chatWorkspaceId, + chatId: generateId(), + }) + return withWorkspaceInvocationScope({ workspaceId: chatWorkspaceId, organizationId }, () => + transport(`${ORIGIN}/api/v2/workspaces/${workspaceId}/permission-config`) + ) +} + +describe('chat-delegated permission configuration', () => { + beforeAll(async () => { + const now = new Date() + const userIds = [restrictedId, defaultMemberId, outsiderId] + await db.insert(user).values( + userIds.map((id) => ({ + id, + name: 'Permission config fixture', + email: `${id}@permission-config.test`, + emailVerified: true, + createdAt: now, + updatedAt: now, + })) + ) + await db.insert(organization).values({ + id: organizationId, + name: 'Permission config fixture', + slug: organizationId, + createdAt: now, + updatedAt: now, + }) + await db.insert(member).values( + userIds.map((userId) => ({ + id: generateId(), + userId, + organizationId, + role: 'member', + createdAt: now, + })) + ) + await db.insert(workspace).values( + [workspaceId, otherWorkspaceId].map((id) => ({ + id, + name: 'Permission config fixture', + ownerId: restrictedId, + billedAccountUserId: restrictedId, + organizationId, + workspaceMode: 'organization' as const, + })) + ) + await db.insert(permissions).values([ + ...[restrictedId, defaultMemberId].map((userId) => ({ + id: generateId(), + userId, + entityType: 'workspace', + entityId: workspaceId, + permissionType: 'read' as const, + })), + { + id: generateId(), + userId: outsiderId, + entityType: 'workspace', + entityId: otherWorkspaceId, + permissionType: 'admin' as const, + }, + ]) + await db.insert(permissionGroup).values([ + { + id: defaultGroupId, + organizationId, + name: 'Everyone', + createdBy: restrictedId, + isDefault: true, + config: {}, + }, + { + id: restrictedGroupId, + organizationId, + name: 'Contractors', + createdBy: restrictedId, + config: { disableInvitations: true }, + }, + ]) + await db.insert(permissionGroupWorkspace).values({ + id: generateId(), + permissionGroupId: restrictedGroupId, + workspaceId, + organizationId, + }) + await db.insert(permissionGroupMember).values({ + id: generateId(), + permissionGroupId: restrictedGroupId, + organizationId, + userId: restrictedId, + }) + }) + + afterAll(async () => { + await db.delete(workspace).where(inArray(workspace.id, [workspaceId, otherWorkspaceId])) + await db.delete(organization).where(eq(organization.id, organizationId)) + await db.delete(user).where(inArray(user.id, [restrictedId, defaultMemberId, outsiderId])) + }) + + it("answers with the delegating member's own group", async () => { + const restricted = await readConfigFromChat(restrictedId) + expect(restricted.status).toBe(200) + expect(await restricted.json()).toMatchObject({ + data: { + permissionGroupId: restrictedGroupId, + groupName: 'Contractors', + config: { disableInvitations: true }, + entitled: true, + organizationId, + isOrgAdmin: false, + }, + }) + + const unrestricted = await readConfigFromChat(defaultMemberId) + expect(unrestricted.status).toBe(200) + expect(await unrestricted.json()).toMatchObject({ + data: { + permissionGroupId: defaultGroupId, + groupName: 'Everyone', + config: { disableInvitations: false }, + }, + }) + }) + + it('refuses a chat pinned to another workspace', async () => { + const response = await readConfigFromChat(outsiderId, otherWorkspaceId) + + expect(response.status).toBe(404) + expect(await response.json()).toMatchObject({ + error: { code: 'NOT_FOUND', message: 'Resource not found in the selected workspace' }, + }) + }) + + it('refuses a delegating user who holds no role in the workspace', async () => { + const response = await readConfigFromChat(outsiderId) + + expect(response.status).toBe(404) + expect(await response.json()).toMatchObject({ + error: { code: 'NOT_FOUND', message: 'Workspace not found' }, + }) + }) +}) diff --git a/apps/sim/lib/permission-groups/application/operations.ts b/apps/sim/lib/permission-groups/application/operations.ts index 607e15275e5..869d8309616 100644 --- a/apps/sim/lib/permission-groups/application/operations.ts +++ b/apps/sim/lib/permission-groups/application/operations.ts @@ -62,12 +62,14 @@ export const permissionGroupOperations = { export const permissionGroupWorkspaceOperations = { /** * permission-group-exempt: Members must be able to read their own restrictions. + * Chat delegated by the member reads the same config, so it can say why a tool is withheld. */ readUserConfig: defineWorkspaceOperation({ id: 'permission_groups.read_user_config', minimumRole: 'read', workspaceApiKey: 'deny', - principalKinds: ['session', 'personal_api_key', 'oauth_access_token'], + principalKinds: ['session', 'personal_api_key', 'oauth_access_token', 'delegated'], + delegatedServices: ['copilot'], oauthScope: 'api:read', capability: 'none', }), diff --git a/apps/sim/lib/permission-groups/application/read-user-config.ts b/apps/sim/lib/permission-groups/application/read-user-config.ts index aec3c504132..b1dd779d202 100644 --- a/apps/sim/lib/permission-groups/application/read-user-config.ts +++ b/apps/sim/lib/permission-groups/application/read-user-config.ts @@ -1,3 +1,4 @@ +import { requirePrincipalSubjectUserId } from '@sim/auth/principal' import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application/authorized-workspace-use-case' import { permissionGroupWorkspaceOperations } from '@/lib/permission-groups/application/operations' import { @@ -11,18 +12,19 @@ export const readUserPermissionConfig = defineAuthorizedWorkspaceUseCase({ operation: permissionGroupWorkspaceOperations.readUserConfig, resolveContext: ({ input }: { input: { workspaceId: string } }) => resolveActiveWorkspaceApplicationContext(input.workspaceId), - authorizationOptions: {}, + authorizationOptions: { delegation: { audience: 'sim:settings', isWithinScope: () => true } }, execute: async ({ principal, context }) => { + const userId = requirePrincipalSubjectUserId(principal) const organizationId = context.workspaceOrganizationId const [isOrgAdmin, entitled] = organizationId ? await Promise.all([ - isOrganizationAdminOrOwner(principal.userId, organizationId), + isOrganizationAdminOrOwner(userId, organizationId), isOrganizationPermissionRegimeActive(organizationId), ]) : [false, false] const resolved = organizationId && entitled - ? await resolveWorkspaceGroup(principal.userId, organizationId, context.workspaceId) + ? await resolveWorkspaceGroup(userId, organizationId, context.workspaceId) : null return { diff --git a/apps/sim/lib/workspace-files/__integration__/copilot-file-versions.integration.ts b/apps/sim/lib/workspace-files/__integration__/copilot-file-versions.integration.ts new file mode 100644 index 00000000000..3788c33cc3b --- /dev/null +++ b/apps/sim/lib/workspace-files/__integration__/copilot-file-versions.integration.ts @@ -0,0 +1,246 @@ +/** Chat's in-process CLI reading and reverting workspace file version history as the delegating user. */ +import { mkdtempSync } from 'node:fs' +import { rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { db } from '@sim/db' +import { auditLog, organization, user, workspace, workspaceFileVersion } from '@sim/db/schema' +import { generateId } from '@sim/utils/id' +import { and, asc, eq, inArray } from 'drizzle-orm' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' + +const fixtureStorage = vi.hoisted(() => ({ root: '' })) +vi.mock('@/lib/uploads/core/setup.server', () => ({ + get UPLOAD_DIR_SERVER() { + return fixtureStorage.root + }, +})) + +import { withWorkspaceInvocationScope } from '@/lib/core/application/workspace-invocation-scope' +import { encryptSecret } from '@/lib/core/security/encryption' +import { + createKnowledgeAclFixtureIds, + seedKnowledgeAclFixture, +} from '@/lib/knowledge/__integration__/seed-source-access-fixture' +import { createFileReadTransport } from '@/lib/mothership/agent-cli/file-read-transport' +import { createScopedCliTransport } from '@/lib/mothership/agent-cli/scoped-transport' +import { + updateWorkspaceFileContent, + uploadWorkspaceFile, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' +import '@/app/api/v2/files/[fileId]/versions/route' +import '@/app/api/v2/files/[fileId]/versions/[version]/route' +import '@/app/api/v2/files/[fileId]/versions/[version]/content/route' +import '@/app/api/v2/files/[fileId]/versions/[version]/revert/route' +import '@/app/api/v2/files/[fileId]/versions/[version]/text/route' + +const ORIGIN = 'http://localhost:3000' +const SECRET = 'SYNTHETIC_CHAT_VERSION_SECRET' +const UNAVAILABLE = { + error: { code: 'FORBIDDEN', message: 'This operation is unavailable through Mothership.' }, +} + +describe('chat-delegated file version history', () => { + const fixtures: ReturnType[] = [] + + beforeAll(() => { + fixtureStorage.root = mkdtempSync(path.join(tmpdir(), 'sim-chat-file-versions-')) + }) + + afterAll(async () => { + for (const ids of fixtures) { + await db.delete(auditLog).where(eq(auditLog.workspaceId, ids.workspaceId)) + await db.delete(workspace).where(eq(workspace.id, ids.workspaceId)) + await db.delete(organization).where(eq(organization.id, ids.organizationId)) + await db.delete(user).where(inArray(user.id, [ids.aliceId, ids.bobId])) + } + await rm(fixtureStorage.root, { recursive: true, force: true }) + }) + + /** Version 2 holds a named secret; version 3 replaced it with public text. */ + async function seedVersionedFile() { + const ids = createKnowledgeAclFixtureIds() + fixtures.push(ids) + await seedKnowledgeAclFixture(ids) + const file = await uploadWorkspaceFile( + ids.workspaceId, + ids.aliceId, + Buffer.from('placeholder'), + `notes-${generateId()}.txt`, + 'text/plain', + { notifyWorkspaceChange: false } + ) + const secretProvenance = { + status: 'exact', + entries: [ + { + encryptedValue: (await encryptSecret(SECRET)).encrypted, + sourceUserId: ids.aliceId, + sourceWorkspaceId: ids.workspaceId, + name: 'FILE_TOKEN', + }, + ], + } as const + for (const [content, provenance] of [ + [`token=${SECRET}`, secretProvenance], + ['public replacement', { status: 'exact', entries: [] }], + ] as const) { + await updateWorkspaceFileContent( + ids.workspaceId, + file.id, + ids.aliceId, + Buffer.from(content), + undefined, + { + version: { source: 'api', authorUserId: ids.aliceId }, + secretProvenancePolicy: { mode: 'replace', provenance }, + } + ) + } + return { ...ids, fileId: file.id } + } + + /** Chat's composed CLI transport: the provenance-observing read layer over in-process admission. */ + function chatTransport( + fixture: { workspaceId: string; organizationId: string }, + userId: string, + registry?: ResolvedSecretTraceRegistry + ) { + const invocation = { userId, workspaceId: fixture.workspaceId, chatId: generateId() } + const transport = createFileReadTransport({ + endpoint: ORIGIN, + transport: createScopedCliTransport(ORIGIN, invocation), + userId, + invocation, + ...(registry ? { registry } : {}), + }) + return (url: string, init?: RequestInit) => + withWorkspaceInvocationScope( + { workspaceId: fixture.workspaceId, organizationId: fixture.organizationId }, + () => transport(`${ORIGIN}${url}`, init) + ) + } + + function versionRows(fileId: string) { + return db + .select({ version: workspaceFileVersion.version, source: workspaceFileVersion.source }) + .from(workspaceFileVersion) + .where(eq(workspaceFileVersion.fileId, fileId)) + .orderBy(asc(workspaceFileVersion.version)) + } + + it('lists and describes versions for a read-only member', async () => { + const fixture = await seedVersionedFile() + const chat = chatTransport(fixture, fixture.bobId) + const query = `workspaceId=${fixture.workspaceId}` + + const list = await chat(`/api/v2/files/${fixture.fileId}/versions?${query}`) + expect(list.status).toBe(200) + expect((await list.json()).data.map((entry: { version: number }) => entry.version)).toEqual([ + 3, 2, 1, + ]) + + const detail = await chat(`/api/v2/files/${fixture.fileId}/versions/2?${query}`) + expect(detail.status).toBe(200) + expect(await detail.json()).toMatchObject({ data: { version: 2, isCurrent: false } }) + }) + + it("reads a historical version's text under its snapshot's secret provenance", async () => { + const fixture = await seedVersionedFile() + const url = `/api/v2/files/${fixture.fileId}/versions/2/text?workspaceId=${fixture.workspaceId}` + const registry = new ResolvedSecretTraceRegistry([], { + userId: fixture.bobId, + workspaceId: fixture.workspaceId, + }) + + const response = await chatTransport(fixture, fixture.bobId, registry)(url) + expect(response.status).toBe(200) + const body = await response.text() + expect(body).toContain(SECRET) + const projected = projectResolvedSecretModelContent(body, registry) + if (!projected.safe) throw new Error('Historical version text was withheld') + expect(projected.value).not.toContain(SECRET) + expect(projected.value).toContain('token=[REDACTED_SECRET]') + + const untracked = await chatTransport(fixture, fixture.bobId)(url) + expect(untracked.status).toBe(503) + const refusal = await untracked.text() + expect(refusal).not.toContain(SECRET) + expect(refusal).toContain('provenance is unavailable') + }) + + it('reverts as the delegating writer and audits the Chat actor', async () => { + const fixture = await seedVersionedFile() + + const response = await chatTransport(fixture, fixture.aliceId)( + `/api/v2/files/${fixture.fileId}/versions/1/revert`, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ workspaceId: fixture.workspaceId, expectedCurrentVersion: 3 }), + } + ) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ + data: { reverted: true, version: { version: 4, source: 'revert', restoredFromVersion: 1 } }, + }) + 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 revert from a read-only member', async () => { + const fixture = await seedVersionedFile() + + const response = await chatTransport(fixture, fixture.bobId)( + `/api/v2/files/${fixture.fileId}/versions/1/revert`, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ workspaceId: fixture.workspaceId }), + } + ) + + expect(response.status).toBe(403) + expect(await response.json()).toMatchObject({ + error: { code: 'FORBIDDEN', details: { code: 'INSUFFICIENT_WORKSPACE_ROLE' } }, + }) + 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) + const query = `workspaceId=${fixture.workspaceId}` + + const deletion = await chat(`/api/v2/files/${fixture.fileId}/versions/1?${query}`, { + method: 'DELETE', + }) + expect(deletion.status).toBe(403) + expect(await deletion.json()).toMatchObject(UNAVAILABLE) + + const download = await chat(`/api/v2/files/${fixture.fileId}/versions/2/content?${query}`) + expect(download.status).toBe(403) + expect(await download.json()).toMatchObject(UNAVAILABLE) + + expect((await versionRows(fixture.fileId)).map((row) => row.version)).toEqual([1, 2, 3]) + }) +}) diff --git a/apps/sim/lib/workspace-files/application/operations.ts b/apps/sim/lib/workspace-files/application/operations.ts index b7d0ebd70f6..ba103eb7937 100644 --- a/apps/sim/lib/workspace-files/application/operations.ts +++ b/apps/sim/lib/workspace-files/application/operations.ts @@ -25,8 +25,12 @@ const HUMAN_FILE_TOOL_PRINCIPAL_POLICY = { delegatedServices: ['copilot', 'executor'], } as const /** - * Version history admits direct callers only; delegated version access is a separate decision. + * Chat may list, read and revert versions as the delegating user: a version text read reports the + * snapshot's secret provenance to Chat's delivery observer like a current-file read, and a revert + * writes a new, undoable version. Deleting a version purges history irreversibly and downloading + * one hands out raw bytes, so both admit direct callers only. */ +const VERSION_HISTORY_PRINCIPAL_POLICY = ALL_COPILOT_PRINCIPAL_POLICY const DIRECT_PRINCIPAL_POLICY = { principalKinds: ['session', 'personal_api_key', 'oauth_access_token', 'workspace_api_key'], } as const @@ -162,7 +166,7 @@ export const fileOperations = { minimumRole: 'read', workspaceApiKey: 'allow', capability: 'files.use', - ...DIRECT_PRINCIPAL_POLICY, + ...VERSION_HISTORY_PRINCIPAL_POLICY, }), readVersion: defineWorkspaceOperation({ id: 'files.versions.read', @@ -170,7 +174,7 @@ export const fileOperations = { minimumRole: 'read', workspaceApiKey: 'allow', capability: 'files.use', - ...DIRECT_PRINCIPAL_POLICY, + ...VERSION_HISTORY_PRINCIPAL_POLICY, }), readVersionContent: defineWorkspaceOperation({ id: 'files.versions.read_content', @@ -178,7 +182,7 @@ export const fileOperations = { minimumRole: 'read', workspaceApiKey: 'allow', capability: 'files.use', - ...DIRECT_PRINCIPAL_POLICY, + ...VERSION_HISTORY_PRINCIPAL_POLICY, }), downloadVersion: defineWorkspaceOperation({ id: 'files.versions.download', @@ -194,7 +198,7 @@ export const fileOperations = { minimumRole: 'write', workspaceApiKey: 'allow', capability: 'files.use', - ...DIRECT_PRINCIPAL_POLICY, + ...VERSION_HISTORY_PRINCIPAL_POLICY, }), deleteVersion: defineWorkspaceOperation({ id: 'files.versions.delete',