mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 04:25:52 +01:00
fix(connections): bound resource checks and preserve current delegation
Limit and deduplicate management selectors before lookups, hide foreign resources, and reuse captured run identity only for request-local capability reads. Re-resolve resources at commit-time checks and enforce proposed provider scope. Preserve rate-limit admission during the final test dispatch check. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
291a2c3b8e
commit
8f16bfc85a
8 files changed
+281
-63
No files matched your search
@@ -8,6 +8,8 @@ import { activityLog, agentApiKeys, companies, chatEndpoints, chatExternalPrinci
|
||||
import { findSatisfiedToolConnection, satisfiedConnectionIntentService } from "../services/satisfied-connection-intents.js";
|
||||
import { connectionManagementService, continuingConnectionManagement } from "../services/connection-management.js";
|
||||
import { redactRunnerApiPayloads } from "../services/native-runtime/runner-api-log-redaction.js";
|
||||
import * as remoteEndpointGuard from "../services/remote-http-endpoint-guard.js";
|
||||
import * as runIdentity from "../services/run-identity.js";
|
||||
import { createLocalAgentJwt } from "../agent-auth-jwt.js";
|
||||
import { startRunnerApiTestServer } from "./helpers/runner-api-server.js";
|
||||
import { getServerAdapter, registerServerAdapter, unregisterServerAdapter } from "../adapters/index.js";
|
||||
@@ -94,6 +96,23 @@ describe("connection management with production default agents", () => {
|
||||
expect(profile, JSON.stringify(profile)).toMatchObject({ status: 201 });
|
||||
});
|
||||
|
||||
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" } });
|
||||
expect(created).toMatchObject({ status: 201 });
|
||||
const original = remoteEndpointGuard.assertPublicRemoteHttpEndpoint;
|
||||
const validation = vi.spyOn(remoteEndpointGuard, "assertPublicRemoteHttpEndpoint").mockImplementation(async (...args) => {
|
||||
const result = await original(...args);
|
||||
await server.db.delete(principalPermissionGrants).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_connections")));
|
||||
return result;
|
||||
});
|
||||
try {
|
||||
expect(await f.request("PATCH", `/tool-connections/${created.body.id}`, { name: "After validation" })).toMatchObject({ status: 403 });
|
||||
expect(validation).toHaveBeenCalled();
|
||||
expect((await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.id)))[0].name).toBe("Before validation");
|
||||
} finally { validation.mockRestore(); }
|
||||
});
|
||||
|
||||
it("checks finish access, all-agent sharing, and retained targets against both management grants", async () => {
|
||||
const f = await fixture();
|
||||
const connection = await f.request("POST", `/companies/${f.companyId}/tools/connections`, { name: "Finish authority", transport: "mcp_remote", authKind: "none", transportConfig: { url: "https://1.1.1.1/mcp" } });
|
||||
@@ -115,7 +134,7 @@ describe("connection management with production default agents", () => {
|
||||
expect(bindings.map(binding => binding.targetId)).toEqual([f.agentId]);
|
||||
});
|
||||
|
||||
it.each(["none", "other-agent", "agent", "user", "cancelled", "project-block"] as const)("keeps delegated test policy and approval continuation bounded (%s)", async revocation => {
|
||||
it.each(["none", "other-agent", "agent", "user", "cancelled", "project-block", "rate-limit"] as const)("keeps delegated test policy and approval continuation bounded (%s)", async revocation => {
|
||||
const f = await fixture();
|
||||
let targetAgentId = f.agentId;
|
||||
if (revocation === "other-agent") {
|
||||
@@ -159,6 +178,14 @@ describe("connection management with production default agents", () => {
|
||||
expect(calls).toBe(0);
|
||||
await server.db.delete(toolPolicies).where(eq(toolPolicies.id, block.id));
|
||||
}
|
||||
if (revocation === "rate-limit") {
|
||||
await server.db.insert(toolPolicies).values({ companyId: f.companyId, name: "One test per minute", policyType: "rate_limit", selectors: { connectionId: created.body.id }, config: { limit: 1, windowSeconds: 60, keyBy: ["agent", "tool"] } });
|
||||
expect(await f.request("POST", path, body, f.jwt)).toMatchObject({ status: 200, body: { decision: "allowed", result: { content: "Fixture result" } } });
|
||||
expect(calls).toBe(1);
|
||||
expect(await f.request("POST", path, body, f.jwt)).toMatchObject({ status: 200, body: { decision: "off", error: { reasonCode: "rate_limited" } } });
|
||||
expect(calls).toBe(1);
|
||||
return;
|
||||
}
|
||||
await server.db.insert(toolPolicies).values({ companyId: f.companyId, name: "Scoped test approval", policyType: "require_approval", selectors: { connectionId: created.body.id, issueId: f.issueId }, priority: 1 });
|
||||
const pending = await f.request("POST", path, body, f.jwt);
|
||||
expect(pending, JSON.stringify(pending)).toMatchObject({ status: 200, body: { decision: "ask_first" } });
|
||||
@@ -184,6 +211,71 @@ describe("connection management with production default agents", () => {
|
||||
} finally { remote.closeAllConnections(); await new Promise<void>((resolve, reject) => remote.close(error => error ? reject(error) : resolve())); }
|
||||
});
|
||||
|
||||
it("bounds and deduplicates resource selectors before per-resource database work", async () => {
|
||||
const f = await fixture();
|
||||
const created = await f.request("POST", `/companies/${f.companyId}/tools/connections`, { name: "Bounded selector", transport: "mcp_remote", authKind: "none", transportConfig: { url: "https://1.1.1.1/mcp" } });
|
||||
let resourceReads = 0;
|
||||
const originalSelect = server.db.select.bind(server.db);
|
||||
const select = vi.spyOn(server.db, "select").mockImplementation(((selection: never) => {
|
||||
const builder = originalSelect(selection);
|
||||
const originalFrom = builder.from.bind(builder);
|
||||
builder.from = ((table: never) => {
|
||||
if (table === toolConnections || table === toolPolicies || table === toolCatalogEntries) resourceReads++;
|
||||
return originalFrom(table);
|
||||
}) as typeof builder.from;
|
||||
return builder;
|
||||
}) as typeof server.db.select);
|
||||
try {
|
||||
const path = `/companies/${f.companyId}/tools/policies`;
|
||||
const body = { name: "Bounded policy", policyType: "allow", selectors: { agentId: f.agentId, connectionIds: Array(5000).fill(created.body.id) } };
|
||||
expect(await f.request("POST", path, body)).toMatchObject({ status: 400 });
|
||||
expect(resourceReads).toBe(0);
|
||||
expect(await f.request("POST", path, { ...body, selectors: { ...body.selectors, connectionIds: Array(100).fill(created.body.id) } })).toMatchObject({ status: 201 });
|
||||
expect(resourceReads).toBeLessThan(20);
|
||||
resourceReads = 0;
|
||||
await accessService(server.db).ensureMembership(f.companyId, "user", f.userId, "operator", "suspended");
|
||||
expect(await f.request("POST", path, { ...body, selectors: { ...body.selectors, connectionIds: Array(100).fill(created.body.id) } })).toMatchObject({ status: 403 });
|
||||
expect(resourceReads).toBe(0);
|
||||
} finally { select.mockRestore(); }
|
||||
});
|
||||
|
||||
it("shares one run capture across a management read without caching authority across requests", async () => {
|
||||
const f = await fixture();
|
||||
for (let index = 0; index < 5; index++) expect(await f.request("POST", `/companies/${f.companyId}/tools/connections`, { name: `Read snapshot ${index}`, transport: "mcp_remote", authKind: "none", transportConfig: { url: "https://1.1.1.1/mcp" } })).toMatchObject({ status: 201 });
|
||||
const capture = vi.spyOn(runIdentity, "captureRunIdentity");
|
||||
try {
|
||||
const listed = await f.request("GET", `/companies/${f.companyId}/tools/connections`, undefined, f.jwt);
|
||||
expect(listed).toMatchObject({ status: 200 });
|
||||
expect(listed.body.connections).toHaveLength(5);
|
||||
expect(capture.mock.calls.length).toBeGreaterThan(0);
|
||||
expect(capture.mock.calls.length).toBeLessThanOrEqual(2);
|
||||
await server.db.delete(principalPermissionGrants).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_connections")));
|
||||
expect(await f.request("GET", `/companies/${f.companyId}/tools/connections`, undefined, f.jwt)).toMatchObject({ status: 403 });
|
||||
} finally { capture.mockRestore(); }
|
||||
});
|
||||
|
||||
it("returns the same missing-resource response for foreign and absent connection IDs", async () => {
|
||||
const f = await fixture(), other = await fixture();
|
||||
const foreign = await other.request("POST", `/companies/${other.companyId}/tools/connections`, { name: "Other company", transport: "mcp_remote", authKind: "none", transportConfig: { url: "https://1.1.1.1/mcp" } });
|
||||
const hidden = await f.request("GET", `/tool-connections/${foreign.body.id}`);
|
||||
const absent = await f.request("GET", `/tool-connections/${randomUUID()}`);
|
||||
expect(hidden).toMatchObject({ status: 404 });
|
||||
expect(absent).toEqual(hidden);
|
||||
});
|
||||
|
||||
it("checks existing and proposed provider scope without treating generic metadata as provider authority", async () => {
|
||||
const f = await fixture();
|
||||
const payload = { name: "Scoped provider", transport: "mcp_remote", authKind: "none", config: { url: "https://1.1.1.1/mcp", provider: "fixture-one" } };
|
||||
const created = await f.request("POST", `/companies/${f.companyId}/tools/connections`, payload);
|
||||
expect(created.status).toBe(201);
|
||||
await server.db.update(principalPermissionGrants).set({ scope: { providers: ["fixture-one"] } }).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_connections")));
|
||||
expect(await f.request("PATCH", `/tool-connections/${created.body.id}`, { name: "Still the same provider" })).toMatchObject({ status: 200 });
|
||||
for (const config of [{ url: "https://1.1.1.1/mcp", provider: "fixture-two" }, { url: "https://1.1.1.1/mcp" }]) expect(await f.request("PATCH", `/tool-connections/${created.body.id}`, { config })).toMatchObject({ status: 403 });
|
||||
expect(await f.request("POST", `/companies/${f.companyId}/tools/connections`, { ...payload, name: "Caller-labelled provider" })).toMatchObject({ status: 403 });
|
||||
await server.db.update(principalPermissionGrants).set({ scope: { purposes: ["tool"] } }).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_connections")));
|
||||
expect(await f.request("POST", `/companies/${f.companyId}/ai-connections`, { provider: "openai", method: "api_key", name: "Wrong purpose", apiKey: "fixture", ownership: "personal", agentIds: [] })).toMatchObject({ status: 403 });
|
||||
});
|
||||
|
||||
it("hands generic app authentication to the saved setup without creating agent-bound OAuth state", async () => {
|
||||
const f = await fixture();
|
||||
const created = await f.request("POST", `/companies/${f.companyId}/tools/connections`, {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { isUuidLike, type ConnectionRequiredUserAction } from "@paperclipai/shared";
|
||||
import type { Request, RequestHandler } from "express";
|
||||
import { and, eq, or } from "drizzle-orm";
|
||||
import { and, eq, or, sql } from "drizzle-orm";
|
||||
import { type Db, toolConnections, toolApplications, toolProfiles, toolProfileEntries, toolProfileBindings,
|
||||
toolMcpGateways, toolMcpGatewayTokens, chatEndpoints, chatConversations, connectionGrants, connectionGrantDelegations, agents, issues,
|
||||
toolConnectionInstalls, heartbeatRuns, toolPolicies, toolRuntimeSlots, toolCatalogEntries, chatSlackManagerGrants, adapterAuthSessions, companySecrets, companies } from "@paperclipai/db";
|
||||
toolConnectionInstalls, heartbeatRuns, toolPolicies, toolRuntimeSlots, toolCatalogEntries, chatSlackManagerGrants, adapterAuthSessions, companySecrets, companies, companyMemberships } from "@paperclipai/db";
|
||||
import { badRequest, forbidden, notFound, HttpError } from "../errors.js";
|
||||
import { assertAuthenticated, assertCompanyAccess, hasCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import { connectionManagementService, responsibleConnectionUser, type ConnectionManagementContext } from "../services/connection-management.js";
|
||||
@@ -31,11 +31,56 @@ export function requiredConnectionUserAction(
|
||||
continuation: "Inspect this saved setup after the addressed user completes the required step." } };
|
||||
}
|
||||
|
||||
function assertBoundedManagementSelectors(body: Record<string, unknown>) {
|
||||
if (!body || typeof body !== "object" || Array.isArray(body)) throw badRequest("Resource selectors must be objects");
|
||||
const arrays = ["policyIds", "credentialRefs", "credentialSecretRefs", "installs", "bindings", "entries", "agentIds"];
|
||||
const identifiers = ["connectionId", "profileId", "gatewayId", "applicationId", "catalogEntryId", "projectId", "issueId", "agentId", "targetAgentId", "subjectAgentId", "assignedAgentId", "secretId", "targetId"];
|
||||
const pending: Record<string, unknown>[] = [body];
|
||||
const unique = new Set<string>();
|
||||
let references = 0;
|
||||
for (let index = 0; index < pending.length; index++) {
|
||||
const selected = pending[index];
|
||||
for (const key of identifiers) {
|
||||
const value = selected[key];
|
||||
if (typeof value === "string") unique.add(`${key}:${value}`);
|
||||
}
|
||||
const keys = new Set([...arrays, ...identifiers.map(key => `${key}s`)]);
|
||||
for (const key of keys) {
|
||||
const values = selected[key];
|
||||
if (values === undefined || values === null) continue;
|
||||
if (!Array.isArray(values)) throw badRequest(`${key} must be an array`);
|
||||
references += values.length;
|
||||
if (references > 2048) throw badRequest("Connection management requests support at most 2048 resource references");
|
||||
for (const value of values) {
|
||||
if (typeof value === "string") unique.add(`${key}:${value}`);
|
||||
else if (value && typeof value === "object" && !Array.isArray(value)) pending.push(value as Record<string, unknown>);
|
||||
}
|
||||
}
|
||||
for (const key of ["selectors", "access", "binding"]) {
|
||||
const value = selected[key];
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) pending.push(value as Record<string, unknown>);
|
||||
}
|
||||
if (selected === body && body.config && typeof body.config === "object" && !Array.isArray(body.config)) {
|
||||
const members = (body.config as Record<string, unknown>).members;
|
||||
if (members !== undefined) {
|
||||
if (!Array.isArray(members)) throw badRequest("Pool members must be an array");
|
||||
references += members.length;
|
||||
if (references > 2048) throw badRequest("Connection management requests support at most 2048 resource references");
|
||||
for (const member of members) if (member && typeof member === "object" && !Array.isArray(member)) pending.push(member as Record<string, unknown>);
|
||||
}
|
||||
}
|
||||
if (unique.size > 1024 || pending.length > 2049) throw badRequest("Connection management requests support at most 1024 unique resource selectors");
|
||||
}
|
||||
}
|
||||
|
||||
/** Each REST registration declares its operation; this adapter only resolves resources. */
|
||||
export function connectionManagementGuard(db: Db, purpose: "tool" | "chat" | "ai") {
|
||||
const management = connectionManagementService(db);
|
||||
return (action: ConnectionManagementAction, operation: string, options: { personal?: boolean; slackManagerGrant?: boolean } = {}): RequestHandler => async (req, _res, next) => {
|
||||
try {
|
||||
const rawBody = req.body ?? {};
|
||||
const authorizeRequest = async (afterHumanAction?: boolean): Promise<ConnectionManagementContext> => {
|
||||
const authorize: typeof management.authorize = (actor, requestedAction, resolved, checkOptions) =>
|
||||
management.authorize(actor, requestedAction, resolved, { ...checkOptions, afterHumanAction });
|
||||
assertAuthenticated(req);
|
||||
if (req.actor.type === "agent" && !connectionRequestUserId(req)) throw forbidden("Connection management requires a responsible user");
|
||||
const inaccessibleMessage = operation.includes("setup-token-login-sessions") ? "Setup-token login session not found." : "Connection management resource not found";
|
||||
@@ -43,8 +88,8 @@ export function connectionManagementGuard(db: Db, purpose: "tool" | "chat" | "ai
|
||||
if (purpose === "chat" || operation.includes("setup-token-login-sessions")) throw notFound(inaccessibleMessage);
|
||||
assertCompanyAccess(req, String(req.params.companyId));
|
||||
}
|
||||
const rawBody = req.body ?? {};
|
||||
const body = purpose === "ai" && rawBody.aiConnection ? { ...rawBody, ...rawBody.aiConnection } : rawBody;
|
||||
assertBoundedManagementSelectors(body);
|
||||
const userId = connectionRequestUserId(req);
|
||||
if (req.actor.type === "agent") {
|
||||
for (const key of ["responsibleUserId", "onBehalfOfUserId", "createdByUserId", "createdByAgentId"]) {
|
||||
@@ -55,13 +100,42 @@ export function connectionManagementGuard(db: Db, purpose: "tool" | "chat" | "ai
|
||||
}
|
||||
}
|
||||
let companyId = typeof req.params.companyId === "string" ? req.params.companyId : null;
|
||||
const earlyCompanyId = companyId ?? req.actor.companyId;
|
||||
if (earlyCompanyId) {
|
||||
assertCompanyAccess(req, earlyCompanyId);
|
||||
if (req.actor.type === "agent") {
|
||||
const memberships = await db.select({ principalType: companyMemberships.principalType, principalId: companyMemberships.principalId }).from(companyMemberships).where(and(
|
||||
eq(companyMemberships.companyId, earlyCompanyId), eq(companyMemberships.status, "active"),
|
||||
or(and(eq(companyMemberships.principalType, "agent"), eq(companyMemberships.principalId, req.actor.agentId!)),
|
||||
and(eq(companyMemberships.principalType, "user"), eq(companyMemberships.principalId, userId!))),
|
||||
));
|
||||
if (!memberships.some(member => member.principalType === "agent") || !memberships.some(member => member.principalType === "user")) throw forbidden("Connection management requires active agent and responsible-user memberships");
|
||||
}
|
||||
}
|
||||
const resource: ConnectionManagementResource = { type: "connection_management", companyId: companyId ?? "", purpose, operation,
|
||||
readOnly: req.method === "GET" && action !== "connections:gateway" && (action !== "connections:audit" || purpose === "chat") };
|
||||
const targets = new Set<string>();
|
||||
const extraResources: ConnectionManagementResource[] = [];
|
||||
const selectors: Record<string, unknown>[] = [body, body.access && typeof body.access === "object" ? body.access : {}, body.selectors ?? {}, ...(Array.isArray(body.entries) ? body.entries : [])];
|
||||
const selectors: Record<string, unknown>[] = [];
|
||||
const selectorKeys = ["connectionId", "profileId", "gatewayId", "applicationId", "catalogEntryId", "projectId", "issueId", "agentId", "targetAgentId", "targetType", "targetId", "agentIds", "connectionIds", "profileIds", "gatewayIds", "applicationIds", "catalogEntryIds", "projectIds", "issueIds"];
|
||||
const seenSelectors = new Set<string>();
|
||||
const addSelector = (selected: Record<string, unknown>) => {
|
||||
assertBoundedManagementSelectors(selected);
|
||||
const key = JSON.stringify(selectorKeys.map(name => selected[name]));
|
||||
if (seenSelectors.has(key)) return;
|
||||
if (selectors.length >= 1024) throw badRequest("Connection management request spans too many resources");
|
||||
seenSelectors.add(key); selectors.push(selected);
|
||||
};
|
||||
const addSelectors = (selected: Record<string, unknown>[]) => selected.forEach(addSelector);
|
||||
addSelectors([body, body.access && typeof body.access === "object" ? body.access : {}, body.selectors ?? {}, ...(Array.isArray(body.entries) ? body.entries : [])]);
|
||||
const lookupCache = new Map<string, Promise<unknown>>();
|
||||
const lookup = <T,>(key: string, read: () => PromiseLike<T>): Promise<T> => {
|
||||
let pending = lookupCache.get(key);
|
||||
if (!pending) { pending = Promise.resolve(read()); lookupCache.set(key, pending); }
|
||||
return pending as Promise<T>;
|
||||
};
|
||||
const bindCompany = (row: { companyId: string } | undefined) => {
|
||||
if (!row || (req.actor.type === "board" && !hasCompanyAccess(req, row.companyId))) throw notFound(inaccessibleMessage);
|
||||
if (!row || (req.actor.type === "agent" && row.companyId !== req.actor.companyId) || (req.actor.type === "board" && !hasCompanyAccess(req, row.companyId))) throw notFound(inaccessibleMessage);
|
||||
if (companyId && companyId !== row.companyId) throw forbidden("Resource is outside this company");
|
||||
companyId = row.companyId;
|
||||
};
|
||||
@@ -70,14 +144,14 @@ export function connectionManagementGuard(db: Db, purpose: "tool" | "chat" | "ai
|
||||
let connectionId = text(req.params.connectionId) ?? text(req.query.connectionId) ?? text(body.resumeConnectionId) ?? text(body.reconnectConnectionId) ?? text(body.connectionId) ?? text(req.params.poolId) ?? (operation.includes("ai-connection-pools") ? text(body.id) : null);
|
||||
let profileId = text(req.params.profileId) ?? text(req.query.profileId) ?? text(body.profileId);
|
||||
let gatewayId = text(req.params.gatewayId) ?? text(req.query.gatewayId) ?? text(req.query.gateway);
|
||||
for (const id of Array.isArray(body.policyIds) ? body.policyIds : []) {
|
||||
for (const id of new Set(Array.isArray(body.policyIds) ? body.policyIds : [])) {
|
||||
const [policy] = await db.select().from(toolPolicies).where(eq(toolPolicies.id, uuid(id)));
|
||||
if (policy && companyId && policy.companyId !== companyId && req.actor.type === "board") throw new HttpError(422, "Policies must belong to this company");
|
||||
bindCompany(policy); selectors.push(policy.selectors);
|
||||
bindCompany(policy); addSelector(policy.selectors);
|
||||
}
|
||||
if (req.params.policyId) {
|
||||
const [policy] = await db.select().from(toolPolicies).where(eq(toolPolicies.id, uuid(req.params.policyId)));
|
||||
bindCompany(policy); selectors.push(policy.selectors);
|
||||
bindCompany(policy); addSelector(policy.selectors);
|
||||
}
|
||||
if (action === "connections:runtime" && (req.params.slotId || req.params.id)) {
|
||||
const [slot] = await db.select().from(toolRuntimeSlots).where(eq(toolRuntimeSlots.id, uuid(req.params.slotId ?? req.params.id)));
|
||||
@@ -90,7 +164,7 @@ export function connectionManagementGuard(db: Db, purpose: "tool" | "chat" | "ai
|
||||
}
|
||||
for (const ref of [...(Array.isArray(body.credentialRefs) ? body.credentialRefs : []), ...(Array.isArray(body.credentialSecretRefs) ? body.credentialSecretRefs : [])]) {
|
||||
if (!ref.secretId) continue;
|
||||
const [secret] = await db.select().from(companySecrets).where(eq(companySecrets.id, uuid(ref.secretId)));
|
||||
const [secret] = await lookup(`secret:${String(ref.secretId)}`, () => db.select().from(companySecrets).where(eq(companySecrets.id, uuid(ref.secretId))));
|
||||
bindCompany(secret);
|
||||
if (secret.deletedAt || secret.status !== "active" || (secret.scope === "user" && secret.ownerUserId !== userId)) throw forbidden("Credential binding is unavailable to the responsible user");
|
||||
}
|
||||
@@ -148,9 +222,10 @@ export function connectionManagementGuard(db: Db, purpose: "tool" | "chat" | "ai
|
||||
if (req.params.applicationId || body.applicationId) {
|
||||
const [application] = await db.select().from(toolApplications).where(eq(toolApplications.id, uuid(req.params.applicationId ?? body.applicationId)));
|
||||
bindCompany(application);
|
||||
resource.provider = text(application.metadata.sourceTemplateKey);
|
||||
resource.provider ??= text(application.metadata.sourceTemplateKey);
|
||||
if (req.method !== "GET") {
|
||||
const connections = await db.select().from(toolConnections).where(eq(toolConnections.applicationId, application.id));
|
||||
if (application.ownerAgentId) targets.add(application.ownerAgentId);
|
||||
const connections = await db.select({ id: toolConnections.id }).from(toolConnections).where(eq(toolConnections.applicationId, application.id)).limit(1025);
|
||||
connections.forEach(connection => extraResources.push({ ...resource, connectionId: connection.id }));
|
||||
}
|
||||
}
|
||||
@@ -163,11 +238,11 @@ export function connectionManagementGuard(db: Db, purpose: "tool" | "chat" | "ai
|
||||
const [connection] = await db.select().from(toolConnections).where((isUuidLike(connectionId) ? eq(toolConnections.id, connectionId) : eq(toolConnections.uid, connectionId)));
|
||||
bindCompany(connection); resource.connectionId = connection.id;
|
||||
resource.purpose = connection.connectionPurpose === "ai" ? "ai" : connection.connectionPurpose === "channel" ? "chat" : "tool";
|
||||
resource.provider ??= text(connection.config.sourceTemplateKey) ?? text(connection.config.provider) ?? text(connection.transportConfig.sourceTemplateKey);
|
||||
resource.provider ??= text(connection.config.sourceTemplateKey) ?? text(connection.config.provider) ?? text((connection.config.ai as Record<string, unknown> | undefined)?.provider) ?? text(connection.transportConfig.sourceTemplateKey);
|
||||
resource.selfServiceUserId = connection.createdByUserId;
|
||||
if (req.method !== "GET") {
|
||||
const installs = await db.select().from(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, connection.id));
|
||||
selectors.push(...installs);
|
||||
addSelectors(installs);
|
||||
if (operation === "PUT /tool-connections/:connectionId/installs" && Array.isArray(body.installs)) {
|
||||
const keys = (rows: Array<{ targetType: string; targetId: string }>) => new Set(rows.filter(row => row.targetType === "company").map(row => row.targetId));
|
||||
const existingCompanies = keys(installs), requestedCompanies = keys(body.installs);
|
||||
@@ -191,7 +266,7 @@ export function connectionManagementGuard(db: Db, purpose: "tool" | "chat" | "ai
|
||||
bindCompany(profile); resource.profileId = profile.id;
|
||||
if (req.method !== "GET") {
|
||||
const entries = await db.select().from(toolProfileEntries).where(eq(toolProfileEntries.profileId, profile.id));
|
||||
selectors.push(...entries);
|
||||
addSelectors(entries);
|
||||
const bindings = await db.select().from(toolProfileBindings).where(eq(toolProfileBindings.profileId, profile.id));
|
||||
for (const binding of bindings) {
|
||||
if (binding.targetType === "agent") targets.add(binding.targetId);
|
||||
@@ -206,8 +281,13 @@ export function connectionManagementGuard(db: Db, purpose: "tool" | "chat" | "ai
|
||||
if (!companyId) throw forbidden("A company is required for connection management");
|
||||
assertCompanyAccess(req, companyId);
|
||||
resource.companyId = companyId;
|
||||
resource.provider ??= text(body.provider) ?? text(body.galleryKey) ?? text(req.params.galleryKey);
|
||||
for (const id of [req.params.agentId, req.query.agentId, req.query.agent, body.agentId, body.assignedAgentId, body.subjectAgentId, body.asAgentId, ...(Array.isArray(body.agentIds) ? body.agentIds : [])]) {
|
||||
if (operation === "POST /companies/:companyId/tools/connections") resource.provider = null;
|
||||
else resource.provider ??= text(body.provider) ?? text(body.galleryKey) ?? text(req.params.galleryKey);
|
||||
if (operation === "PATCH /tool-connections/:connectionId" && (body.config || body.transportConfig)) {
|
||||
const proposed = body.config ?? body.transportConfig;
|
||||
resource.requestedProvider = text(proposed.sourceTemplateKey) ?? text(proposed.provider) ?? text(proposed.ai?.provider);
|
||||
}
|
||||
for (const id of [req.params.agentId, req.query.agentId, req.query.agent, body.agentId, body.assignedAgentId, body.subjectAgentId, body.ownerAgentId, body.asAgentId, ...(Array.isArray(body.agentIds) ? body.agentIds : [])]) {
|
||||
if (text(id)) targets.add(String(id));
|
||||
}
|
||||
if (body.targetType === "agent" && text(body.targetId)) targets.add(body.targetId);
|
||||
@@ -221,8 +301,8 @@ export function connectionManagementGuard(db: Db, purpose: "tool" | "chat" | "ai
|
||||
resource.projectId ??= text(body.projectId) ?? text(body.executionDefaults?.projectId);
|
||||
resource.issueId ??= text(body.issueId);
|
||||
if (body.approvalIssueId) extraResources.push({ ...resource, issueId: uuid(body.approvalIssueId) });
|
||||
for (const member of Array.isArray(body.config?.members) ? body.config.members : []) selectors.push(member.binding ?? {});
|
||||
for (const binding of Array.isArray(body.bindings) ? body.bindings : []) selectors.push(binding);
|
||||
for (const member of Array.isArray(body.config?.members) ? body.config.members : []) addSelector(member.binding ?? {});
|
||||
for (const binding of Array.isArray(body.bindings) ? body.bindings : []) addSelector(binding);
|
||||
if (req.method !== "GET" && (operation.includes("/tools/policies") || operation.includes("/trust-rules/")) &&
|
||||
selectors.some(selected => !selected.agentId && !(Array.isArray(selected.agentIds) && selected.agentIds.length))) {
|
||||
const companyAgents = await db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, companyId));
|
||||
@@ -233,13 +313,13 @@ export function connectionManagementGuard(db: Db, purpose: "tool" | "chat" | "ai
|
||||
// Expand each advertised plural selector. A permission over one member
|
||||
// cannot authorize a policy that also changes another member's access.
|
||||
for (const key of ["connectionId", "profileId", "gatewayId", "applicationId", "catalogEntryId", "projectId", "issueId"] as const) {
|
||||
for (const id of Array.isArray(selected[`${key}s`]) ? selected[`${key}s`] as unknown[] : []) selectors.push({ [key]: uuid(id) });
|
||||
for (const id of Array.isArray(selected[`${key}s`]) ? selected[`${key}s`] as unknown[] : []) addSelector({ [key]: uuid(id) });
|
||||
}
|
||||
if (selected.applicationId) {
|
||||
const [application] = await db.select().from(toolApplications).where(eq(toolApplications.id, uuid(selected.applicationId)));
|
||||
const [application] = await lookup(`application:${String(selected.applicationId)}`, () => db.select().from(toolApplications).where(eq(toolApplications.id, uuid(selected.applicationId))));
|
||||
bindCompany(application);
|
||||
const connections = await db.select({ id: toolConnections.id }).from(toolConnections).where(eq(toolConnections.applicationId, application.id));
|
||||
connections.forEach(connection => selectors.push({ connectionId: connection.id }));
|
||||
const connections = await lookup(`application-connections:${application.id}`, () => db.select({ id: toolConnections.id }).from(toolConnections).where(eq(toolConnections.applicationId, application.id)).limit(1025));
|
||||
connections.forEach(connection => addSelector({ connectionId: connection.id }));
|
||||
}
|
||||
if (text(selected.profileId) && selected.profileId !== profileId) extraResources.push({ ...resource, profileId: uuid(selected.profileId) });
|
||||
if (text(selected.gatewayId) && selected.gatewayId !== gatewayId) extraResources.push({ ...resource, gatewayId: uuid(selected.gatewayId) });
|
||||
@@ -255,14 +335,16 @@ export function connectionManagementGuard(db: Db, purpose: "tool" | "chat" | "ai
|
||||
}
|
||||
let selectedConnectionId = text(selected.connectionId);
|
||||
if (selected.catalogEntryId) {
|
||||
const [entry] = await 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, String(selected.catalogEntryId))));
|
||||
bindCompany(entry); selectedConnectionId = entry.connectionId;
|
||||
}
|
||||
if (selectedConnectionId && selectedConnectionId !== connectionId) {
|
||||
const [connection] = await db.select().from(toolConnections).where(eq(toolConnections.id, selectedConnectionId));
|
||||
const [connection] = await lookup(`connection:${selectedConnectionId}`, () => db.select({ id: toolConnections.id, companyId: toolConnections.companyId, createdByUserId: toolConnections.createdByUserId,
|
||||
sourceTemplateKey: sql<string | null>`${toolConnections.config}->>'sourceTemplateKey'`, provider: sql<string | null>`${toolConnections.config}->>'provider'`,
|
||||
}).from(toolConnections).where(eq(toolConnections.id, selectedConnectionId)));
|
||||
bindCompany(connection);
|
||||
extraResources.push({ ...resource, connectionId: connection.id,
|
||||
provider: text(connection.config.sourceTemplateKey) ?? text(connection.config.provider),
|
||||
provider: text(connection.sourceTemplateKey) ?? text(connection.provider),
|
||||
selfServiceUserId: connection.createdByUserId,
|
||||
});
|
||||
}
|
||||
@@ -271,42 +353,49 @@ export function connectionManagementGuard(db: Db, purpose: "tool" | "chat" | "ai
|
||||
}
|
||||
if (!connectionId && options.personal && (body.ownership === "personal" || body.grantKind === "user" || req.method === "GET")) resource.selfServiceUserId = userId;
|
||||
for (const targetId of targets) uuid(targetId);
|
||||
const context = await management.authorize(req.actor, action, resource, { targetAgentIds: [...targets], mutation: req.method !== "GET" });
|
||||
const context = await authorize(req.actor, action, resource, { targetAgentIds: [...targets], mutation: req.method !== "GET" });
|
||||
if (action === "connections:profiles" && resource.connectionId && req.method !== "GET" && (req.actor.type === "agent" || !resource.selfServiceAgentInstalls)) {
|
||||
await management.authorize(req.actor, "connections:configure", resource, { targetAgentIds: [...targets] });
|
||||
await authorize(req.actor, "connections:configure", resource, { targetAgentIds: [...targets] });
|
||||
}
|
||||
// Clearing a binding or audience still changes access. Check field
|
||||
// presence so null, false and empty arrays cannot bypass profile authority.
|
||||
const changesGatewayBindings = action === "connections:gateway" &&
|
||||
["profileId", "agentId", "projectId", "issueId"].some(key => Object.hasOwn(body, key));
|
||||
if (changesGatewayBindings) {
|
||||
await management.authorize(req.actor, "connections:profiles", resource, { targetAgentIds: [...targets], mutation: req.method !== "GET" });
|
||||
await authorize(req.actor, "connections:profiles", resource, { targetAgentIds: [...targets], mutation: req.method !== "GET" });
|
||||
}
|
||||
const changesAudience = req.actor.type === "agent" && req.method !== "GET" && action === "connections:configure" && (
|
||||
["allAgents", "agentIds", "assignedAgentId", "installs", "bindings", "ownership", "grantKind", "credentialPolicy", "access"].some(key => Object.hasOwn(body, key)) ||
|
||||
operation.includes("ai-connection-pools"));
|
||||
if (changesAudience) await management.authorize(req.actor, "connections:profiles", resource, { targetAgentIds: [...targets] });
|
||||
if (changesAudience) await authorize(req.actor, "connections:profiles", resource, { targetAgentIds: [...targets] });
|
||||
const uniqueResources = new Map(extraResources.map(referenced => [JSON.stringify([
|
||||
referenced.connectionId, referenced.profileId, referenced.gatewayId, referenced.projectId, referenced.issueId, referenced.provider, referenced.purpose,
|
||||
]), referenced]));
|
||||
if (uniqueResources.size > 1024) throw badRequest("Connection management request spans too many resources");
|
||||
const authorizeReferenced = async (afterHumanAction?: boolean) => {
|
||||
for (const referenced of extraResources) {
|
||||
for (const referenced of uniqueResources.values()) {
|
||||
const resolved = { ...referenced, companyId: resource.companyId };
|
||||
const checkOptions = { targetAgentIds: [...targets], mutation: req.method !== "GET", afterHumanAction };
|
||||
await management.authorize(req.actor, action, resolved, checkOptions);
|
||||
if (changesGatewayBindings || changesAudience) await management.authorize(req.actor, "connections:profiles", resolved, checkOptions);
|
||||
await authorize(req.actor, action, resolved, checkOptions);
|
||||
if (changesGatewayBindings || changesAudience) await authorize(req.actor, "connections:profiles", resolved, checkOptions);
|
||||
if (action === "connections:profiles" && resolved.connectionId && req.method !== "GET" && (req.actor.type === "agent" || !resolved.selfServiceAgentInstalls)) {
|
||||
await management.authorize(req.actor, "connections:configure", resolved, checkOptions);
|
||||
await authorize(req.actor, "connections:configure", resolved, checkOptions);
|
||||
}
|
||||
}
|
||||
};
|
||||
await authorizeReferenced();
|
||||
const primaryReauthorize = context.reauthorize;
|
||||
context.reauthorize = async (continuation) => {
|
||||
await primaryReauthorize(continuation);
|
||||
if (changesAudience) await management.authorize(req.actor, "connections:profiles", resource, { targetAgentIds: [...targets], afterHumanAction: continuation?.afterHumanAction });
|
||||
await authorizeReferenced(continuation?.afterHumanAction);
|
||||
if (action === "connections:profiles" && resource.connectionId && req.method !== "GET" && (req.actor.type === "agent" || !resource.selfServiceAgentInstalls)) await management.authorize(req.actor, "connections:configure", resource, { targetAgentIds: [...targets], afterHumanAction: continuation?.afterHumanAction });
|
||||
if (changesGatewayBindings) await management.authorize(req.actor, "connections:profiles", resource, { targetAgentIds: [...targets], mutation: req.method !== "GET", afterHumanAction: continuation?.afterHumanAction });
|
||||
const current = await authorizeRequest(continuation?.afterHumanAction);
|
||||
context.workContext = current.workContext;
|
||||
context.resource = current.resource;
|
||||
context.decision = current.decision;
|
||||
context.audit = current.audit;
|
||||
contexts.set(req, current);
|
||||
};
|
||||
contexts.set(req, context);
|
||||
return context;
|
||||
};
|
||||
try {
|
||||
contexts.set(req, await authorizeRequest());
|
||||
next();
|
||||
} catch (error) { next(error); }
|
||||
};
|
||||
@@ -329,7 +418,7 @@ export async function filterConnectionManagementRows<T>(db: Db, req: Request, ac
|
||||
if (!context) throw forbidden("Connection management authority is required");
|
||||
const visible: T[] = [];
|
||||
for (const row of rows) {
|
||||
const decision = await connectionManagementService(db).capability(req.actor, action, { ...context.resource, ...resource(row) }, { mutation: false });
|
||||
const decision = await connectionManagementService(db).capability(req.actor, action, { ...context.resource, ...resource(row) }, { mutation: false, readContext: context });
|
||||
if (decision.allowed) visible.push(row);
|
||||
}
|
||||
return visible;
|
||||
@@ -356,7 +445,7 @@ export async function filterConnectionManagementPolicies<T extends { selectors:
|
||||
}
|
||||
let allowed = true;
|
||||
for (const resource of resources) {
|
||||
if (!(await connectionManagementService(db).capability(req.actor, "connections:profiles", { ...context.resource, ...resource }, { mutation: false })).allowed) { allowed = false; break; }
|
||||
if (!(await connectionManagementService(db).capability(req.actor, "connections:profiles", { ...context.resource, ...resource }, { mutation: false, readContext: context })).allowed) { allowed = false; break; }
|
||||
}
|
||||
if (allowed) visible.push(row);
|
||||
}
|
||||
|
||||
@@ -630,7 +630,7 @@ function connectorEnrollmentPrincipal(req: Request): string {
|
||||
...connectionRequestContext(req)?.resource, type: "connection_management", companyId,
|
||||
connectionId: undefined, selfServiceUserId: undefined, readOnly: false,
|
||||
operation: connectionRequestContext(req)?.resource.operation ?? "configure",
|
||||
})).allowed;
|
||||
}, { readContext: connectionRequestContext(req) })).allowed;
|
||||
const membership = toolMembershipRole(req, companyId);
|
||||
if (membership.unrestricted) return true;
|
||||
if (!membership.isActive || membership.isViewer) return false;
|
||||
@@ -674,8 +674,8 @@ function connectorEnrollmentPrincipal(req: Request): string {
|
||||
const resource = { ...connectionRequestContext(req)?.resource, type: "connection_management" as const,
|
||||
companyId: connection.companyId, connectionId: connection.id, operation: "configure",
|
||||
readOnly: false, selfServiceUserId: connection.createdByUserId };
|
||||
const manage = await management.capability(req.actor, "connections:configure", resource);
|
||||
const use = await management.capability(req.actor, "connections:test", { ...resource, operation: "use" }, { mutation: false });
|
||||
const manage = await management.capability(req.actor, "connections:configure", resource, { readContext: connectionRequestContext(req) });
|
||||
const use = await management.capability(req.actor, "connections:test", { ...resource, operation: "use" }, { mutation: false, readContext: connectionRequestContext(req) });
|
||||
const toolAccess = req.actor.type === "agent" && req.actor.agentId && options.toolGateway
|
||||
? await options.toolGateway.summarizeConnectionAccessForAgent({ companyId: connection.companyId, connectionId: connection.id,
|
||||
agentId: req.actor.agentId, heartbeatRunId: req.actor.runId, issueId: resource.issueId, projectId: resource.projectId }) : null;
|
||||
@@ -688,7 +688,7 @@ function connectorEnrollmentPrincipal(req: Request): string {
|
||||
if (mutationCapable) {
|
||||
const companyAgents = await db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, connection.companyId));
|
||||
for (const agent of companyAgents) {
|
||||
const capability = await management.capability(req.actor, "connections:profiles", { ...resource, targetAgentId: agent.id });
|
||||
const capability = await management.capability(req.actor, "connections:profiles", { ...resource, targetAgentId: agent.id }, { readContext: connectionRequestContext(req) });
|
||||
if (capability.allowed) editableAgentIds.push(agent.id);
|
||||
}
|
||||
}
|
||||
@@ -1632,7 +1632,7 @@ function connectorEnrollmentPrincipal(req: Request): string {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertToolAppMutationAccess(req, companyId);
|
||||
try {
|
||||
const application = await svc.createApplication(companyId, req.body);
|
||||
const application = await svc.createApplication(companyId, req.body, connectionRequestContext(req));
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
...connectionRequestActor(req),
|
||||
@@ -1652,7 +1652,7 @@ function connectorEnrollmentPrincipal(req: Request): string {
|
||||
if (!existing) return;
|
||||
assertToolAppMutationAccess(req, existing.companyId);
|
||||
try {
|
||||
const application = await svc.updateApplication(existing.id, req.body);
|
||||
const application = await svc.updateApplication(existing.id, req.body, connectionRequestContext(req));
|
||||
await logActivity(db, {
|
||||
companyId: application.companyId,
|
||||
...connectionRequestActor(req),
|
||||
@@ -1671,7 +1671,7 @@ function connectorEnrollmentPrincipal(req: Request): string {
|
||||
const existing = await getAccessibleResource(req, res, svc.getApplication(req.params.applicationId as string), "Tool application not found");
|
||||
if (!existing) return;
|
||||
assertToolAppMutationAccess(req, existing.companyId);
|
||||
const application = await svc.deleteApplication(existing.id);
|
||||
const application = await svc.deleteApplication(existing.id, connectionRequestContext(req));
|
||||
await logActivity(db, {
|
||||
companyId: application.companyId,
|
||||
...connectionRequestActor(req),
|
||||
@@ -1695,7 +1695,7 @@ function connectorEnrollmentPrincipal(req: Request): string {
|
||||
const allowed = await Promise.all(visible.map(connection => management.capability(req.actor, "connections:read", {
|
||||
type: "connection_management", companyId, connectionId: connection.id,
|
||||
operation: "GET /companies/:companyId/tools/connections", readOnly: true,
|
||||
}, { mutation: false })));
|
||||
}, { mutation: false, readContext: connectionRequestContext(req) })));
|
||||
visible = visible.filter((_, index) => allowed[index].allowed);
|
||||
}
|
||||
res.json({ connections: visible });
|
||||
@@ -2174,7 +2174,7 @@ function connectorEnrollmentPrincipal(req: Request): string {
|
||||
if (!existing) return;
|
||||
await assertToolConnectionConfigureAccess(req, existing);
|
||||
if (aiConnectionRouterPluginKey(existing)) throw badRequest("Update this connection pool through its pool settings.");
|
||||
const connection = await svc.updateConnection(existing.id, req.body);
|
||||
const connection = await svc.updateConnection(existing.id, req.body, connectionRequestContext(req));
|
||||
const lifecycleChanges = classifyConnectionUpdate(
|
||||
{ enabled: existing.enabled, config: existing.config },
|
||||
{ enabled: connection.enabled, config: connection.config },
|
||||
@@ -2589,7 +2589,7 @@ function connectorEnrollmentPrincipal(req: Request): string {
|
||||
router.post("/companies/:companyId/tools/policies/reorder", managementGuard("connections:profiles", "POST /companies/:companyId/tools/policies/reorder", { personal: true }), validate(reorderToolPoliciesSchema), async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertToolAppMutationAccess(req, companyId);
|
||||
const policies = await policySvc.reorderPolicies(companyId, req.body);
|
||||
const policies = await policySvc.reorderPolicies(companyId, req.body, connectionRequestContext(req));
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
...connectionRequestActor(req),
|
||||
@@ -2608,7 +2608,7 @@ function connectorEnrollmentPrincipal(req: Request): string {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertToolAppMutationAccess(req, companyId);
|
||||
try {
|
||||
const policy = await policySvc.createPolicy(companyId, req.body, { userId: connectionRequestUserId(req) ?? null, agentId: req.actor.agentId ?? null });
|
||||
const policy = await policySvc.createPolicy(companyId, req.body, { userId: connectionRequestUserId(req) ?? null, agentId: req.actor.agentId ?? null }, connectionRequestContext(req));
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
...connectionRequestActor(req),
|
||||
@@ -2629,6 +2629,7 @@ function connectorEnrollmentPrincipal(req: Request): string {
|
||||
try {
|
||||
const policy = await policySvc.duplicatePolicy({
|
||||
companyId,
|
||||
managementContext: connectionRequestContext(req),
|
||||
policyId: req.params.policyId as string,
|
||||
body: req.body,
|
||||
actor: { userId: connectionRequestUserId(req) ?? null, agentId: req.actor.agentId ?? null },
|
||||
@@ -2658,6 +2659,7 @@ function connectorEnrollmentPrincipal(req: Request): string {
|
||||
try {
|
||||
const policy = await policySvc.updatePolicy({
|
||||
companyId,
|
||||
managementContext: connectionRequestContext(req),
|
||||
policyId: req.params.policyId as string,
|
||||
body: req.body,
|
||||
});
|
||||
@@ -2680,6 +2682,7 @@ function connectorEnrollmentPrincipal(req: Request): string {
|
||||
assertToolAppMutationAccess(req, companyId);
|
||||
const policy = await policySvc.deletePolicy({
|
||||
companyId,
|
||||
managementContext: connectionRequestContext(req),
|
||||
policyId: req.params.policyId as string,
|
||||
});
|
||||
await logActivity(db, {
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface ConnectionManagementResource {
|
||||
profileId?: string | null;
|
||||
gatewayId?: string | null;
|
||||
provider?: string | null;
|
||||
requestedProvider?: string | null;
|
||||
purpose?: "tool" | "chat" | "ai";
|
||||
operation: string;
|
||||
readOnly?: boolean;
|
||||
|
||||
@@ -16,6 +16,9 @@ export type ConnectionManagementContext = {
|
||||
reauthorize: (continuation?: { afterHumanAction: true }) => Promise<void>;
|
||||
};
|
||||
|
||||
type CapturedConnectionRun = Omit<Awaited<ReturnType<typeof captureRunIdentity>>, "context"> & { context: Awaited<ReturnType<typeof captureRunIdentity>>["context"] | null };
|
||||
const connectionReadRuns = new WeakMap<ConnectionManagementContext, CapturedConnectionRun>();
|
||||
|
||||
export function responsibleConnectionUser(actor: AuthorizationActor): string | null {
|
||||
return (actor.type === "agent" ? actor.onBehalfOfUserId : actor.userId) ?? null;
|
||||
}
|
||||
@@ -28,10 +31,11 @@ export function connectionManagementService(db: Db) {
|
||||
actor: AuthorizationActor,
|
||||
action: ConnectionManagementAction,
|
||||
resource: ConnectionManagementResource,
|
||||
options: { targetAgentIds?: string[]; mutation?: boolean; afterHumanAction?: boolean; expandedProfile?: boolean; gatewayContextChecked?: boolean } = {},
|
||||
options: { targetAgentIds?: string[]; mutation?: boolean; afterHumanAction?: boolean; expandedProfile?: boolean; gatewayContextChecked?: boolean; readContext?: ConnectionManagementContext; capabilityProbe?: boolean } = {},
|
||||
): Promise<ConnectionManagementContext> {
|
||||
const responsibleUserId = responsibleConnectionUser(actor);
|
||||
let workContext: ConnectionManagementContext["workContext"] = { runId: null, issueId: null, projectId: null };
|
||||
let capturedRun: CapturedConnectionRun | undefined;
|
||||
if (actor.type === "agent") {
|
||||
if (!responsibleUserId || !actor.agentId || actor.companyId !== resource.companyId) {
|
||||
throw forbidden("Connection management requires a responsible user in this company");
|
||||
@@ -43,13 +47,16 @@ export function connectionManagementService(db: Db) {
|
||||
if (!key || key.revokedAt || key.responsibleUserId !== responsibleUserId || (key.scopeConfig && key.scopeConfig.kind !== "standard")) throw forbidden("Agent key authority is no longer valid");
|
||||
}
|
||||
if (actor.runId) {
|
||||
const captured = options.afterHumanAction ? await (async () => {
|
||||
const reusable = options.capabilityProbe && !options.afterHumanAction && options.readContext?.actor === actor && options.readContext.resource.companyId === resource.companyId
|
||||
? connectionReadRuns.get(options.readContext) : undefined;
|
||||
const captured = reusable ?? (options.afterHumanAction ? await (async () => {
|
||||
const [run] = await db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.id, actor.runId!), eq(heartbeatRuns.agentId, actor.agentId!), eq(heartbeatRuns.companyId, resource.companyId)));
|
||||
if (!run || !["running", "succeeded"].includes(run.status)) throw forbidden("The originating run cannot continue this authentication");
|
||||
const [pending] = await db.select().from(runIdentityContexts).where(and(eq(runIdentityContexts.runId, run.id), eq(runIdentityContexts.status, "pending")));
|
||||
if (pending) throw forbidden("Run identity is being reconciled");
|
||||
return { run, context: null };
|
||||
})() : await captureRunIdentity(db, { companyId: resource.companyId, agentId: actor.agentId, runId: actor.runId });
|
||||
})() : await captureRunIdentity(db, { companyId: resource.companyId, agentId: actor.agentId, runId: actor.runId }));
|
||||
capturedRun = captured;
|
||||
const { run, context } = captured;
|
||||
if ((context?.responsibleUserId ?? run.responsibleUserId) !== responsibleUserId) throw forbidden("Run responsible-user authority has changed");
|
||||
const snapshot = run.contextSnapshot ?? {};
|
||||
@@ -103,7 +110,8 @@ export function connectionManagementService(db: Db) {
|
||||
resource = { ...resource, selfServiceUserId: connection.createdByUserId,
|
||||
purpose: connection.connectionPurpose === "ai" ? "ai" : connection.connectionPurpose === "channel" ? "chat" : "tool",
|
||||
provider: typeof connection.config.sourceTemplateKey === "string" ? connection.config.sourceTemplateKey
|
||||
: typeof connection.config.provider === "string" ? connection.config.provider : resource.provider,
|
||||
: typeof connection.config.provider === "string" ? connection.config.provider
|
||||
: typeof (connection.config.ai as Record<string, unknown> | undefined)?.provider === "string" ? String((connection.config.ai as Record<string, unknown>).provider) : resource.provider,
|
||||
};
|
||||
if (options.mutation ?? action !== "connections:read") {
|
||||
const installs = await db.select().from(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, connection.id));
|
||||
@@ -162,6 +170,11 @@ export function connectionManagementService(db: Db) {
|
||||
const resolved = { ...resource, ...(targetAgentId ? { targetAgentId } : {}) };
|
||||
decision = await authorization.decide({ actor, action, resource: resolved, scope: { ...resolved } });
|
||||
if (!decision.allowed) throw forbidden(decision.explanation, { reason: decision.reason, code: decision.code, operation: resource.operation });
|
||||
if (Object.hasOwn(resource, "requestedProvider") && resource.requestedProvider !== resource.provider) {
|
||||
const proposed = { ...resolved, provider: resource.requestedProvider };
|
||||
const proposedDecision = await authorization.decide({ actor, action, resource: proposed, scope: { ...proposed } });
|
||||
if (!proposedDecision.allowed) throw forbidden(proposedDecision.explanation, { reason: proposedDecision.reason, code: proposedDecision.code, operation: resource.operation });
|
||||
}
|
||||
if (actor.type === "agent" && actor.runId && (resolved.projectId || resolved.issueId)) {
|
||||
const resourceTrust = await authorization.decide({ actor: { ...actor, runId: undefined }, action, resource: resolved, scope: { ...resolved } });
|
||||
if (!resourceTrust.allowed) throw forbidden(resourceTrust.explanation);
|
||||
@@ -196,14 +209,17 @@ export function connectionManagementService(db: Db) {
|
||||
reauthorize: async (continuation) => {
|
||||
const current = await authorize(actor, action, resource, { ...options, afterHumanAction: continuation?.afterHumanAction ?? options.afterHumanAction });
|
||||
context.workContext = current.workContext;
|
||||
const snapshot = connectionReadRuns.get(current);
|
||||
if (snapshot) connectionReadRuns.set(context, snapshot);
|
||||
},
|
||||
};
|
||||
if (capturedRun) connectionReadRuns.set(context, capturedRun);
|
||||
return context;
|
||||
}
|
||||
|
||||
async function capability(...args: Parameters<typeof authorize>) {
|
||||
try {
|
||||
const context = await authorize(...args);
|
||||
const context = await authorize(args[0], args[1], args[2], { ...args[3], capabilityProbe: true });
|
||||
return { allowed: true, reason: context.decision.reason, operation: args[2].operation };
|
||||
} catch (error) {
|
||||
// Database/provider faults are not authorization denials.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ConnectionManagementContext } from "./connection-management.js";
|
||||
import { createHash } from "node:crypto";
|
||||
import { boundRunIssueIdSql } from "./run-task-binding.js";
|
||||
import { and, asc, desc, eq, gt, inArray, isNull, ne, sql } from "drizzle-orm";
|
||||
@@ -792,7 +793,7 @@ export function toolAccessPolicyService(db: Db, options: { toolActionSigningSecr
|
||||
.orderBy(asc(toolPolicies.priority), desc(toolPolicies.updatedAt));
|
||||
}
|
||||
|
||||
async function reorderPolicies(companyId: string, body: ReorderToolPolicies) {
|
||||
async function reorderPolicies(companyId: string, body: ReorderToolPolicies, managementContext?: ConnectionManagementContext) {
|
||||
const uniquePolicyIds = [...new Set(body.policyIds)];
|
||||
if (uniquePolicyIds.length !== body.policyIds.length) {
|
||||
throw badRequest("policyIds must not contain duplicates");
|
||||
@@ -820,6 +821,7 @@ export function toolAccessPolicyService(db: Db, options: { toolActionSigningSecr
|
||||
});
|
||||
}
|
||||
|
||||
await managementContext?.reauthorize();
|
||||
const now = new Date();
|
||||
await tx.execute(sql`UPDATE tool_policies
|
||||
SET priority = (requested.ordinality * 100)::integer, updated_at = ${now.toISOString()}
|
||||
@@ -839,6 +841,7 @@ export function toolAccessPolicyService(db: Db, options: { toolActionSigningSecr
|
||||
policyId: string;
|
||||
body: DuplicateToolPolicy;
|
||||
actor?: { agentId?: string | null; userId?: string | null };
|
||||
managementContext?: ConnectionManagementContext;
|
||||
}) {
|
||||
const existing = await getGenericPolicyRow(db, input.companyId, input.policyId);
|
||||
assertSupportedGenericPolicyShape(existing.policyType, existing.conditions ?? null, existing.config ?? null);
|
||||
@@ -854,6 +857,7 @@ export function toolAccessPolicyService(db: Db, options: { toolActionSigningSecr
|
||||
while (names.has(`${baseName} ${suffix}`)) suffix += 1;
|
||||
name = `${baseName} ${suffix}`;
|
||||
}
|
||||
await input.managementContext?.reauthorize();
|
||||
const now = new Date();
|
||||
const [policy] = await db
|
||||
.insert(toolPolicies)
|
||||
@@ -880,8 +884,10 @@ export function toolAccessPolicyService(db: Db, options: { toolActionSigningSecr
|
||||
companyId: string,
|
||||
body: CreateToolPolicy,
|
||||
actor?: { agentId?: string | null; userId?: string | null },
|
||||
managementContext?: ConnectionManagementContext,
|
||||
) {
|
||||
assertSupportedGenericPolicyShape(body.policyType, body.conditions ?? null, body.config ?? null);
|
||||
await managementContext?.reauthorize();
|
||||
const now = new Date();
|
||||
const [policy] = await db
|
||||
.insert(toolPolicies)
|
||||
@@ -908,12 +914,14 @@ export function toolAccessPolicyService(db: Db, options: { toolActionSigningSecr
|
||||
companyId: string;
|
||||
policyId: string;
|
||||
body: UpdateToolPolicy;
|
||||
managementContext?: ConnectionManagementContext;
|
||||
}) {
|
||||
const existing = await getGenericPolicyRow(db, input.companyId, input.policyId);
|
||||
const nextPolicyType = input.body.policyType ?? existing.policyType;
|
||||
const nextConditions = input.body.conditions !== undefined ? input.body.conditions ?? null : existing.conditions ?? null;
|
||||
const nextConfig = input.body.config !== undefined ? input.body.config ?? null : existing.config ?? null;
|
||||
assertSupportedGenericPolicyShape(nextPolicyType, nextConditions, nextConfig);
|
||||
await input.managementContext?.reauthorize();
|
||||
const now = new Date();
|
||||
const [policy] = await db
|
||||
.update(toolPolicies)
|
||||
@@ -933,8 +941,9 @@ export function toolAccessPolicyService(db: Db, options: { toolActionSigningSecr
|
||||
return policy;
|
||||
}
|
||||
|
||||
async function deletePolicy(input: { companyId: string; policyId: string }) {
|
||||
async function deletePolicy(input: { companyId: string; policyId: string; managementContext?: ConnectionManagementContext }) {
|
||||
const existing = await getGenericPolicyRow(db, input.companyId, input.policyId);
|
||||
await input.managementContext?.reauthorize();
|
||||
const [deleted] = await db
|
||||
.delete(toolPolicies)
|
||||
.where(eq(toolPolicies.id, existing.id))
|
||||
|
||||
@@ -4086,6 +4086,7 @@ export function toolAccessService(
|
||||
createApplication: async (
|
||||
companyId: string,
|
||||
input: CreateToolApplication,
|
||||
managementContext?: ConnectionManagementContext,
|
||||
): Promise<ToolApplication> => {
|
||||
await assertOptionalPlugin(input.pluginId);
|
||||
await assertOptionalAgent(
|
||||
@@ -4093,6 +4094,7 @@ export function toolAccessService(
|
||||
input.ownerAgentId,
|
||||
"Tool application owner agent",
|
||||
);
|
||||
await managementContext?.reauthorize();
|
||||
const [row] = await db
|
||||
.insert(toolApplications)
|
||||
.values({
|
||||
@@ -4129,6 +4131,7 @@ export function toolAccessService(
|
||||
updateApplication: async (
|
||||
applicationId: string,
|
||||
input: UpdateToolApplication,
|
||||
managementContext?: ConnectionManagementContext,
|
||||
): Promise<ToolApplication> => {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
@@ -4159,6 +4162,7 @@ export function toolAccessService(
|
||||
});
|
||||
}
|
||||
}
|
||||
await managementContext?.reauthorize();
|
||||
const [row] = await db
|
||||
.update(toolApplications)
|
||||
.set({
|
||||
@@ -4178,6 +4182,7 @@ export function toolAccessService(
|
||||
|
||||
deleteApplication: async (
|
||||
applicationId: string,
|
||||
managementContext?: ConnectionManagementContext,
|
||||
): Promise<ToolApplication> => {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
@@ -4201,6 +4206,7 @@ export function toolAccessService(
|
||||
// ON DELETE RESTRICT, so such a delete fails closed with a foreign_key_violation instead
|
||||
// of silently cascading the new connection away. Translate that into the same 409 so the
|
||||
// endpoint keeps its contract instead of surfacing a 500.
|
||||
await managementContext?.reauthorize();
|
||||
let row: typeof toolApplications.$inferSelect | undefined;
|
||||
try {
|
||||
[row] = await db
|
||||
@@ -4583,6 +4589,7 @@ export function toolAccessService(
|
||||
updateConnection: async (
|
||||
connectionId: string,
|
||||
input: UpdateToolConnection,
|
||||
managementContext?: ConnectionManagementContext,
|
||||
): Promise<ToolConnection> => {
|
||||
const existing = await getConnectionRow(connectionId);
|
||||
if (existing.connectionPurpose === "ai" && (input.config || input.transportConfig || input.credentialRefs || input.credentialSecretRefs || (input.credentialPolicy && input.credentialPolicy !== existing.credentialPolicy))) throw badRequest("Use AI account reconnect to change credentials. Provider, sign-in method, and ownership cannot be changed.");
|
||||
@@ -4609,6 +4616,7 @@ export function toolAccessService(
|
||||
...(input.credentialRefs ?? existing.credentialRefs),
|
||||
...(input.credentialSecretRefs ?? existing.credentialSecretRefs),
|
||||
]);
|
||||
await managementContext?.reauthorize();
|
||||
const write = (database: Db) => database
|
||||
.update(toolConnections)
|
||||
.set({
|
||||
|
||||
@@ -7488,7 +7488,7 @@ export function createToolGatewayService(
|
||||
await args.managementContext?.reauthorize();
|
||||
if (args.managementContext?.actor.type === "agent") {
|
||||
Object.assign(args.session, args.managementContext.workContext);
|
||||
const current = await policyService.decide(policyInputForTool({ session: args.session, tool: args.tool, parameters: args.parameters }));
|
||||
const current = await policyService.decide(policyInputForTool({ session: args.session, tool: args.tool, parameters: args.parameters }), { admittedInvocationId: args.invocationId });
|
||||
if (!current.allowed && !(args.session.approvedInvocationId && current.decision === "require_approval")) {
|
||||
throw new ToolGatewayHttpError(403, current.explanation, current.reasonCode);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user