diff --git a/server/src/__tests__/chat-channels.integration.test.ts b/server/src/__tests__/chat-channels.integration.test.ts index 5fa01500bf4..ec80d71128a 100644 --- a/server/src/__tests__/chat-channels.integration.test.ts +++ b/server/src/__tests__/chat-channels.integration.test.ts @@ -57961,7 +57961,8 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { const before = await db .select({ id: chatActions.id }) .from(chatActions) - .where(eq(chatActions.endpointId, context.endpoint.id)); + .where(eq(chatActions.endpointId, context.endpoint.id)) + .orderBy(chatActions.id); if (linkedPrincipalId) { await db .update(chatIdentityLinks) @@ -58043,7 +58044,8 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { db .select({ id: chatActions.id }) .from(chatActions) - .where(eq(chatActions.endpointId, context.endpoint.id)), + .where(eq(chatActions.endpointId, context.endpoint.id)) + .orderBy(chatActions.id), ).resolves.toEqual(before); } finally { await db diff --git a/server/src/__tests__/connection-management.integration.test.ts b/server/src/__tests__/connection-management.integration.test.ts index 5d46ae6bc5c..4bfd63ee9e1 100644 --- a/server/src/__tests__/connection-management.integration.test.ts +++ b/server/src/__tests__/connection-management.integration.test.ts @@ -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((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`, { diff --git a/server/src/routes/connection-management-authz.ts b/server/src/routes/connection-management-authz.ts index 47965e44639..7bde759698f 100644 --- a/server/src/routes/connection-management-authz.ts +++ b/server/src/routes/connection-management-authz.ts @@ -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) { + 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[] = [body]; + const unique = new Set(); + 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); + } + } + for (const key of ["selectors", "access", "binding"]) { + const value = selected[key]; + if (value && typeof value === "object" && !Array.isArray(value)) pending.push(value as Record); + } + if (selected === body && body.config && typeof body.config === "object" && !Array.isArray(body.config)) { + const members = (body.config as Record).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); + } + } + 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 => { + 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(); const extraResources: ConnectionManagementResource[] = []; - const selectors: Record[] = [body, body.access && typeof body.access === "object" ? body.access : {}, body.selectors ?? {}, ...(Array.isArray(body.entries) ? body.entries : [])]; + const selectors: Record[] = []; + 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(); + const addSelector = (selected: Record) => { + 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[]) => 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>(); + const lookup = (key: string, read: () => PromiseLike): Promise => { + let pending = lookupCache.get(key); + if (!pending) { pending = Promise.resolve(read()); lookupCache.set(key, pending); } + return pending as Promise; + }; 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 | 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`${toolConnections.config}->>'sourceTemplateKey'`, provider: sql`${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(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 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, { diff --git a/server/src/services/connection-management-policy.ts b/server/src/services/connection-management-policy.ts index ee971ebb907..58c37af1410 100644 --- a/server/src/services/connection-management-policy.ts +++ b/server/src/services/connection-management-policy.ts @@ -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; diff --git a/server/src/services/connection-management.ts b/server/src/services/connection-management.ts index 486268773bd..8d5acdea00b 100644 --- a/server/src/services/connection-management.ts +++ b/server/src/services/connection-management.ts @@ -16,6 +16,9 @@ export type ConnectionManagementContext = { reauthorize: (continuation?: { afterHumanAction: true }) => Promise; }; +type CapturedConnectionRun = Omit>, "context"> & { context: Awaited>["context"] | null }; +const connectionReadRuns = new WeakMap(); + 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 { 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 | undefined)?.provider === "string" ? String((connection.config.ai as Record).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) { 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. diff --git a/server/src/services/tool-access-policy.ts b/server/src/services/tool-access-policy.ts index cbc8323abe6..d5a9ad03e3c 100644 --- a/server/src/services/tool-access-policy.ts +++ b/server/src/services/tool-access-policy.ts @@ -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)) diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index 49c87de7680..049d24511ad 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -4086,6 +4086,7 @@ export function toolAccessService( createApplication: async ( companyId: string, input: CreateToolApplication, + managementContext?: ConnectionManagementContext, ): Promise => { 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 => { 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 => { 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 => { 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({ diff --git a/server/src/services/tool-gateway.ts b/server/src/services/tool-gateway.ts index ad4fcd16291..2350ac09e44 100644 --- a/server/src/services/tool-gateway.ts +++ b/server/src/services/tool-gateway.ts @@ -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); }