mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 04:25:52 +01:00
Merge complete profile resource authorization
Co-Authored-By: Paperclip <noreply@paperclip.ing> * codex/agent-connection-api-parity: Authorize complete profile selectors and effective account projections
This commit is contained in:
commit
54d91c685a
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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)));
|
||||
|
||||
Reference in new issue
Block a user