From 99e4653019b37113701b50c8ea01baf01717e859 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 26 Oct 2026 13:06:21 -0500 Subject: [PATCH] Authorize complete profile selectors and effective account projections Co-Authored-By: Paperclip --- .../connection-management.integration.test.ts | 47 +++++++++++++++++++ .../src/routes/connection-management-authz.ts | 14 +++++- server/src/routes/tool-access.ts | 7 ++- server/src/services/connection-management.ts | 36 +++++++++++++- 4 files changed, 99 insertions(+), 5 deletions(-) diff --git a/server/src/__tests__/connection-management.integration.test.ts b/server/src/__tests__/connection-management.integration.test.ts index 215939719e7..8f35f853904 100644 --- a/server/src/__tests__/connection-management.integration.test.ts +++ b/server/src/__tests__/connection-management.integration.test.ts @@ -96,6 +96,53 @@ describe("connection management with production default agents", () => { expect(profile, JSON.stringify(profile)).toMatchObject({ status: 201 }); }); + it("checks every saved profile selector and effective projection against personal account visibility", async () => { + const f = await fixture(); + const first = await f.request("POST", `/companies/${f.companyId}/tools/connections`, { name: "Owned profile account", transport: "mcp_remote", authKind: "none", config: { url: "https://1.1.1.1/mcp" } }); + expect(first).toMatchObject({ status: 201 }); + const second = await f.request("POST", `/companies/${f.companyId}/tools/connections`, { applicationId: first.body.applicationId, name: "Private profile account", transport: "mcp_remote", authKind: "none", config: { url: "https://1.1.1.1/mcp" } }); + expect(second, JSON.stringify(second)).toMatchObject({ status: 201, body: { applicationId: first.body.applicationId } }); + const [known] = await server.db.insert(toolCatalogEntries).values({ companyId: f.companyId, applicationId: first.body.applicationId, connectionId: first.body.id, name: "known", toolName: "known", versionHash: "known" }).returning(); + const profileIds: string[] = []; + const selectors = [ + { selectorType: "application", applicationId: first.body.applicationId }, + { selectorType: "catalog_entry", catalogEntryId: known.id }, + { selectorType: "tool_name", toolName: "private_tool" }, + { selectorType: "risk_level", riskLevel: "read" }, + ]; + for (const [index, entry] of selectors.entries()) { + const profile = await f.request("POST", `/companies/${f.companyId}/tools/profiles`, { profileKey: `privacy-${index}`, name: `Privacy ${index}`, entries: [entry] }); + expect(profile, JSON.stringify(profile)).toMatchObject({ status: 201 }); + profileIds.push(profile.body.id); + } + const all = await f.request("POST", `/companies/${f.companyId}/tools/profiles`, { profileKey: "privacy-all", name: "All tools", defaultAction: "allow", entries: [] }); + expect(all).toMatchObject({ status: 201 }); + profileIds.push(all.body.id); + expect(await f.request("POST", `/companies/${f.companyId}/tools/profiles/${profileIds[0]}/bind`, { targetType: "agent", targetId: f.agentId })).toMatchObject({ status: 201 }); + await server.db.insert(toolCatalogEntries).values({ companyId: f.companyId, applicationId: first.body.applicationId, connectionId: second.body.id, name: "private_tool", toolName: "private_tool", description: "Private tool metadata", versionHash: "private", firstSeenAt: new Date(Date.now() + 60_000) }); + expect(await f.request("GET", `/tool-profiles/${profileIds[1]}/new-tools`)).toMatchObject({ status: 200, body: { tools: [expect.objectContaining({ connectionName: "Private profile account", description: "Private tool metadata" })] } }); + const effectivePath = `/companies/${f.companyId}/tools/profiles/effective/agents/${f.agentId}`; + expect((await f.request("GET", effectivePath)).body.allowedTools.some((tool: { toolName: string }) => tool.toolName === "private_tool")).toBe(true); + expect(await f.request("PUT", `/tool-connections/${second.body.id}/installs`, { installs: [{ targetType: "agent", targetId: f.agentId }] })).toMatchObject({ status: 200 }); + await server.db.update(toolConnections).set({ credentialPolicy: "per_user", createdByUserId: randomUUID() }).where(eq(toolConnections.id, second.body.id)); + for (const token of [f.key.token, f.jwt]) { + expect(await f.request("GET", `/tool-profiles/${profileIds[1]}/new-tools`, undefined, token)).toMatchObject({ status: 403 }); + for (const id of profileIds) expect(await f.request("GET", `/tool-profiles/${id}/new-tools`, undefined, token)).toMatchObject({ status: 403 }); + expect((await f.request("GET", `/companies/${f.companyId}/tools/profiles`, undefined, token)).body.profiles).toEqual([]); + expect(await f.request("GET", effectivePath, undefined, token)).toMatchObject({ status: 403 }); + } + for (const [index, entry] of selectors.entries()) expect(await f.request("POST", `/companies/${f.companyId}/tools/profiles`, { profileKey: `private-attempt-${index}`, name: `Private attempt ${index}`, entries: [entry] })).toMatchObject({ status: 403 }); + expect(await f.request("POST", `/companies/${f.companyId}/tools/profiles`, { profileKey: "private-attempt-all", name: "Private all", defaultAction: "allow", entries: [] })).toMatchObject({ status: 403 }); + await server.db.delete(toolProfileBindings).where(and(eq(toolProfileBindings.companyId, f.companyId), eq(toolProfileBindings.targetType, "agent"), eq(toolProfileBindings.targetId, f.agentId))); + expect(await f.request("GET", effectivePath)).toMatchObject({ status: 403 }); + await server.db.update(toolConnections).set({ createdByUserId: f.userId }).where(eq(toolConnections.id, second.body.id)); + expect(await f.request("GET", effectivePath)).toMatchObject({ status: 200 }); + await server.db.update(principalPermissionGrants).set({ scope: { connectionIds: [first.body.id] } }).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_profiles"))); + for (const id of profileIds) expect(await f.request("GET", `/tool-profiles/${id}/new-tools?connectionId=${first.body.id}`)).toMatchObject({ status: 403 }); + await server.db.update(principalPermissionGrants).set({ scope: { connectionIds: [first.body.id, second.body.id] } }).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_profiles"))); + for (const id of profileIds) expect(await f.request("GET", `/tool-profiles/${id}/new-tools?connectionId=${first.body.id}`)).toMatchObject({ status: 200 }); + }); + it("rechecks authority after remote endpoint validation before committing a connection update", async () => { const f = await fixture(); const created = await f.request("POST", `/companies/${f.companyId}/tools/connections`, { name: "Before validation", transport: "mcp_remote", authKind: "none", transportConfig: { url: "https://1.1.1.1/mcp" } }); diff --git a/server/src/routes/connection-management-authz.ts b/server/src/routes/connection-management-authz.ts index 8b0ac2718f8..d747ebfb271 100644 --- a/server/src/routes/connection-management-authz.ts +++ b/server/src/routes/connection-management-authz.ts @@ -121,7 +121,7 @@ export function connectionManagementGuard(db: Db, purpose: "tool" | "chat" | "ai const selectors: Record[] = []; const policyAudiences: Record[] = []; if (Object.hasOwn(body, "selectors")) policyAudiences.push(body.selectors ?? {}); - 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 selectorKeys = ["connectionId", "profileId", "gatewayId", "applicationId", "catalogEntryId", "projectId", "issueId", "routineId", "agentId", "targetAgentId", "targetType", "targetId", "agentIds", "connectionIds", "profileIds", "gatewayIds", "applicationIds", "catalogEntryIds", "projectIds", "issueIds", "routineId", "routineIds", "selectorType", "toolName", "riskLevel", "defaultAction"]; const seenSelectors = new Set(); const addSelector = (selected: Record) => { assertBoundedManagementSelectors(selected); @@ -210,6 +210,7 @@ export function connectionManagementGuard(db: Db, purpose: "tool" | "chat" | "ai if (req.params.entryId) { const [entry] = await db.select().from(toolProfileEntries).where(eq(toolProfileEntries.id, uuid(req.params.entryId))); bindCompany(entry); profileId = entry.profileId; connectionId ??= entry.connectionId; + addSelector({ ...entry, ...body }); } if (req.params.tokenId) { const [token] = await db.select().from(toolMcpGatewayTokens).where(eq(toolMcpGatewayTokens.id, uuid(req.params.tokenId))); @@ -317,6 +318,14 @@ export function connectionManagementGuard(db: Db, purpose: "tool" | "chat" | "ai } for (let index = 0; index < selectors.length; index++) { const selected = selectors[index]; + const profileOperation = operation.includes("profile"); + if (profileOperation && (selected.defaultAction === "allow" || selected.selectorType === "tool_name" && text(selected.toolName) || selected.selectorType === "risk_level" && text(selected.riskLevel))) { + const connections = await lookup(`profile-catalog:${JSON.stringify([selected.defaultAction, selected.selectorType, selected.toolName, selected.riskLevel])}`, () => db.selectDistinct({ connectionId: toolCatalogEntries.connectionId }).from(toolCatalogEntries).where(and( + eq(toolCatalogEntries.companyId, resource.companyId), eq(toolCatalogEntries.status, "active"), + selected.defaultAction === "allow" ? undefined : selected.selectorType === "tool_name" ? eq(toolCatalogEntries.toolName, String(selected.toolName)) : sql`${toolCatalogEntries.riskLevel} = ${String(selected.riskLevel)}`, + )).limit(1025)); + connections.forEach(connection => addSelector(connection)); + } // 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", "routineId"] as const) { @@ -342,8 +351,9 @@ export function connectionManagementGuard(db: Db, purpose: "tool" | "chat" | "ai } let selectedConnectionId = text(selected.connectionId); if (selected.catalogEntryId) { - const [entry] = await lookup(`catalog:${String(selected.catalogEntryId)}`, () => db.select().from(toolCatalogEntries).where(eq(toolCatalogEntries.id, String(selected.catalogEntryId)))); + const [entry] = await lookup(`catalog:${String(selected.catalogEntryId)}`, () => db.select().from(toolCatalogEntries).where(eq(toolCatalogEntries.id, uuid(selected.catalogEntryId)))); bindCompany(entry); selectedConnectionId = entry.connectionId; + if (profileOperation && entry.applicationId) addSelector({ applicationId: entry.applicationId }); } if (selectedConnectionId && selectedConnectionId !== connectionId) { const [connection] = await lookup(`connection:${selectedConnectionId}`, () => db.select({ id: toolConnections.id, companyId: toolConnections.companyId, createdByUserId: toolConnections.createdByUserId, diff --git a/server/src/routes/tool-access.ts b/server/src/routes/tool-access.ts index bc8fefc8431..bd1a7063195 100644 --- a/server/src/routes/tool-access.ts +++ b/server/src/routes/tool-access.ts @@ -2356,7 +2356,12 @@ function connectorEnrollmentPrincipal(req: Request): string { assertBoardOrAgent(req); const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); - res.json(await svc.getEffectiveProfilesForAgent(companyId, req.params.agentId as string)); + const effective = await svc.getEffectiveProfilesForAgent(companyId, req.params.agentId as string); + const profiles = await filterConnectionManagementRows(db, req, "connections:profiles", effective.profiles, profile => ({ profileId: profile.id })); + const tools = await filterConnectionManagementRows(db, req, "connections:profiles", effective.allowedTools, tool => ({ connectionId: tool.connectionId })); + const installed = await filterConnectionManagementRows(db, req, "connections:profiles", effective.installedConnections, connection => ({ connectionId: connection.id })); + if (profiles.length !== effective.profiles.length || tools.length !== effective.allowedTools.length || installed.length !== effective.installedConnections.length) throw forbidden("Effective profile resources are unavailable to this delegated principal"); + res.json(effective); }); router.patch("/tool-profiles/:profileId", managementGuard("connections:profiles", "PATCH /tool-profiles/:profileId", { personal: true }), validate(updateToolProfileWithEntriesSchema), async (req, res) => { diff --git a/server/src/services/connection-management.ts b/server/src/services/connection-management.ts index 4de417d2605..fcc532476be 100644 --- a/server/src/services/connection-management.ts +++ b/server/src/services/connection-management.ts @@ -1,5 +1,6 @@ -import { and, eq, or } from "drizzle-orm"; -import { agents, agentApiKeys, heartbeatRuns, runIdentityContexts, issues, projects, toolConnections, toolConnectionInstalls, toolProfiles, toolProfileEntries, toolProfileBindings, toolMcpGateways, aiConnectionPools, connectionGrants, routines, type Db } from "@paperclipai/db"; +import { and, eq, inArray, or } from "drizzle-orm"; +import { isUuidLike } from "@paperclipai/shared"; +import { agents, agentApiKeys, heartbeatRuns, runIdentityContexts, issues, projects, toolApplications, toolCatalogEntries, 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"; @@ -90,6 +91,9 @@ export function connectionManagementService(db: Db) { if (resource.profileId && !options.expandedProfile) { const [profile] = await db.select().from(toolProfiles).where(and(eq(toolProfiles.id, resource.profileId), eq(toolProfiles.companyId, resource.companyId))); if (!profile) throw forbidden("Profile is unavailable in this company"); + // Managed installation profiles can be empty while their labels still + // identify the connection they administer. + if (profile.profileKey.startsWith("app:") && isUuidLike(profile.profileKey.slice(4))) referencedConnections.push(profile.profileKey.slice(4)); const bindings = await db.select().from(toolProfileBindings).where(eq(toolProfileBindings.profileId, profile.id)); for (const binding of bindings) { if (binding.targetType === "agent") expandedTargets.add(binding.targetId); @@ -106,6 +110,34 @@ export function connectionManagementService(db: Db) { 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); + const applicationIds = new Set(entries.flatMap(entry => entry.selectorType === "application" && entry.applicationId ? [entry.applicationId] : [])); + const catalogIds = new Set(entries.flatMap(entry => entry.catalogEntryId ? [entry.catalogEntryId] : [])); + if (catalogIds.size) { + const selected = await db.select({ id: toolCatalogEntries.id, connectionId: toolCatalogEntries.connectionId, applicationId: toolCatalogEntries.applicationId }).from(toolCatalogEntries) + .where(and(eq(toolCatalogEntries.companyId, resource.companyId), inArray(toolCatalogEntries.id, [...catalogIds]))); + if (selected.length !== catalogIds.size) throw forbidden("Profile catalog selection is unavailable in this company"); + for (const entry of selected) { + referencedConnections.push(entry.connectionId); + // New-tool review also covers other connections in the selected + // catalog entry's application, not just its original connection. + if (entry.applicationId) applicationIds.add(entry.applicationId); + } + } + if (applicationIds.size) { + const selected = await db.select({ id: toolApplications.id }).from(toolApplications).where(and(eq(toolApplications.companyId, resource.companyId), inArray(toolApplications.id, [...applicationIds]))); + if (selected.length !== applicationIds.size) throw forbidden("Profile application selection is unavailable in this company"); + const connections = await db.select({ id: toolConnections.id }).from(toolConnections).where(and(eq(toolConnections.companyId, resource.companyId), inArray(toolConnections.applicationId, [...applicationIds]))); + connections.forEach(connection => referencedConnections.push(connection.id)); + } + const toolNames = entries.flatMap(entry => entry.selectorType === "tool_name" && entry.toolName ? [entry.toolName] : []); + const riskLevels = entries.flatMap(entry => entry.selectorType === "risk_level" && entry.riskLevel ? [entry.riskLevel] : []); + if (profile.defaultAction === "allow" || toolNames.length || riskLevels.length) { + const selected = await db.selectDistinct({ connectionId: toolCatalogEntries.connectionId }).from(toolCatalogEntries).where(and( + eq(toolCatalogEntries.companyId, resource.companyId), eq(toolCatalogEntries.status, "active"), + profile.defaultAction === "allow" ? undefined : or(toolNames.length ? inArray(toolCatalogEntries.toolName, toolNames) : undefined, riskLevels.length ? inArray(toolCatalogEntries.riskLevel, riskLevels) : undefined), + )); + selected.forEach(entry => referencedConnections.push(entry.connectionId)); + } } if (resource.connectionId) { const [connection] = await db.select().from(toolConnections).where(and(eq(toolConnections.id, resource.connectionId), eq(toolConnections.companyId, resource.companyId)));