diff --git a/server/src/__tests__/connection-management.integration.test.ts b/server/src/__tests__/connection-management.integration.test.ts index 328b973e019..1d0b85a2a5d 100644 --- a/server/src/__tests__/connection-management.integration.test.ts +++ b/server/src/__tests__/connection-management.integration.test.ts @@ -4,13 +4,14 @@ 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, 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, 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"; import * as remoteEndpointGuard from "../services/remote-http-endpoint-guard.js"; import * as remoteHttpFetch from "../services/remote-http-fetch.js"; import * as runIdentity from "../services/run-identity.js"; +import * as connectionManagementModule from "../services/connection-management.js"; import { createLocalAgentJwt } from "../agent-auth-jwt.js"; import { startRunnerApiTestServer } from "./helpers/runner-api-server.js"; import { getServerAdapter, registerServerAdapter, unregisterServerAdapter } from "../adapters/index.js"; @@ -761,6 +762,32 @@ describe("connection management with production default agents", () => { } finally { provider = null; } }); + 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 }); + expect(endpoint).toMatchObject({ status: 201 }); + const conversations = await server.db.insert(chatConversations).values(Array.from({ length: 200 }, (_, index) => ({ + companyId: f.companyId, endpointId: endpoint.body.id, issueId: f.issueId, + externalConversationId: `history-${index}`, externalLabel: `History ${index}`, + }))).returning(); + const [delivery] = await server.db.insert(chatDeliveries).values({ companyId: f.companyId, endpointId: endpoint.body.id, + conversationId: conversations[0].id, providerEventId: randomUUID(), deduplicationKey: randomUUID(), eventKind: "mention", state: "processed", normalizedEvent: {} }).returning(); + 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); + } }; + }); + try { + const result = await f.request("GET", `/chat-endpoints/${endpoint.body.id}/activity?limit=1`, undefined, f.jwt); + expect(result, JSON.stringify(result)).toMatchObject({ status: 200, body: { items: [expect.objectContaining({ id: delivery.id })] } }); + expect(checked).toEqual([f.issueId]); + } finally { authorization.mockRestore(); } + }); + it("saves delegated Arcade discovery for the responsible user and shares its inventory with that user", async () => { const f = await fixture(); const transport = vi.spyOn(remoteHttpFetch, "guardedRemoteHttpFetch").mockImplementation(async (url, init) => provider!(String(url), init)); diff --git a/server/src/services/chat-channels/activity.ts b/server/src/services/chat-channels/activity.ts index 0637780aef7..13217b69e89 100644 --- a/server/src/services/chat-channels/activity.ts +++ b/server/src/services/chat-channels/activity.ts @@ -336,18 +336,32 @@ export function createChatActivity(db: Db, options: ChatActivityOptions) { // An endpoint manager may still lack access to individual private tasks. let visibleIds: Set | undefined; if (managementContext?.actor.type === "agent") { - const conversations = await db.select().from(chatConversations).where(eq(chatConversations.endpointId, endpointId)); - const visibleConversations = new Set(); - for (const conversation of conversations) { - const decision = await connectionManagementService(db).capability(managementContext.actor, "connections:audit", - { ...managementContext.resource, issueId: conversation.issueId }, { mutation: false }); - if (decision.allowed) visibleConversations.add(conversation.id); - } - const visible = (row: { id: string; conversationId: string | null }) => !row.conversationId || visibleConversations.has(row.conversationId); - visibleIds = new Set([ + const activityRows = [ ...deliveries, ...publications, ...slashActions, ...providerEffects, ...githubIngressFailures, ...slackSessionActions, ...githubRecoveries.map(row => row.action), - ].filter(visible).map(row => row.id)); + ]; + // Each source is page-bounded above. Endpoint history does not contribute + // authorization work unless a conversation appears in these candidates. + const conversationIds = [...new Set(activityRows.flatMap(row => row.conversationId ? [row.conversationId] : []))]; + const conversations = conversationIds.length ? await db.select({ id: chatConversations.id, issueId: chatConversations.issueId }).from(chatConversations).where(and( + eq(chatConversations.companyId, managementContext.resource.companyId), + eq(chatConversations.endpointId, endpointId), inArray(chatConversations.id, conversationIds), + )) : []; + const visibleConversations = new Set(); + const issueVisibility = new Map(); + const management = connectionManagementService(db); + for (const conversation of conversations) { + let allowed = issueVisibility.get(conversation.issueId); + if (allowed === undefined) { + const decision = await management.capability(managementContext.actor, "connections:audit", + { ...managementContext.resource, issueId: conversation.issueId }, { mutation: false, readContext: managementContext }); + allowed = decision.allowed; + issueVisibility.set(conversation.issueId, allowed); + } + if (allowed) visibleConversations.add(conversation.id); + } + const visible = (row: { id: string; conversationId: string | null }) => !row.conversationId || visibleConversations.has(row.conversationId); + visibleIds = new Set(activityRows.filter(visible).map(row => row.id)); githubRecoveryScans.forEach(row => visibleIds!.add(row.id)); } return [