Bound chat activity authorization to page candidates

Resolve only conversation IDs referenced by the bounded activity sources, share issue decisions, and reuse the run read context. The real-auth regression measures 200 checks before the fix and one afterward for one activity item.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-26 17:07:01 -05:00
1 parent 66360a5f1f
commit 69bf11aef9
2 files changed
+52 -11

No files matched your search

@@ -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<typeof service.capability>) => {
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));
+24 -10
View File
@@ -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<string> | undefined;
if (managementContext?.actor.type === "agent") {
const conversations = await db.select().from(chatConversations).where(eq(chatConversations.endpointId, endpointId));
const visibleConversations = new Set<string>();
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<string>();
const issueVisibility = new Map<string, boolean>();
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 [