mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 18:31:34 +01:00
feat: enforce connection access permissions
Limit connection configuration and reconnect operations to the connection creator or a connection manager. Keep agent installs self-service for members who have connection access. Require agent or company installs when minting connection tokens and generating managed MCP gateway configuration. Add audit events for grant and install mutations, and map owner and admin roles to the default tools permissions. Add focused coverage for install enforcement, delegation isolation, creator-or-manager authorization, self-service installs, role defaults, and audit events. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
adfbe2d4b9
commit
ca8d7ee2c1
8 files changed
+538
-34
No files matched your search
@@ -21,7 +21,7 @@ import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { buildPaperclipRuntimeMcpServers } from "../services/heartbeat.js";
|
||||
import { buildPaperclipRuntimeMcpServers, createManagedMcpRunConfig } from "../services/heartbeat.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
@@ -240,4 +240,84 @@ describeEmbeddedPostgres("heartbeat runtime MCP servers", () => {
|
||||
details: expect.objectContaining({ runId, deliveredServerCount: 0 }),
|
||||
});
|
||||
});
|
||||
|
||||
it("injects only managed gateways whose profile connections are installed for the agent", async () => {
|
||||
const [company] = await db.insert(companies).values({
|
||||
name: `Managed gateway installs ${randomUUID()}`,
|
||||
issuePrefix: `MG${randomUUID().slice(0, 5).toUpperCase()}`,
|
||||
}).returning();
|
||||
const [agent] = await db.insert(agents).values({
|
||||
companyId: company!.id,
|
||||
name: "Managed Gateway Agent",
|
||||
role: "engineer",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
}).returning();
|
||||
const [application] = await db.insert(toolApplications).values({
|
||||
companyId: company!.id,
|
||||
applicationKey: `managed-gateway-${randomUUID().slice(0, 8)}`,
|
||||
name: "Managed Gateway App",
|
||||
type: "mcp_http",
|
||||
status: "active",
|
||||
}).returning();
|
||||
const connections = await db.insert(toolConnections).values([
|
||||
{
|
||||
companyId: company!.id,
|
||||
applicationId: application!.id,
|
||||
name: "Installed gateway connection",
|
||||
uid: `test/${randomUUID()}`,
|
||||
transport: "mcp_remote",
|
||||
status: "active",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
companyId: company!.id,
|
||||
applicationId: application!.id,
|
||||
name: "Uninstalled gateway connection",
|
||||
uid: `test/${randomUUID()}`,
|
||||
transport: "mcp_remote",
|
||||
status: "active",
|
||||
enabled: true,
|
||||
},
|
||||
]).returning();
|
||||
const profiles = await db.insert(toolProfiles).values(connections.map((connection) => ({
|
||||
companyId: company!.id,
|
||||
profileKey: `gateway:${connection.id}`,
|
||||
name: connection.name,
|
||||
defaultAction: "deny" as const,
|
||||
}))).returning();
|
||||
await db.insert(toolProfileEntries).values(profiles.map((profile, index) => ({
|
||||
companyId: company!.id,
|
||||
profileId: profile.id,
|
||||
selectorType: "connection" as const,
|
||||
effect: "include" as const,
|
||||
connectionId: connections[index]!.id,
|
||||
})));
|
||||
const gateways = await db.insert(toolMcpGateways).values(profiles.map((profile, index) => ({
|
||||
companyId: company!.id,
|
||||
name: `${connections[index]!.name} gateway`,
|
||||
slug: `gateway-${index}-${randomUUID().slice(0, 8)}`,
|
||||
profileId: profile.id,
|
||||
status: "active" as const,
|
||||
}))).returning();
|
||||
await db.insert(toolConnectionInstalls).values({
|
||||
companyId: company!.id,
|
||||
connectionId: connections[0]!.id,
|
||||
targetType: "agent",
|
||||
targetId: agent!.id,
|
||||
});
|
||||
|
||||
const config = await createManagedMcpRunConfig({
|
||||
db,
|
||||
agent: agent!,
|
||||
runId: randomUUID(),
|
||||
config: {},
|
||||
projectId: null,
|
||||
issueId: null,
|
||||
});
|
||||
|
||||
expect(config?.gateways).toHaveLength(1);
|
||||
expect(config?.gateways[0]).toMatchObject({ id: gateways[0]!.id, name: gateways[0]!.name });
|
||||
expect(config?.gateways.some((gateway) => gateway.id === gateways[1]!.id)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -75,6 +75,10 @@ describe("human invite roles", () => {
|
||||
{ permissionKey: "users:manage_permissions", scope: null },
|
||||
{ permissionKey: "tasks:assign", scope: null },
|
||||
{ permissionKey: "joins:approve", scope: null },
|
||||
{ permissionKey: "tools:manage_connections", scope: null },
|
||||
{ permissionKey: "tools:manage_runtime", scope: null },
|
||||
{ permissionKey: "tools:use", scope: null },
|
||||
{ permissionKey: "tools:admin", scope: null },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -87,6 +91,10 @@ describe("human invite roles", () => {
|
||||
{ permissionKey: "users:invite", scope: null },
|
||||
{ permissionKey: "tasks:assign", scope: null },
|
||||
{ permissionKey: "joins:approve", scope: null },
|
||||
{ permissionKey: "tools:manage_connections", scope: null },
|
||||
{ permissionKey: "tools:manage_runtime", scope: null },
|
||||
{ permissionKey: "tools:use", scope: null },
|
||||
{ permissionKey: "tools:admin", scope: null },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -212,6 +212,12 @@ async function allowConnectionForAgent(
|
||||
connectionId: string,
|
||||
input: { brokerMint?: boolean } = {},
|
||||
) {
|
||||
await db.insert(toolConnectionInstalls).values({
|
||||
companyId,
|
||||
connectionId,
|
||||
targetType: "agent",
|
||||
targetId: agentId,
|
||||
});
|
||||
const [profile] = await db.insert(toolProfiles).values({
|
||||
companyId,
|
||||
profileKey: `broker-${randomUUID()}`,
|
||||
@@ -542,6 +548,56 @@ describeEmbeddedPostgres("tool access service", () => {
|
||||
]));
|
||||
});
|
||||
|
||||
it("denies token minting with an actionable error when the requesting agent has no install", async () => {
|
||||
const company = await createCompany(db);
|
||||
const agent = await createAgent(db, company.id);
|
||||
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
||||
const { connection } = await createBrokerConnection(db, company.id);
|
||||
await allowConnectionForAgent(db, company.id, agent.id, connection.id);
|
||||
await db.delete(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, connection.id));
|
||||
const app = createRouteApp(db, agentJwtActor(company.id, agent.id, run.id));
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch");
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/agents/me/connections/${encodeURIComponent(connection.uid)}/token`)
|
||||
.set("X-Paperclip-Run-Id", run.id)
|
||||
.send({ scope: "pages:publish:ns/dotta" });
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body).toMatchObject({
|
||||
code: "installation_required",
|
||||
connection: { id: connection.id, name: connection.name },
|
||||
remediation: { action: "install_connection", targetType: "agent", targetId: agent.id },
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
const [audit] = await db
|
||||
.select()
|
||||
.from(toolAccessAuditEvents)
|
||||
.where(eq(toolAccessAuditEvents.reasonCode, "installation_required"));
|
||||
expect(audit).toMatchObject({ actorType: "agent", actorId: agent.id, outcome: "failure" });
|
||||
});
|
||||
|
||||
it("accepts a company-wide install when minting a token", async () => {
|
||||
const company = await createCompany(db);
|
||||
const agent = await createAgent(db, company.id);
|
||||
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
||||
const { connection } = await createBrokerConnection(db, company.id, { path: "static" });
|
||||
await allowConnectionForAgent(db, company.id, agent.id, connection.id);
|
||||
await db
|
||||
.update(toolConnectionInstalls)
|
||||
.set({ targetType: "company", targetId: company.id })
|
||||
.where(eq(toolConnectionInstalls.connectionId, connection.id));
|
||||
const app = createRouteApp(db, agentJwtActor(company.id, agent.id, run.id));
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/agents/me/connections/${encodeURIComponent(connection.uid)}/token`)
|
||||
.set("X-Paperclip-Run-Id", run.id)
|
||||
.send({ scope: "pages:publish:ns/dotta" });
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body).toMatchObject({ status: "use_env_lease", connectionId: connection.id });
|
||||
});
|
||||
|
||||
it("selects scoped credentials for array scopes and fails closed for unknown selectors", async () => {
|
||||
const company = await createCompany(db);
|
||||
const agent = await createAgent(db, company.id);
|
||||
@@ -3182,7 +3238,11 @@ describeEmbeddedPostgres("tool access service", () => {
|
||||
vi.stubEnv("PAPERCLIP_PUBLIC_URL", "http://paperclip.test");
|
||||
const company = await createCompany(db);
|
||||
const service = toolAccessService(db);
|
||||
const connect = await service.connectGalleryApp(company.id, { galleryKey: "slack", name: "Slack reauth" });
|
||||
const connect = await service.connectGalleryApp(
|
||||
company.id,
|
||||
{ galleryKey: "slack", name: "Slack reauth" },
|
||||
{ actorType: "user", actorId: "operator-user" },
|
||||
);
|
||||
await db
|
||||
.update(toolConnections)
|
||||
.set({ status: "active", updatedAt: new Date() })
|
||||
@@ -3258,7 +3318,13 @@ describeEmbeddedPostgres("tool access service", () => {
|
||||
vi.stubEnv("PAPERCLIP_PUBLIC_URL", "http://paperclip.test");
|
||||
const company = await createCompany(db);
|
||||
const service = toolAccessService(db);
|
||||
const connect = await service.connectGalleryApp(company.id, { galleryKey: "slack", name: "Slack bound" });
|
||||
// The initiating operator owns the connection: reconnecting is limited to the
|
||||
// creator or a connection manager, and this case asserts session binding.
|
||||
const connect = await service.connectGalleryApp(
|
||||
company.id,
|
||||
{ galleryKey: "slack", name: "Slack bound" },
|
||||
{ actorType: "user", actorId: "oauth-operator" },
|
||||
);
|
||||
const initiatingActor = boardSessionActor(company.id, "operator", "oauth-operator");
|
||||
const initiatingApp = createRouteApp(db, initiatingActor);
|
||||
const startRes = await request(initiatingApp)
|
||||
@@ -7007,6 +7073,73 @@ describeEmbeddedPostgres("tool access service", () => {
|
||||
expect.objectContaining({ targetType: "agent", targetId: agent.id }),
|
||||
]));
|
||||
});
|
||||
|
||||
it("limits connection configuration to the creator or a manager with role defaults", async () => {
|
||||
const company = await createCompany(db);
|
||||
const creator = boardSessionActor(company.id, "member", `creator-${randomUUID()}`);
|
||||
const otherMember = boardSessionActor(company.id, "member", `member-${randomUUID()}`);
|
||||
const admin = boardSessionActor(company.id, "admin", `admin-${randomUUID()}`);
|
||||
await grantBoardUser(db, company.id, creator.userId!, [], "member");
|
||||
await grantBoardUser(db, company.id, otherMember.userId!, [], "member");
|
||||
await grantBoardUser(db, company.id, admin.userId!, [], "admin");
|
||||
const connection = await toolAccessService(db).createConnection(company.id, {
|
||||
name: "Creator-owned connection",
|
||||
transport: "mcp_remote",
|
||||
config: { url: "https://fixture.example/mcp" },
|
||||
}, { actorType: "user", actorId: creator.userId! });
|
||||
|
||||
const denied = await request(createRouteApp(db, otherMember))
|
||||
.patch(`/api/tool-connections/${connection.id}`)
|
||||
.send({ name: "Member edit" });
|
||||
expect(denied.status).toBe(403);
|
||||
expect(denied.body.error).toContain("connection creator or a connection manager");
|
||||
|
||||
await request(createRouteApp(db, creator))
|
||||
.patch(`/api/tool-connections/${connection.id}`)
|
||||
.send({ name: "Creator edit" })
|
||||
.expect(200);
|
||||
await request(createRouteApp(db, admin))
|
||||
.patch(`/api/tool-connections/${connection.id}`)
|
||||
.send({ name: "Admin edit" })
|
||||
.expect(200);
|
||||
|
||||
const adminGrants = await db
|
||||
.select()
|
||||
.from(principalPermissionGrants)
|
||||
.where(eq(principalPermissionGrants.principalId, admin.userId!));
|
||||
expect(adminGrants).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps agent installs self-serve for members with connection access and audits changes", async () => {
|
||||
const company = await createCompany(db);
|
||||
const creator = boardSessionActor(company.id, "member", `creator-${randomUUID()}`);
|
||||
const member = boardSessionActor(company.id, "member", `member-${randomUUID()}`);
|
||||
await grantBoardUser(db, company.id, creator.userId!, [], "member");
|
||||
await grantBoardUser(db, company.id, member.userId!, ["agents:configure"], "member");
|
||||
const agent = await createAgent(db, company.id);
|
||||
const connection = await toolAccessService(db).createConnection(company.id, {
|
||||
name: "Shared organization connection",
|
||||
transport: "mcp_remote",
|
||||
config: { url: "https://fixture.example/mcp" },
|
||||
}, { actorType: "user", actorId: creator.userId! });
|
||||
const app = createRouteApp(db, member);
|
||||
|
||||
await request(app)
|
||||
.put(`/api/tool-connections/${connection.id}/installs`)
|
||||
.send({ installs: [{ targetType: "agent", targetId: agent.id }] })
|
||||
.expect(200);
|
||||
await request(app)
|
||||
.put(`/api/tool-connections/${connection.id}/installs`)
|
||||
.send({ installs: [] })
|
||||
.expect(200);
|
||||
|
||||
const audits = await db
|
||||
.select()
|
||||
.from(toolAccessAuditEvents)
|
||||
.where(eq(toolAccessAuditEvents.action, "connection_installs.changed"));
|
||||
expect(audits).toHaveLength(2);
|
||||
expect(audits.every((audit) => audit.actorType === "user" && audit.actorId === member.userId)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyRisk", () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Router, type Request } from "express";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agents, companies } from "@paperclipai/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { agents, companies, connectionGrants, toolConnectionInstalls } from "@paperclipai/db";
|
||||
import { and, eq, or } from "drizzle-orm";
|
||||
import {
|
||||
CONNECTABLE_APP_DEFINITIONS,
|
||||
DEFAULT_OWNERSHIP_AVAILABILITY,
|
||||
@@ -124,6 +124,71 @@ export function toolAccessRoutes(
|
||||
throw forbidden(`Missing permission: ${permissionKey}`);
|
||||
}
|
||||
|
||||
function activeToolMembership(req: Request, companyId: string) {
|
||||
assertBoard(req);
|
||||
assertCompanyAccess(req, companyId);
|
||||
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return null;
|
||||
const membership = Array.isArray(req.actor.memberships)
|
||||
? req.actor.memberships.find((item) => item.companyId === companyId)
|
||||
: null;
|
||||
if (!membership || membership.status !== "active") {
|
||||
throw forbidden("User does not have active company access");
|
||||
}
|
||||
if (!membership.membershipRole || membership.membershipRole === "viewer") {
|
||||
throw forbidden("Viewer access is read-only");
|
||||
}
|
||||
return membership;
|
||||
}
|
||||
|
||||
async function isToolConnectionManager(req: Request, companyId: string) {
|
||||
const membership = activeToolMembership(req, companyId);
|
||||
if (!membership) return true;
|
||||
if (membership.membershipRole === "owner" || membership.membershipRole === "admin") return true;
|
||||
return Boolean(req.actor.userId && await access.hasPermission(
|
||||
companyId,
|
||||
"user",
|
||||
req.actor.userId,
|
||||
"tools:manage_connections",
|
||||
));
|
||||
}
|
||||
|
||||
async function assertToolConnectionConfigureAccess(
|
||||
req: Request,
|
||||
connection: { companyId: string; createdByUserId?: string | null },
|
||||
) {
|
||||
if (await isToolConnectionManager(req, connection.companyId)) return;
|
||||
if (req.actor.userId && connection.createdByUserId === req.actor.userId) return;
|
||||
throw forbidden(
|
||||
"Only the connection creator or a connection manager can configure, reconnect, or delete this connection",
|
||||
);
|
||||
}
|
||||
|
||||
async function assertToolConnectionAccess(
|
||||
req: Request,
|
||||
connection: { id: string; companyId: string; createdByUserId?: string | null },
|
||||
) {
|
||||
activeToolMembership(req, connection.companyId);
|
||||
if (await isToolConnectionManager(req, connection.companyId)) return;
|
||||
if (req.actor.userId && connection.createdByUserId === req.actor.userId) return;
|
||||
const [grant] = await db
|
||||
.select({ id: connectionGrants.id })
|
||||
.from(connectionGrants)
|
||||
.where(and(
|
||||
eq(connectionGrants.companyId, connection.companyId),
|
||||
eq(connectionGrants.connectionId, connection.id),
|
||||
eq(connectionGrants.status, "active"),
|
||||
or(
|
||||
eq(connectionGrants.kind, "workspace"),
|
||||
req.actor.userId
|
||||
? and(eq(connectionGrants.kind, "user"), eq(connectionGrants.subjectUserId, req.actor.userId))
|
||||
: eq(connectionGrants.kind, "workspace"),
|
||||
),
|
||||
))
|
||||
.limit(1);
|
||||
if (grant) return;
|
||||
throw forbidden("You need access to this connection before you can install it on an agent");
|
||||
}
|
||||
|
||||
async function assertBoardAnyToolPermission(req: Request, companyId: string, permissionKeys: PermissionKey[]) {
|
||||
assertBoard(req);
|
||||
assertCompanyAccess(req, companyId);
|
||||
@@ -218,18 +283,7 @@ export function toolAccessRoutes(
|
||||
});
|
||||
|
||||
function assertToolAppMutationAccess(req: Request, companyId: string) {
|
||||
assertBoard(req);
|
||||
assertCompanyAccess(req, companyId);
|
||||
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return;
|
||||
const membership = Array.isArray(req.actor.memberships)
|
||||
? req.actor.memberships.find((item) => item.companyId === companyId)
|
||||
: null;
|
||||
if (!membership || membership.status !== "active") {
|
||||
throw forbidden("User does not have active company access");
|
||||
}
|
||||
if (!membership.membershipRole || membership.membershipRole === "viewer") {
|
||||
throw forbidden("Viewer access is read-only");
|
||||
}
|
||||
activeToolMembership(req, companyId);
|
||||
}
|
||||
|
||||
router.get("/companies/:companyId/tools/gallery", async (req, res) => {
|
||||
@@ -291,11 +345,12 @@ export function toolAccessRoutes(
|
||||
validate(startConnectionAuthorizationSchema),
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertToolAppMutationAccess(req, companyId);
|
||||
activeToolMembership(req, companyId);
|
||||
if (!req.actor.userId || req.actor.userId !== req.body.subjectUserId) {
|
||||
throw forbidden("Board users may only authorize their own connection subject");
|
||||
}
|
||||
const existing = await svc.getConnection(req.params.connectionId as string, companyId);
|
||||
await assertToolConnectionAccess(req, existing);
|
||||
const result = await svc.startOAuth(companyId, existing.id, {
|
||||
redirectUri: oauthRedirectUri(),
|
||||
actor: getActorInfo(req),
|
||||
@@ -310,7 +365,7 @@ export function toolAccessRoutes(
|
||||
router.post("/tools/oauth/:connectionId/start", async (req, res) => {
|
||||
const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
|
||||
if (!existing) return;
|
||||
assertToolAppMutationAccess(req, existing.companyId);
|
||||
await assertToolConnectionConfigureAccess(req, existing);
|
||||
const result = await svc.startOAuth(existing.companyId, existing.id, {
|
||||
redirectUri: oauthRedirectUri(),
|
||||
actor: getActorInfo(req),
|
||||
@@ -328,7 +383,12 @@ export function toolAccessRoutes(
|
||||
if (!pendingState || !hasCompanyAccess(req, pendingState.companyId)) {
|
||||
throw badRequest("Invalid or expired OAuth state");
|
||||
}
|
||||
assertToolAppMutationAccess(req, pendingState.companyId);
|
||||
const pendingConnection = await svc.getConnection(pendingState.connectionId, pendingState.companyId);
|
||||
if (pendingState.subjectUserId && pendingState.subjectUserId === req.actor.userId) {
|
||||
await assertToolConnectionAccess(req, pendingConnection);
|
||||
} else {
|
||||
await assertToolConnectionConfigureAccess(req, pendingConnection);
|
||||
}
|
||||
const result = await svc.completeOAuthCallback({
|
||||
state,
|
||||
code,
|
||||
@@ -364,8 +424,8 @@ export function toolAccessRoutes(
|
||||
|
||||
router.post("/companies/:companyId/tools/apps/:connectionId/finish", validate(finishToolAppSchema), async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertToolAppMutationAccess(req, companyId);
|
||||
const existing = await svc.getConnection(req.params.connectionId as string, companyId);
|
||||
await assertToolConnectionConfigureAccess(req, existing);
|
||||
const result = await svc.finishGalleryAppConnection(companyId, existing.id, req.body, getActorInfo(req));
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
@@ -538,7 +598,7 @@ export function toolAccessRoutes(
|
||||
const companyId = req.params.companyId as string;
|
||||
assertToolAppMutationAccess(req, companyId);
|
||||
try {
|
||||
const connection = await svc.createConnection(companyId, req.body);
|
||||
const connection = await svc.createConnection(companyId, req.body, getActorInfo(req));
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: "user",
|
||||
@@ -577,7 +637,7 @@ export function toolAccessRoutes(
|
||||
assertBoard(req);
|
||||
const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
|
||||
if (!connection) return;
|
||||
await assertBoardToolPermission(req, connection.companyId, "tools:manage_connections");
|
||||
await assertToolConnectionConfigureAccess(req, connection);
|
||||
const body = req.body && typeof req.body === "object" ? req.body as Record<string, unknown> : {};
|
||||
const credentialSecretRefs = Array.isArray(body.credentialSecretRefs) ? body.credentialSecretRefs : [];
|
||||
const providerTenant = body.providerTenant && typeof body.providerTenant === "object"
|
||||
@@ -604,7 +664,14 @@ export function toolAccessRoutes(
|
||||
assertBoard(req);
|
||||
const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
|
||||
if (!connection) return;
|
||||
await assertBoardToolPermission(req, connection.companyId, "tools:manage_connections");
|
||||
const { grants } = await svc.listConnectionGrants(connection.id, connection.companyId);
|
||||
const grantToRevoke = grants.find((grant) => grant.id === req.params.grantId);
|
||||
const canRevokeOwnGrant = Boolean(
|
||||
req.actor.userId
|
||||
&& grantToRevoke
|
||||
&& (grantToRevoke.subjectUserId === req.actor.userId || grantToRevoke.createdByUserId === req.actor.userId),
|
||||
);
|
||||
if (!canRevokeOwnGrant) await assertToolConnectionConfigureAccess(req, connection);
|
||||
const grant = await svc.revokeConnectionGrant(connection.id, req.params.grantId as string, getActorInfo(req));
|
||||
await logActivity(db, {
|
||||
companyId: connection.companyId,
|
||||
@@ -641,7 +708,47 @@ export function toolAccessRoutes(
|
||||
assertBoard(req);
|
||||
const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
|
||||
if (!connection) return;
|
||||
await assertBoardToolPermission(req, connection.companyId, "tools:manage_connections");
|
||||
const existingInstalls = await db
|
||||
.select()
|
||||
.from(toolConnectionInstalls)
|
||||
.where(and(
|
||||
eq(toolConnectionInstalls.companyId, connection.companyId),
|
||||
eq(toolConnectionInstalls.connectionId, connection.id),
|
||||
));
|
||||
const requestedInstalls = req.body.installs as Array<{ targetType: "company" | "agent"; targetId: string }>;
|
||||
const requestedKeys = new Set(requestedInstalls.map((install) => `${install.targetType}:${install.targetId}`));
|
||||
const existingKeys = new Set(existingInstalls.map((install) => `${install.targetType}:${install.targetId}`));
|
||||
const changedInstalls = [
|
||||
...requestedInstalls.filter((install) => !existingKeys.has(`${install.targetType}:${install.targetId}`)),
|
||||
...existingInstalls.filter((install) => !requestedKeys.has(`${install.targetType}:${install.targetId}`)),
|
||||
];
|
||||
if (changedInstalls.some((install) => install.targetType === "company")) {
|
||||
await assertToolConnectionConfigureAccess(req, connection);
|
||||
}
|
||||
if (changedInstalls.some((install) => install.targetType === "agent")) {
|
||||
await assertToolConnectionAccess(req, connection);
|
||||
const changedAgentIds = [...new Set(
|
||||
changedInstalls
|
||||
.filter((install) => install.targetType === "agent")
|
||||
.map((install) => install.targetId),
|
||||
)];
|
||||
for (const agentId of changedAgentIds) {
|
||||
const [agent] = await db
|
||||
.select({ id: agents.id, companyId: agents.companyId })
|
||||
.from(agents)
|
||||
.where(and(eq(agents.id, agentId), eq(agents.companyId, connection.companyId)))
|
||||
.limit(1);
|
||||
if (!agent) throw forbidden("The target agent is not available in this company");
|
||||
const decision = await access.decide({
|
||||
actor: req.actor,
|
||||
action: "agent_config:update",
|
||||
resource: { type: "agent", companyId: connection.companyId, agentId: agent.id },
|
||||
});
|
||||
if (!decision.allowed) {
|
||||
throw forbidden(`You cannot edit agent ${agent.id}, so you cannot change its connection installs`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const snapshot = await svc.putConnectionInstalls(connection.id, req.body, getActorInfo(req));
|
||||
await logActivity(db, {
|
||||
companyId: connection.companyId,
|
||||
@@ -745,7 +852,7 @@ export function toolAccessRoutes(
|
||||
router.patch("/tool-connections/:connectionId", validate(updateToolConnectionSchema), async (req, res) => {
|
||||
const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
|
||||
if (!existing) return;
|
||||
assertToolAppMutationAccess(req, existing.companyId);
|
||||
await assertToolConnectionConfigureAccess(req, existing);
|
||||
const connection = await svc.updateConnection(existing.id, req.body);
|
||||
const lifecycleChanges = classifyConnectionUpdate(
|
||||
{ enabled: existing.enabled, config: existing.config },
|
||||
@@ -789,7 +896,7 @@ export function toolAccessRoutes(
|
||||
router.delete("/tool-connections/:connectionId", async (req, res) => {
|
||||
const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
|
||||
if (!existing) return;
|
||||
assertToolAppMutationAccess(req, existing.companyId);
|
||||
await assertToolConnectionConfigureAccess(req, existing);
|
||||
const applicationBefore = await svc.getApplication(existing.applicationId);
|
||||
const connection = await svc.archiveConnection(existing.id);
|
||||
const applicationAfter = await svc.getApplication(existing.applicationId);
|
||||
@@ -819,7 +926,7 @@ export function toolAccessRoutes(
|
||||
router.post("/tool-connections/:connectionId/health-check", async (req, res) => {
|
||||
const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
|
||||
if (!existing) return;
|
||||
assertToolAppMutationAccess(req, existing.companyId);
|
||||
await assertToolConnectionConfigureAccess(req, existing);
|
||||
res.json(await svc.checkHealth(existing.id, getActorInfo(req)));
|
||||
});
|
||||
|
||||
@@ -829,7 +936,7 @@ export function toolAccessRoutes(
|
||||
async (req, res) => {
|
||||
const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
|
||||
if (!existing) return;
|
||||
assertToolAppMutationAccess(req, existing.companyId);
|
||||
await assertToolConnectionConfigureAccess(req, existing);
|
||||
const result = await svc.reconnectGalleryApp(
|
||||
existing.id,
|
||||
existing.companyId,
|
||||
@@ -852,7 +959,7 @@ export function toolAccessRoutes(
|
||||
router.post("/tool-connections/:connectionId/catalog/refresh", async (req, res) => {
|
||||
const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
|
||||
if (!existing) return;
|
||||
assertToolAppMutationAccess(req, existing.companyId);
|
||||
await assertToolConnectionConfigureAccess(req, existing);
|
||||
res.json(await svc.refreshCatalog(existing.id, getActorInfo(req)));
|
||||
});
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
type TrustPresetResolution,
|
||||
} from "./trust-preset-resolver.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import { grantsForHumanRole, normalizeHumanRole } from "./company-member-roles.js";
|
||||
|
||||
export type AuthorizationActor =
|
||||
{
|
||||
@@ -102,6 +103,7 @@ export type AuthorizationDecision = {
|
||||
| "allow_local_board"
|
||||
| "allow_instance_admin"
|
||||
| "allow_explicit_grant"
|
||||
| "allow_role_default"
|
||||
| "allow_user_inbox_policy"
|
||||
| "allow_direct_change"
|
||||
| "allow_consented_change"
|
||||
@@ -664,6 +666,19 @@ export function authorizationService(db: Db) {
|
||||
|
||||
const grant = await findGrant(input.companyId, input.principalType, input.principalId, input.permissionKey);
|
||||
if (!grant) {
|
||||
if (
|
||||
input.principalType === "user"
|
||||
&& input.permissionKey.startsWith("tools:")
|
||||
&& (membership.membershipRole === "owner" || membership.membershipRole === "admin")
|
||||
&& grantsForHumanRole(normalizeHumanRole(membership.membershipRole, "operator"))
|
||||
.some((defaultGrant) => defaultGrant.permissionKey === input.permissionKey)
|
||||
) {
|
||||
return allow({
|
||||
action: input.action,
|
||||
reason: "allow_role_default",
|
||||
explanation: `Allowed by the ${membership.membershipRole ?? "operator"} membership role.`,
|
||||
});
|
||||
}
|
||||
return deny({
|
||||
action: input.action,
|
||||
reason: "deny_missing_grant",
|
||||
|
||||
@@ -35,6 +35,10 @@ export function grantsForHumanRole(
|
||||
{ permissionKey: "users:manage_permissions", scope: null },
|
||||
{ permissionKey: "tasks:assign", scope: null },
|
||||
{ permissionKey: "joins:approve", scope: null },
|
||||
{ permissionKey: "tools:manage_connections", scope: null },
|
||||
{ permissionKey: "tools:manage_runtime", scope: null },
|
||||
{ permissionKey: "tools:use", scope: null },
|
||||
{ permissionKey: "tools:admin", scope: null },
|
||||
];
|
||||
case "admin":
|
||||
return [
|
||||
@@ -45,6 +49,10 @@ export function grantsForHumanRole(
|
||||
{ permissionKey: "users:invite", scope: null },
|
||||
{ permissionKey: "tasks:assign", scope: null },
|
||||
{ permissionKey: "joins:approve", scope: null },
|
||||
{ permissionKey: "tools:manage_connections", scope: null },
|
||||
{ permissionKey: "tools:manage_runtime", scope: null },
|
||||
{ permissionKey: "tools:use", scope: null },
|
||||
{ permissionKey: "tools:admin", scope: null },
|
||||
];
|
||||
case "operator":
|
||||
return [{ permissionKey: "tasks:assign", scope: null }];
|
||||
|
||||
@@ -65,7 +65,10 @@ import {
|
||||
routines,
|
||||
toolMcpGateways,
|
||||
toolMcpGatewayTokens,
|
||||
toolCatalogEntries,
|
||||
toolConnectionInstalls,
|
||||
toolConnections,
|
||||
toolProfileEntries,
|
||||
toolProfiles,
|
||||
workspaceOperations,
|
||||
} from "@paperclipai/db";
|
||||
@@ -3462,7 +3465,68 @@ function gatewayAppliesToRun(input: {
|
||||
return true;
|
||||
}
|
||||
|
||||
async function createManagedMcpRunConfig(input: {
|
||||
async function gatewayConnectionIds(input: {
|
||||
db: Db;
|
||||
companyId: string;
|
||||
gateway: typeof toolMcpGateways.$inferSelect;
|
||||
}): Promise<Set<string>> {
|
||||
const managedRuntimeConnectionId = readNonEmptyString(input.gateway.metadata?.managedRuntimeConnectionId);
|
||||
if (managedRuntimeConnectionId) return new Set([managedRuntimeConnectionId]);
|
||||
|
||||
const [profile, entries, catalog, connections] = await Promise.all([
|
||||
input.db
|
||||
.select({ defaultAction: toolProfiles.defaultAction })
|
||||
.from(toolProfiles)
|
||||
.where(and(eq(toolProfiles.companyId, input.companyId), eq(toolProfiles.id, input.gateway.profileId)))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
input.db
|
||||
.select()
|
||||
.from(toolProfileEntries)
|
||||
.where(and(
|
||||
eq(toolProfileEntries.companyId, input.companyId),
|
||||
eq(toolProfileEntries.profileId, input.gateway.profileId),
|
||||
)),
|
||||
input.db
|
||||
.select({
|
||||
id: toolCatalogEntries.id,
|
||||
connectionId: toolCatalogEntries.connectionId,
|
||||
applicationId: toolCatalogEntries.applicationId,
|
||||
toolName: toolCatalogEntries.toolName,
|
||||
riskLevel: toolCatalogEntries.riskLevel,
|
||||
})
|
||||
.from(toolCatalogEntries)
|
||||
.where(and(eq(toolCatalogEntries.companyId, input.companyId), eq(toolCatalogEntries.status, "active"))),
|
||||
input.db
|
||||
.select({ id: toolConnections.id, applicationId: toolConnections.applicationId })
|
||||
.from(toolConnections)
|
||||
.where(eq(toolConnections.companyId, input.companyId)),
|
||||
]);
|
||||
if (!profile) return new Set();
|
||||
if (profile.defaultAction === "allow") return new Set(catalog.map((entry) => entry.connectionId));
|
||||
|
||||
const connectionIds = new Set<string>();
|
||||
for (const entry of entries) {
|
||||
if (entry.effect !== "include") continue;
|
||||
if (entry.connectionId) connectionIds.add(entry.connectionId);
|
||||
if (entry.applicationId) {
|
||||
for (const connection of connections) {
|
||||
if (connection.applicationId === entry.applicationId) connectionIds.add(connection.id);
|
||||
}
|
||||
}
|
||||
for (const catalogEntry of catalog) {
|
||||
if (
|
||||
(entry.catalogEntryId && entry.catalogEntryId === catalogEntry.id)
|
||||
|| (entry.toolName && entry.toolName === catalogEntry.toolName)
|
||||
|| (entry.riskLevel && entry.riskLevel === catalogEntry.riskLevel)
|
||||
) {
|
||||
connectionIds.add(catalogEntry.connectionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
return connectionIds;
|
||||
}
|
||||
|
||||
export async function createManagedMcpRunConfig(input: {
|
||||
db: Db;
|
||||
agent: Pick<typeof agents.$inferSelect, "id" | "companyId" | "name" | "adapterType">;
|
||||
runId: string;
|
||||
@@ -3483,12 +3547,33 @@ async function createManagedMcpRunConfig(input: {
|
||||
))
|
||||
.orderBy(asc(toolMcpGateways.name));
|
||||
|
||||
const gateways = rows.filter((gateway) => gatewayAppliesToRun({
|
||||
const installRows = await input.db
|
||||
.select({ connectionId: toolConnectionInstalls.connectionId })
|
||||
.from(toolConnectionInstalls)
|
||||
.where(and(
|
||||
eq(toolConnectionInstalls.companyId, input.agent.companyId),
|
||||
sql`((${toolConnectionInstalls.targetType} = 'company' and ${toolConnectionInstalls.targetId} = ${input.agent.companyId}) or (${toolConnectionInstalls.targetType} = 'agent' and ${toolConnectionInstalls.targetId} = ${input.agent.id}))`,
|
||||
));
|
||||
const installedConnectionIds = new Set(installRows.map((install) => install.connectionId));
|
||||
|
||||
const applicableGateways = rows.filter((gateway) => gatewayAppliesToRun({
|
||||
gateway,
|
||||
agentId: input.agent.id,
|
||||
projectId: input.projectId,
|
||||
issueId: input.issueId,
|
||||
}));
|
||||
const gateways = (await Promise.all(applicableGateways.map(async (gateway) => ({
|
||||
gateway,
|
||||
connectionIds: await gatewayConnectionIds({
|
||||
db: input.db,
|
||||
companyId: input.agent.companyId,
|
||||
gateway,
|
||||
}),
|
||||
}))))
|
||||
.filter(({ connectionIds }) =>
|
||||
connectionIds.size > 0
|
||||
&& [...connectionIds].every((connectionId) => installedConnectionIds.has(connectionId)))
|
||||
.map(({ gateway }) => gateway);
|
||||
if (gateways.length === 0) return null;
|
||||
|
||||
const service = createToolGatewayService(input.db);
|
||||
|
||||
@@ -5602,7 +5602,11 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
||||
|
||||
async function peekOAuthState(state: string) {
|
||||
const [row] = await db
|
||||
.select({ companyId: toolOauthStates.companyId })
|
||||
.select({
|
||||
companyId: toolOauthStates.companyId,
|
||||
connectionId: toolOauthStates.connectionId,
|
||||
subjectUserId: toolOauthStates.subjectUserId,
|
||||
})
|
||||
.from(toolOauthStates)
|
||||
.where(eq(toolOauthStates.state, state))
|
||||
.limit(1);
|
||||
@@ -6306,7 +6310,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
||||
return connections;
|
||||
},
|
||||
|
||||
createConnection: async (companyId: string, input: CreateToolConnection): Promise<ToolConnection> => {
|
||||
createConnection: async (companyId: string, input: CreateToolConnection, actor?: ActorInfo): Promise<ToolConnection> => {
|
||||
let applicationId = input.applicationId;
|
||||
let applicationNamespace = input.applicationName ?? input.name;
|
||||
const transport = input.transport;
|
||||
@@ -6335,6 +6339,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
||||
}
|
||||
await assertSecretRefs(companyId, [...(input.credentialRefs ?? []), ...(input.credentialSecretRefs ?? [])]);
|
||||
const connectionId = randomUUID();
|
||||
const binding = actorBinding(actor);
|
||||
const [row] = await db.insert(toolConnections).values({
|
||||
id: connectionId,
|
||||
companyId,
|
||||
@@ -6351,6 +6356,8 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
||||
transportConfig: isGoogleSheetsConnectionConfig(config) ? config : input.transportConfig ?? config,
|
||||
credentialRefs: input.credentialRefs ?? [],
|
||||
credentialSecretRefs: input.credentialSecretRefs ?? [],
|
||||
createdByAgentId: binding.actorType === "agent" ? binding.actorId : null,
|
||||
createdByUserId: binding.actorType === "user" ? binding.actorId : null,
|
||||
}).returning();
|
||||
await ensureDefaultWorkspaceGrant(row);
|
||||
await syncCredentialBindings(row);
|
||||
@@ -6399,6 +6406,16 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
||||
createdByUserId: binding.actorType === "user" ? binding.actorId : null,
|
||||
}).returning();
|
||||
if (!grant) throw new Error("Failed to create connection installation");
|
||||
await db.insert(toolAccessAuditEvents).values({
|
||||
companyId: connection.companyId,
|
||||
connectionId: connection.id,
|
||||
actorType: binding.actorType ?? "system",
|
||||
actorId: binding.actorId,
|
||||
action: "connection_grant.created",
|
||||
outcome: "success",
|
||||
reasonCode: "grant_created",
|
||||
details: { grantId: grant.id, kind: grant.kind, isDefault: grant.isDefault },
|
||||
});
|
||||
return grant;
|
||||
},
|
||||
|
||||
@@ -6418,6 +6435,16 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
||||
eq(connectionGrants.connectionId, connection.id),
|
||||
)).returning();
|
||||
if (!grant) throw notFound("Connection grant not found");
|
||||
await db.insert(toolAccessAuditEvents).values({
|
||||
companyId: connection.companyId,
|
||||
connectionId: connection.id,
|
||||
actorType: binding.actorType ?? "system",
|
||||
actorId: binding.actorId,
|
||||
action: "connection_grant.revoked",
|
||||
outcome: "success",
|
||||
reasonCode: "grant_revoked",
|
||||
details: { grantId: grant.id, kind: grant.kind },
|
||||
});
|
||||
return grant;
|
||||
},
|
||||
|
||||
@@ -6530,6 +6557,24 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
||||
if (binding) accessExtensions.push({ targetType: install.targetType, targetId: install.targetId, profileId: profile.id });
|
||||
}
|
||||
}
|
||||
if (removeIds.length > 0 || additions.length > 0) {
|
||||
const binding = actorBinding(actor);
|
||||
await tx.insert(toolAccessAuditEvents).values({
|
||||
companyId: connection.companyId,
|
||||
connectionId: connection.id,
|
||||
actorType: binding.actorType ?? "system",
|
||||
actorId: binding.actorId,
|
||||
action: "connection_installs.changed",
|
||||
outcome: "success",
|
||||
reasonCode: "installs_changed",
|
||||
details: {
|
||||
added: additions.map((install) => ({ targetType: install.targetType, targetId: install.targetId })),
|
||||
removed: existing
|
||||
.filter((install) => removeIds.includes(install.id))
|
||||
.map((install) => ({ targetType: install.targetType, targetId: install.targetId })),
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
for (const extension of accessExtensions) {
|
||||
await logActivity(db, {
|
||||
@@ -7341,6 +7386,29 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
||||
throw new HttpError(status, message, { code: errorCode, path, ...details });
|
||||
};
|
||||
|
||||
const [install] = await db
|
||||
.select({ id: toolConnectionInstalls.id })
|
||||
.from(toolConnectionInstalls)
|
||||
.where(and(
|
||||
eq(toolConnectionInstalls.companyId, connection.companyId),
|
||||
eq(toolConnectionInstalls.connectionId, connection.id),
|
||||
sql`((${toolConnectionInstalls.targetType} = 'company' and ${toolConnectionInstalls.targetId} = ${connection.companyId}) or (${toolConnectionInstalls.targetType} = 'agent' and ${toolConnectionInstalls.targetId} = ${input.agentId}))`,
|
||||
))
|
||||
.limit(1);
|
||||
if (!install) {
|
||||
await fail(
|
||||
403,
|
||||
`Connection ${connection.name} must be installed for this agent before it can mint a token`,
|
||||
"denied",
|
||||
"installation_required",
|
||||
{
|
||||
connection: { id: connection.id, uid: connection.uid, name: connection.name },
|
||||
agentId: input.agentId,
|
||||
remediation: { action: "install_connection", targetType: "agent", targetId: input.agentId },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const subject = input.body.subject ?? { type: "app" as const };
|
||||
if (subject.type === "user" && subject.userId !== runContext.responsibleUserId) {
|
||||
await fail(403, "The agent run cannot act as the requested user", "denied", "subject_not_permitted", {
|
||||
|
||||
Reference in new issue
Block a user