diff --git a/server/src/__tests__/heartbeat-runtime-mcp-servers.test.ts b/server/src/__tests__/heartbeat-runtime-mcp-servers.test.ts index fe16ee5b62c..d7b8958f791 100644 --- a/server/src/__tests__/heartbeat-runtime-mcp-servers.test.ts +++ b/server/src/__tests__/heartbeat-runtime-mcp-servers.test.ts @@ -21,7 +21,7 @@ import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; -import { buildPaperclipRuntimeMcpServers } from "../services/heartbeat.js"; +import { buildPaperclipRuntimeMcpServers, createManagedMcpRunConfig } from "../services/heartbeat.js"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -240,4 +240,84 @@ describeEmbeddedPostgres("heartbeat runtime MCP servers", () => { details: expect.objectContaining({ runId, deliveredServerCount: 0 }), }); }); + + it("injects only managed gateways whose profile connections are installed for the agent", async () => { + const [company] = await db.insert(companies).values({ + name: `Managed gateway installs ${randomUUID()}`, + issuePrefix: `MG${randomUUID().slice(0, 5).toUpperCase()}`, + }).returning(); + const [agent] = await db.insert(agents).values({ + companyId: company!.id, + name: "Managed Gateway Agent", + role: "engineer", + adapterType: "codex_local", + adapterConfig: {}, + }).returning(); + const [application] = await db.insert(toolApplications).values({ + companyId: company!.id, + applicationKey: `managed-gateway-${randomUUID().slice(0, 8)}`, + name: "Managed Gateway App", + type: "mcp_http", + status: "active", + }).returning(); + const connections = await db.insert(toolConnections).values([ + { + companyId: company!.id, + applicationId: application!.id, + name: "Installed gateway connection", + uid: `test/${randomUUID()}`, + transport: "mcp_remote", + status: "active", + enabled: true, + }, + { + companyId: company!.id, + applicationId: application!.id, + name: "Uninstalled gateway connection", + uid: `test/${randomUUID()}`, + transport: "mcp_remote", + status: "active", + enabled: true, + }, + ]).returning(); + const profiles = await db.insert(toolProfiles).values(connections.map((connection) => ({ + companyId: company!.id, + profileKey: `gateway:${connection.id}`, + name: connection.name, + defaultAction: "deny" as const, + }))).returning(); + await db.insert(toolProfileEntries).values(profiles.map((profile, index) => ({ + companyId: company!.id, + profileId: profile.id, + selectorType: "connection" as const, + effect: "include" as const, + connectionId: connections[index]!.id, + }))); + const gateways = await db.insert(toolMcpGateways).values(profiles.map((profile, index) => ({ + companyId: company!.id, + name: `${connections[index]!.name} gateway`, + slug: `gateway-${index}-${randomUUID().slice(0, 8)}`, + profileId: profile.id, + status: "active" as const, + }))).returning(); + await db.insert(toolConnectionInstalls).values({ + companyId: company!.id, + connectionId: connections[0]!.id, + targetType: "agent", + targetId: agent!.id, + }); + + const config = await createManagedMcpRunConfig({ + db, + agent: agent!, + runId: randomUUID(), + config: {}, + projectId: null, + issueId: null, + }); + + expect(config?.gateways).toHaveLength(1); + expect(config?.gateways[0]).toMatchObject({ id: gateways[0]!.id, name: gateways[0]!.name }); + expect(config?.gateways.some((gateway) => gateway.id === gateways[1]!.id)).toBe(false); + }); }); diff --git a/server/src/__tests__/invite-join-grants.test.ts b/server/src/__tests__/invite-join-grants.test.ts index 7b7fa63e016..427fef0fa63 100644 --- a/server/src/__tests__/invite-join-grants.test.ts +++ b/server/src/__tests__/invite-join-grants.test.ts @@ -75,6 +75,10 @@ describe("human invite roles", () => { { permissionKey: "users:manage_permissions", scope: null }, { permissionKey: "tasks:assign", scope: null }, { permissionKey: "joins:approve", scope: null }, + { permissionKey: "tools:manage_connections", scope: null }, + { permissionKey: "tools:manage_runtime", scope: null }, + { permissionKey: "tools:use", scope: null }, + { permissionKey: "tools:admin", scope: null }, ]); }); @@ -87,6 +91,10 @@ describe("human invite roles", () => { { permissionKey: "users:invite", scope: null }, { permissionKey: "tasks:assign", scope: null }, { permissionKey: "joins:approve", scope: null }, + { permissionKey: "tools:manage_connections", scope: null }, + { permissionKey: "tools:manage_runtime", scope: null }, + { permissionKey: "tools:use", scope: null }, + { permissionKey: "tools:admin", scope: null }, ]); }); diff --git a/server/src/__tests__/tool-access-service.test.ts b/server/src/__tests__/tool-access-service.test.ts index 291cec311a7..0d6e1e4fe20 100644 --- a/server/src/__tests__/tool-access-service.test.ts +++ b/server/src/__tests__/tool-access-service.test.ts @@ -212,6 +212,12 @@ async function allowConnectionForAgent( connectionId: string, input: { brokerMint?: boolean } = {}, ) { + await db.insert(toolConnectionInstalls).values({ + companyId, + connectionId, + targetType: "agent", + targetId: agentId, + }); const [profile] = await db.insert(toolProfiles).values({ companyId, profileKey: `broker-${randomUUID()}`, @@ -542,6 +548,56 @@ describeEmbeddedPostgres("tool access service", () => { ])); }); + it("denies token minting with an actionable error when the requesting agent has no install", async () => { + const company = await createCompany(db); + const agent = await createAgent(db, company.id); + const { run } = await createIssueAndRun(db, company.id, agent.id); + const { connection } = await createBrokerConnection(db, company.id); + await allowConnectionForAgent(db, company.id, agent.id, connection.id); + await db.delete(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, connection.id)); + const app = createRouteApp(db, agentJwtActor(company.id, agent.id, run.id)); + const fetchMock = vi.spyOn(globalThis, "fetch"); + + const res = await request(app) + .post(`/api/agents/me/connections/${encodeURIComponent(connection.uid)}/token`) + .set("X-Paperclip-Run-Id", run.id) + .send({ scope: "pages:publish:ns/dotta" }); + + expect(res.status).toBe(403); + expect(res.body).toMatchObject({ + code: "installation_required", + connection: { id: connection.id, name: connection.name }, + remediation: { action: "install_connection", targetType: "agent", targetId: agent.id }, + }); + expect(fetchMock).not.toHaveBeenCalled(); + const [audit] = await db + .select() + .from(toolAccessAuditEvents) + .where(eq(toolAccessAuditEvents.reasonCode, "installation_required")); + expect(audit).toMatchObject({ actorType: "agent", actorId: agent.id, outcome: "failure" }); + }); + + it("accepts a company-wide install when minting a token", async () => { + const company = await createCompany(db); + const agent = await createAgent(db, company.id); + const { run } = await createIssueAndRun(db, company.id, agent.id); + const { connection } = await createBrokerConnection(db, company.id, { path: "static" }); + await allowConnectionForAgent(db, company.id, agent.id, connection.id); + await db + .update(toolConnectionInstalls) + .set({ targetType: "company", targetId: company.id }) + .where(eq(toolConnectionInstalls.connectionId, connection.id)); + const app = createRouteApp(db, agentJwtActor(company.id, agent.id, run.id)); + + const res = await request(app) + .post(`/api/agents/me/connections/${encodeURIComponent(connection.uid)}/token`) + .set("X-Paperclip-Run-Id", run.id) + .send({ scope: "pages:publish:ns/dotta" }); + + expect(res.status).toBe(409); + expect(res.body).toMatchObject({ status: "use_env_lease", connectionId: connection.id }); + }); + it("selects scoped credentials for array scopes and fails closed for unknown selectors", async () => { const company = await createCompany(db); const agent = await createAgent(db, company.id); @@ -3182,7 +3238,11 @@ describeEmbeddedPostgres("tool access service", () => { vi.stubEnv("PAPERCLIP_PUBLIC_URL", "http://paperclip.test"); const company = await createCompany(db); const service = toolAccessService(db); - const connect = await service.connectGalleryApp(company.id, { galleryKey: "slack", name: "Slack reauth" }); + const connect = await service.connectGalleryApp( + company.id, + { galleryKey: "slack", name: "Slack reauth" }, + { actorType: "user", actorId: "operator-user" }, + ); await db .update(toolConnections) .set({ status: "active", updatedAt: new Date() }) @@ -3258,7 +3318,13 @@ describeEmbeddedPostgres("tool access service", () => { vi.stubEnv("PAPERCLIP_PUBLIC_URL", "http://paperclip.test"); const company = await createCompany(db); const service = toolAccessService(db); - const connect = await service.connectGalleryApp(company.id, { galleryKey: "slack", name: "Slack bound" }); + // The initiating operator owns the connection: reconnecting is limited to the + // creator or a connection manager, and this case asserts session binding. + const connect = await service.connectGalleryApp( + company.id, + { galleryKey: "slack", name: "Slack bound" }, + { actorType: "user", actorId: "oauth-operator" }, + ); const initiatingActor = boardSessionActor(company.id, "operator", "oauth-operator"); const initiatingApp = createRouteApp(db, initiatingActor); const startRes = await request(initiatingApp) @@ -7007,6 +7073,73 @@ describeEmbeddedPostgres("tool access service", () => { expect.objectContaining({ targetType: "agent", targetId: agent.id }), ])); }); + + it("limits connection configuration to the creator or a manager with role defaults", async () => { + const company = await createCompany(db); + const creator = boardSessionActor(company.id, "member", `creator-${randomUUID()}`); + const otherMember = boardSessionActor(company.id, "member", `member-${randomUUID()}`); + const admin = boardSessionActor(company.id, "admin", `admin-${randomUUID()}`); + await grantBoardUser(db, company.id, creator.userId!, [], "member"); + await grantBoardUser(db, company.id, otherMember.userId!, [], "member"); + await grantBoardUser(db, company.id, admin.userId!, [], "admin"); + const connection = await toolAccessService(db).createConnection(company.id, { + name: "Creator-owned connection", + transport: "mcp_remote", + config: { url: "https://fixture.example/mcp" }, + }, { actorType: "user", actorId: creator.userId! }); + + const denied = await request(createRouteApp(db, otherMember)) + .patch(`/api/tool-connections/${connection.id}`) + .send({ name: "Member edit" }); + expect(denied.status).toBe(403); + expect(denied.body.error).toContain("connection creator or a connection manager"); + + await request(createRouteApp(db, creator)) + .patch(`/api/tool-connections/${connection.id}`) + .send({ name: "Creator edit" }) + .expect(200); + await request(createRouteApp(db, admin)) + .patch(`/api/tool-connections/${connection.id}`) + .send({ name: "Admin edit" }) + .expect(200); + + const adminGrants = await db + .select() + .from(principalPermissionGrants) + .where(eq(principalPermissionGrants.principalId, admin.userId!)); + expect(adminGrants).toEqual([]); + }); + + it("keeps agent installs self-serve for members with connection access and audits changes", async () => { + const company = await createCompany(db); + const creator = boardSessionActor(company.id, "member", `creator-${randomUUID()}`); + const member = boardSessionActor(company.id, "member", `member-${randomUUID()}`); + await grantBoardUser(db, company.id, creator.userId!, [], "member"); + await grantBoardUser(db, company.id, member.userId!, ["agents:configure"], "member"); + const agent = await createAgent(db, company.id); + const connection = await toolAccessService(db).createConnection(company.id, { + name: "Shared organization connection", + transport: "mcp_remote", + config: { url: "https://fixture.example/mcp" }, + }, { actorType: "user", actorId: creator.userId! }); + const app = createRouteApp(db, member); + + await request(app) + .put(`/api/tool-connections/${connection.id}/installs`) + .send({ installs: [{ targetType: "agent", targetId: agent.id }] }) + .expect(200); + await request(app) + .put(`/api/tool-connections/${connection.id}/installs`) + .send({ installs: [] }) + .expect(200); + + const audits = await db + .select() + .from(toolAccessAuditEvents) + .where(eq(toolAccessAuditEvents.action, "connection_installs.changed")); + expect(audits).toHaveLength(2); + expect(audits.every((audit) => audit.actorType === "user" && audit.actorId === member.userId)).toBe(true); + }); }); describe("classifyRisk", () => { diff --git a/server/src/routes/tool-access.ts b/server/src/routes/tool-access.ts index 0c4bef458e2..dcfc4d49b75 100644 --- a/server/src/routes/tool-access.ts +++ b/server/src/routes/tool-access.ts @@ -1,7 +1,7 @@ import { Router, type Request } from "express"; import type { Db } from "@paperclipai/db"; -import { agents, companies } from "@paperclipai/db"; -import { eq } from "drizzle-orm"; +import { agents, companies, connectionGrants, toolConnectionInstalls } from "@paperclipai/db"; +import { and, eq, or } from "drizzle-orm"; import { CONNECTABLE_APP_DEFINITIONS, DEFAULT_OWNERSHIP_AVAILABILITY, @@ -124,6 +124,71 @@ export function toolAccessRoutes( throw forbidden(`Missing permission: ${permissionKey}`); } + function activeToolMembership(req: Request, companyId: string) { + assertBoard(req); + assertCompanyAccess(req, companyId); + if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return null; + const membership = Array.isArray(req.actor.memberships) + ? req.actor.memberships.find((item) => item.companyId === companyId) + : null; + if (!membership || membership.status !== "active") { + throw forbidden("User does not have active company access"); + } + if (!membership.membershipRole || membership.membershipRole === "viewer") { + throw forbidden("Viewer access is read-only"); + } + return membership; + } + + async function isToolConnectionManager(req: Request, companyId: string) { + const membership = activeToolMembership(req, companyId); + if (!membership) return true; + if (membership.membershipRole === "owner" || membership.membershipRole === "admin") return true; + return Boolean(req.actor.userId && await access.hasPermission( + companyId, + "user", + req.actor.userId, + "tools:manage_connections", + )); + } + + async function assertToolConnectionConfigureAccess( + req: Request, + connection: { companyId: string; createdByUserId?: string | null }, + ) { + if (await isToolConnectionManager(req, connection.companyId)) return; + if (req.actor.userId && connection.createdByUserId === req.actor.userId) return; + throw forbidden( + "Only the connection creator or a connection manager can configure, reconnect, or delete this connection", + ); + } + + async function assertToolConnectionAccess( + req: Request, + connection: { id: string; companyId: string; createdByUserId?: string | null }, + ) { + activeToolMembership(req, connection.companyId); + if (await isToolConnectionManager(req, connection.companyId)) return; + if (req.actor.userId && connection.createdByUserId === req.actor.userId) return; + const [grant] = await db + .select({ id: connectionGrants.id }) + .from(connectionGrants) + .where(and( + eq(connectionGrants.companyId, connection.companyId), + eq(connectionGrants.connectionId, connection.id), + eq(connectionGrants.status, "active"), + or( + eq(connectionGrants.kind, "workspace"), + req.actor.userId + ? and(eq(connectionGrants.kind, "user"), eq(connectionGrants.subjectUserId, req.actor.userId)) + : eq(connectionGrants.kind, "workspace"), + ), + )) + .limit(1); + if (grant) return; + throw forbidden("You need access to this connection before you can install it on an agent"); + } + async function assertBoardAnyToolPermission(req: Request, companyId: string, permissionKeys: PermissionKey[]) { assertBoard(req); assertCompanyAccess(req, companyId); @@ -218,18 +283,7 @@ export function toolAccessRoutes( }); function assertToolAppMutationAccess(req: Request, companyId: string) { - assertBoard(req); - assertCompanyAccess(req, companyId); - if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return; - const membership = Array.isArray(req.actor.memberships) - ? req.actor.memberships.find((item) => item.companyId === companyId) - : null; - if (!membership || membership.status !== "active") { - throw forbidden("User does not have active company access"); - } - if (!membership.membershipRole || membership.membershipRole === "viewer") { - throw forbidden("Viewer access is read-only"); - } + activeToolMembership(req, companyId); } router.get("/companies/:companyId/tools/gallery", async (req, res) => { @@ -291,11 +345,12 @@ export function toolAccessRoutes( validate(startConnectionAuthorizationSchema), async (req, res) => { const companyId = req.params.companyId as string; - assertToolAppMutationAccess(req, companyId); + activeToolMembership(req, companyId); if (!req.actor.userId || req.actor.userId !== req.body.subjectUserId) { throw forbidden("Board users may only authorize their own connection subject"); } const existing = await svc.getConnection(req.params.connectionId as string, companyId); + await assertToolConnectionAccess(req, existing); const result = await svc.startOAuth(companyId, existing.id, { redirectUri: oauthRedirectUri(), actor: getActorInfo(req), @@ -310,7 +365,7 @@ export function toolAccessRoutes( router.post("/tools/oauth/:connectionId/start", async (req, res) => { const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); if (!existing) return; - assertToolAppMutationAccess(req, existing.companyId); + await assertToolConnectionConfigureAccess(req, existing); const result = await svc.startOAuth(existing.companyId, existing.id, { redirectUri: oauthRedirectUri(), actor: getActorInfo(req), @@ -328,7 +383,12 @@ export function toolAccessRoutes( if (!pendingState || !hasCompanyAccess(req, pendingState.companyId)) { throw badRequest("Invalid or expired OAuth state"); } - assertToolAppMutationAccess(req, pendingState.companyId); + const pendingConnection = await svc.getConnection(pendingState.connectionId, pendingState.companyId); + if (pendingState.subjectUserId && pendingState.subjectUserId === req.actor.userId) { + await assertToolConnectionAccess(req, pendingConnection); + } else { + await assertToolConnectionConfigureAccess(req, pendingConnection); + } const result = await svc.completeOAuthCallback({ state, code, @@ -364,8 +424,8 @@ export function toolAccessRoutes( router.post("/companies/:companyId/tools/apps/:connectionId/finish", validate(finishToolAppSchema), async (req, res) => { const companyId = req.params.companyId as string; - assertToolAppMutationAccess(req, companyId); const existing = await svc.getConnection(req.params.connectionId as string, companyId); + await assertToolConnectionConfigureAccess(req, existing); const result = await svc.finishGalleryAppConnection(companyId, existing.id, req.body, getActorInfo(req)); await logActivity(db, { companyId, @@ -538,7 +598,7 @@ export function toolAccessRoutes( const companyId = req.params.companyId as string; assertToolAppMutationAccess(req, companyId); try { - const connection = await svc.createConnection(companyId, req.body); + const connection = await svc.createConnection(companyId, req.body, getActorInfo(req)); await logActivity(db, { companyId, actorType: "user", @@ -577,7 +637,7 @@ export function toolAccessRoutes( assertBoard(req); const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); if (!connection) return; - await assertBoardToolPermission(req, connection.companyId, "tools:manage_connections"); + await assertToolConnectionConfigureAccess(req, connection); const body = req.body && typeof req.body === "object" ? req.body as Record : {}; const credentialSecretRefs = Array.isArray(body.credentialSecretRefs) ? body.credentialSecretRefs : []; const providerTenant = body.providerTenant && typeof body.providerTenant === "object" @@ -604,7 +664,14 @@ export function toolAccessRoutes( assertBoard(req); const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); if (!connection) return; - await assertBoardToolPermission(req, connection.companyId, "tools:manage_connections"); + const { grants } = await svc.listConnectionGrants(connection.id, connection.companyId); + const grantToRevoke = grants.find((grant) => grant.id === req.params.grantId); + const canRevokeOwnGrant = Boolean( + req.actor.userId + && grantToRevoke + && (grantToRevoke.subjectUserId === req.actor.userId || grantToRevoke.createdByUserId === req.actor.userId), + ); + if (!canRevokeOwnGrant) await assertToolConnectionConfigureAccess(req, connection); const grant = await svc.revokeConnectionGrant(connection.id, req.params.grantId as string, getActorInfo(req)); await logActivity(db, { companyId: connection.companyId, @@ -641,7 +708,47 @@ export function toolAccessRoutes( assertBoard(req); const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); if (!connection) return; - await assertBoardToolPermission(req, connection.companyId, "tools:manage_connections"); + const existingInstalls = await db + .select() + .from(toolConnectionInstalls) + .where(and( + eq(toolConnectionInstalls.companyId, connection.companyId), + eq(toolConnectionInstalls.connectionId, connection.id), + )); + const requestedInstalls = req.body.installs as Array<{ targetType: "company" | "agent"; targetId: string }>; + const requestedKeys = new Set(requestedInstalls.map((install) => `${install.targetType}:${install.targetId}`)); + const existingKeys = new Set(existingInstalls.map((install) => `${install.targetType}:${install.targetId}`)); + const changedInstalls = [ + ...requestedInstalls.filter((install) => !existingKeys.has(`${install.targetType}:${install.targetId}`)), + ...existingInstalls.filter((install) => !requestedKeys.has(`${install.targetType}:${install.targetId}`)), + ]; + if (changedInstalls.some((install) => install.targetType === "company")) { + await assertToolConnectionConfigureAccess(req, connection); + } + if (changedInstalls.some((install) => install.targetType === "agent")) { + await assertToolConnectionAccess(req, connection); + const changedAgentIds = [...new Set( + changedInstalls + .filter((install) => install.targetType === "agent") + .map((install) => install.targetId), + )]; + for (const agentId of changedAgentIds) { + const [agent] = await db + .select({ id: agents.id, companyId: agents.companyId }) + .from(agents) + .where(and(eq(agents.id, agentId), eq(agents.companyId, connection.companyId))) + .limit(1); + if (!agent) throw forbidden("The target agent is not available in this company"); + const decision = await access.decide({ + actor: req.actor, + action: "agent_config:update", + resource: { type: "agent", companyId: connection.companyId, agentId: agent.id }, + }); + if (!decision.allowed) { + throw forbidden(`You cannot edit agent ${agent.id}, so you cannot change its connection installs`); + } + } + } const snapshot = await svc.putConnectionInstalls(connection.id, req.body, getActorInfo(req)); await logActivity(db, { companyId: connection.companyId, @@ -745,7 +852,7 @@ export function toolAccessRoutes( router.patch("/tool-connections/:connectionId", validate(updateToolConnectionSchema), async (req, res) => { const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); if (!existing) return; - assertToolAppMutationAccess(req, existing.companyId); + await assertToolConnectionConfigureAccess(req, existing); const connection = await svc.updateConnection(existing.id, req.body); const lifecycleChanges = classifyConnectionUpdate( { enabled: existing.enabled, config: existing.config }, @@ -789,7 +896,7 @@ export function toolAccessRoutes( router.delete("/tool-connections/:connectionId", async (req, res) => { const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); if (!existing) return; - assertToolAppMutationAccess(req, existing.companyId); + await assertToolConnectionConfigureAccess(req, existing); const applicationBefore = await svc.getApplication(existing.applicationId); const connection = await svc.archiveConnection(existing.id); const applicationAfter = await svc.getApplication(existing.applicationId); @@ -819,7 +926,7 @@ export function toolAccessRoutes( router.post("/tool-connections/:connectionId/health-check", async (req, res) => { const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); if (!existing) return; - assertToolAppMutationAccess(req, existing.companyId); + await assertToolConnectionConfigureAccess(req, existing); res.json(await svc.checkHealth(existing.id, getActorInfo(req))); }); @@ -829,7 +936,7 @@ export function toolAccessRoutes( async (req, res) => { const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); if (!existing) return; - assertToolAppMutationAccess(req, existing.companyId); + await assertToolConnectionConfigureAccess(req, existing); const result = await svc.reconnectGalleryApp( existing.id, existing.companyId, @@ -852,7 +959,7 @@ export function toolAccessRoutes( router.post("/tool-connections/:connectionId/catalog/refresh", async (req, res) => { const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); if (!existing) return; - assertToolAppMutationAccess(req, existing.companyId); + await assertToolConnectionConfigureAccess(req, existing); res.json(await svc.refreshCatalog(existing.id, getActorInfo(req))); }); diff --git a/server/src/services/authorization.ts b/server/src/services/authorization.ts index 063de1e2710..19107b875d2 100644 --- a/server/src/services/authorization.ts +++ b/server/src/services/authorization.ts @@ -28,6 +28,7 @@ import { type TrustPresetResolution, } from "./trust-preset-resolver.js"; import { logger } from "../middleware/logger.js"; +import { grantsForHumanRole, normalizeHumanRole } from "./company-member-roles.js"; export type AuthorizationActor = { @@ -102,6 +103,7 @@ export type AuthorizationDecision = { | "allow_local_board" | "allow_instance_admin" | "allow_explicit_grant" + | "allow_role_default" | "allow_user_inbox_policy" | "allow_direct_change" | "allow_consented_change" @@ -664,6 +666,19 @@ export function authorizationService(db: Db) { const grant = await findGrant(input.companyId, input.principalType, input.principalId, input.permissionKey); if (!grant) { + if ( + input.principalType === "user" + && input.permissionKey.startsWith("tools:") + && (membership.membershipRole === "owner" || membership.membershipRole === "admin") + && grantsForHumanRole(normalizeHumanRole(membership.membershipRole, "operator")) + .some((defaultGrant) => defaultGrant.permissionKey === input.permissionKey) + ) { + return allow({ + action: input.action, + reason: "allow_role_default", + explanation: `Allowed by the ${membership.membershipRole ?? "operator"} membership role.`, + }); + } return deny({ action: input.action, reason: "deny_missing_grant", diff --git a/server/src/services/company-member-roles.ts b/server/src/services/company-member-roles.ts index 0c3057c07db..2a9d781734e 100644 --- a/server/src/services/company-member-roles.ts +++ b/server/src/services/company-member-roles.ts @@ -35,6 +35,10 @@ export function grantsForHumanRole( { permissionKey: "users:manage_permissions", scope: null }, { permissionKey: "tasks:assign", scope: null }, { permissionKey: "joins:approve", scope: null }, + { permissionKey: "tools:manage_connections", scope: null }, + { permissionKey: "tools:manage_runtime", scope: null }, + { permissionKey: "tools:use", scope: null }, + { permissionKey: "tools:admin", scope: null }, ]; case "admin": return [ @@ -45,6 +49,10 @@ export function grantsForHumanRole( { permissionKey: "users:invite", scope: null }, { permissionKey: "tasks:assign", scope: null }, { permissionKey: "joins:approve", scope: null }, + { permissionKey: "tools:manage_connections", scope: null }, + { permissionKey: "tools:manage_runtime", scope: null }, + { permissionKey: "tools:use", scope: null }, + { permissionKey: "tools:admin", scope: null }, ]; case "operator": return [{ permissionKey: "tasks:assign", scope: null }]; diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 64cac948d07..e0c0b044d56 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -65,7 +65,10 @@ import { routines, toolMcpGateways, toolMcpGatewayTokens, + toolCatalogEntries, + toolConnectionInstalls, toolConnections, + toolProfileEntries, toolProfiles, workspaceOperations, } from "@paperclipai/db"; @@ -3462,7 +3465,68 @@ function gatewayAppliesToRun(input: { return true; } -async function createManagedMcpRunConfig(input: { +async function gatewayConnectionIds(input: { + db: Db; + companyId: string; + gateway: typeof toolMcpGateways.$inferSelect; +}): Promise> { + const managedRuntimeConnectionId = readNonEmptyString(input.gateway.metadata?.managedRuntimeConnectionId); + if (managedRuntimeConnectionId) return new Set([managedRuntimeConnectionId]); + + const [profile, entries, catalog, connections] = await Promise.all([ + input.db + .select({ defaultAction: toolProfiles.defaultAction }) + .from(toolProfiles) + .where(and(eq(toolProfiles.companyId, input.companyId), eq(toolProfiles.id, input.gateway.profileId))) + .then((rows) => rows[0] ?? null), + input.db + .select() + .from(toolProfileEntries) + .where(and( + eq(toolProfileEntries.companyId, input.companyId), + eq(toolProfileEntries.profileId, input.gateway.profileId), + )), + input.db + .select({ + id: toolCatalogEntries.id, + connectionId: toolCatalogEntries.connectionId, + applicationId: toolCatalogEntries.applicationId, + toolName: toolCatalogEntries.toolName, + riskLevel: toolCatalogEntries.riskLevel, + }) + .from(toolCatalogEntries) + .where(and(eq(toolCatalogEntries.companyId, input.companyId), eq(toolCatalogEntries.status, "active"))), + input.db + .select({ id: toolConnections.id, applicationId: toolConnections.applicationId }) + .from(toolConnections) + .where(eq(toolConnections.companyId, input.companyId)), + ]); + if (!profile) return new Set(); + if (profile.defaultAction === "allow") return new Set(catalog.map((entry) => entry.connectionId)); + + const connectionIds = new Set(); + for (const entry of entries) { + if (entry.effect !== "include") continue; + if (entry.connectionId) connectionIds.add(entry.connectionId); + if (entry.applicationId) { + for (const connection of connections) { + if (connection.applicationId === entry.applicationId) connectionIds.add(connection.id); + } + } + for (const catalogEntry of catalog) { + if ( + (entry.catalogEntryId && entry.catalogEntryId === catalogEntry.id) + || (entry.toolName && entry.toolName === catalogEntry.toolName) + || (entry.riskLevel && entry.riskLevel === catalogEntry.riskLevel) + ) { + connectionIds.add(catalogEntry.connectionId); + } + } + } + return connectionIds; +} + +export async function createManagedMcpRunConfig(input: { db: Db; agent: Pick; runId: string; @@ -3483,12 +3547,33 @@ async function createManagedMcpRunConfig(input: { )) .orderBy(asc(toolMcpGateways.name)); - const gateways = rows.filter((gateway) => gatewayAppliesToRun({ + const installRows = await input.db + .select({ connectionId: toolConnectionInstalls.connectionId }) + .from(toolConnectionInstalls) + .where(and( + eq(toolConnectionInstalls.companyId, input.agent.companyId), + sql`((${toolConnectionInstalls.targetType} = 'company' and ${toolConnectionInstalls.targetId} = ${input.agent.companyId}) or (${toolConnectionInstalls.targetType} = 'agent' and ${toolConnectionInstalls.targetId} = ${input.agent.id}))`, + )); + const installedConnectionIds = new Set(installRows.map((install) => install.connectionId)); + + const applicableGateways = rows.filter((gateway) => gatewayAppliesToRun({ gateway, agentId: input.agent.id, projectId: input.projectId, issueId: input.issueId, })); + const gateways = (await Promise.all(applicableGateways.map(async (gateway) => ({ + gateway, + connectionIds: await gatewayConnectionIds({ + db: input.db, + companyId: input.agent.companyId, + gateway, + }), + })))) + .filter(({ connectionIds }) => + connectionIds.size > 0 + && [...connectionIds].every((connectionId) => installedConnectionIds.has(connectionId))) + .map(({ gateway }) => gateway); if (gateways.length === 0) return null; const service = createToolGatewayService(input.db); diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index 90e0bab1449..7b794dc066b 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -5602,7 +5602,11 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} async function peekOAuthState(state: string) { const [row] = await db - .select({ companyId: toolOauthStates.companyId }) + .select({ + companyId: toolOauthStates.companyId, + connectionId: toolOauthStates.connectionId, + subjectUserId: toolOauthStates.subjectUserId, + }) .from(toolOauthStates) .where(eq(toolOauthStates.state, state)) .limit(1); @@ -6306,7 +6310,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} return connections; }, - createConnection: async (companyId: string, input: CreateToolConnection): Promise => { + createConnection: async (companyId: string, input: CreateToolConnection, actor?: ActorInfo): Promise => { let applicationId = input.applicationId; let applicationNamespace = input.applicationName ?? input.name; const transport = input.transport; @@ -6335,6 +6339,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} } await assertSecretRefs(companyId, [...(input.credentialRefs ?? []), ...(input.credentialSecretRefs ?? [])]); const connectionId = randomUUID(); + const binding = actorBinding(actor); const [row] = await db.insert(toolConnections).values({ id: connectionId, companyId, @@ -6351,6 +6356,8 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} transportConfig: isGoogleSheetsConnectionConfig(config) ? config : input.transportConfig ?? config, credentialRefs: input.credentialRefs ?? [], credentialSecretRefs: input.credentialSecretRefs ?? [], + createdByAgentId: binding.actorType === "agent" ? binding.actorId : null, + createdByUserId: binding.actorType === "user" ? binding.actorId : null, }).returning(); await ensureDefaultWorkspaceGrant(row); await syncCredentialBindings(row); @@ -6399,6 +6406,16 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} createdByUserId: binding.actorType === "user" ? binding.actorId : null, }).returning(); if (!grant) throw new Error("Failed to create connection installation"); + await db.insert(toolAccessAuditEvents).values({ + companyId: connection.companyId, + connectionId: connection.id, + actorType: binding.actorType ?? "system", + actorId: binding.actorId, + action: "connection_grant.created", + outcome: "success", + reasonCode: "grant_created", + details: { grantId: grant.id, kind: grant.kind, isDefault: grant.isDefault }, + }); return grant; }, @@ -6418,6 +6435,16 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} eq(connectionGrants.connectionId, connection.id), )).returning(); if (!grant) throw notFound("Connection grant not found"); + await db.insert(toolAccessAuditEvents).values({ + companyId: connection.companyId, + connectionId: connection.id, + actorType: binding.actorType ?? "system", + actorId: binding.actorId, + action: "connection_grant.revoked", + outcome: "success", + reasonCode: "grant_revoked", + details: { grantId: grant.id, kind: grant.kind }, + }); return grant; }, @@ -6530,6 +6557,24 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} if (binding) accessExtensions.push({ targetType: install.targetType, targetId: install.targetId, profileId: profile.id }); } } + if (removeIds.length > 0 || additions.length > 0) { + const binding = actorBinding(actor); + await tx.insert(toolAccessAuditEvents).values({ + companyId: connection.companyId, + connectionId: connection.id, + actorType: binding.actorType ?? "system", + actorId: binding.actorId, + action: "connection_installs.changed", + outcome: "success", + reasonCode: "installs_changed", + details: { + added: additions.map((install) => ({ targetType: install.targetType, targetId: install.targetId })), + removed: existing + .filter((install) => removeIds.includes(install.id)) + .map((install) => ({ targetType: install.targetType, targetId: install.targetId })), + }, + }); + } }); for (const extension of accessExtensions) { await logActivity(db, { @@ -7341,6 +7386,29 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} throw new HttpError(status, message, { code: errorCode, path, ...details }); }; + const [install] = await db + .select({ id: toolConnectionInstalls.id }) + .from(toolConnectionInstalls) + .where(and( + eq(toolConnectionInstalls.companyId, connection.companyId), + eq(toolConnectionInstalls.connectionId, connection.id), + sql`((${toolConnectionInstalls.targetType} = 'company' and ${toolConnectionInstalls.targetId} = ${connection.companyId}) or (${toolConnectionInstalls.targetType} = 'agent' and ${toolConnectionInstalls.targetId} = ${input.agentId}))`, + )) + .limit(1); + if (!install) { + await fail( + 403, + `Connection ${connection.name} must be installed for this agent before it can mint a token`, + "denied", + "installation_required", + { + connection: { id: connection.id, uid: connection.uid, name: connection.name }, + agentId: input.agentId, + remediation: { action: "install_connection", targetType: "agent", targetId: input.agentId }, + }, + ); + } + const subject = input.body.subject ?? { type: "app" as const }; if (subject.type === "user" && subject.userId !== runContext.responsibleUserId) { await fail(403, "The agent run cannot act as the requested user", "denied", "subject_not_permitted", {