mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 04:25:52 +01:00
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:
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));
|
||||
|
||||
@@ -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 [
|
||||
|
||||
Reference in new issue
Block a user