fix(connections): enforce linked targets and preserve governed call admission

Authorize issue, routine, and gateway profile targets, including retained gateway references. Filter GitHub reviews by linked task visibility, count Slack setup sends against policy quotas, reject caller-labelled application provider spoofing, preserve approved invocation rate admission, and authorize before taking profile/policy transaction locks. Add actual-authenticated scope, approval, visibility, quota, and concurrency regressions.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-26 11:20:49 -05:00
1 parent dc329dd938
commit 833b090ecc
9 files changed
+197 -22

No files matched your search

@@ -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<void>((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; }
});
+6 -2
View File
@@ -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;
@@ -34,7 +34,7 @@ export function requiredConnectionUserAction(
function assertBoundedManagementSelectors(body: Record<string, unknown>) {
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<string, unknown>[] = [body];
const unique = new Set<string>();
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<string>();
const extraResources: ConnectionManagementResource[] = [];
const bindingTargets: { targetType: string; targetId: string }[] = [];
const selectors: Record<string, unknown>[] = [];
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<string>();
const addSelector = (selected: Record<string, unknown>) => {
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<T extends { selectors:
for (const row of rows) {
const resources: Partial<ConnectionManagementResource>[] = [{}];
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)));
@@ -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. */
+54 -3
View File
@@ -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<string, unknown> | undefined)?.provider === "string" ? String((connection.config.ai as Record<string, unknown>).provider) : resource.provider,
: typeof (connection.config.ai as Record<string, unknown> | undefined)?.provider === "string" ? String((connection.config.ai as Record<string, unknown>).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<Parameters<typeof authorize>[3]> = {}): Promise<ConnectionManagementContext> {
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<typeof authorize>) {
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
@@ -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 } },
+5 -3
View File
@@ -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
+1 -1
View File
@@ -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
+8 -1
View File
@@ -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(