From e5b8070d2fc89ad74c558ebb7ed41825d9266196 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 26 Oct 2026 17:47:24 -0500 Subject: [PATCH] Reuse request authority for tool connection activity Authorize each task once per bounded activity page and reuse the captured run identity while preserving private event filtering and request-time revocation. Co-Authored-By: Paperclip --- .../connection-management.integration.test.ts | 33 ++++++++++++++++++- .../services/tool-access/activity/service.ts | 13 ++++++-- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/server/src/__tests__/connection-management.integration.test.ts b/server/src/__tests__/connection-management.integration.test.ts index 977d4fd840d..51ffd178cc9 100644 --- a/server/src/__tests__/connection-management.integration.test.ts +++ b/server/src/__tests__/connection-management.integration.test.ts @@ -4,7 +4,7 @@ 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, companySecrets, chatConversations, chatDeliveries, 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 { activityLog, agentApiKeys, companies, companySecrets, chatConversations, chatDeliveries, chatEndpoints, chatExternalPrincipals, chatIdentityLinks, toolPolicies, connectionGrants, connectionGrantDelegations, toolAccessAuditEvents, toolOauthStates, issueThreadInteractions, connectionIntentDeliveries, agents, companyMemberships, heartbeatRuns, issues, principalPermissionGrants, projects, toolConnections, toolMcpGateways, toolMcpGatewayTokens, toolCatalogEntries, toolInvocations, toolCallEvents, 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"; @@ -802,6 +802,37 @@ describe("connection management with production default agents", () => { } finally { provider = null; } }); + it("shares tool-activity authorization by task and reuses run identity within one page", async () => { + const f = await fixture(); + const connection = await f.request("POST", `/companies/${f.companyId}/tools/connections`, { name: "Activity page", transport: "mcp_remote", authKind: "none", transportConfig: { url: "https://1.1.1.1/mcp" } }); + expect(connection).toMatchObject({ status: 201 }); + const [hidden] = await server.db.insert(issues).values({ companyId: f.companyId, title: "Private activity task", visibility: "private", status: "in_progress", responsibleUserId: randomUUID() }).returning(); + await server.db.insert(toolCallEvents).values(Array.from({ length: 100 }, (_, index) => ({ companyId: f.companyId, connectionId: connection.body.id, + eventType: "policy_decision" as const, actorType: "agent", actorId: f.agentId, issueId: index % 2 ? f.issueId : hidden.id, toolName: index % 2 ? "visible" : "private_activity" }))); + const original = connectionManagementModule.connectionManagementService; + const checked: string[] = []; + const authorization = vi.spyOn(connectionManagementModule, "connectionManagementService").mockImplementation((db) => { + const service = original(db); + return { ...service, capability: async (...args: Parameters) => { + if (args[1] === "connections:audit") checked.push(args[2].issueId ?? "unbound"); + return service.capability(...args); + } }; + }); + const capture = vi.spyOn(runIdentity, "captureRunIdentity"); + try { + const path = `/tool-connections/${connection.body.id}/activity?limit=100`; + const result = await f.request("GET", path, undefined, f.jwt); + expect(result, JSON.stringify(result)).toMatchObject({ status: 200 }); + expect(result.body.events).toHaveLength(50); + expect(JSON.stringify(result.body)).not.toContain("private_activity"); + expect(checked, JSON.stringify({ checks: checked.length, captures: capture.mock.calls.length })).toHaveLength(2); + expect(capture.mock.calls.length).toBeGreaterThan(0); + expect(capture.mock.calls.length).toBeLessThanOrEqual(2); + await server.db.delete(principalPermissionGrants).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:view_audit"))); + expect(await f.request("GET", path, undefined, f.jwt)).toMatchObject({ status: 403 }); + } finally { authorization.mockRestore(); capture.mockRestore(); } + }); + it("bounds activity authorization to the requested page rather than endpoint history", async () => { const f = await fixture(); const endpoint = await f.request("POST", `/companies/${f.companyId}/chat-endpoints`, { provider: "github", assignedAgentId: f.agentId }); diff --git a/server/src/services/tool-access/activity/service.ts b/server/src/services/tool-access/activity/service.ts index 655c17a55a3..8f5227b8c74 100644 --- a/server/src/services/tool-access/activity/service.ts +++ b/server/src/services/tool-access/activity/service.ts @@ -110,11 +110,18 @@ listConnectionActivity: async ( .orderBy(desc(toolCallEvents.createdAt)) .limit(safeLimit); if (managementContext?.actor.type === "agent") { + const management = connectionManagementService(db); + const taskVisibility = new Map(); const visible: typeof rows = []; for (const row of rows) { - if ((await connectionManagementService(db).capability(managementContext.actor, "connections:audit", { - ...managementContext.resource, connectionId: connection.id, issueId: row.issueId, - }, { mutation: false })).allowed) visible.push(row); + let allowed = taskVisibility.get(row.issueId); + if (allowed === undefined) { + allowed = (await management.capability(managementContext.actor, "connections:audit", { + ...managementContext.resource, connectionId: connection.id, issueId: row.issueId, + }, { mutation: false, readContext: managementContext })).allowed; + taskVisibility.set(row.issueId, allowed); + } + if (allowed) visible.push(row); } rows = visible; }