diff --git a/server/src/__tests__/connection-management.integration.test.ts b/server/src/__tests__/connection-management.integration.test.ts index 0f31b6af0b4..78140ec31d6 100644 --- a/server/src/__tests__/connection-management.integration.test.ts +++ b/server/src/__tests__/connection-management.integration.test.ts @@ -1,10 +1,10 @@ -import { SLACK_CHAT_BOT_SCOPES, SLACK_BOT_TOOL_SCOPES } from "@paperclipai/shared"; +import { defaultGitHubReviewPolicy, SLACK_CHAT_BOT_SCOPES, SLACK_BOT_TOOL_SCOPES } from "@paperclipai/shared"; import { randomUUID, generateKeyPairSync } from "node:crypto"; import { join } from "node:path"; import { createServer } from "node:http"; import { and, eq } from "drizzle-orm"; import { beforeAll, afterAll, describe, expect, it, vi } from "vitest"; -import { activityLog, agentApiKeys, companies, chatEndpoints, chatExternalPrincipals, chatIdentityLinks, toolPolicies, connectionGrants, connectionGrantDelegations, toolAccessAuditEvents, toolOauthStates, issueThreadInteractions, connectionIntentDeliveries, agents, companyMemberships, heartbeatRuns, issues, principalPermissionGrants, projects, toolConnections, toolMcpGatewayTokens, toolCatalogEntries, toolInvocations, toolProfileBindings, plugins, adapterAuthSessions, environments } from "@paperclipai/db"; +import { activityLog, agentApiKeys, companies, chatEndpoints, chatExternalPrincipals, chatIdentityLinks, toolPolicies, connectionGrants, connectionGrantDelegations, toolAccessAuditEvents, toolOauthStates, issueThreadInteractions, connectionIntentDeliveries, agents, companyMemberships, heartbeatRuns, issues, principalPermissionGrants, projects, toolConnections, toolMcpGateways, toolMcpGatewayTokens, toolCatalogEntries, toolInvocations, toolProfileBindings, plugins, adapterAuthSessions, environments, routines, chatGitHubReviews } from "@paperclipai/db"; import { findSatisfiedToolConnection, satisfiedConnectionIntentService } from "../services/satisfied-connection-intents.js"; import { connectionManagementService, continuingConnectionManagement } from "../services/connection-management.js"; import { redactRunnerApiPayloads } from "../services/native-runtime/runner-api-log-redaction.js"; @@ -154,7 +154,7 @@ describe("connection management with production default agents", () => { expect(bindings.map(binding => binding.targetId)).toEqual([f.agentId]); }); - it.each(["none", "other-agent", "agent", "user", "cancelled", "project-block", "rate-limit"] as const)("keeps delegated test policy and approval continuation bounded (%s)", async revocation => { + it.each(["none", "other-agent", "agent", "user", "cancelled", "project-block", "rate-limit", "agent-rate-approval"] as const)("keeps delegated test policy and approval continuation bounded (%s)", async revocation => { const f = await fixture(); let targetAgentId = f.agentId; if (revocation === "other-agent") { @@ -206,6 +206,34 @@ describe("connection management with production default agents", () => { expect(calls).toBe(1); return; } + if (revocation === "agent-rate-approval") { + await server.db.insert(toolPolicies).values([ + { companyId: f.companyId, name: "One admitted call", policyType: "rate_limit", selectors: { connectionId: created.body.id }, priority: 0, config: { limit: 1, windowSeconds: 60, keyBy: ["agent", "tool"] } }, + { companyId: f.companyId, name: "Approve admitted call", policyType: "require_approval", selectors: { connectionId: created.body.id }, priority: 1 }, + ]); + const session = await f.request("POST", "/tool-gateway/sessions", {}, f.jwt); + expect(session, JSON.stringify(session)).toMatchObject({ status: 201 }); + const gatewayRequest = async (path: string, parameters?: unknown) => { + const response = await fetch(server.apiUrl + "/api" + path, { method: parameters === undefined ? "GET" : "POST", headers: { "x-paperclip-tool-gateway-token": session.body.token, "Content-Type": "application/json" }, ...(parameters === undefined ? {} : { body: JSON.stringify(parameters) }) }); + return { status: response.status, body: await response.json() }; + }; + const listed = await gatewayRequest("/tool-gateway/tools"); + expect(listed).toMatchObject({ status: 200 }); + const tool = listed.body.find((item: { catalogEntryId: string }) => item.catalogEntryId === entry.id); + expect(tool, JSON.stringify(listed.body)).toBeTruthy(); + const requested = await gatewayRequest("/tool-gateway/tools/call", { tool: tool.name, parameters: {}, idempotencyKey: "rate-approval" }); + expect(requested, JSON.stringify(requested)).toMatchObject({ status: 409, body: { reasonCode: "approval_required" } }); + expect(calls).toBe(0); + expect(await f.request("POST", `/tool-gateway/action-requests/${requested.body.actionRequestId}/approve`, { companyId: f.companyId }, f.jwt)).toMatchObject({ status: 403 }); + const cookie = await server.humanSession(f.userId); + const response = await fetch(`${server.apiUrl}/api/tool-gateway/action-requests/${requested.body.actionRequestId}/approve`, { method: "POST", headers: { cookie, origin: server.apiUrl, "Content-Type": "application/json" }, body: JSON.stringify({ companyId: f.companyId }) }); + expect(response.status, JSON.stringify(await response.json())).toBe(200); + expect((await server.db.select().from(toolInvocations).where(eq(toolInvocations.id, requested.body.invocationId)))[0].status).toBe("succeeded"); + expect(calls).toBe(1); + expect(await gatewayRequest("/tool-gateway/tools/call", { tool: tool.name, parameters: { anotherCall: true }, idempotencyKey: "next-rate-approval" })).toMatchObject({ status: 429, body: { reasonCode: "rate_limited" } }); + expect(calls).toBe(1); + return; + } await server.db.insert(toolPolicies).values({ companyId: f.companyId, name: "Scoped test approval", policyType: "require_approval", selectors: { connectionId: created.body.id, issueId: f.issueId }, priority: 1 }); const pending = await f.request("POST", path, body, f.jwt); expect(pending, JSON.stringify(pending)).toMatchObject({ status: 200, body: { decision: "ask_first" } }); @@ -231,6 +259,68 @@ describe("connection management with production default agents", () => { } finally { remote.closeAllConnections(); await new Promise((resolve, reject) => remote.close(error => error ? reject(error) : resolve())); } }); + it("completes concurrent run-authenticated profile finishing and policy writes without lock-order failures", async () => { + const f = await fixture(); + const connection = await f.request("POST", `/companies/${f.companyId}/tools/connections`, { name: "Concurrent management", transport: "mcp_remote", authKind: "none", transportConfig: { url: "https://1.1.1.1/mcp" } }); + const policy = await f.request("POST", `/companies/${f.companyId}/tools/policies`, { name: "Concurrent policy", policyType: "allow", selectors: { agentId: f.agentId } }); + expect(policy).toMatchObject({ status: 201 }); + const finishPath = `/companies/${f.companyId}/tools/apps/${connection.body.id}/finish`; + const finish = { enabledCatalogEntryIds: [], access: { agentIds: [f.agentId] } }; + expect(await f.request("POST", finishPath, finish, f.jwt)).toMatchObject({ status: 200 }); + for (let round = 0; round < 3; round++) { + const rows = await server.db.select({ id: toolPolicies.id }).from(toolPolicies).where(eq(toolPolicies.companyId, f.companyId)); + const results = await Promise.all([ + f.request("POST", `/companies/${f.companyId}/tools/policies/reorder`, { policyIds: rows.map(row => row.id) }, f.jwt), + f.request("PATCH", `/companies/${f.companyId}/tools/policies/${policy.body.id}`, { description: `Round ${round}` }, f.jwt), + f.request("POST", finishPath, finish, f.jwt), + ]); + expect([200, 422]).toContain(results[0].status); + expect(results.slice(1), JSON.stringify(results)).toEqual([expect.objectContaining({ status: 200 }), expect.objectContaining({ status: 200 })]); + } + expect((await server.db.select().from(toolPolicies).where(eq(toolPolicies.id, policy.body.id)))[0].description).toBe("Round 2"); + }); + + it("checks issue, routine, and gateway profile targets including retained bindings", async () => { + const f = await fixture(); + const other = (await server.db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, f.companyId))).find(agent => agent.id !== f.agentId)!; + const profile = await f.request("POST", `/companies/${f.companyId}/tools/profiles`, { profileKey: "target-checks", name: "Target checks", entries: [] }); + const gatewayProfile = await f.request("POST", `/companies/${f.companyId}/tools/profiles`, { profileKey: "gateway-target", name: "Gateway target", entries: [] }); + const gateway = await f.request("POST", `/companies/${f.companyId}/tools/gateways`, { name: "Target gateway", slug: "binding-target", profileId: gatewayProfile.body.id, agentId: other.id }); + expect(gateway, JSON.stringify(gateway)).toMatchObject({ status: 201 }); + const [issue] = await server.db.insert(issues).values({ companyId: f.companyId, title: "Other agent task", assigneeAgentId: other.id, status: "in_progress", responsibleUserId: f.userId }).returning(); + const [routine] = await server.db.insert(routines).values({ companyId: f.companyId, title: "Other agent routine", assigneeAgentId: other.id, responsibleUserId: f.userId }).returning(); + const [configurationGrant] = await server.db.select().from(principalPermissionGrants).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "agents:configure"))); + await server.db.update(principalPermissionGrants).set({ scope: { ...configurationGrant.scope, targetAgentIds: [f.agentId] } }).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "agents:configure"))); + const path = `/companies/${f.companyId}/tools/profiles/${profile.body.id}`; + const ownBinding = await f.request("POST", `${path}/bind`, { targetType: "issue", targetId: f.issueId }); + expect(ownBinding, JSON.stringify(ownBinding)).toMatchObject({ status: 201 }); + expect(await f.request("POST", `${path}/unbind`, { targetType: "issue", targetId: f.issueId })).toMatchObject({ status: 200 }); + for (const target of [{ targetType: "issue", targetId: issue.id }, { targetType: "routine", targetId: routine.id }, { targetType: "gateway", targetId: gateway.body.id }]) { + expect(await f.request("POST", `${path}/bind`, target), JSON.stringify(target)).toMatchObject({ status: 403 }); + const [saved] = await server.db.insert(toolProfileBindings).values({ ...target, companyId: f.companyId, profileId: profile.body.id }).returning(); + expect(await f.request("PATCH", `/tool-profiles/${profile.body.id}`, { description: "Cannot edit retained target" })).toMatchObject({ status: 403 }); + expect(await f.request("POST", `${path}/unbind`, target)).toMatchObject({ status: 403 }); + await server.db.delete(toolProfileBindings).where(eq(toolProfileBindings.id, saved.id)); + } + await server.db.update(toolMcpGateways).set({ profileId: profile.body.id }).where(eq(toolMcpGateways.id, gateway.body.id)); + expect(await f.request("PATCH", `/tool-profiles/${profile.body.id}`, { description: "Cannot edit a gateway's retained profile" })).toMatchObject({ status: 403 }); + }); + + it("filters GitHub review history and detail by the linked task's private visibility", async () => { + const f = await fixture(); + const endpoint = await f.request("POST", `/companies/${f.companyId}/chat-endpoints`, { provider: "github", assignedAgentId: f.agentId }); + const [privateIssue] = await server.db.insert(issues).values({ companyId: f.companyId, title: "Private review task", visibility: "private", status: "in_progress", responsibleUserId: randomUUID() }).returning(); + const [visible, hidden] = await server.db.insert(chatGitHubReviews).values([f.issueId, privateIssue.id].map((issueId, index) => ({ companyId: f.companyId, endpointId: endpoint.body.id, + issueId, repositoryId: "42", repository: "fixture/repo", pullNumber: index + 1, headSha: "b".repeat(40), deliveryId: randomUUID(), configurationRevision: 1, + policySnapshot: defaultGitHubReviewPolicy(), event: { event: "opened" as const, deliveryId: randomUUID(), repositoryId: "42", repository: "fixture/repo", pullNumber: index + 1, + title: "Review", body: index ? "Private provider context" : "Public context", baseSha: "a".repeat(40), headSha: "b".repeat(40), baseBranch: "master", author: { id: "1", login: "fixture", isBot: false }, sender: { id: "1", login: "fixture" }, draft: false, labels: [] }, + }))).returning(); + const path = `/chat-endpoints/${endpoint.body.id}/github/reviews`; + expect((await f.request("GET", path)).body.map((review: { id: string }) => review.id)).toEqual([visible.id]); + expect(await f.request("GET", `${path}/${visible.id}`)).toMatchObject({ status: 200 }); + expect(await f.request("GET", `${path}/${hidden.id}`)).toMatchObject({ status: 404 }); + }); + it("bounds and deduplicates resource selectors before per-resource database work", async () => { const f = await fixture(); const created = await f.request("POST", `/companies/${f.companyId}/tools/connections`, { name: "Bounded selector", transport: "mcp_remote", authKind: "none", transportConfig: { url: "https://1.1.1.1/mcp" } }); @@ -288,7 +378,14 @@ describe("connection management with production default agents", () => { const payload = { name: "Scoped provider", transport: "mcp_remote", authKind: "none", config: { url: "https://1.1.1.1/mcp", provider: "fixture-one" } }; const created = await f.request("POST", `/companies/${f.companyId}/tools/connections`, payload); expect(created.status).toBe(201); + const untagged = await f.request("POST", `/companies/${f.companyId}/tools/connections`, { ...payload, name: "Untagged", config: { url: "https://1.1.1.1/mcp" } }); + const application = await f.request("POST", `/companies/${f.companyId}/tools/applications`, { name: "Provider app", type: "rest_api", metadata: { sourceTemplateKey: "fixture-one" } }); + expect(application).toMatchObject({ status: 201 }); await server.db.update(principalPermissionGrants).set({ scope: { providers: ["fixture-one"] } }).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_connections"))); + expect(await f.request("PATCH", `/tool-connections/${untagged.body.id}`, { name: "Spoofed label", provider: "fixture-one" })).toMatchObject({ status: 403 }); + expect(await f.request("POST", `/companies/${f.companyId}/tools/applications`, { name: "Spoofed application", type: "rest_api", provider: "fixture-one", metadata: { sourceTemplateKey: "fixture-two" } })).toMatchObject({ status: 403 }); + expect(await f.request("PATCH", `/tool-applications/${application.body.id}`, { name: "Same app provider" })).toMatchObject({ status: 200 }); + for (const metadata of [{ sourceTemplateKey: "fixture-two" }, {}]) expect(await f.request("PATCH", `/tool-applications/${application.body.id}`, { provider: "fixture-one", metadata })).toMatchObject({ status: 403 }); expect(await f.request("PATCH", `/tool-connections/${created.body.id}`, { name: "Still the same provider" })).toMatchObject({ status: 200 }); for (const config of [{ url: "https://1.1.1.1/mcp", provider: "fixture-two" }, { url: "https://1.1.1.1/mcp" }]) expect(await f.request("PATCH", `/tool-connections/${created.body.id}`, { config })).toMatchObject({ status: 403 }); expect(await f.request("POST", `/companies/${f.companyId}/tools/connections`, { ...payload, name: "Caller-labelled provider" })).toMatchObject({ status: 403 }); @@ -385,7 +482,7 @@ describe("connection management with production default agents", () => { provider = async (url, init) => { if (url.endsWith("auth.test")) return Response.json({ ok: true, team_id: "TFIXTURE", team: "Fixture workspace", user_id: botId, user: "fixture-bot" }, { headers: { "x-oauth-scopes": scopes.join(",") } }); if (url.includes("conversations.list")) return Response.json({ ok: true, channels: [], response_metadata: { next_cursor: "" } }); - if (url.endsWith("users.info")) return Response.json({ ok: true, user: { id: "UHUMAN", team_id: "TFIXTURE", name: "Fixture user", is_bot: false, deleted: false } }); + if (url.endsWith("users.info")) return Response.json({ ok: true, user: { id: new URLSearchParams(String(init?.body)).get("user") ?? "UHUMAN", team_id: "TFIXTURE", name: "Fixture user", is_bot: false, deleted: false } }); if (url.endsWith("chat.postMessage")) { sent++; return Response.json({ ok: true, channel: "DTEST", ts: "1.000001" }); } if (url.endsWith("agents.sessions.setStatus")) return Response.json({ ok: true }); throw new Error(`Unexpected Slack fixture operation: ${new URL(url).pathname}`); @@ -405,10 +502,13 @@ describe("connection management with production default agents", () => { expect(denied, JSON.stringify(denied)).toMatchObject({ status: 403 }); expect(sent).toBe(0); await server.db.update(toolPolicies).set({ policyType: "allow" }).where(eq(toolPolicies.id, deny.id)); + await server.db.insert(toolPolicies).values({ companyId: f.companyId, name: "One Slack setup send", policyType: "rate_limit", selectors: { connectionId: created.body.connectionId }, priority: 0, config: { limit: 1, windowSeconds: 60, keyBy: ["agent", "tool"] } }); const requestId = randomUUID(); const tested = await f.request("POST", testPath, { requestId }); expect(tested, JSON.stringify(tested)).toMatchObject({ status: 200, body: { status: "sent" } }); expect(await f.request("POST", testPath, { requestId })).toMatchObject({ status: 200 }); + expect(await f.request("POST", testPath, { requestId: randomUUID() })).toMatchObject({ status: 403, body: { details: { reasonCode: "rate_limited" } } }); + expect(await f.request("POST", testPath, { requestId: randomUUID(), slackUserId: "UOTHER" })).toMatchObject({ status: 403 }); expect(sent).toBe(1); } finally { provider = null; } }); diff --git a/server/src/routes/chat-channels.ts b/server/src/routes/chat-channels.ts index 743b2b9d0cd..29e4e8815dd 100644 --- a/server/src/routes/chat-channels.ts +++ b/server/src/routes/chat-channels.ts @@ -53,6 +53,7 @@ import { } from "./authz.js"; import { badRequest, + notFound, forbidden, HttpError, tooManyRequests, @@ -321,11 +322,14 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) { if (!(await assertEndpointAccess(req, res, service))) return; const reviewId = z.string().uuid().safeParse(req.params.reviewId); if (!reviewId.success) throw badRequest("Invalid review ID"); - res.json(await github.review(endpointId(req), reviewId.data)); + const review = await github.review(endpointId(req), reviewId.data); + const visible = await filterConnectionManagementRows(db, req, "connections:read", [review], row => ({ issueId: row.issueId })); + if (!visible.length) throw notFound("This review was not found in this connection"); + res.json(visible[0]); }); router.get("/chat-endpoints/:endpointId/github/reviews", managementGuard("connections:read", "GET /chat-endpoints/:endpointId/github/reviews"), async (req, res) => { if (!(await assertEndpointAccess(req, res, service))) return; - res.json(await github.reviews(endpointId(req))); + res.json(await filterConnectionManagementRows(db, req, "connections:read", await github.reviews(endpointId(req)), row => ({ issueId: row.issueId }))); }); router.get("/chat-endpoints/:endpointId/github/personal-connections", managementGuard("connections:read", "GET /chat-endpoints/:endpointId/github/personal-connections"), async (req, res) => { if (!(await assertEndpointAccess(req, res, service))) return; diff --git a/server/src/routes/connection-management-authz.ts b/server/src/routes/connection-management-authz.ts index 7bde759698f..0e589266c6f 100644 --- a/server/src/routes/connection-management-authz.ts +++ b/server/src/routes/connection-management-authz.ts @@ -34,7 +34,7 @@ export function requiredConnectionUserAction( function assertBoundedManagementSelectors(body: Record) { if (!body || typeof body !== "object" || Array.isArray(body)) throw badRequest("Resource selectors must be objects"); const arrays = ["policyIds", "credentialRefs", "credentialSecretRefs", "installs", "bindings", "entries", "agentIds"]; - const identifiers = ["connectionId", "profileId", "gatewayId", "applicationId", "catalogEntryId", "projectId", "issueId", "agentId", "targetAgentId", "subjectAgentId", "assignedAgentId", "secretId", "targetId"]; + const identifiers = ["connectionId", "profileId", "gatewayId", "applicationId", "catalogEntryId", "projectId", "issueId", "routineId", "agentId", "targetAgentId", "subjectAgentId", "assignedAgentId", "secretId", "targetId"]; const pending: Record[] = [body]; const unique = new Set(); let references = 0; @@ -99,6 +99,7 @@ export function connectionManagementGuard(db: Db, purpose: "tool" | "chat" | "ai if (body[key] != null && body[key] !== userId) throw forbidden("Personal credentials must belong to the responsible user"); } } + let applicationProvider: string | null = null; let companyId = typeof req.params.companyId === "string" ? req.params.companyId : null; const earlyCompanyId = companyId ?? req.actor.companyId; if (earlyCompanyId) { @@ -116,8 +117,9 @@ export function connectionManagementGuard(db: Db, purpose: "tool" | "chat" | "ai readOnly: req.method === "GET" && action !== "connections:gateway" && (action !== "connections:audit" || purpose === "chat") }; const targets = new Set(); const extraResources: ConnectionManagementResource[] = []; + const bindingTargets: { targetType: string; targetId: string }[] = []; const selectors: Record[] = []; - const selectorKeys = ["connectionId", "profileId", "gatewayId", "applicationId", "catalogEntryId", "projectId", "issueId", "agentId", "targetAgentId", "targetType", "targetId", "agentIds", "connectionIds", "profileIds", "gatewayIds", "applicationIds", "catalogEntryIds", "projectIds", "issueIds"]; + const selectorKeys = ["connectionId", "profileId", "gatewayId", "applicationId", "catalogEntryId", "projectId", "issueId", "routineId", "agentId", "targetAgentId", "targetType", "targetId", "agentIds", "connectionIds", "profileIds", "gatewayIds", "applicationIds", "catalogEntryIds", "projectIds", "issueIds", "routineId", "routineIds"]; const seenSelectors = new Set(); const addSelector = (selected: Record) => { assertBoundedManagementSelectors(selected); @@ -222,7 +224,8 @@ export function connectionManagementGuard(db: Db, purpose: "tool" | "chat" | "ai if (req.params.applicationId || body.applicationId) { const [application] = await db.select().from(toolApplications).where(eq(toolApplications.id, uuid(req.params.applicationId ?? body.applicationId))); bindCompany(application); - resource.provider ??= text(application.metadata.sourceTemplateKey); + applicationProvider = text(application.metadata.sourceTemplateKey); + resource.provider ??= applicationProvider; if (req.method !== "GET") { if (application.ownerAgentId) targets.add(application.ownerAgentId); const connections = await db.select({ id: toolConnections.id }).from(toolConnections).where(eq(toolConnections.applicationId, application.id)).limit(1025); @@ -281,8 +284,10 @@ export function connectionManagementGuard(db: Db, purpose: "tool" | "chat" | "ai if (!companyId) throw forbidden("A company is required for connection management"); assertCompanyAccess(req, companyId); resource.companyId = companyId; - if (operation === "POST /companies/:companyId/tools/connections") resource.provider = null; + if (operation === "POST /companies/:companyId/tools/connections" || operation === "POST /companies/:companyId/tools/applications") resource.provider = null; + else if (operation.includes("/tool-applications/:applicationId")) resource.provider = applicationProvider; else resource.provider ??= text(body.provider) ?? text(body.galleryKey) ?? text(req.params.galleryKey); + if (operation === "PATCH /tool-applications/:applicationId" && Object.hasOwn(body, "metadata")) resource.requestedProvider = text(body.metadata?.sourceTemplateKey); if (operation === "PATCH /tool-connections/:connectionId" && (body.config || body.transportConfig)) { const proposed = body.config ?? body.transportConfig; resource.requestedProvider = text(proposed.sourceTemplateKey) ?? text(proposed.provider) ?? text(proposed.ai?.provider); @@ -312,7 +317,7 @@ export function connectionManagementGuard(db: Db, purpose: "tool" | "chat" | "ai const selected = selectors[index]; // Expand each advertised plural selector. A permission over one member // cannot authorize a policy that also changes another member's access. - for (const key of ["connectionId", "profileId", "gatewayId", "applicationId", "catalogEntryId", "projectId", "issueId"] as const) { + for (const key of ["connectionId", "profileId", "gatewayId", "applicationId", "catalogEntryId", "projectId", "issueId", "routineId"] as const) { for (const id of Array.isArray(selected[`${key}s`]) ? selected[`${key}s`] as unknown[] : []) addSelector({ [key]: uuid(id) }); } if (selected.applicationId) { @@ -348,6 +353,8 @@ export function connectionManagementGuard(db: Db, purpose: "tool" | "chat" | "ai selfServiceUserId: connection.createdByUserId, }); } + if (["issue", "routine", "gateway"].includes(String(selected.targetType)) && text(selected.targetId)) bindingTargets.push({ targetType: String(selected.targetType), targetId: uuid(selected.targetId) }); + if (text(selected.routineId)) extraResources.push({ ...resource, routineId: uuid(selected.routineId) }); if (selected.targetType === "project" && text(selected.targetId)) extraResources.push({ ...resource, projectId: String(selected.targetId) }); if (text(selected.projectId)) extraResources.push({ ...resource, projectId: String(selected.projectId) }); } @@ -369,7 +376,7 @@ export function connectionManagementGuard(db: Db, purpose: "tool" | "chat" | "ai operation.includes("ai-connection-pools")); if (changesAudience) await authorize(req.actor, "connections:profiles", resource, { targetAgentIds: [...targets] }); const uniqueResources = new Map(extraResources.map(referenced => [JSON.stringify([ - referenced.connectionId, referenced.profileId, referenced.gatewayId, referenced.projectId, referenced.issueId, referenced.provider, referenced.purpose, + referenced.connectionId, referenced.profileId, referenced.gatewayId, referenced.projectId, referenced.issueId, referenced.routineId, referenced.provider, referenced.purpose, ]), referenced])); if (uniqueResources.size > 1024) throw badRequest("Connection management request spans too many resources"); const authorizeReferenced = async (afterHumanAction?: boolean) => { @@ -383,6 +390,7 @@ export function connectionManagementGuard(db: Db, purpose: "tool" | "chat" | "ai } } }; + for (const binding of bindingTargets) await management.authorizeBindingTarget(req.actor, action, resource, binding, { targetAgentIds: [...targets], mutation: req.method !== "GET", afterHumanAction }); await authorizeReferenced(); context.reauthorize = async (continuation) => { const current = await authorizeRequest(continuation?.afterHumanAction); @@ -433,7 +441,7 @@ export async function filterConnectionManagementPolicies[] = [{}]; const ids = (key: string) => [row.selectors[key], ...(Array.isArray(row.selectors[`${key}s`]) ? row.selectors[`${key}s`] as unknown[] : [])].filter((id): id is string => typeof id === "string"); - for (const key of ["connectionId", "profileId", "gatewayId", "projectId", "issueId"] as const) for (const id of ids(key)) resources.push({ [key]: id }); + for (const key of ["connectionId", "profileId", "gatewayId", "projectId", "issueId", "routineId"] as const) for (const id of ids(key)) resources.push({ [key]: id }); for (const id of ids("agentId")) resources.push({ targetAgentId: id }); for (const applicationId of ids("applicationId")) { const connections = await db.select({ connectionId: toolConnections.id }).from(toolConnections).where(and(eq(toolConnections.applicationId, applicationId), eq(toolConnections.companyId, context.resource.companyId))); diff --git a/server/src/services/connection-management-policy.ts b/server/src/services/connection-management-policy.ts index 58c37af1410..368878a2851 100644 --- a/server/src/services/connection-management-policy.ts +++ b/server/src/services/connection-management-policy.ts @@ -32,6 +32,7 @@ export interface ConnectionManagementResource { targetAgentId?: string | null; projectId?: string | null; issueId?: string | null; + routineId?: string | null; /** Personal self-service/creator authority, only for operations that permit it. */ selfServiceUserId?: string | null; /** Resolved installation diff changes only agent targets, preserving company sharing. */ diff --git a/server/src/services/connection-management.ts b/server/src/services/connection-management.ts index 3ca9b976d82..299cf897e13 100644 --- a/server/src/services/connection-management.ts +++ b/server/src/services/connection-management.ts @@ -1,5 +1,5 @@ import { and, eq, or } from "drizzle-orm"; -import { agents, agentApiKeys, heartbeatRuns, runIdentityContexts, issues, projects, toolConnections, toolConnectionInstalls, toolProfiles, toolProfileEntries, toolProfileBindings, toolMcpGateways, aiConnectionPools, connectionGrants, type Db } from "@paperclipai/db"; +import { agents, agentApiKeys, heartbeatRuns, runIdentityContexts, issues, projects, toolConnections, toolConnectionInstalls, toolProfiles, toolProfileEntries, toolProfileBindings, toolMcpGateways, aiConnectionPools, connectionGrants, routines, type Db } from "@paperclipai/db"; import { forbidden } from "../errors.js"; import { authorizationService, canActorReadIssuePrivacy, canActorReadProjectPrivacy, type AuthorizationActor, type AuthorizationDecision } from "./authorization.js"; import { captureRunIdentity } from "./run-identity.js"; @@ -97,10 +97,15 @@ export function connectionManagementService(db: Db) { const companyAgents = await db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, resource.companyId)); companyAgents.forEach(agent => expandedTargets.add(agent.id)); } + if (["issue", "routine", "gateway"].includes(binding.targetType)) { + await authorizeBindingTarget(actor, action, resource, binding, options); + } if (binding.targetType === "project") { await authorize(actor, action, { ...resource, projectId: binding.targetId }, { ...options, expandedProfile: true, gatewayContextChecked: options.gatewayContextChecked || Boolean(resource.gatewayId) }); } } + const linkedGateways = await db.select({ id: toolMcpGateways.id }).from(toolMcpGateways).where(and(eq(toolMcpGateways.companyId, resource.companyId), eq(toolMcpGateways.profileId, profile.id))); + for (const gateway of linkedGateways) await authorizeBindingTarget(actor, action, resource, { targetType: "gateway", targetId: gateway.id }, options); const entries = await db.select().from(toolProfileEntries).where(eq(toolProfileEntries.profileId, profile.id)); for (const entry of entries) if (entry.connectionId) referencedConnections.push(entry.connectionId); } @@ -111,7 +116,8 @@ export function connectionManagementService(db: Db) { purpose: connection.connectionPurpose === "ai" ? "ai" : connection.connectionPurpose === "channel" ? "chat" : "tool", provider: typeof connection.config.sourceTemplateKey === "string" ? connection.config.sourceTemplateKey : typeof connection.config.provider === "string" ? connection.config.provider - : typeof (connection.config.ai as Record | undefined)?.provider === "string" ? String((connection.config.ai as Record).provider) : resource.provider, + : typeof (connection.config.ai as Record | undefined)?.provider === "string" ? String((connection.config.ai as Record).provider) + : typeof connection.transportConfig.sourceTemplateKey === "string" ? connection.transportConfig.sourceTemplateKey : null, }; if (options.mutation ?? action !== "connections:read") { const installs = await db.select().from(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, connection.id)); @@ -153,6 +159,16 @@ export function connectionManagementService(db: Db) { if (!grant) throw forbidden("This personal connection is unavailable to the responsible user"); } } + if (resource.routineId) { + const [routine] = await db.select().from(routines).where(and(eq(routines.id, resource.routineId), eq(routines.companyId, resource.companyId))); + if (!routine) throw forbidden("Routine is unavailable in this company"); + resource = { ...resource, projectId: routine.projectId, issueId: routine.parentIssueId }; + if (routine.assigneeAgentId) expandedTargets.add(routine.assigneeAgentId); + else if (options.mutation ?? action !== "connections:read") { + const rows = await db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, resource.companyId)); + rows.forEach(row => expandedTargets.add(row.id)); + } + } if (actor.type === "agent") { if (resource.projectId) { const [project] = await db.select().from(projects).where(and(eq(projects.id, resource.projectId), eq(projects.companyId, resource.companyId))); @@ -161,6 +177,18 @@ export function connectionManagementService(db: Db) { if (resource.issueId) { const [issue] = await db.select().from(issues).where(and(eq(issues.id, resource.issueId), eq(issues.companyId, resource.companyId))); if (!issue || !(await canActorReadIssuePrivacy(db, actor, issue))) throw forbidden("Task is unavailable to this delegated principal"); + if (options.mutation ?? action !== "connections:read") { + if (issue.assigneeAgentId) expandedTargets.add(issue.assigneeAgentId); + else { + const rows = await db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, resource.companyId)); + rows.forEach(row => expandedTargets.add(row.id)); + } + } + if (issue.projectId && issue.projectId !== resource.projectId) { + const [project] = await db.select().from(projects).where(and(eq(projects.id, issue.projectId), eq(projects.companyId, resource.companyId))); + if (!project || !(await canActorReadProjectPrivacy(db, actor, project))) throw forbidden("Task project is unavailable to this delegated principal"); + } + resource = { ...resource, projectId: resource.projectId ?? issue.projectId }; if (issue.workMode === "skill_test" || ((options.mutation ?? action !== "connections:read") && issue.workMode !== "standard")) throw forbidden("Connection changes require a standard task"); } } @@ -217,6 +245,29 @@ export function connectionManagementService(db: Db) { return context; } + async function authorizeBindingTarget(actor: AuthorizationActor, action: ConnectionManagementAction, + resource: ConnectionManagementResource, binding: { targetType: string; targetId: string }, + options: NonNullable[3]> = {}): Promise { + let resolved = { ...resource }; + const checkOptions = { ...options, expandedProfile: true }; + if (binding.targetType === "issue") resolved.issueId = binding.targetId; + else if (binding.targetType === "routine") resolved.routineId = binding.targetId; + else if (binding.targetType === "gateway") { + const [gateway] = await db.select().from(toolMcpGateways).where(and(eq(toolMcpGateways.id, binding.targetId), eq(toolMcpGateways.companyId, resource.companyId))); + if (!gateway) throw forbidden("Binding gateway is unavailable in this company"); + resolved = { ...resolved, gatewayId: gateway.id, projectId: gateway.projectId, issueId: gateway.issueId }; + checkOptions.gatewayContextChecked = true; + const targets = new Set(options.targetAgentIds ?? []); + if (gateway.agentId) targets.add(gateway.agentId); + else { + const rows = await db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, resource.companyId)); + rows.forEach(row => targets.add(row.id)); + } + checkOptions.targetAgentIds = [...targets]; + } else throw forbidden("Unsupported profile binding target"); + return authorize(actor, action, resolved, checkOptions); + } + async function capability(...args: Parameters) { try { const context = await authorize(args[0], args[1], args[2], { ...args[3], capabilityProbe: true }); @@ -227,7 +278,7 @@ export function connectionManagementService(db: Db) { return { allowed: false, reason: error.message, operation: args[2].operation }; } } - return { authorize, capability }; + return { authorize, authorizeBindingTarget, capability }; } /** A pending provider login can finish after a successful yielding run. This diff --git a/server/src/services/connectors/slack/test-message.ts b/server/src/services/connectors/slack/test-message.ts index 51e9b08a6f3..8006ccf5621 100644 --- a/server/src/services/connectors/slack/test-message.ts +++ b/server/src/services/connectors/slack/test-message.ts @@ -44,7 +44,9 @@ export function slackTestMessageService(db: Db, options: Options) { const request = { companyId: endpoint.companyId, actor: { actorType: "agent" as const, actorId: context.actor.agentId!, agentId: endpoint.assignedAgentId }, - runContext: { heartbeatRunId: context.actor.runId ?? null }, + runContext: { heartbeatRunId: context.workContext.runId, issueId: context.workContext.issueId, + projectId: context.workContext.projectId, originatingAgentId: context.actor.agentId! }, + consumeRateLimit: true, request: { connectionId: endpoint.connectionId, providerType: "slack", applicationKey: "slack", toolName: "slack.send_message", upstreamToolName: "chat.postMessage", riskLevel: "write" as const, sideEffecting: true, arguments: { purpose: "setup_test", recipient: actor.userId } }, diff --git a/server/src/services/tool-access-policy.ts b/server/src/services/tool-access-policy.ts index d5a9ad03e3c..33724d02f00 100644 --- a/server/src/services/tool-access-policy.ts +++ b/server/src/services/tool-access-policy.ts @@ -799,6 +799,7 @@ export function toolAccessPolicyService(db: Db, options: { toolActionSigningSecr throw badRequest("policyIds must not contain duplicates"); } + await managementContext?.reauthorize(); return db.transaction(async (tx) => { const rows = await tx .select() @@ -821,7 +822,6 @@ export function toolAccessPolicyService(db: Db, options: { toolActionSigningSecr }); } - await managementContext?.reauthorize(); const now = new Date(); await tx.execute(sql`UPDATE tool_policies SET priority = (requested.ordinality * 100)::integer, updated_at = ${now.toISOString()} @@ -1384,8 +1384,10 @@ export function toolAccessPolicyService(db: Db, options: { toolActionSigningSecr status: toolInvocations.status, policyDecision: toolInvocations.policyDecision, approvalState: toolInvocations.approvalState, policyExplanation: toolInvocations.policyExplanation, }).from(toolInvocations).where(and(eq(toolInvocations.companyId, ctx.companyId), eq(toolInvocations.id, invocationId))); - const authorized = invocation && invocation.status === "executing" - && (invocation.policyDecision === "allow" || (invocation.policyDecision === "require_approval" && invocation.approvalState === "approved")) + const authorized = invocation && ( + (invocation.status === "executing" && (invocation.policyDecision === "allow" || (invocation.policyDecision === "require_approval" && invocation.approvalState === "approved"))) + || (invocation.status === "awaiting_approval" && invocation.policyDecision === "require_approval" && ["pending", "approved"].includes(invocation.approvalState)) + ) && invocation.actorType === ctx.actorType && invocation.actorId === ctx.actorId && invocation.agentId === ctx.agentId && invocation.runId === ctx.heartbeatRunId && invocation.issueId === ctx.issueId && invocation.gatewayId === ctx.gatewayId && invocation.applicationId === ctx.applicationId && invocation.connectionId === ctx.connectionId diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index 049d24511ad..cead6192f79 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -3226,6 +3226,7 @@ export function toolAccessService( priority: 100, metadata: { source: "app_gallery_finish" }, })); + await actor?.managementContext?.reauthorize(); const transactionResult = await db.transaction(async (tx) => { await tx.select({ id: toolApplications.id }).from(toolApplications) .where(and(eq(toolApplications.id, connection.applicationId), eq(toolApplications.companyId, companyId))) @@ -3259,7 +3260,6 @@ export function toolAccessService( const companyTargets = lockedConnection.status !== "active" || !lockedConnection.enabled || (existingProfile && existingProfile.status !== "active") ? [{ targetType: "company", targetId: companyId }] : []; await lockToolAssignmentAgents(tx, companyId, [...existingBindings, ...bindingInputs, ...existingInstalls, ...companyTargets]); - await actor?.managementContext?.reauthorize(); // A renamed connection can leave its old profile name in use. Resolve // names against profiles too, including when finalizing an installed draft. const otherProfiles = await tx diff --git a/server/src/services/tool-gateway.ts b/server/src/services/tool-gateway.ts index 2350ac09e44..5c62a0e4a9f 100644 --- a/server/src/services/tool-gateway.ts +++ b/server/src/services/tool-gateway.ts @@ -7766,7 +7766,7 @@ export function createToolGatewayService( runId: delegated.runId, keyId: delegated.keyId, }, "connections:test", delegated.resource, { targetAgentIds: [agentId], afterHumanAction: true }); Object.assign(session, managementContext.workContext, { originatingAgentId: delegated.agentId, approvedInvocationId: invocation.id }); - const current = await policyService.decide(policyInputForTool({ session, tool, parameters })); + const current = await policyService.decide(policyInputForTool({ session, tool, parameters }), { admittedInvocationId: invocation.id }); if (!current.allowed && current.decision !== "require_approval") throw new ToolGatewayHttpError(403, current.explanation, current.reasonCode); } const outcome = await runTestToolInvocation({ @@ -8232,6 +8232,7 @@ export function createToolGatewayService( tool, parameters: signedPayload.arguments, }), + { admittedInvocationId: invocation.id }, ); if ( !currentAccess.allowed && @@ -10131,6 +10132,11 @@ export function createToolGatewayService( token: "", companyId: input.companyId, agentId: invocation.agentId, + actorType: "agent", + actorId: invocation.actorId, + gatewayId: invocation.gatewayId, + gatewayPublicId: invocation.gatewayPublicId, + gatewayTokenId: invocation.gatewayTokenId, runId: invocation.runId, issueId: issue.id, projectId: issue.projectId, @@ -10160,6 +10166,7 @@ export function createToolGatewayService( tool, parameters: signedPayload.arguments, }), + { admittedInvocationId: invocation.id }, ); if (!access.allowed && access.decision !== "require_approval") throw new ToolGatewayHttpError(