mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 20:21:50 +01:00
Merge linked-resource authorization review fixes
Co-Authored-By: Paperclip <noreply@paperclip.ing> * codex/agent-connection-api-parity: fix(connections): enforce linked targets and preserve governed call admission
This commit is contained in:
commit
f364ca34dd
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; }
|
||||
});
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user