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:
DottaandPaperclip committed 2026-10-26 10:29:34 -05:00
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`, {
+132 -43
View File
@@ -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);
}
+14 -11
View File
@@ -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;
+21 -5
View File
@@ -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.
+11 -2
View File
@@ -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))
+8
View File
@@ -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({
+1 -1
View File
@@ -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);
}