diff --git a/packages/server/api/src/app/mcp/mcp-access.ts b/packages/server/api/src/app/mcp/mcp-access.ts new file mode 100644 index 000000000000..fc8179fef74d --- /dev/null +++ b/packages/server/api/src/app/mcp/mcp-access.ts @@ -0,0 +1,32 @@ +import { Permission } from '@activepieces/core-utils' +import { ApEdition, PlatformRole, Project, ProjectType } from '@activepieces/shared' +import { FastifyBaseLogger } from 'fastify' +import { projectMemberService } from '../ee/projects/project-members/project-member.service' +import { system } from '../helper/system/system' +import { projectService } from '../project/project-service' +import { userService } from '../user/user-service' + +const EDITION_REQUIRES_RBAC = [ApEdition.CLOUD, ApEdition.ENTERPRISE].includes(system.getEdition()) + +async function listMcpAccessibleProjects({ platformId, userId, log }: { + platformId: string + userId: string + log: FastifyBaseLogger +}): Promise { + const user = await userService(log).getOneOrFail({ id: userId }) + const isPrivileged = userService(log).isUserPrivileged(user) + const projects = await projectService(log).getAllForUser({ platformId, userId, isPrivileged }) + + if (!EDITION_REQUIRES_RBAC || user.platformRole === PlatformRole.ADMIN) { + return projects + } + if (isPrivileged) { + return projects.filter((project) => project.type !== ProjectType.PERSONAL || project.ownerId === userId) + } + const mcpProjectIds = new Set(await projectMemberService(log).listProjectIdsWithPermission({ userId, platformId, permission: Permission.READ_MCP })) + return projects.filter((project) => mcpProjectIds.has(project.id) || (project.type === ProjectType.PERSONAL && project.ownerId === userId)) +} + +export const mcpAccess = { + listMcpAccessibleProjects, +} diff --git a/packages/server/api/src/app/mcp/mcp-permissions.ts b/packages/server/api/src/app/mcp/mcp-permissions.ts index bee9f2503c73..fe24ead879be 100644 --- a/packages/server/api/src/app/mcp/mcp-permissions.ts +++ b/packages/server/api/src/app/mcp/mcp-permissions.ts @@ -30,15 +30,10 @@ export async function resolvePermissionChecker({ userId, projectId, log }: { } catch (err) { if (err instanceof ActivepiecesError && err.error.code === ErrorCode.AUTHORIZATION) { - return buildChecker((permission, toolTitle) => { - if (isNil(permission)) { - return null - } - return { - content: [{ type: 'text' as const, text: `❌ Permission denied: no role found for this user in the project. Cannot execute "${toolTitle}".` }], - isError: true, - } - }) + return buildChecker((_permission, toolTitle) => ({ + content: [{ type: 'text' as const, text: `❌ Permission denied: no role found for this user in the project. Cannot execute "${toolTitle}".` }], + isError: true, + })) } throw err } diff --git a/packages/server/api/src/app/mcp/oauth/code/mcp-oauth-approve.controller.ts b/packages/server/api/src/app/mcp/oauth/code/mcp-oauth-approve.controller.ts index c5649631bf97..60a28f3ff276 100644 --- a/packages/server/api/src/app/mcp/oauth/code/mcp-oauth-approve.controller.ts +++ b/packages/server/api/src/app/mcp/oauth/code/mcp-oauth-approve.controller.ts @@ -1,11 +1,10 @@ import { isNil } from '@activepieces/core-utils' -import { PlatformRole, PrincipalType } from '@activepieces/shared' +import { PrincipalType } from '@activepieces/shared' import { FastifyPluginAsyncZod } from 'fastify-type-provider-zod' import { z } from 'zod' import { securityAccess } from '../../../core/security/authorization/fastify-security' import { JwtAudience, jwtUtils } from '../../../helper/jwt-utils' -import { projectService } from '../../../project/project-service' -import { userService } from '../../../user/user-service' +import { mcpAccess } from '../../mcp-access' import { mcpOAuthCodeService } from './mcp-oauth-code.service' export const mcpOAuthApproveController: FastifyPluginAsyncZod = async (app) => { @@ -15,22 +14,15 @@ export const mcpOAuthApproveController: FastifyPluginAsyncZod = async (app) => { const userId = req.principal.id const platformId = req.principal.platform.id + const mcpAccessibleProjects = await mcpAccess.listMcpAccessibleProjects({ platformId, userId, log: req.log }) + if (isNil(projectId)) { - const user = await userService(req.log).getOneOrFail({ id: userId }) - if (user.platformRole !== PlatformRole.ADMIN) { - return reply.status(403).send({ error: 'access_denied', error_description: 'Only platform administrators can authorize platform-wide MCP access' }) + if (mcpAccessibleProjects.length === 0) { + return reply.status(403).send({ error: 'access_denied', error_description: 'You do not have MCP access in any project' }) } } - else { - const user = await userService(req.log).getOneOrFail({ id: userId }) - const accessibleProjects = await projectService(req.log).getAllForUser({ - platformId, - userId, - isPrivileged: userService(req.log).isUserPrivileged(user), - }) - if (!accessibleProjects.some(p => p.id === projectId)) { - return reply.status(403).send({ error: 'access_denied', error_description: 'You do not have access to this project' }) - } + else if (!mcpAccessibleProjects.some(p => p.id === projectId)) { + return reply.status(403).send({ error: 'access_denied', error_description: 'You do not have MCP access to this project' }) } const key = await jwtUtils.getJwtSecret() diff --git a/packages/server/api/src/app/mcp/tools/ap-set-project-context.ts b/packages/server/api/src/app/mcp/tools/ap-set-project-context.ts index 319337f27e80..1bf1e24bd13d 100644 --- a/packages/server/api/src/app/mcp/tools/ap-set-project-context.ts +++ b/packages/server/api/src/app/mcp/tools/ap-set-project-context.ts @@ -2,8 +2,7 @@ import { isNil } from '@activepieces/core-utils' import { McpToolDefinition } from '@activepieces/shared' import { FastifyBaseLogger } from 'fastify' import { z } from 'zod' -import { projectService } from '../../project/project-service' -import { userService } from '../../user/user-service' +import { mcpAccess } from '../mcp-access' import { mcpProjectSelection, ProjectSelectionScope } from '../mcp-project-selection' export const apSetProjectContextTool = ({ platformId, userId, selectionScope, log }: { @@ -26,12 +25,7 @@ export const apSetProjectContextTool = ({ platformId, userId, selectionScope, lo execute: async (args: Record) => { const projectId = args.projectId as string | undefined - const user = await userService(log).getOneOrFail({ id: userId }) - const projects = await projectService(log).getAllForUser({ - platformId, - userId, - isPrivileged: userService(log).isUserPrivileged(user), - }) + const projects = await mcpAccess.listMcpAccessibleProjects({ platformId, userId, log }) if (!isNil(projectId) && projectId !== '') { const targetProject = projects.find(p => p.id === projectId) diff --git a/packages/server/api/test/integration/ce/mcp/mcp-platform-project-selection.test.ts b/packages/server/api/test/integration/ce/mcp/mcp-platform-project-selection.test.ts index b8dbb7f6cc54..5d83f42ade99 100644 --- a/packages/server/api/test/integration/ce/mcp/mcp-platform-project-selection.test.ts +++ b/packages/server/api/test/integration/ce/mcp/mcp-platform-project-selection.test.ts @@ -1,10 +1,10 @@ -import { FlowStatus, FlowVersionState, Project } from '@activepieces/shared' +import { DefaultProjectRole, FlowStatus, FlowVersionState, Project } from '@activepieces/shared' import { FastifyInstance } from 'fastify' import { beforeAll, describe, expect, it } from 'vitest' import { db } from '../../../helpers/db' import { MCP_OAUTH_REDIRECT_URI, mcpOAuthTestHelpers } from '../../../helpers/mcp-oauth' import { createMockFlow, createMockFlowVersion, createMockProject } from '../../../helpers/mocks' -import { createTestContext, TestContext } from '../../../helpers/test-context' +import { createMemberContext, createTestContext, TestContext } from '../../../helpers/test-context' import { setupTestEnvironment } from '../../../helpers/test-setup' let app: FastifyInstance @@ -128,6 +128,29 @@ describe('platform MCP project selection', () => { expect(listedBySecond).not.toContain(FLOW_IN_FIRST_PROJECT) }) + it('lets a platform MEMBER authorize platform-wide MCP', async () => { + const member = await createMemberContext(app, ctx, { projectRole: DefaultProjectRole.EDITOR }) + const client = await mcpOAuthTestHelpers.registerClient({ app, tokenEndpointAuthMethod: 'none' }) + const { challenge } = mcpOAuthTestHelpers.generatePkce() + + const consent = await app.inject({ + method: 'GET', + url: '/authorize?' + new URLSearchParams({ + client_id: client.client_id, + redirect_uri: MCP_OAUTH_REDIRECT_URI, + response_type: 'code', + code_challenge: challenge, + code_challenge_method: 'S256', + scope: 'mcp', + resource: PLATFORM_RESOURCE, + }).toString(), + }) + const authRequestId = new URL(String(consent.headers.location), MCP_OAUTH_REDIRECT_URI).searchParams.get('authRequestId') ?? '' + + const approved = await member.post('/v1/mcp-oauth/approve', { authRequestId }) + expect(approved.statusCode).toBe(200) + }) + it('asks a client with no selection of its own to pick a project', async () => { const alreadySelectedClient = await connectPlatformWideClient() await callTool({ accessToken: alreadySelectedClient, name: 'ap_set_project_context', args: { projectId: ctx.project.id } }) diff --git a/packages/server/api/test/integration/cloud/mcp/mcp-access.test.ts b/packages/server/api/test/integration/cloud/mcp/mcp-access.test.ts new file mode 100644 index 000000000000..33478da9da41 --- /dev/null +++ b/packages/server/api/test/integration/cloud/mcp/mcp-access.test.ts @@ -0,0 +1,75 @@ +import { apId, Permission, RoleType } from '@activepieces/core-utils' +import { DefaultProjectRole, PlatformRole, ProjectType } from '@activepieces/shared' +import { FastifyBaseLogger, FastifyInstance } from 'fastify' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { mcpAccess } from '../../../../src/app/mcp/mcp-access' +import { db } from '../../../helpers/db' +import { createMockProject, createMockProjectRole, mockBasicUser } from '../../../helpers/mocks' +import { createMemberContext, createTestContext } from '../../../helpers/test-context' +import { setupTestEnvironment, teardownTestEnvironment } from '../../../helpers/test-setup' + +let app: FastifyInstance +let mockLog: FastifyBaseLogger + +beforeAll(async () => { + app = await setupTestEnvironment() + mockLog = app.log +}) + +afterAll(async () => { + await teardownTestEnvironment() +}) + +describe('mcpAccess.listMcpAccessibleProjects', () => { + it('returns every project for a privileged (admin) user', async () => { + const ctx = await createTestContext(app) + + const projects = await mcpAccess.listMcpAccessibleProjects({ platformId: ctx.platform.id, userId: ctx.user.id, log: mockLog }) + + expect(projects.map(p => p.id)).toContain(ctx.project.id) + }) + + it('includes a project whose member role grants READ_MCP', async () => { + const ctx = await createTestContext(app) + const member = await createMemberContext(app, ctx, { projectRole: DefaultProjectRole.EDITOR }) + + const projects = await mcpAccess.listMcpAccessibleProjects({ platformId: ctx.platform.id, userId: member.user.id, log: mockLog }) + + expect(projects.map(p => p.id)).toContain(ctx.project.id) + }) + + it('gives an operator team projects but not other users\' personal projects', async () => { + const ctx = await createTestContext(app) + const { mockUser: operator } = await mockBasicUser({ + user: { platformId: ctx.platform.id, platformRole: PlatformRole.OPERATOR }, + }) + const otherPersonalProject = createMockProject({ + platformId: ctx.platform.id, + ownerId: ctx.user.id, + type: ProjectType.PERSONAL, + }) + await db.save('project', otherPersonalProject) + + const projects = await mcpAccess.listMcpAccessibleProjects({ platformId: ctx.platform.id, userId: operator.id, log: mockLog }) + const ids = projects.map(p => p.id) + + expect(ids).toContain(ctx.project.id) + expect(ids).not.toContain(otherPersonalProject.id) + }) + + it('excludes a project whose member role lacks READ_MCP', async () => { + const ctx = await createTestContext(app) + const noMcpRole = createMockProjectRole({ + platformId: ctx.platform.id, + name: `no-mcp-${apId()}`, + permissions: [Permission.READ_FLOW], + type: RoleType.CUSTOM, + }) + await db.save('project_role', noMcpRole) + const member = await createMemberContext(app, ctx, { projectRole: noMcpRole.name }) + + const projects = await mcpAccess.listMcpAccessibleProjects({ platformId: ctx.platform.id, userId: member.user.id, log: mockLog }) + + expect(projects.map(p => p.id)).not.toContain(ctx.project.id) + }) +}) diff --git a/packages/server/api/test/integration/cloud/mcp/mcp-rbac.test.ts b/packages/server/api/test/integration/cloud/mcp/mcp-rbac.test.ts index 64ca79c32073..7c36f86cbf22 100644 --- a/packages/server/api/test/integration/cloud/mcp/mcp-rbac.test.ts +++ b/packages/server/api/test/integration/cloud/mcp/mcp-rbac.test.ts @@ -1,11 +1,12 @@ import { apId, Permission } from '@activepieces/core-utils' -import { DefaultProjectRole, McpServerType, ProjectScopedMcpServer } from '@activepieces/shared' +import { DefaultProjectRole, McpServerType, PlatformRole, ProjectScopedMcpServer } from '@activepieces/shared' import { FastifyBaseLogger, FastifyInstance } from 'fastify' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { resolvePermissionChecker } from '../../../../src/app/mcp/mcp-permissions' import { apCreateFlowTool } from '../../../../src/app/mcp/tools/ap-create-flow' import { apListFlowsTool } from '../../../../src/app/mcp/tools/ap-list-flows' import { apSetupGuideTool } from '../../../../src/app/mcp/tools/ap-setup-guide' +import { mockBasicUser } from '../../../helpers/mocks' import { createMemberContext, createTestContext } from '../../../helpers/test-context' import { setupTestEnvironment, teardownTestEnvironment } from '../../../helpers/test-setup' @@ -106,6 +107,26 @@ describe('MCP Tool RBAC', () => { const execute = checker.wrapExecute({ execute: tool.execute, permission: tool.permission, toolTitle: tool.title }) expect(execute).toBe(tool.execute) }) + + it('denies every tool, even no-permission ones, when the user has no role in the project', async () => { + const ctx = await createTestContext(app) + const { mockUser } = await mockBasicUser({ + user: { platformId: ctx.platform.id, platformRole: PlatformRole.MEMBER }, + }) + const mcp = makeMcp(ctx.project.id) + + const checker = await resolvePermissionChecker({ userId: mockUser.id, projectId: ctx.project.id, log: mockLog }) + const tool = apSetupGuideTool(mcp, mockLog) + + const error = checker.check(tool.permission, tool.title) + expect(error).not.toBeNull() + expect(error!.isError).toBe(true) + expect(text(error!)).toContain('no role') + + const execute = checker.wrapExecute({ execute: tool.execute, permission: tool.permission, toolTitle: tool.title }) + const result = await execute({}) + expect(text(result)).toContain('Permission denied') + }) }) })