Authorize complete profile selectors and effective account projections

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-26 13:06:21 -05:00
1 parent 6bc3859883
commit 99e4653019
4 files changed
+99 -5

No files matched your search

@@ -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" } });
@@ -121,7 +121,7 @@ export function connectionManagementGuard(db: Db, purpose: "tool" | "chat" | "ai
const selectors: Record<string, unknown>[] = [];
const policyAudiences: Record<string, unknown>[] = [];
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<string>();
const addSelector = (selected: Record<string, unknown>) => {
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,
+6 -1
View File
@@ -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) => {
+34 -2
View File
@@ -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)));