From a42f2132446dcc7f5929afd49e9d215529ebaff6 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 21 Oct 2026 22:21:42 -0500 Subject: [PATCH] refactor(tools): isolate connection token broker (#17157) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agents can request short-lived tokens for connections that allow token exchange. > - Admission, scope checks, exchange protocols and issuance records lived inside the general tool service. > - The security flow needs one visible owner while provider payloads need a separate boundary. > - This pull request extracts the broker into admission, configuration, endpoint, exchange and issuance modules. > - The benefit is a focused issuance flow with its locks, authority checks and audit order intact. ## Linked Issues or Issue Description Depends on #17155. Earlier stack: #17144 → #17146 → #17147 → #17155 → this PR. **What existing behavior does this improve?** Maintenance and verification of connection token issuance for agent runs. **Current behavior** A 17,752-line service contains broker configuration, endpoint policy, credential exchange, admission and issuance. **Proposed behavior** A dedicated broker coordinates the same admission and transaction flow. The exchange owner returns normalized token material. It receives the existing secret client, including the transaction-scoped client at issuance time. **Reason and benefit** The entry point falls to 16,100 lines, a reduction of 1,652 lines. Reviewers can see the responsible-user lock and hash-only issuance write without navigating app setup or OAuth callbacks. ## What Changed - Extract six broker modules. The largest is 755 lines; together they contain 1,721 lines. - Give tool-access audit persistence a named owner shared by the broker and coordinator. - Keep current-state checks, policy decisions, rate limits, secret resolution and transaction order unchanged. Private-host configuration is still read live. - Add two public-route integration cases with a real local HTTP server and disposable PostgreSQL. Cover Pages and RFC 8693 success and upstream failure. ## Verification - Baseline: all 466 existing public service tests passed before this move. The two new real HTTP cases passed on the original implementation before extraction. - After extraction: 495 tests pass across the public service and HTTP rebinding suites, with one database worker. This includes the two new cases, membership revocation ordering, scope denials, disabled broker behavior and hash-only persistence. - Server TypeScript checking, the module-boundary gate and `git diff --check` pass. - Normalized syntax comparison finds all 294 pre-existing function/method bodies unchanged. - Full typecheck and build passed for the preceding batch. Its interrupted local full-suite result remains unverified. All current-head CI gates pass; Greptile is 5/5 with no open review threads. ## Risks - A new closure could use the wrong secret client or move exchange outside the membership lock. The existing transaction still passes `secretService(tx)` to the exchange owner after taking that lock. - Endpoint validation must preserve live configuration and fail before credential disclosure. Existing rebinding and private-host tests cover this path. - The local fixture proves real HTTP and persisted application behavior. It does not qualify a live third-party broker. - No schema, API or access policy change is intended. ## Model Used OpenAI Codex, GPT-6 family, with repository tools and code execution. The exact serving model ID and context-window size are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip --- .../2026-10-21-tool-access-boundaries.md | 20 + .../src/__tests__/tool-access-service.test.ts | 65 + server/src/services/tool-access.ts | 1672 +---------------- server/src/services/tool-access/audit.ts | 37 + .../tool-access/token-broker/admission.ts | 254 +++ .../tool-access/token-broker/configuration.ts | 237 +++ .../token-broker/endpoint-policy.ts | 74 + .../tool-access/token-broker/exchange.ts | 306 +++ .../tool-access/token-broker/issuance.ts | 95 + .../tool-access/token-broker/service.ts | 755 ++++++++ 10 files changed, 1853 insertions(+), 1662 deletions(-) create mode 100644 server/src/services/tool-access/audit.ts create mode 100644 server/src/services/tool-access/token-broker/admission.ts create mode 100644 server/src/services/tool-access/token-broker/configuration.ts create mode 100644 server/src/services/tool-access/token-broker/endpoint-policy.ts create mode 100644 server/src/services/tool-access/token-broker/exchange.ts create mode 100644 server/src/services/tool-access/token-broker/issuance.ts create mode 100644 server/src/services/tool-access/token-broker/service.ts diff --git a/doc/plans/2026-10-21-tool-access-boundaries.md b/doc/plans/2026-10-21-tool-access-boundaries.md index a1d6b2cae9c..6362f9453ba 100644 --- a/doc/plans/2026-10-21-tool-access-boundaries.md +++ b/doc/plans/2026-10-21-tool-access-boundaries.md @@ -155,3 +155,23 @@ match normalized syntax across the move. The baseline and post-move runs pass cases. Server typechecking and the module-boundary gate pass. Existing tests exercise both full catalog DTOs and lightweight assignments; no new behavior or fixture-only test was added. + +### Connection token broker extraction + +The broker owns run admission, explicit mint grants, rate limits, selected grant +checks, issuance and audit. Configuration parsing, endpoint policy, exchange, +and issuance storage are separate modules. The service receives the existing +secret service, policy service, audit function, clock and two connection/grant +operations. The issuance transaction still acquires the responsible membership +lock before resolving the parent credential and performing the exchange. It +stores only the token hash. Private-host policy still reads live environment +values on each request. No resource, cache or timer is created by the factories. + +The entry point is 16,100 lines / 644,119 bytes. The six broker modules total +1,721 lines; the largest is 755. All 294 pre-existing function/method bodies +match normalized syntax. Two new public-route tests passed before production +changes and after extraction. They use real HTTP and disposable PostgreSQL for +Pages and RFC 8693 exchanges, plus a failing upstream request, and assert that +issuance/audit rows contain no token material. The complete public service and +HTTP rebinding run passes 495 cases. Server typechecking and module boundaries +pass. These fixtures do not claim live third-party qualification. diff --git a/server/src/__tests__/tool-access-service.test.ts b/server/src/__tests__/tool-access-service.test.ts index 715e6ed2b14..be3e12337a9 100644 --- a/server/src/__tests__/tool-access-service.test.ts +++ b/server/src/__tests__/tool-access-service.test.ts @@ -1,5 +1,6 @@ import * as fs from "node:fs/promises"; import { createHash, generateKeyPairSync, randomUUID } from "node:crypto"; +import { createServer } from "node:http"; import express from "express"; import request from "supertest"; import { @@ -978,6 +979,70 @@ describeEmbeddedPostgres("tool access service", () => { return false; } + it.each(["pages", "rfc8693"] as const)("mints %s tokens over real HTTP and records upstream failures without token material", async (protocol) => { + vi.stubEnv("PAPERCLIP_TOKEN_BROKER_ALLOWED_HOSTS", "127.0.0.1"); + const received: Array<{ authorization: string | undefined; body: string }> = []; + let failExchange = false; + const server = createServer(async (incoming, response) => { + const chunks: Buffer[] = []; + for await (const chunk of incoming) chunks.push(Buffer.from(chunk)); + received.push({ authorization: incoming.headers.authorization, body: Buffer.concat(chunks).toString() }); + response.writeHead(failExchange ? 503 : 200, { "content-type": "application/json" }); + response.end(JSON.stringify(failExchange + ? { error: "temporarily_unavailable" } + : { access_token: "real-http-child-token", expires_in: 600, scope: "pages:publish:ns/dotta" })); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + try { + const company = await createCompany(db); + const agent = await createAgent(db, company.id); + const { run } = await createIssueAndRun(db, company.id, agent.id); + const port = (server.address() as { port: number }).port; + const { connection } = await createBrokerConnection(db, company.id, { + tokenUrl: `http://127.0.0.1:${port}/exchange`, protocol, + }); + await allowConnectionForAgent(db, company.id, agent.id, connection.id); + const app = createRouteApp(db, agentJwtActor(company.id, agent.id, run.id)); + const mint = () => request(app) + .post(`/api/agents/me/connections/${connection.id}/token`) + .send({ scope: "pages:publish:ns/dotta" }); + const result = await mint(); + expect(result.status).toBe(200); + expect(result.body).toMatchObject({ status: "minted", token: "real-http-child-token", scope: ["pages:publish:ns/dotta"] }); + expect(received).toHaveLength(1); + if (protocol === "pages") { + expect(received[0].authorization).toBe("Bearer parent-deploy-token"); + expect(JSON.parse(received[0].body)).toMatchObject({ namespace: "dotta", actor: { id: agent.id, runId: run.id, onBehalfOf: "user:user-for-run" } }); + } else { + const form = new URLSearchParams(received[0].body); + expect(form.get("grant_type")).toBe("urn:ietf:params:oauth:grant-type:token-exchange"); + expect(form.get("subject_token")).toBe("parent-deploy-token"); + expect(form.get("scope")).toBe("pages:publish:ns/dotta"); + } + failExchange = true; + const failed = await mint(); + expect(failed.status).toBe(502); + expect(failed.body).toMatchObject({ code: "upstream_error" }); + expect(received).toHaveLength(2); + const issuances = await db.select().from(connectionTokenIssuances) + .where(eq(connectionTokenIssuances.connectionId, connection.id)); + expect(issuances).toHaveLength(2); + expect(issuances.find(row => row.outcome === "success")).toMatchObject({ + tokenHash: createHash("sha256").update("real-http-child-token").digest("hex"), + companyId: company.id, agentId: agent.id, runId: run.id, + }); + expect(issuances.find(row => row.outcome === "upstream_error")).toMatchObject({ tokenHash: null, errorCode: "upstream_error" }); + const audits = await db.select().from(toolAccessAuditEvents) + .where(eq(toolAccessAuditEvents.connectionId, connection.id)); + const persisted = JSON.stringify({ issuances, audits }); + expect(persisted).not.toContain("parent-deploy-token"); + expect(persisted).not.toContain("real-http-child-token"); + } finally { + server.closeAllConnections(); + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); + } + }); + it("mints generic exchange connection tokens through the agent route and stores only hashes", async () => { const company = await createCompany(db); const agent = await createAgent(db, company.id); diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index 8057451407c..54e4185fd2c 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -1,3 +1,6 @@ +import { createToolAccessAudit } from "./tool-access/audit.js"; +import { createConnectionTokenBroker } from "./tool-access/token-broker/service.js"; +import { assertConfiguredTokenBrokerEndpointsAllowed } from "./tool-access/token-broker/endpoint-policy.js"; import { createToolProfileService } from "./tool-access/profiles/service.js"; import { createToolProfileResolver } from "./tool-access/profiles/resolution.js"; import { toProfile, toProfileEntry, toProfileBinding, pendingNewToolsForProfile, profileCatalogColumns } from "./tool-access/profiles/projection.js"; @@ -65,7 +68,7 @@ import { emitConnectionUpdated, } from "./connector-telemetry.js"; import { canBrowseProjectRepositoryGrant, mergeProjectRepository } from "./project-repositories.js"; -import { captureRunIdentity } from "./run-identity.js"; + import { measureRunPhase } from "./execution-preparation-timing.js"; import { measureRunTransaction } from "./execution-transaction-timing.js"; import { createHash, randomBytes, randomUUID } from "node:crypto"; @@ -89,7 +92,7 @@ import { } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { agents, connectionGrantMembers, connectionGrantDelegations, connectionGrants, connectionTokenIssuances, authUsers, companyMemberships, companySecretBindings, companySecrets, principalPermissionGrants, userSecretDefinitions, userSecretDeclarations, heartbeatRuns, issues, issueThreadInteractions, plugins, toolAccessAuditEvents, toolApplications, toolActionRequests, toolCatalogEntries, toolConnectionInstalls, toolConnectionAppSnapshots, toolConnectionAppSyncs, toolConnections, toolOauthStates, toolStdioCommandTemplates, toolCallEvents, toolInvocations, toolPolicies, toolGatewaySessions, toolMcpGateways, toolMcpGatewayTokens, toolProfileBindings, toolProfileEntries, toolProfiles, toolRuntimeMetricCounters, toolRuntimeSlots } from "@paperclipai/db"; -import type { AppDefinition, ConnectionGrantKind, ConnectionTokenIssuanceOutcome, ConnectionTokenIssuancePath, ConnectionTokenRequest, ConnectionTokenResponse, CreateToolApplication, CreateToolConnection, ConnectToolApp, ConnectToolAppResult, CreateToolStdioCommandTemplate, FinishToolApp, FinishToolAppResult, FinalizeOAuthAccess, CreateToolProfileBindingForProfile, CreateToolProfileEntryForProfile, DeploymentExposure, DeploymentMode, ImportMcpJson, McpConnectionCredentialRef, McpJsonImportPreview, ToolApplication, ToolCatalogEntry, ToolCatalogRefreshResult, ToolConnection, ToolConnectionInstall, ToolConnectionInstallSnapshot, ToolConnectionRemovalResult, ToolConnectionRemovalSummary, ToolConnectionHealthCheckResult, ToolConnectionHealthStatus, ToolConnectionAuthKind, ToolConnectionCredentialPolicy, ToolConnectionCredentialSource, ToolConnectionTransport, ToolCredentialSecretRef, ToolOAuthStartResult, ToolAppsAttentionResponse, ToolAppMetadataPreflightResult, ToolActionRequest, ToolActionRequestListItem, ToolActionRequestStatus, ToolConnectionActivityResponse, ToolAppConnectionActionSummary, ToolExampleInstallResult, ToolExampleSmokeCheck, ToolExampleSmokeResult, ToolExampleSummary, ToolCallEvent, ToolInvocation, ToolProfileBinding, ToolProfileEntry, ToolPolicyDecision, ToolPolicy, ToolRiskLevel, ToolRuntimeAlertRecommendation, ToolRuntimeHealthSummary, ToolRunDecision, ToolRunDecisionLookup, ToolRuntimeSlot, ToolStdioCommandTemplate, UpdateToolApplication, UpdateToolConnection, PutToolConnectionInstalls, VercelConnectCredentialReference, VercelConnectGrantReference } from "@paperclipai/shared"; +import type { AppDefinition, ConnectionGrantKind, CreateToolApplication, CreateToolConnection, ConnectToolApp, ConnectToolAppResult, CreateToolStdioCommandTemplate, FinishToolApp, FinishToolAppResult, FinalizeOAuthAccess, CreateToolProfileBindingForProfile, CreateToolProfileEntryForProfile, DeploymentExposure, DeploymentMode, ImportMcpJson, McpConnectionCredentialRef, McpJsonImportPreview, ToolApplication, ToolCatalogEntry, ToolCatalogRefreshResult, ToolConnection, ToolConnectionInstall, ToolConnectionInstallSnapshot, ToolConnectionRemovalResult, ToolConnectionRemovalSummary, ToolConnectionHealthCheckResult, ToolConnectionHealthStatus, ToolConnectionAuthKind, ToolConnectionCredentialPolicy, ToolConnectionCredentialSource, ToolConnectionTransport, ToolCredentialSecretRef, ToolOAuthStartResult, ToolAppsAttentionResponse, ToolAppMetadataPreflightResult, ToolActionRequest, ToolActionRequestListItem, ToolActionRequestStatus, ToolConnectionActivityResponse, ToolAppConnectionActionSummary, ToolExampleInstallResult, ToolExampleSmokeCheck, ToolExampleSmokeResult, ToolExampleSummary, ToolCallEvent, ToolInvocation, ToolProfileBinding, ToolProfileEntry, ToolPolicyDecision, ToolPolicy, ToolRiskLevel, ToolRuntimeAlertRecommendation, ToolRuntimeHealthSummary, ToolRunDecision, ToolRunDecisionLookup, ToolRuntimeSlot, ToolStdioCommandTemplate, UpdateToolApplication, UpdateToolConnection, PutToolConnectionInstalls, VercelConnectCredentialReference, VercelConnectGrantReference } from "@paperclipai/shared"; import { CLASS3_STATIC_LEASE_ALLOWLIST, GITHUB_CONNECTOR_PROFILES, @@ -149,13 +152,10 @@ import { readSignedToolArgumentsPayload, TOOL_ACTION_REQUEST_SIGNING_GRACE_MS, } from "./tool-content-guards.js"; -import { narrowestScopeBindings, profileIdsInBindingOrder } from "./tool-profile-binding-precedence.js"; + import { googleChatToolDescription } from "./google-chat-tool-policy.js"; import { gmailArchiveToolDescription } from "./gmail-archive-tool-policy.js"; -import { - recordToolRuntimeAuditWriteFailure, - TOOL_RUNTIME_AUDIT_WRITE_FAILURE_METRIC, -} from "./tool-runtime-metrics.js"; +import { TOOL_RUNTIME_AUDIT_WRITE_FAILURE_METRIC } from "./tool-runtime-metrics.js"; import { createToolRuntimeSupervisor, ToolRuntimeSupervisorError, @@ -181,8 +181,6 @@ import { VercelConnectClientError, type VercelConnectClient, } from "./vercel-connect.js"; - -const ACTIVE_BROKER_RUN_STATUSES = new Set(["running"]); const REMOTE_HTTP_REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); const MAX_REMOTE_HTTP_REDIRECTS = 5; const OAUTH_REFRESH_LEASE_MS = 120_000; @@ -492,7 +490,6 @@ const GOOGLE_SHEETS_GALLERY_KEY = "google-sheets"; const GOOGLE_SHEETS_TEMPLATE_ID = "paperclip.google-sheets"; const GOOGLE_SHEETS_ALLOWED_SPREADSHEET_IDS_ENV = "GOOGLE_SHEETS_ALLOWED_SPREADSHEET_IDS"; -const CONNECTION_TOKEN_MINT_TOOL_NAME = "connection_token.mint"; type ToolExampleDefinition = { id: string; @@ -1777,6 +1774,8 @@ export function toolAccessService( } const policySvc = toolAccessPolicyService(db); const now = options.now ?? (() => new Date()); + const audit = createToolAccessAudit(db); + const { mintConnectionTokenForAgent, loadBrokerRunContext } = createConnectionTokenBroker(db, { now, secrets, policySvc, audit, getConnectionRow, ensureDefaultOrganizationGrant }); const profileService = createToolProfileService(db, now); const profileDetails = profileService.getProfile; const profileResolver = createToolProfileResolver(db, { assertOptionalAgent, resolveInstalledConnectionsForAgent, toCatalogEntry }); @@ -2068,77 +2067,6 @@ export function toolAccessService( return assertRemoteHttpUrlAllowed(remoteEndpoint(config)); } - function normalizeTokenBrokerAllowedHost(value: string): string | null { - const trimmed = value.trim(); - if (!trimmed) return null; - try { - const parsed = new URL( - trimmed.includes("://") ? trimmed : `http://${trimmed}`, - ); - return ( - parsed.hostname - .replace(/^\[|\]$/g, "") - .replace(/\.$/, "") - .toLowerCase() || null - ); - } catch { - // Invalid allowlist entries grant no access. The configured broker URL is - // still evaluated under the public-only policy below. - return null; - } - } - - function tokenBrokerAllowedPrivateHosts(): Set { - const configured = (process.env.PAPERCLIP_TOKEN_BROKER_ALLOWED_HOSTS ?? "") - .split(/[,\s]+/) - .map(normalizeTokenBrokerAllowedHost) - .filter((host): host is string => host !== null); - const pagesApiHost = normalizeTokenBrokerAllowedHost( - process.env.PAPERCLIP_PAGES_API_URL ?? "", - ); - if (pagesApiHost) configured.push(pagesApiHost); - return new Set(configured); - } - - function tokenBrokerAllowsPrivateNetwork(endpoint: URL): boolean { - const hostname = endpoint.hostname - .replace(/^\[|\]$/g, "") - .replace(/\.$/, "") - .toLowerCase(); - return tokenBrokerAllowedPrivateHosts().has(hostname); - } - - function tokenBrokerHttpFetchOptions( - endpoint: URL, - ): GuardedRemoteHttpFetchOptions { - return { - allowPrivateNetwork: tokenBrokerAllowsPrivateNetwork(endpoint), - error: (message, code) => badRequest(message, { code }), - }; - } - - async function assertTokenBrokerHttpUrlAllowed( - value: string, - ): Promise { - const endpoint = parseRemoteHttpEndpoint(value, (message, code) => - badRequest(message, { code }), - ); - await assertPublicRemoteHttpEndpoint( - endpoint, - { allowPrivateNetwork: tokenBrokerAllowsPrivateNetwork(endpoint) }, - (message, code) => badRequest(message, { code }), - ); - return endpoint.toString(); - } - - async function assertConfiguredTokenBrokerEndpointsAllowed( - config: Record, - ): Promise { - for (const url of configuredTokenBrokerExchangeUrls(config)) { - await assertTokenBrokerHttpUrlAllowed(url); - } - } - async function assertRemoteConnectionEndpointsAllowed( config: Record, ): Promise { @@ -2581,877 +2509,6 @@ export function toolAccessService( return syncAggregatorApps(connectionId, true, actor); } - async function audit(input: { - companyId: string; - connectionId?: string | null; - catalogEntryId?: string | null; - action: string; - outcome: "success" | "failure"; - reasonCode?: string | null; - details?: Record; - actor?: ActorInfo; - }) { - try { - await db.insert(toolAccessAuditEvents).values({ - companyId: input.companyId, - connectionId: input.connectionId ?? null, - catalogEntryId: input.catalogEntryId ?? null, - actorType: input.actor?.actorType ?? "system", - actorId: input.actor?.actorId ?? null, - action: input.action, - outcome: input.outcome, - reasonCode: input.reasonCode ?? null, - details: input.details ?? {}, - }); - } catch (error) { - await recordToolRuntimeAuditWriteFailure(db, input.companyId); - throw error; - } - } - - function readConfigString( - record: Record, - key: string, - ): string | null { - const value = record[key]; - return typeof value === "string" && value.trim().length > 0 - ? value.trim() - : null; - } - - function readConfigStringArray(value: unknown): string[] { - if (Array.isArray(value)) { - return value - .filter((item): item is string => typeof item === "string") - .map((item) => item.trim()) - .filter(Boolean); - } - if (typeof value === "string") - return value - .split(/\s+/) - .map((item) => item.trim()) - .filter(Boolean); - return []; - } - - function normalizeConnectionTokenScopes( - scope: ConnectionTokenRequest["scope"], - ): string[] { - if (Array.isArray(scope)) - return [...new Set(scope.map((item) => item.trim()).filter(Boolean))]; - if (typeof scope === "string") - return [ - ...new Set( - scope - .split(/\s+/) - .map((item) => item.trim()) - .filter(Boolean), - ), - ]; - return []; - } - - function tokenBrokerConfigFromConnectionConfig( - config: Record, - ): Record { - const broker = asRecord(config.tokenBroker); - if (Object.keys(broker).length > 0) return broker; - return asRecord(config.broker); - } - - function tokenBrokerConfig( - connection: typeof toolConnections.$inferSelect, - ): Record { - return tokenBrokerConfigFromConnectionConfig(asRecord(connection.config)); - } - - function configuredTokenBrokerExchangeUrls( - config: Record, - ): string[] { - const broker = tokenBrokerConfigFromConnectionConfig(config); - return [ - ...new Set( - [ - readConfigString(broker, "tokenUrl"), - readConfigString(broker, "exchangeTokenUrl"), - readConfigString(config, "tokenExchangeUrl"), - readConfigString(config, "pagesTokenExchangeUrl"), - ].filter((url): url is string => url !== null), - ), - ]; - } - - function connectionTokenBrokerEnabled( - connection: typeof toolConnections.$inferSelect, - ): boolean { - const config = asRecord(connection.config); - const tokenBroker = asRecord(config.tokenBroker); - if (Object.keys(tokenBroker).length > 0) - return tokenBroker.enabled === true; - const broker = asRecord(config.broker); - if (Object.keys(broker).length > 0) return broker.enabled === true; - return false; - } - - function isPagesTokenConnection( - connection: typeof toolConnections.$inferSelect, - application?: typeof toolApplications.$inferSelect | null, - ) { - const config = asRecord(connection.config); - const broker = tokenBrokerConfig(connection); - const applicationKey = application?.applicationKey ?? ""; - return Boolean( - applicationKey === "paperclip-pages" || - applicationKey === "paperclip.pages" || - applicationKey === "pages.paperclip" || - readConfigString(config, "connectionType") === "pages" || - readConfigString(config, "service") === "pages" || - readConfigString(broker, "connectionType") === "pages" || - readConfigString(broker, "service") === "pages" || - asRecord(config.pages).enabled === true, - ); - } - - async function getConnectionApplication( - connection: typeof toolConnections.$inferSelect, - ) { - const [application] = await db - .select() - .from(toolApplications) - .where(eq(toolApplications.id, connection.applicationId)); - return application ?? null; - } - - function inferConnectionTokenPath( - connection: typeof toolConnections.$inferSelect, - application?: typeof toolApplications.$inferSelect | null, - ): ConnectionTokenIssuancePath { - const broker = tokenBrokerConfig(connection); - const configuredPath = - readConfigString(broker, "path") ?? - readConfigString(asRecord(connection.config), "tokenPath"); - if ( - configuredPath === "exchange" || - configuredPath === "oauth_access" || - configuredPath === "static" - ) - return configuredPath; - if (isPagesTokenConnection(connection, application)) return "exchange"; - if ( - readConfigString(broker, "tokenUrl") || - readConfigString(asRecord(connection.config), "tokenExchangeUrl") - ) - return "exchange"; - return "static"; - } - - function parentScopesForConnection( - connection: typeof toolConnections.$inferSelect, - ): string[] { - const config = asRecord(connection.config); - const broker = tokenBrokerConfig(connection); - const configured = [ - ...readConfigStringArray(broker.parentScopes), - ...readConfigStringArray(broker.scopes), - ...readConfigStringArray(config.parentScopes), - ...readConfigStringArray(asRecord(config.oauth).scopes), - ...readConfigStringArray(asRecord(config.oauth).scope), - ]; - const namespaceAllowlist = readConfigStringArray( - config.namespaceAllowlist, - ).map((namespace) => `pages:publish:ns/${namespace}`); - return [...new Set([...configured, ...namespaceAllowlist])]; - } - - function defaultScopesForConnection( - connection: typeof toolConnections.$inferSelect, - ): string[] { - const broker = tokenBrokerConfig(connection); - return [ - ...new Set([ - ...readConfigStringArray(broker.defaultScopes), - ...readConfigStringArray(asRecord(connection.config).defaultScopes), - ]), - ]; - } - - function assertScopeSubset(input: { - requestedScope: string[]; - parentScopes: string[]; - }) { - if (input.requestedScope.length === 0) return; - const parent = new Set(input.parentScopes); - if ( - parent.size === 0 || - input.requestedScope.some((scope) => !parent.has(scope)) - ) { - throw forbidden( - "Requested token scope exceeds the connection parent scope", - ); - } - } - - function requestedTtlSeconds( - body: ConnectionTokenRequest, - connection: typeof toolConnections.$inferSelect, - ): number { - const broker = tokenBrokerConfig(connection); - const configured = Number( - broker.defaultTtlSeconds ?? broker.ttlSeconds ?? 900, - ); - const requested = Number(body.requestedTtlSeconds ?? configured); - const finite = - Number.isFinite(requested) && requested > 0 ? Math.trunc(requested) : 900; - return Math.max(1, Math.min(900, finite)); - } - - function sha256Hex(value: string): string { - return createHash("sha256").update(value).digest("hex"); - } - - function bearerTokenHash(token: string): string { - return sha256Hex(token); - } - - function runSnapshotString( - snapshot: Record, - ...keys: string[] - ): string | null { - for (const key of keys) { - const value = snapshot[key]; - if (typeof value === "string" && value.trim().length > 0) return value; - } - return null; - } - - async function loadBrokerRunContext(input: { - companyId: string; - agentId: string; - runId: string; - }) { - const [initialRun] = await db - .select() - .from(heartbeatRuns) - .where(eq(heartbeatRuns.id, input.runId)); - const run = initialRun?.activeIdentityContextId - ? (await captureRunIdentity(db, input)).run - : initialRun; - if ( - !run || - run.companyId !== input.companyId || - run.agentId !== input.agentId - ) { - throw forbidden( - "Agent run context does not match the authenticated actor", - ); - } - if (!ACTIVE_BROKER_RUN_STATUSES.has(run.status)) { - throw forbidden("Agent run is not active"); - } - const snapshot = asRecord(run.contextSnapshot); - const paperclipIssue = asRecord(snapshot.paperclipIssue); - const responsibleUserId = run.activeIdentityContextId - ? run.responsibleUserId - : (runSnapshotString( - snapshot, - "responsibleUserId", - "responsible_user_id", - ) ?? - runSnapshotString( - paperclipIssue, - "responsibleUserId", - "responsible_user_id", - ) ?? - run.responsibleUserId); - if (!responsibleUserId) { - throw forbidden( - "Agent run has no responsible user for delegated connection access", - ); - } - const responsibleMembership = await db - .select({ - status: companyMemberships.status, - membershipRole: companyMemberships.membershipRole, - }) - .from(companyMemberships) - .where( - and( - eq(companyMemberships.companyId, run.companyId), - eq(companyMemberships.principalType, "user"), - eq(companyMemberships.principalId, responsibleUserId), - ), - ) - .then((rows) => rows[0] ?? null); - if ( - !responsibleMembership || - responsibleMembership.status !== "active" || - !responsibleMembership.membershipRole || - responsibleMembership.membershipRole === "viewer" - ) { - throw forbidden( - "Responsible user is no longer authorized for company write access", - ); - } - return { - run, - issueId: - runSnapshotString(snapshot, "issueId") ?? - runSnapshotString(paperclipIssue, "id"), - projectId: - runSnapshotString(snapshot, "projectId") ?? - runSnapshotString(paperclipIssue, "projectId"), - routineId: runSnapshotString(snapshot, "routineId"), - responsibleUserId, - }; - } - - async function lockAuthorizedBrokerResponsibleMembership( - input: { - companyId: string; - responsibleUserId: string; - }, - tx: DbTransaction, - ) { - const membership = await tx - .select({ - id: companyMemberships.id, - status: companyMemberships.status, - membershipRole: companyMemberships.membershipRole, - }) - .from(companyMemberships) - .where( - and( - eq(companyMemberships.companyId, input.companyId), - eq(companyMemberships.principalType, "user"), - eq(companyMemberships.principalId, input.responsibleUserId), - ), - ) - .limit(1) - .for("update") - .then((rows) => rows[0] ?? null); - if ( - !membership || - membership.status !== "active" || - !membership.membershipRole || - membership.membershipRole === "viewer" - ) { - throw new HttpError( - 403, - "Responsible user is no longer authorized for company write access", - { - code: "responsible_user_unauthorized", - }, - ); - } - return membership; - } - - async function recordConnectionTokenIssuance( - input: { - companyId: string; - applicationId: string | null; - connectionId: string; - agentId: string; - runId: string | null; - issueId: string | null; - projectId: string | null; - responsibleUserId: string | null; - path: ConnectionTokenIssuancePath; - requestedScope: string[]; - issuedScope: string[]; - ttlSeconds: number | null; - expiresAt: Date | null; - tokenHash: string | null; - outcome: ConnectionTokenIssuanceOutcome; - errorCode?: string | null; - metadata?: Record; - }, - dbClient: ToolAccessMutationDb = db, - ) { - await dbClient.insert(connectionTokenIssuances).values({ - companyId: input.companyId, - applicationId: input.applicationId, - connectionId: input.connectionId, - agentId: input.agentId, - runId: input.runId, - issueId: input.issueId, - projectId: input.projectId, - responsibleUserId: input.responsibleUserId, - path: input.path, - requestedScope: input.requestedScope, - issuedScope: input.issuedScope, - ttlSeconds: input.ttlSeconds, - expiresAt: input.expiresAt, - tokenHash: input.tokenHash, - outcome: input.outcome, - errorCode: input.errorCode ?? null, - metadata: input.metadata ?? {}, - }); - } - - async function auditConnectionTokenIssuance(input: { - companyId: string; - connectionId: string; - agentId: string; - runId: string; - path: ConnectionTokenIssuancePath; - outcome: ConnectionTokenIssuanceOutcome; - reasonCode?: string | null; - details?: Record; - }) { - const success = input.outcome === "success"; - await audit({ - companyId: input.companyId, - connectionId: input.connectionId, - action: success ? "connection_token.minted" : "connection_token.denied", - outcome: success ? "success" : "failure", - reasonCode: input.reasonCode ?? null, - actor: { actorType: "agent", actorId: input.agentId }, - details: { - path: input.path, - outcome: input.outcome, - runId: input.runId, - ...(input.details ?? {}), - }, - }); - await logActivity(db, { - companyId: input.companyId, - actorType: "agent", - actorId: input.agentId, - agentId: input.agentId, - runId: input.runId, - action: success ? "connection_token.minted" : "connection_token.denied", - entityType: "tool_connection", - entityId: input.connectionId, - details: { - path: input.path, - outcome: input.outcome, - reasonCode: input.reasonCode ?? null, - ...(input.details ?? {}), - }, - }); - } - - async function enforceDefaultConnectionTokenRateLimit(input: { - connection: typeof toolConnections.$inferSelect; - agentId: string; - path: ConnectionTokenIssuancePath; - }) { - const broker = tokenBrokerConfig(input.connection); - const configured = Number(broker.rateLimitPerHour ?? 30); - const limit = - Number.isFinite(configured) && configured > 0 - ? Math.trunc(configured) - : 30; - const since = new Date(now().getTime() - 60 * 60 * 1000); - const [row] = await db - .select({ count: sql`count(*)::int` }) - .from(connectionTokenIssuances) - .where( - and( - eq(connectionTokenIssuances.companyId, input.connection.companyId), - eq(connectionTokenIssuances.connectionId, input.connection.id), - eq(connectionTokenIssuances.agentId, input.agentId), - eq(connectionTokenIssuances.outcome, "success"), - gte(connectionTokenIssuances.createdAt, since), - ), - ); - const count = Number(row?.count ?? 0); - if (count >= limit) { - throw new HttpError(429, "Connection token mint rate limit exceeded", { - code: "rate_limited", - path: input.path, - limit, - windowSeconds: 3600, - }); - } - } - - async function hasExplicitConnectionTokenMintProfileGrant(input: { - companyId: string; - agentId: string; - issueId: string | null; - projectId: string | null; - routineId: string | null; - }) { - const bindings = await db - .select() - .from(toolProfileBindings) - .where(eq(toolProfileBindings.companyId, input.companyId)); - const matchingBindings = bindings.filter((binding) => { - if (binding.targetType === "company") - return binding.targetId === input.companyId; - if (binding.targetType === "agent") - return binding.targetId === input.agentId; - if (binding.targetType === "issue") - return Boolean(input.issueId && binding.targetId === input.issueId); - if (binding.targetType === "project") - return Boolean(input.projectId && binding.targetId === input.projectId); - if (binding.targetType === "routine") - return Boolean(input.routineId && binding.targetId === input.routineId); - return false; - }); - const profileIds = profileIdsInBindingOrder( - narrowestScopeBindings(matchingBindings), - ); - if (profileIds.length === 0) return false; - const profiles = await db - .select() - .from(toolProfiles) - .where( - and( - eq(toolProfiles.companyId, input.companyId), - inArray(toolProfiles.id, profileIds), - ), - ); - const activeProfileIds = profiles - .filter((profile) => profile.status === "active") - .map((profile) => profile.id); - if (activeProfileIds.length === 0) return false; - const entries = await db - .select() - .from(toolProfileEntries) - .where( - and( - eq(toolProfileEntries.companyId, input.companyId), - inArray(toolProfileEntries.profileId, activeProfileIds), - ), - ); - return activeProfileIds.some((profileId) => { - const profileEntries = entries.filter( - (entry) => entry.profileId === profileId, - ); - const exactBrokerEntries = profileEntries.filter( - (entry) => - entry.selectorType === "tool_name" && - entry.toolName === CONNECTION_TOKEN_MINT_TOOL_NAME && - Object.keys(asRecord(entry.conditions)).length === 0, - ); - if (exactBrokerEntries.some((entry) => entry.effect === "exclude")) - return false; - return exactBrokerEntries.some((entry) => entry.effect === "include"); - }); - } - - function accessContextForBroker(input: { - connection: typeof toolConnections.$inferSelect; - agentId: string; - runId: string; - issueId: string | null; - actorSource?: ActorInfo["actorType"] | null; - configPath: string; - }) { - return { - consumerType: "tool_connection" as const, - consumerId: input.connection.id, - configPath: input.configPath, - actorType: "agent" as const, - actorId: input.agentId, - actorSource: "agent_jwt" as const, - issueId: input.issueId, - heartbeatRunId: input.runId, - }; - } - - function findBrokerCredentialRef( - connection: typeof toolConnections.$inferSelect, - ) { - const broker = tokenBrokerConfig(connection); - const configuredPath = - readConfigString(broker, "parentCredentialConfigPath") ?? - readConfigString(broker, "credentialConfigPath") ?? - readConfigString(broker, "secretConfigPath"); - const configuredName = - readConfigString(broker, "parentCredentialName") ?? - readConfigString(broker, "credentialName"); - const secretCandidates = connection.credentialSecretRefs.filter( - (ref) => - ref.configPath !== "oauth.access_token" && - ref.configPath !== "oauth.refresh_token" && - ref.configPath !== "oauth.client_secret", - ); - const secretRef = configuredPath - ? connection.credentialSecretRefs.find( - (ref) => ref.configPath === configuredPath, - ) - : (secretCandidates.find( - (ref) => ref.configPath === "credentials.deploy_token", - ) ?? - secretCandidates.find( - (ref) => ref.configPath === "pages.deploy_token", - ) ?? - secretCandidates[0]); - if (secretRef) - return { - kind: "secret_ref" as const, - ref: secretRef, - configPath: secretRef.configPath, - }; - const credentialRef = configuredName - ? connection.credentialRefs.find((ref) => ref.name === configuredName) - : connection.credentialRefs[0]; - if (credentialRef) - return { - kind: "credential_ref" as const, - ref: credentialRef, - configPath: `credentials.${credentialRef.name}`, - }; - return null; - } - - async function resolveBrokerParentCredential( - input: { - connection: typeof toolConnections.$inferSelect; - agentId: string; - runId: string; - issueId: string | null; - }, - secretClient: ReturnType = secrets, - ) { - const ref = findBrokerCredentialRef(input.connection); - if (!ref) { - throw unprocessable( - "Connection token exchange requires a vault-backed parent credential", - { - code: "parent_credential_missing", - }, - ); - } - if (ref.kind === "secret_ref") { - return secretClient.resolveSecretValue( - input.connection.companyId, - ref.ref.secretId, - ref.ref.versionSelector ?? "latest", - { - accessContext: accessContextForBroker({ - ...input, - configPath: ref.configPath, - }), - bindingContext: accessContextForBroker({ - ...input, - configPath: ref.configPath, - }), - }, - ); - } - return secretClient.resolveSecretValue( - input.connection.companyId, - ref.ref.secretId, - ref.ref.version ?? "latest", - { - accessContext: accessContextForBroker({ - ...input, - configPath: ref.configPath, - }), - bindingContext: accessContextForBroker({ - ...input, - configPath: ref.configPath, - }), - }, - ); - } - - function exchangeTokenUrl( - connection: typeof toolConnections.$inferSelect, - isPages: boolean, - ): string { - const broker = tokenBrokerConfig(connection); - const config = asRecord(connection.config); - const url = - readConfigString(broker, "tokenUrl") ?? - readConfigString(broker, "exchangeTokenUrl") ?? - readConfigString(config, "tokenExchangeUrl") ?? - readConfigString(config, "pagesTokenExchangeUrl"); - if (url) return url; - const pagesApiBase = process.env.PAPERCLIP_PAGES_API_URL?.trim(); - if (isPages && pagesApiBase) - return new URL( - "/v1/tokens/exchange", - pagesApiBase.endsWith("/") ? pagesApiBase : `${pagesApiBase}/`, - ).toString(); - throw unprocessable("Connection token exchange URL is not configured", { - code: "exchange_url_missing", - }); - } - - function pagesNamespaceFromScope(scope: string[]): string | null { - const first = scope[0]; - if (!first) return null; - const match = first.match(/^pages:publish:ns\/([^/\s]+)$/); - return match?.[1] ?? null; - } - - async function mintExchangeConnectionToken( - input: { - connection: typeof toolConnections.$inferSelect; - application: typeof toolApplications.$inferSelect | null; - agentId: string; - runId: string; - issueId: string | null; - responsibleUserId: string | null; - scope: string[]; - ttlSeconds: number; - }, - secretClient: ReturnType = secrets, - ) { - const isPages = isPagesTokenConnection(input.connection, input.application); - const parentToken = await resolveBrokerParentCredential( - input, - secretClient, - ); - const broker = tokenBrokerConfig(input.connection); - const protocol = - readConfigString(broker, "protocol") ?? - readConfigString(broker, "exchangeProtocol") ?? - (isPages ? "pages" : "generic"); - const url = exchangeTokenUrl(input.connection, isPages); - const actor = { - type: "agent", - id: input.agentId, - runId: input.runId, - ...(input.responsibleUserId - ? { onBehalfOf: `user:${input.responsibleUserId}` } - : {}), - }; - let response: Response; - if (protocol === "rfc8693") { - const body = new URLSearchParams(); - body.set("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange"); - body.set("subject_token", parentToken); - body.set( - "subject_token_type", - readConfigString(broker, "subjectTokenType") ?? - "urn:ietf:params:oauth:token-type:access_token", - ); - body.set("scope", input.scope.join(" ")); - const audience = readConfigString(broker, "audience"); - if (audience) body.set("audience", audience); - body.set( - "requested_token_type", - readConfigString(broker, "requestedTokenType") ?? - "urn:ietf:params:oauth:token-type:access_token", - ); - body.set( - "actor_token", - Buffer.from(JSON.stringify(actor)).toString("base64url"), - ); - body.set( - "actor_token_type", - readConfigString(broker, "actorTokenType") ?? - "urn:ietf:params:oauth:token-type:jwt", - ); - const endpoint = parseRemoteHttpEndpoint(url, (message, code) => - badRequest(message, { code }), - ); - response = await guardedRemoteHttpFetch( - endpoint, - { - method: "POST", - headers: { "content-type": "application/x-www-form-urlencoded" }, - body, - }, - tokenBrokerHttpFetchOptions(endpoint), - ); - } else { - const namespace = isPages ? pagesNamespaceFromScope(input.scope) : null; - const body = - isPages && namespace - ? { - namespace, - ttlSeconds: input.ttlSeconds, - actions: ["publish"], - actor, - } - : { - scope: input.scope, - ttlSeconds: input.ttlSeconds, - actor, - audience: readConfigString(broker, "audience"), - }; - const endpoint = parseRemoteHttpEndpoint(url, (message, code) => - badRequest(message, { code }), - ); - response = await guardedRemoteHttpFetch( - endpoint, - { - method: "POST", - headers: { - authorization: `Bearer ${parentToken}`, - "content-type": "application/json", - }, - body: JSON.stringify(body), - }, - tokenBrokerHttpFetchOptions(endpoint), - ); - } - const payload = (await response.json().catch(() => ({}))) as unknown; - const record = asRecord(payload); - if (!response.ok) { - const code = - typeof record.code === "string" - ? record.code - : typeof record.error === "string" - ? record.error - : "upstream_error"; - throw new HttpError( - response.status === 401 || response.status === 403 ? 409 : 502, - "Connection token exchange failed", - { - code: - code === "parent_revoked" ? "credential_revoked" : "upstream_error", - upstreamCode: code, - upstreamStatus: response.status, - upstreamRequestId: - typeof record.requestId === "string" ? record.requestId : null, - }, - ); - } - const token = - typeof record.token === "string" - ? record.token - : typeof record.access_token === "string" - ? record.access_token - : null; - if (!token) - throw new HttpError( - 502, - "Connection token exchange did not return a token", - { code: "upstream_token_missing" }, - ); - const expiresIn = - typeof record.expires_in === "number" - ? record.expires_in - : Number(record.expires_in); - const expiresAt = - typeof record.expiresAt === "string" && - Number.isFinite(Date.parse(record.expiresAt)) - ? new Date(record.expiresAt) - : typeof record.expires_at === "string" && - Number.isFinite(Date.parse(record.expires_at)) - ? new Date(record.expires_at) - : new Date( - now().getTime() + - Math.min( - input.ttlSeconds, - Number.isFinite(expiresIn) && expiresIn > 0 - ? expiresIn - : input.ttlSeconds, - ) * - 1000, - ); - const responseScope = - readConfigStringArray(record.scope).length > 0 - ? readConfigStringArray(record.scope) - : input.scope; - return { - token, - tokenType: - typeof record.token_type === "string" ? record.token_type : "Bearer", - expiresAt, - scope: responseScope, - }; - } - function runtimeAlert( input: ToolRuntimeAlertRecommendation, ): ToolRuntimeAlertRecommendation { @@ -16758,716 +15815,7 @@ export function toolAccessService( }, ...profileService, ...profileResolver, - - mintConnectionTokenForAgent: async (input: { - connectionId: string; - companyId: string; - agentId: string; - runId: string; - body: ConnectionTokenRequest; - }): Promise => { - const runContext = await loadBrokerRunContext({ - companyId: input.companyId, - agentId: input.agentId, - runId: input.runId, - }); - const connection = await getConnectionRow( - input.connectionId, - input.companyId, - ); - if (isDaytonaConnection(connection)) throw forbidden("Daytona credentials stay in the governed tool gateway and cannot be exported."); - if (isBrowserUseConnection(connection)) throw forbidden("Browser Use credentials stay in the governed tool gateway and cannot be exported."); - if (connection.connectionPurpose === "ai") throw unprocessable("AI credentials are available only through the runtime resolver"); - const application = await getConnectionApplication(connection); - const brokerEnabled = connectionTokenBrokerEnabled(connection); - const path = brokerEnabled - ? inferConnectionTokenPath(connection, application) - : "static"; - const requestedScope = normalizeConnectionTokenScopes(input.body.scope); - const parentScopes = parentScopesForConnection(connection); - const fallbackScopes = defaultScopesForConnection(connection); - const issuedScope = - requestedScope.length > 0 - ? requestedScope - : fallbackScopes.length > 0 - ? fallbackScopes - : parentScopes; - const ttlSeconds = requestedTtlSeconds(input.body, connection); - const attribution = { - agentId: input.agentId, - runId: input.runId, - issueId: runContext.issueId, - projectId: runContext.projectId, - responsibleUserId: runContext.responsibleUserId, - }; - - const recordFailure = async ( - outcome: ConnectionTokenIssuanceOutcome, - errorCode: string, - details: Record = {}, - ) => { - await recordConnectionTokenIssuance({ - companyId: connection.companyId, - applicationId: connection.applicationId, - connectionId: connection.id, - agentId: input.agentId, - runId: input.runId, - issueId: runContext.issueId, - projectId: runContext.projectId, - responsibleUserId: runContext.responsibleUserId, - path, - requestedScope, - issuedScope, - ttlSeconds: outcome === "use_env_lease" ? null : ttlSeconds, - expiresAt: null, - tokenHash: null, - outcome, - errorCode, - metadata: details, - }); - await auditConnectionTokenIssuance({ - companyId: connection.companyId, - connectionId: connection.id, - agentId: input.agentId, - runId: input.runId, - path, - outcome, - reasonCode: errorCode, - details, - }); - }; - - const fail = async ( - status: number, - message: string, - outcome: ConnectionTokenIssuanceOutcome, - errorCode: string, - details: Record = {}, - ): Promise => { - await recordFailure(outcome, errorCode, details); - throw new HttpError(status, message, { - code: errorCode, - path, - ...details, - }); - }; - - const [install] = await db - .select({ id: toolConnectionInstalls.id }) - .from(toolConnectionInstalls) - .where( - and( - eq(toolConnectionInstalls.companyId, connection.companyId), - eq(toolConnectionInstalls.connectionId, connection.id), - sql`((${toolConnectionInstalls.targetType} = 'company' and ${toolConnectionInstalls.targetId} = ${connection.companyId}) or (${toolConnectionInstalls.targetType} = 'agent' and ${toolConnectionInstalls.targetId} = ${input.agentId}))`, - ), - ) - .limit(1); - if (!install) { - await fail( - 403, - `Connection ${connection.name} must be installed for this agent before it can mint a token`, - "denied", - "installation_required", - { - connection: { - id: connection.id, - uid: connection.uid, - name: connection.name, - }, - agentId: input.agentId, - remediation: { - action: "install_connection", - targetType: "agent", - targetId: input.agentId, - }, - }, - ); - } - - if ( - runContext.run.activeIdentityContextId && - (connection.config.sourceTemplateKey === "github" || - connection.transportConfig?.sourceTemplateKey === "github") - ) { - await fail( - 409, - "Use managed git, gh, or GitHub tools for this run", - "denied", - "managed_github_invocation_required", - ); - } - const requestedSubject = input.body.subject; - if ( - requestedSubject?.type === "user" && - requestedSubject.userId !== runContext.responsibleUserId - ) { - await fail( - 403, - "The agent run cannot act as the requested user", - "denied", - "subject_not_permitted", - { - connection: { uid: connection.uid }, - subject: requestedSubject, - }, - ); - } - - const actingUserId = runContext.responsibleUserId; - const autonomous = - runContext.run.invocationSource === "automation" || - runContext.run.invocationSource === "timer"; - let subject: { type: "app" } | { type: "user"; userId: string } = - connection.credentialPolicy === "shared" || - connection.credentialPolicy === "per_agent" || - !actingUserId - ? { type: "app" as const } - : { type: "user" as const, userId: actingUserId }; - let grant: typeof connectionGrants.$inferSelect | undefined; - if (connection.credentialPolicy === "per_agent") { - [grant] = await db - .select() - .from(connectionGrants) - .where( - and( - eq(connectionGrants.companyId, connection.companyId), - eq(connectionGrants.connectionId, connection.id), - eq(connectionGrants.kind, "agent"), - eq(connectionGrants.subjectAgentId, input.agentId), - ), - ) - .limit(1); - if (!grant) { - await fail( - 409, - "This agent's dedicated authorization is required", - "denied", - "agent_authorization_required", - { - connection: { uid: connection.uid }, - agentId: input.agentId, - remediation: { - action: "start_agent_authorization", - agentId: input.agentId, - }, - }, - ); - } - } else if (connection.credentialPolicy !== "shared" && actingUserId) { - // Installation targets express the grant owner's consent to agent use; - // the control-plane-resolved responsible user selects whose grant is in - // force. A separate standing delegation is required only when no such - // responsible user exists for unattended work. - const [membership] = await db - .select({ id: companyMemberships.id }) - .from(companyMemberships) - .where( - and( - eq(companyMemberships.companyId, connection.companyId), - eq(companyMemberships.principalType, "user"), - eq(companyMemberships.principalId, actingUserId), - eq(companyMemberships.status, "active"), - ), - ) - .limit(1); - if (!membership) { - await fail( - 403, - "The personal grant owner is not an active company member", - "denied", - "grant_owner_membership_inactive", - { - connection: { - id: connection.id, - uid: connection.uid, - name: connection.name, - }, - subject: { type: "user", userId: actingUserId }, - remediation: { action: "restore_membership_or_reconnect" }, - }, - ); - } - [grant] = await db - .select() - .from(connectionGrants) - .where( - and( - eq(connectionGrants.companyId, connection.companyId), - eq(connectionGrants.connectionId, connection.id), - eq(connectionGrants.kind, "user"), - eq(connectionGrants.subjectUserId, actingUserId), - ), - ) - .limit(1); - } else if (connection.credentialPolicy === "per_user" && autonomous) { - const delegated = await db - .select({ grant: connectionGrants }) - .from(connectionGrantDelegations) - .innerJoin( - connectionGrants, - and( - eq(connectionGrants.id, connectionGrantDelegations.grantId), - eq( - connectionGrants.companyId, - connectionGrantDelegations.companyId, - ), - ), - ) - .where( - and( - eq(connectionGrantDelegations.companyId, connection.companyId), - eq(connectionGrantDelegations.agentId, input.agentId), - eq(connectionGrants.connectionId, connection.id), - eq(connectionGrants.kind, "user"), - ), - ) - .limit(2); - if (delegated.length > 1) { - await fail( - 409, - "More than one delegated personal authorization matches this run", - "denied", - "subject_not_permitted", - { - connection: { uid: connection.uid }, - agentId: input.agentId, - }, - ); - } - grant = delegated[0]?.grant; - if (grant?.subjectUserId) - subject = { type: "user", userId: grant.subjectUserId }; - } - if (!grant && connection.credentialPolicy === "per_user") { - await fail( - 409, - "User authorization is required", - "denied", - "user_authorization_required", - { - connection: { uid: connection.uid }, - subject: actingUserId - ? { type: "user", userId: actingUserId } - : { type: "app" }, - remediation: { action: "start_authorization" }, - }, - ); - } - if (!grant) { - grant = await ensureDefaultOrganizationGrant(connection); - } - - if ( - grant.kind === "user" && - grant.subjectUserId && - grant.subjectUserId !== actingUserId - ) { - const [membership] = await db - .select({ id: companyMemberships.id }) - .from(companyMemberships) - .where( - and( - eq(companyMemberships.companyId, connection.companyId), - eq(companyMemberships.principalType, "user"), - eq(companyMemberships.principalId, grant.subjectUserId), - eq(companyMemberships.status, "active"), - ), - ) - .limit(1); - if (!membership) { - await fail( - 403, - "The delegated personal grant owner is not an active company member", - "denied", - "grant_owner_membership_inactive", - { - connection: { - id: connection.id, - uid: connection.uid, - name: connection.name, - }, - subject, - remediation: { action: "restore_membership_or_reconnect" }, - }, - ); - } - } - - if (grant.kind === "organization") { - const activeAudienceMember = actingUserId - ? await db - .select({ id: companyMemberships.id }) - .from(companyMemberships) - .where( - and( - eq(companyMemberships.companyId, connection.companyId), - eq(companyMemberships.principalType, "user"), - eq(companyMemberships.principalId, actingUserId), - eq(companyMemberships.status, "active"), - ), - ) - .limit(1) - .then((rows) => rows[0] ?? null) - : null; - const audience = await db - .select({ subjectId: connectionGrantMembers.subjectId }) - .from(connectionGrantMembers) - .where( - and( - eq(connectionGrantMembers.companyId, connection.companyId), - eq(connectionGrantMembers.grantId, grant.id), - eq(connectionGrantMembers.subjectType, "user"), - ), - ); - if ( - (actingUserId !== null && !activeAudienceMember) || - (audience.length > 0 && - (!actingUserId || - !audience.some((member) => member.subjectId === actingUserId))) - ) { - await fail( - 403, - "The acting user is not in this grant's audience", - "denied", - "grant_audience_denied", - { - connection: { uid: connection.uid }, - subject, - grantId: grant.id, - }, - ); - } - } - - if (grant.status !== "active") { - const code = - grant.status === "needs_reauthorization" - ? "needs_reauthorization" - : "grant_revoked"; - await fail( - 409, - "The selected connection grant is not active", - "denied", - code, - { - connection: { uid: connection.uid }, - subject, - grantId: grant.id, - remediation: { action: "reauthorize" }, - }, - ); - } - const requestedScopeSelectors = new Set(requestedScope); - const matchingScopedRefs = grant.credentialSecretRefs.filter( - (ref) => ref.keyScope && requestedScopeSelectors.has(ref.keyScope), - ); - const selectedCredentialSecretRefs = - matchingScopedRefs.length > 0 - ? grant.credentialSecretRefs.filter( - (ref) => - !ref.keyScope || requestedScopeSelectors.has(ref.keyScope), - ) - : grant.credentialSecretRefs.filter((ref) => !ref.keyScope); - const rotateBefore = Date.now() + 14 * 24 * 60 * 60 * 1000; - const expiringRef = selectedCredentialSecretRefs.find( - (ref) => ref.expiresAt && Date.parse(ref.expiresAt) <= rotateBefore, - ); - if (expiringRef && connection.healthStatus !== "degraded") { - await db - .update(toolConnections) - .set({ - healthStatus: "degraded", - healthMessage: `Rotate ${expiringRef.label ?? expiringRef.configPath} before it expires.`, - updatedAt: new Date(), - }) - .where(eq(toolConnections.id, connection.id)); - } - const credentialConnection = { - ...connection, - credentialSecretRefs: selectedCredentialSecretRefs, - }; - - if (!connection.enabled || connection.status !== "active") { - await fail( - 409, - "Connection is not active", - "denied", - "connection_not_active", - { - connectionStatus: connection.status, - enabled: connection.enabled, - }, - ); - } - if ( - ["failed", "error", "missing_secret"].includes(connection.healthStatus) - ) { - await fail( - 409, - "Connection credential needs attention", - "denied", - "credential_revoked", - { - healthStatus: connection.healthStatus, - healthMessage: connection.healthMessage ?? null, - }, - ); - } - if (!brokerEnabled) { - await fail( - 403, - "Connection token broker is not enabled for this connection", - "denied", - "broker_not_enabled", - { - reason: - "Connections must explicitly opt in with tokenBroker.enabled before agents can request brokered tokens.", - }, - ); - } - try { - assertScopeSubset({ requestedScope: issuedScope, parentScopes }); - } catch { - await fail( - 403, - "Requested token scope exceeds the connection parent scope", - "denied", - "scope_exceeds_parent", - { - parentScopeCount: parentScopes.length, - }, - ); - } - - const hasBrokerGrant = await hasExplicitConnectionTokenMintProfileGrant({ - companyId: connection.companyId, - agentId: input.agentId, - issueId: runContext.issueId, - projectId: runContext.projectId, - routineId: runContext.routineId, - }); - if (!hasBrokerGrant) { - await fail( - 403, - "Connection token minting requires an explicit broker profile grant", - "denied", - "broker_mint_not_granted", - { - reason: - "A connection-level profile grant is not sufficient for connection_token.mint.", - }, - ); - } - - const decisionInput = { - companyId: connection.companyId, - actor: { - actorType: "agent" as const, - actorId: input.agentId, - agentId: input.agentId, - }, - runContext: { - heartbeatRunId: input.runId, - issueId: runContext.issueId, - projectId: runContext.projectId, - routineId: runContext.routineId, - }, - request: { - applicationId: connection.applicationId, - connectionId: connection.id, - providerType: "connection_token_broker", - applicationKey: application?.applicationKey ?? null, - upstreamToolName: CONNECTION_TOKEN_MINT_TOOL_NAME, - riskLevel: "write", - toolName: CONNECTION_TOKEN_MINT_TOOL_NAME, - arguments: { - path, - scope: issuedScope, - requestedTtlSeconds: input.body.requestedTtlSeconds ?? null, - }, - }, - consumeRateLimit: true, - }; - const decision = await policySvc.decide(decisionInput); - await policySvc.writeAudit(decisionInput, decision); - if (!decision.allowed) { - await fail( - decision.decision === "rate_limited" ? 429 : 403, - decision.explanation, - decision.decision === "rate_limited" ? "rate_limited" : "denied", - decision.reasonCode, - { - decision: decision.decision, - effectiveProfileIds: decision.effectiveProfileIds, - matchedPolicyIds: decision.matchedPolicyIds, - rateLimitState: decision.rateLimitState ?? null, - }, - ); - } - - try { - await enforceDefaultConnectionTokenRateLimit({ - connection, - agentId: input.agentId, - path, - }); - } catch (error) { - if (error instanceof HttpError && error.status === 429) { - await fail( - 429, - error.message, - "rate_limited", - "rate_limited", - asRecord(error.details), - ); - } - throw error; - } - - if (path === "static") { - await recordFailure("use_env_lease", "use_env_lease", { - reason: - "Connection uses durable static credentials; broker token delivery is refused.", - }); - return { - status: "use_env_lease", - code: "use_env_lease", - connectionId: connection.id, - connection: { id: connection.id, uid: connection.uid }, - grantId: grant.id, - path: "static", - message: - "This connection uses static credentials. Use an audited environment lease projection instead.", - scope: issuedScope, - attribution, - }; - } - if (path === "oauth_access") { - await fail( - 422, - "OAuth access-token projection is disabled; configure a short-lived exchange mint path instead", - "denied", - "oauth_access_projection_disabled", - { - reason: - "The broker must not return stored upstream OAuth bearer tokens directly.", - }, - ); - } - - const selectedGrant = grant; - try { - const mintResult = await db.transaction(async (tx) => { - await lockAuthorizedBrokerResponsibleMembership( - { - companyId: connection.companyId, - responsibleUserId: runContext.responsibleUserId, - }, - tx, - ); - const minted = await mintExchangeConnectionToken( - { - connection: credentialConnection, - application, - agentId: input.agentId, - runId: input.runId, - issueId: runContext.issueId, - responsibleUserId: runContext.responsibleUserId, - scope: issuedScope, - ttlSeconds, - }, - secretService(tx), - ); - const expiresAt = minted.expiresAt; - const mintedScope = "scope" in minted ? minted.scope : issuedScope; - const effectiveTtlSeconds = Math.max( - 1, - Math.min( - 900, - Math.ceil((expiresAt.getTime() - now().getTime()) / 1000), - ), - ); - const tokenHash = bearerTokenHash(minted.token); - await recordConnectionTokenIssuance( - { - companyId: connection.companyId, - applicationId: connection.applicationId, - connectionId: connection.id, - agentId: input.agentId, - runId: input.runId, - issueId: runContext.issueId, - projectId: runContext.projectId, - responsibleUserId: runContext.responsibleUserId, - path, - requestedScope, - issuedScope: mintedScope, - ttlSeconds: effectiveTtlSeconds, - expiresAt, - tokenHash, - outcome: "success", - metadata: { tokenRef: tokenHash, tokenType: minted.tokenType }, - }, - tx, - ); - await tx - .update(connectionGrants) - .set({ lastUsedAt: new Date(), updatedAt: new Date() }) - .where(eq(connectionGrants.id, selectedGrant.id)); - return { - minted, - expiresAt, - mintedScope, - effectiveTtlSeconds, - tokenHash, - }; - }); - await auditConnectionTokenIssuance({ - companyId: connection.companyId, - connectionId: connection.id, - agentId: input.agentId, - runId: input.runId, - path, - outcome: "success", - details: { - ttlSeconds: mintResult.effectiveTtlSeconds, - scopeCount: mintResult.mintedScope.length, - tokenRef: mintResult.tokenHash, - }, - }); - return { - status: "minted", - connectionId: connection.id, - connection: { id: connection.id, uid: connection.uid }, - grantId: selectedGrant.id, - providerTenantId: selectedGrant.providerTenant?.externalId, - path: "exchange", - token: mintResult.minted.token, - tokenType: mintResult.minted.tokenType, - expiresAt: mintResult.expiresAt.toISOString(), - ttlSeconds: mintResult.effectiveTtlSeconds, - scope: mintResult.mintedScope, - attribution, - }; - } catch (error) { - const details = - error instanceof HttpError && asRecord(error.details).code - ? asRecord(error.details) - : {}; - const errorCode = - typeof details.code === "string" ? details.code : "mint_failed"; - const outcome: ConnectionTokenIssuanceOutcome = - errorCode === "upstream_error" || - errorCode === "upstream_token_missing" - ? "upstream_error" - : "failure"; - await recordFailure(outcome, errorCode, { - ...details, - message: error instanceof Error ? error.message : String(error), - }); - throw error; - } - }, + mintConnectionTokenForAgent, listRuntimeSlots: async (companyId: string): Promise => { const rows = await db diff --git a/server/src/services/tool-access/audit.ts b/server/src/services/tool-access/audit.ts new file mode 100644 index 00000000000..47458c663c1 --- /dev/null +++ b/server/src/services/tool-access/audit.ts @@ -0,0 +1,37 @@ +import type { ActorInfo } from "./types.js"; +import type { Db } from "@paperclipai/db"; +import { toolAccessAuditEvents } from "@paperclipai/db"; +import { recordToolRuntimeAuditWriteFailure } from "../tool-runtime-metrics.js"; + +export function createToolAccessAudit(db: Db) { + async function audit(input: { + companyId: string; + connectionId?: string | null; + catalogEntryId?: string | null; + action: string; + outcome: "success" | "failure"; + reasonCode?: string | null; + details?: Record; + actor?: ActorInfo; + }) { + try { + await db.insert(toolAccessAuditEvents).values({ + companyId: input.companyId, + connectionId: input.connectionId ?? null, + catalogEntryId: input.catalogEntryId ?? null, + actorType: input.actor?.actorType ?? "system", + actorId: input.actor?.actorId ?? null, + action: input.action, + outcome: input.outcome, + reasonCode: input.reasonCode ?? null, + details: input.details ?? {}, + }); + } catch (error) { + await recordToolRuntimeAuditWriteFailure(db, input.companyId); + throw error; + } + } + return audit; +} + +export type ToolAccessAudit = ReturnType; diff --git a/server/src/services/tool-access/token-broker/admission.ts b/server/src/services/tool-access/token-broker/admission.ts new file mode 100644 index 00000000000..5b30a3fd915 --- /dev/null +++ b/server/src/services/tool-access/token-broker/admission.ts @@ -0,0 +1,254 @@ +import type { DbTransaction } from "../types.js"; +import { captureRunIdentity } from "../../run-identity.js"; +import { and, eq, gte, inArray, sql } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { connectionTokenIssuances, companyMemberships, heartbeatRuns, toolConnections, toolProfileBindings, toolProfileEntries, toolProfiles } from "@paperclipai/db"; +import type { ConnectionTokenIssuancePath } from "@paperclipai/shared"; +import { forbidden, HttpError } from "../../../errors.js"; +import { narrowestScopeBindings, profileIdsInBindingOrder } from "../../tool-profile-binding-precedence.js"; + +import { CONNECTION_TOKEN_MINT_TOOL_NAME, tokenBrokerConfig } from "./configuration.js"; +function asRecord(value: unknown): Record { + if (value && typeof value === "object" && !Array.isArray(value)) + return value as Record; + return {}; +} + +const ACTIVE_BROKER_RUN_STATUSES = new Set(["running"]); + +export async function lockAuthorizedBrokerResponsibleMembership( + input: { + companyId: string; + responsibleUserId: string; + }, + tx: DbTransaction, +) { + const membership = await tx + .select({ + id: companyMemberships.id, + status: companyMemberships.status, + membershipRole: companyMemberships.membershipRole, + }) + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, input.companyId), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.principalId, input.responsibleUserId), + ), + ) + .limit(1) + .for("update") + .then((rows) => rows[0] ?? null); + if ( + !membership || + membership.status !== "active" || + !membership.membershipRole || + membership.membershipRole === "viewer" + ) { + throw new HttpError( + 403, + "Responsible user is no longer authorized for company write access", + { + code: "responsible_user_unauthorized", + }, + ); + } + return membership; +} + +export function createTokenBrokerAdmission(db: Db, now: () => Date) { + function runSnapshotString( + snapshot: Record, + ...keys: string[] + ): string | null { + for (const key of keys) { + const value = snapshot[key]; + if (typeof value === "string" && value.trim().length > 0) return value; + } + return null; + } + + async function loadBrokerRunContext(input: { + companyId: string; + agentId: string; + runId: string; + }) { + const [initialRun] = await db + .select() + .from(heartbeatRuns) + .where(eq(heartbeatRuns.id, input.runId)); + const run = initialRun?.activeIdentityContextId + ? (await captureRunIdentity(db, input)).run + : initialRun; + if ( + !run || + run.companyId !== input.companyId || + run.agentId !== input.agentId + ) { + throw forbidden( + "Agent run context does not match the authenticated actor", + ); + } + if (!ACTIVE_BROKER_RUN_STATUSES.has(run.status)) { + throw forbidden("Agent run is not active"); + } + const snapshot = asRecord(run.contextSnapshot); + const paperclipIssue = asRecord(snapshot.paperclipIssue); + const responsibleUserId = run.activeIdentityContextId + ? run.responsibleUserId + : (runSnapshotString( + snapshot, + "responsibleUserId", + "responsible_user_id", + ) ?? + runSnapshotString( + paperclipIssue, + "responsibleUserId", + "responsible_user_id", + ) ?? + run.responsibleUserId); + if (!responsibleUserId) { + throw forbidden( + "Agent run has no responsible user for delegated connection access", + ); + } + const responsibleMembership = await db + .select({ + status: companyMemberships.status, + membershipRole: companyMemberships.membershipRole, + }) + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, run.companyId), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.principalId, responsibleUserId), + ), + ) + .then((rows) => rows[0] ?? null); + if ( + !responsibleMembership || + responsibleMembership.status !== "active" || + !responsibleMembership.membershipRole || + responsibleMembership.membershipRole === "viewer" + ) { + throw forbidden( + "Responsible user is no longer authorized for company write access", + ); + } + return { + run, + issueId: + runSnapshotString(snapshot, "issueId") ?? + runSnapshotString(paperclipIssue, "id"), + projectId: + runSnapshotString(snapshot, "projectId") ?? + runSnapshotString(paperclipIssue, "projectId"), + routineId: runSnapshotString(snapshot, "routineId"), + responsibleUserId, + }; + } + + async function enforceDefaultConnectionTokenRateLimit(input: { + connection: typeof toolConnections.$inferSelect; + agentId: string; + path: ConnectionTokenIssuancePath; + }) { + const broker = tokenBrokerConfig(input.connection); + const configured = Number(broker.rateLimitPerHour ?? 30); + const limit = + Number.isFinite(configured) && configured > 0 + ? Math.trunc(configured) + : 30; + const since = new Date(now().getTime() - 60 * 60 * 1000); + const [row] = await db + .select({ count: sql`count(*)::int` }) + .from(connectionTokenIssuances) + .where( + and( + eq(connectionTokenIssuances.companyId, input.connection.companyId), + eq(connectionTokenIssuances.connectionId, input.connection.id), + eq(connectionTokenIssuances.agentId, input.agentId), + eq(connectionTokenIssuances.outcome, "success"), + gte(connectionTokenIssuances.createdAt, since), + ), + ); + const count = Number(row?.count ?? 0); + if (count >= limit) { + throw new HttpError(429, "Connection token mint rate limit exceeded", { + code: "rate_limited", + path: input.path, + limit, + windowSeconds: 3600, + }); + } + } + + async function hasExplicitConnectionTokenMintProfileGrant(input: { + companyId: string; + agentId: string; + issueId: string | null; + projectId: string | null; + routineId: string | null; + }) { + const bindings = await db + .select() + .from(toolProfileBindings) + .where(eq(toolProfileBindings.companyId, input.companyId)); + const matchingBindings = bindings.filter((binding) => { + if (binding.targetType === "company") + return binding.targetId === input.companyId; + if (binding.targetType === "agent") + return binding.targetId === input.agentId; + if (binding.targetType === "issue") + return Boolean(input.issueId && binding.targetId === input.issueId); + if (binding.targetType === "project") + return Boolean(input.projectId && binding.targetId === input.projectId); + if (binding.targetType === "routine") + return Boolean(input.routineId && binding.targetId === input.routineId); + return false; + }); + const profileIds = profileIdsInBindingOrder( + narrowestScopeBindings(matchingBindings), + ); + if (profileIds.length === 0) return false; + const profiles = await db + .select() + .from(toolProfiles) + .where( + and( + eq(toolProfiles.companyId, input.companyId), + inArray(toolProfiles.id, profileIds), + ), + ); + const activeProfileIds = profiles + .filter((profile) => profile.status === "active") + .map((profile) => profile.id); + if (activeProfileIds.length === 0) return false; + const entries = await db + .select() + .from(toolProfileEntries) + .where( + and( + eq(toolProfileEntries.companyId, input.companyId), + inArray(toolProfileEntries.profileId, activeProfileIds), + ), + ); + return activeProfileIds.some((profileId) => { + const profileEntries = entries.filter( + (entry) => entry.profileId === profileId, + ); + const exactBrokerEntries = profileEntries.filter( + (entry) => + entry.selectorType === "tool_name" && + entry.toolName === CONNECTION_TOKEN_MINT_TOOL_NAME && + Object.keys(asRecord(entry.conditions)).length === 0, + ); + if (exactBrokerEntries.some((entry) => entry.effect === "exclude")) + return false; + return exactBrokerEntries.some((entry) => entry.effect === "include"); + }); + } + return { loadBrokerRunContext, enforceDefaultConnectionTokenRateLimit, hasExplicitConnectionTokenMintProfileGrant }; +} diff --git a/server/src/services/tool-access/token-broker/configuration.ts b/server/src/services/tool-access/token-broker/configuration.ts new file mode 100644 index 00000000000..2a6658cea62 --- /dev/null +++ b/server/src/services/tool-access/token-broker/configuration.ts @@ -0,0 +1,237 @@ +import { createHash } from "node:crypto"; + +import { toolApplications, toolConnections } from "@paperclipai/db"; +import type { ConnectionTokenIssuancePath, ConnectionTokenRequest } from "@paperclipai/shared"; +import { forbidden, unprocessable } from "../../../errors.js"; + +function asRecord(value: unknown): Record { + if (value && typeof value === "object" && !Array.isArray(value)) + return value as Record; + return {}; +} + +export function readConfigString( + record: Record, + key: string, +): string | null { + const value = record[key]; + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : null; +} + +export function readConfigStringArray(value: unknown): string[] { + if (Array.isArray(value)) { + return value + .filter((item): item is string => typeof item === "string") + .map((item) => item.trim()) + .filter(Boolean); + } + if (typeof value === "string") + return value + .split(/\s+/) + .map((item) => item.trim()) + .filter(Boolean); + return []; +} + +export function normalizeConnectionTokenScopes( + scope: ConnectionTokenRequest["scope"], +): string[] { + if (Array.isArray(scope)) + return [...new Set(scope.map((item) => item.trim()).filter(Boolean))]; + if (typeof scope === "string") + return [ + ...new Set( + scope + .split(/\s+/) + .map((item) => item.trim()) + .filter(Boolean), + ), + ]; + return []; +} + +export function tokenBrokerConfigFromConnectionConfig( + config: Record, +): Record { + const broker = asRecord(config.tokenBroker); + if (Object.keys(broker).length > 0) return broker; + return asRecord(config.broker); +} + +export function tokenBrokerConfig( + connection: typeof toolConnections.$inferSelect, +): Record { + return tokenBrokerConfigFromConnectionConfig(asRecord(connection.config)); +} + +export function configuredTokenBrokerExchangeUrls( + config: Record, +): string[] { + const broker = tokenBrokerConfigFromConnectionConfig(config); + return [ + ...new Set( + [ + readConfigString(broker, "tokenUrl"), + readConfigString(broker, "exchangeTokenUrl"), + readConfigString(config, "tokenExchangeUrl"), + readConfigString(config, "pagesTokenExchangeUrl"), + ].filter((url): url is string => url !== null), + ), + ]; +} + +export function connectionTokenBrokerEnabled( + connection: typeof toolConnections.$inferSelect, +): boolean { + const config = asRecord(connection.config); + const tokenBroker = asRecord(config.tokenBroker); + if (Object.keys(tokenBroker).length > 0) + return tokenBroker.enabled === true; + const broker = asRecord(config.broker); + if (Object.keys(broker).length > 0) return broker.enabled === true; + return false; +} + +export function isPagesTokenConnection( + connection: typeof toolConnections.$inferSelect, + application?: typeof toolApplications.$inferSelect | null, +) { + const config = asRecord(connection.config); + const broker = tokenBrokerConfig(connection); + const applicationKey = application?.applicationKey ?? ""; + return Boolean( + applicationKey === "paperclip-pages" || + applicationKey === "paperclip.pages" || + applicationKey === "pages.paperclip" || + readConfigString(config, "connectionType") === "pages" || + readConfigString(config, "service") === "pages" || + readConfigString(broker, "connectionType") === "pages" || + readConfigString(broker, "service") === "pages" || + asRecord(config.pages).enabled === true, + ); +} + +export function inferConnectionTokenPath( + connection: typeof toolConnections.$inferSelect, + application?: typeof toolApplications.$inferSelect | null, +): ConnectionTokenIssuancePath { + const broker = tokenBrokerConfig(connection); + const configuredPath = + readConfigString(broker, "path") ?? + readConfigString(asRecord(connection.config), "tokenPath"); + if ( + configuredPath === "exchange" || + configuredPath === "oauth_access" || + configuredPath === "static" + ) + return configuredPath; + if (isPagesTokenConnection(connection, application)) return "exchange"; + if ( + readConfigString(broker, "tokenUrl") || + readConfigString(asRecord(connection.config), "tokenExchangeUrl") + ) + return "exchange"; + return "static"; +} + +export function parentScopesForConnection( + connection: typeof toolConnections.$inferSelect, +): string[] { + const config = asRecord(connection.config); + const broker = tokenBrokerConfig(connection); + const configured = [ + ...readConfigStringArray(broker.parentScopes), + ...readConfigStringArray(broker.scopes), + ...readConfigStringArray(config.parentScopes), + ...readConfigStringArray(asRecord(config.oauth).scopes), + ...readConfigStringArray(asRecord(config.oauth).scope), + ]; + const namespaceAllowlist = readConfigStringArray( + config.namespaceAllowlist, + ).map((namespace) => `pages:publish:ns/${namespace}`); + return [...new Set([...configured, ...namespaceAllowlist])]; +} + +export function defaultScopesForConnection( + connection: typeof toolConnections.$inferSelect, +): string[] { + const broker = tokenBrokerConfig(connection); + return [ + ...new Set([ + ...readConfigStringArray(broker.defaultScopes), + ...readConfigStringArray(asRecord(connection.config).defaultScopes), + ]), + ]; +} + +export function assertScopeSubset(input: { + requestedScope: string[]; + parentScopes: string[]; +}) { + if (input.requestedScope.length === 0) return; + const parent = new Set(input.parentScopes); + if ( + parent.size === 0 || + input.requestedScope.some((scope) => !parent.has(scope)) + ) { + throw forbidden( + "Requested token scope exceeds the connection parent scope", + ); + } +} + +export function requestedTtlSeconds( + body: ConnectionTokenRequest, + connection: typeof toolConnections.$inferSelect, +): number { + const broker = tokenBrokerConfig(connection); + const configured = Number( + broker.defaultTtlSeconds ?? broker.ttlSeconds ?? 900, + ); + const requested = Number(body.requestedTtlSeconds ?? configured); + const finite = + Number.isFinite(requested) && requested > 0 ? Math.trunc(requested) : 900; + return Math.max(1, Math.min(900, finite)); +} + +export function sha256Hex(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +export function bearerTokenHash(token: string): string { + return sha256Hex(token); +} + +export function exchangeTokenUrl( + connection: typeof toolConnections.$inferSelect, + isPages: boolean, +): string { + const broker = tokenBrokerConfig(connection); + const config = asRecord(connection.config); + const url = + readConfigString(broker, "tokenUrl") ?? + readConfigString(broker, "exchangeTokenUrl") ?? + readConfigString(config, "tokenExchangeUrl") ?? + readConfigString(config, "pagesTokenExchangeUrl"); + if (url) return url; + const pagesApiBase = process.env.PAPERCLIP_PAGES_API_URL?.trim(); + if (isPages && pagesApiBase) + return new URL( + "/v1/tokens/exchange", + pagesApiBase.endsWith("/") ? pagesApiBase : `${pagesApiBase}/`, + ).toString(); + throw unprocessable("Connection token exchange URL is not configured", { + code: "exchange_url_missing", + }); +} + +export function pagesNamespaceFromScope(scope: string[]): string | null { + const first = scope[0]; + if (!first) return null; + const match = first.match(/^pages:publish:ns\/([^/\s]+)$/); + return match?.[1] ?? null; +} + +export const CONNECTION_TOKEN_MINT_TOOL_NAME = "connection_token.mint"; diff --git a/server/src/services/tool-access/token-broker/endpoint-policy.ts b/server/src/services/tool-access/token-broker/endpoint-policy.ts new file mode 100644 index 00000000000..306333df3bd --- /dev/null +++ b/server/src/services/tool-access/token-broker/endpoint-policy.ts @@ -0,0 +1,74 @@ +import { badRequest } from "../../../errors.js"; +import { assertPublicRemoteHttpEndpoint, parseRemoteHttpEndpoint } from "../../remote-http-endpoint-guard.js"; +import { type GuardedRemoteHttpFetchOptions } from "../../remote-http-fetch.js"; +import { configuredTokenBrokerExchangeUrls } from "./configuration.js"; +function normalizeTokenBrokerAllowedHost(value: string): string | null { + const trimmed = value.trim(); + if (!trimmed) return null; + try { + const parsed = new URL( + trimmed.includes("://") ? trimmed : `http://${trimmed}`, + ); + return ( + parsed.hostname + .replace(/^\[|\]$/g, "") + .replace(/\.$/, "") + .toLowerCase() || null + ); + } catch { + // Invalid allowlist entries grant no access. The configured broker URL is + // still evaluated under the public-only policy below. + return null; + } +} + +function tokenBrokerAllowedPrivateHosts(): Set { + const configured = (process.env.PAPERCLIP_TOKEN_BROKER_ALLOWED_HOSTS ?? "") + .split(/[,\s]+/) + .map(normalizeTokenBrokerAllowedHost) + .filter((host): host is string => host !== null); + const pagesApiHost = normalizeTokenBrokerAllowedHost( + process.env.PAPERCLIP_PAGES_API_URL ?? "", + ); + if (pagesApiHost) configured.push(pagesApiHost); + return new Set(configured); +} + +function tokenBrokerAllowsPrivateNetwork(endpoint: URL): boolean { + const hostname = endpoint.hostname + .replace(/^\[|\]$/g, "") + .replace(/\.$/, "") + .toLowerCase(); + return tokenBrokerAllowedPrivateHosts().has(hostname); +} + +export function tokenBrokerHttpFetchOptions( + endpoint: URL, +): GuardedRemoteHttpFetchOptions { + return { + allowPrivateNetwork: tokenBrokerAllowsPrivateNetwork(endpoint), + error: (message, code) => badRequest(message, { code }), + }; +} + +async function assertTokenBrokerHttpUrlAllowed( + value: string, +): Promise { + const endpoint = parseRemoteHttpEndpoint(value, (message, code) => + badRequest(message, { code }), + ); + await assertPublicRemoteHttpEndpoint( + endpoint, + { allowPrivateNetwork: tokenBrokerAllowsPrivateNetwork(endpoint) }, + (message, code) => badRequest(message, { code }), + ); + return endpoint.toString(); +} + +export async function assertConfiguredTokenBrokerEndpointsAllowed( + config: Record, +): Promise { + for (const url of configuredTokenBrokerExchangeUrls(config)) { + await assertTokenBrokerHttpUrlAllowed(url); + } +} diff --git a/server/src/services/tool-access/token-broker/exchange.ts b/server/src/services/tool-access/token-broker/exchange.ts new file mode 100644 index 00000000000..72b6e040d41 --- /dev/null +++ b/server/src/services/tool-access/token-broker/exchange.ts @@ -0,0 +1,306 @@ +import type { ActorInfo } from "../types.js"; +import { toolApplications, toolConnections } from "@paperclipai/db"; +import { badRequest, HttpError, unprocessable } from "../../../errors.js"; +import { parseRemoteHttpEndpoint } from "../../remote-http-endpoint-guard.js"; +import { guardedRemoteHttpFetch } from "../../remote-http-fetch.js"; +import { secretService } from "../../secrets.js"; + +import { tokenBrokerHttpFetchOptions } from "./endpoint-policy.js"; +import { readConfigString, readConfigStringArray, tokenBrokerConfig, isPagesTokenConnection, exchangeTokenUrl, pagesNamespaceFromScope } from "./configuration.js"; +function asRecord(value: unknown): Record { + if (value && typeof value === "object" && !Array.isArray(value)) + return value as Record; + return {}; +} + +export function createConnectionTokenExchange(now: () => Date, secrets: ReturnType) { + function accessContextForBroker(input: { + connection: typeof toolConnections.$inferSelect; + agentId: string; + runId: string; + issueId: string | null; + actorSource?: ActorInfo["actorType"] | null; + configPath: string; + }) { + return { + consumerType: "tool_connection" as const, + consumerId: input.connection.id, + configPath: input.configPath, + actorType: "agent" as const, + actorId: input.agentId, + actorSource: "agent_jwt" as const, + issueId: input.issueId, + heartbeatRunId: input.runId, + }; + } + + function findBrokerCredentialRef( + connection: typeof toolConnections.$inferSelect, + ) { + const broker = tokenBrokerConfig(connection); + const configuredPath = + readConfigString(broker, "parentCredentialConfigPath") ?? + readConfigString(broker, "credentialConfigPath") ?? + readConfigString(broker, "secretConfigPath"); + const configuredName = + readConfigString(broker, "parentCredentialName") ?? + readConfigString(broker, "credentialName"); + const secretCandidates = connection.credentialSecretRefs.filter( + (ref) => + ref.configPath !== "oauth.access_token" && + ref.configPath !== "oauth.refresh_token" && + ref.configPath !== "oauth.client_secret", + ); + const secretRef = configuredPath + ? connection.credentialSecretRefs.find( + (ref) => ref.configPath === configuredPath, + ) + : (secretCandidates.find( + (ref) => ref.configPath === "credentials.deploy_token", + ) ?? + secretCandidates.find( + (ref) => ref.configPath === "pages.deploy_token", + ) ?? + secretCandidates[0]); + if (secretRef) + return { + kind: "secret_ref" as const, + ref: secretRef, + configPath: secretRef.configPath, + }; + const credentialRef = configuredName + ? connection.credentialRefs.find((ref) => ref.name === configuredName) + : connection.credentialRefs[0]; + if (credentialRef) + return { + kind: "credential_ref" as const, + ref: credentialRef, + configPath: `credentials.${credentialRef.name}`, + }; + return null; + } + + async function resolveBrokerParentCredential( + input: { + connection: typeof toolConnections.$inferSelect; + agentId: string; + runId: string; + issueId: string | null; + }, + secretClient: ReturnType = secrets, + ) { + const ref = findBrokerCredentialRef(input.connection); + if (!ref) { + throw unprocessable( + "Connection token exchange requires a vault-backed parent credential", + { + code: "parent_credential_missing", + }, + ); + } + if (ref.kind === "secret_ref") { + return secretClient.resolveSecretValue( + input.connection.companyId, + ref.ref.secretId, + ref.ref.versionSelector ?? "latest", + { + accessContext: accessContextForBroker({ + ...input, + configPath: ref.configPath, + }), + bindingContext: accessContextForBroker({ + ...input, + configPath: ref.configPath, + }), + }, + ); + } + return secretClient.resolveSecretValue( + input.connection.companyId, + ref.ref.secretId, + ref.ref.version ?? "latest", + { + accessContext: accessContextForBroker({ + ...input, + configPath: ref.configPath, + }), + bindingContext: accessContextForBroker({ + ...input, + configPath: ref.configPath, + }), + }, + ); + } + + async function mintExchangeConnectionToken( + input: { + connection: typeof toolConnections.$inferSelect; + application: typeof toolApplications.$inferSelect | null; + agentId: string; + runId: string; + issueId: string | null; + responsibleUserId: string | null; + scope: string[]; + ttlSeconds: number; + }, + secretClient: ReturnType = secrets, + ) { + const isPages = isPagesTokenConnection(input.connection, input.application); + const parentToken = await resolveBrokerParentCredential( + input, + secretClient, + ); + const broker = tokenBrokerConfig(input.connection); + const protocol = + readConfigString(broker, "protocol") ?? + readConfigString(broker, "exchangeProtocol") ?? + (isPages ? "pages" : "generic"); + const url = exchangeTokenUrl(input.connection, isPages); + const actor = { + type: "agent", + id: input.agentId, + runId: input.runId, + ...(input.responsibleUserId + ? { onBehalfOf: `user:${input.responsibleUserId}` } + : {}), + }; + let response: Response; + if (protocol === "rfc8693") { + const body = new URLSearchParams(); + body.set("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange"); + body.set("subject_token", parentToken); + body.set( + "subject_token_type", + readConfigString(broker, "subjectTokenType") ?? + "urn:ietf:params:oauth:token-type:access_token", + ); + body.set("scope", input.scope.join(" ")); + const audience = readConfigString(broker, "audience"); + if (audience) body.set("audience", audience); + body.set( + "requested_token_type", + readConfigString(broker, "requestedTokenType") ?? + "urn:ietf:params:oauth:token-type:access_token", + ); + body.set( + "actor_token", + Buffer.from(JSON.stringify(actor)).toString("base64url"), + ); + body.set( + "actor_token_type", + readConfigString(broker, "actorTokenType") ?? + "urn:ietf:params:oauth:token-type:jwt", + ); + const endpoint = parseRemoteHttpEndpoint(url, (message, code) => + badRequest(message, { code }), + ); + response = await guardedRemoteHttpFetch( + endpoint, + { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body, + }, + tokenBrokerHttpFetchOptions(endpoint), + ); + } else { + const namespace = isPages ? pagesNamespaceFromScope(input.scope) : null; + const body = + isPages && namespace + ? { + namespace, + ttlSeconds: input.ttlSeconds, + actions: ["publish"], + actor, + } + : { + scope: input.scope, + ttlSeconds: input.ttlSeconds, + actor, + audience: readConfigString(broker, "audience"), + }; + const endpoint = parseRemoteHttpEndpoint(url, (message, code) => + badRequest(message, { code }), + ); + response = await guardedRemoteHttpFetch( + endpoint, + { + method: "POST", + headers: { + authorization: `Bearer ${parentToken}`, + "content-type": "application/json", + }, + body: JSON.stringify(body), + }, + tokenBrokerHttpFetchOptions(endpoint), + ); + } + const payload = (await response.json().catch(() => ({}))) as unknown; + const record = asRecord(payload); + if (!response.ok) { + const code = + typeof record.code === "string" + ? record.code + : typeof record.error === "string" + ? record.error + : "upstream_error"; + throw new HttpError( + response.status === 401 || response.status === 403 ? 409 : 502, + "Connection token exchange failed", + { + code: + code === "parent_revoked" ? "credential_revoked" : "upstream_error", + upstreamCode: code, + upstreamStatus: response.status, + upstreamRequestId: + typeof record.requestId === "string" ? record.requestId : null, + }, + ); + } + const token = + typeof record.token === "string" + ? record.token + : typeof record.access_token === "string" + ? record.access_token + : null; + if (!token) + throw new HttpError( + 502, + "Connection token exchange did not return a token", + { code: "upstream_token_missing" }, + ); + const expiresIn = + typeof record.expires_in === "number" + ? record.expires_in + : Number(record.expires_in); + const expiresAt = + typeof record.expiresAt === "string" && + Number.isFinite(Date.parse(record.expiresAt)) + ? new Date(record.expiresAt) + : typeof record.expires_at === "string" && + Number.isFinite(Date.parse(record.expires_at)) + ? new Date(record.expires_at) + : new Date( + now().getTime() + + Math.min( + input.ttlSeconds, + Number.isFinite(expiresIn) && expiresIn > 0 + ? expiresIn + : input.ttlSeconds, + ) * + 1000, + ); + const responseScope = + readConfigStringArray(record.scope).length > 0 + ? readConfigStringArray(record.scope) + : input.scope; + return { + token, + tokenType: + typeof record.token_type === "string" ? record.token_type : "Bearer", + expiresAt, + scope: responseScope, + }; + } + return mintExchangeConnectionToken; +} diff --git a/server/src/services/tool-access/token-broker/issuance.ts b/server/src/services/tool-access/token-broker/issuance.ts new file mode 100644 index 00000000000..988b583a287 --- /dev/null +++ b/server/src/services/tool-access/token-broker/issuance.ts @@ -0,0 +1,95 @@ +import type { ToolAccessMutationDb } from "../types.js"; +import type { Db } from "@paperclipai/db"; +import { connectionTokenIssuances } from "@paperclipai/db"; +import type { ConnectionTokenIssuanceOutcome, ConnectionTokenIssuancePath } from "@paperclipai/shared"; +import { logActivity } from "../../activity-log.js"; + +import type { ToolAccessAudit } from "../audit.js"; +export function createTokenIssuanceRecorder(db: Db, audit: ToolAccessAudit) { + async function recordConnectionTokenIssuance( + input: { + companyId: string; + applicationId: string | null; + connectionId: string; + agentId: string; + runId: string | null; + issueId: string | null; + projectId: string | null; + responsibleUserId: string | null; + path: ConnectionTokenIssuancePath; + requestedScope: string[]; + issuedScope: string[]; + ttlSeconds: number | null; + expiresAt: Date | null; + tokenHash: string | null; + outcome: ConnectionTokenIssuanceOutcome; + errorCode?: string | null; + metadata?: Record; + }, + dbClient: ToolAccessMutationDb = db, + ) { + await dbClient.insert(connectionTokenIssuances).values({ + companyId: input.companyId, + applicationId: input.applicationId, + connectionId: input.connectionId, + agentId: input.agentId, + runId: input.runId, + issueId: input.issueId, + projectId: input.projectId, + responsibleUserId: input.responsibleUserId, + path: input.path, + requestedScope: input.requestedScope, + issuedScope: input.issuedScope, + ttlSeconds: input.ttlSeconds, + expiresAt: input.expiresAt, + tokenHash: input.tokenHash, + outcome: input.outcome, + errorCode: input.errorCode ?? null, + metadata: input.metadata ?? {}, + }); + } + + async function auditConnectionTokenIssuance(input: { + companyId: string; + connectionId: string; + agentId: string; + runId: string; + path: ConnectionTokenIssuancePath; + outcome: ConnectionTokenIssuanceOutcome; + reasonCode?: string | null; + details?: Record; + }) { + const success = input.outcome === "success"; + await audit({ + companyId: input.companyId, + connectionId: input.connectionId, + action: success ? "connection_token.minted" : "connection_token.denied", + outcome: success ? "success" : "failure", + reasonCode: input.reasonCode ?? null, + actor: { actorType: "agent", actorId: input.agentId }, + details: { + path: input.path, + outcome: input.outcome, + runId: input.runId, + ...(input.details ?? {}), + }, + }); + await logActivity(db, { + companyId: input.companyId, + actorType: "agent", + actorId: input.agentId, + agentId: input.agentId, + runId: input.runId, + action: success ? "connection_token.minted" : "connection_token.denied", + entityType: "tool_connection", + entityId: input.connectionId, + details: { + path: input.path, + outcome: input.outcome, + reasonCode: input.reasonCode ?? null, + ...(input.details ?? {}), + }, + }); + } + return { recordConnectionTokenIssuance, auditConnectionTokenIssuance }; +} diff --git a/server/src/services/tool-access/token-broker/service.ts b/server/src/services/tool-access/token-broker/service.ts new file mode 100644 index 00000000000..f6757e383bc --- /dev/null +++ b/server/src/services/tool-access/token-broker/service.ts @@ -0,0 +1,755 @@ +import { isDaytonaConnection } from "../../daytona.js"; +import { isBrowserUseConnection } from "../../browser-use-client.js"; +import { and, eq, sql } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { connectionGrantMembers, connectionGrantDelegations, connectionGrants, companyMemberships, toolApplications, toolConnectionInstalls, toolConnections } from "@paperclipai/db"; +import type { ConnectionTokenIssuanceOutcome, ConnectionTokenRequest, ConnectionTokenResponse } from "@paperclipai/shared"; +import { forbidden, HttpError, unprocessable } from "../../../errors.js"; +import { secretService } from "../../secrets.js"; +import { toolAccessPolicyService } from "../../tool-access-policy.js"; +import type { ToolAccessAudit } from "../audit.js"; +import { createTokenBrokerAdmission, lockAuthorizedBrokerResponsibleMembership } from "./admission.js"; +import { createTokenIssuanceRecorder } from "./issuance.js"; +import { createConnectionTokenExchange } from "./exchange.js"; +import { CONNECTION_TOKEN_MINT_TOOL_NAME, assertScopeSubset, bearerTokenHash, connectionTokenBrokerEnabled, defaultScopesForConnection, inferConnectionTokenPath, normalizeConnectionTokenScopes, parentScopesForConnection, requestedTtlSeconds } from "./configuration.js"; +type TokenBrokerDependencies = { + now: () => Date; + secrets: ReturnType; + policySvc: Pick, "decide" | "writeAudit">; + audit: ToolAccessAudit; + getConnectionRow(idOrUid: string, companyId?: string): Promise; + ensureDefaultOrganizationGrant(connection: typeof toolConnections.$inferSelect): Promise; +}; + +function asRecord(value: unknown): Record { + if (value && typeof value === "object" && !Array.isArray(value)) + return value as Record; + return {}; +} + +export function createConnectionTokenBroker(db: Db, { now, secrets, policySvc, audit, getConnectionRow, ensureDefaultOrganizationGrant }: TokenBrokerDependencies) { + const { loadBrokerRunContext, enforceDefaultConnectionTokenRateLimit, hasExplicitConnectionTokenMintProfileGrant } = createTokenBrokerAdmission(db, now); + const { recordConnectionTokenIssuance, auditConnectionTokenIssuance } = createTokenIssuanceRecorder(db, audit); + const mintExchangeConnectionToken = createConnectionTokenExchange(now, secrets); + async function getConnectionApplication( + connection: typeof toolConnections.$inferSelect, + ) { + const [application] = await db + .select() + .from(toolApplications) + .where(eq(toolApplications.id, connection.applicationId)); + return application ?? null; + } + return { + loadBrokerRunContext, + mintConnectionTokenForAgent: async (input: { + connectionId: string; + companyId: string; + agentId: string; + runId: string; + body: ConnectionTokenRequest; + }): Promise => { + const runContext = await loadBrokerRunContext({ + companyId: input.companyId, + agentId: input.agentId, + runId: input.runId, + }); + const connection = await getConnectionRow( + input.connectionId, + input.companyId, + ); + if (isDaytonaConnection(connection)) throw forbidden("Daytona credentials stay in the governed tool gateway and cannot be exported."); + if (isBrowserUseConnection(connection)) throw forbidden("Browser Use credentials stay in the governed tool gateway and cannot be exported."); + if (connection.connectionPurpose === "ai") throw unprocessable("AI credentials are available only through the runtime resolver"); + const application = await getConnectionApplication(connection); + const brokerEnabled = connectionTokenBrokerEnabled(connection); + const path = brokerEnabled + ? inferConnectionTokenPath(connection, application) + : "static"; + const requestedScope = normalizeConnectionTokenScopes(input.body.scope); + const parentScopes = parentScopesForConnection(connection); + const fallbackScopes = defaultScopesForConnection(connection); + const issuedScope = + requestedScope.length > 0 + ? requestedScope + : fallbackScopes.length > 0 + ? fallbackScopes + : parentScopes; + const ttlSeconds = requestedTtlSeconds(input.body, connection); + const attribution = { + agentId: input.agentId, + runId: input.runId, + issueId: runContext.issueId, + projectId: runContext.projectId, + responsibleUserId: runContext.responsibleUserId, + }; + + const recordFailure = async ( + outcome: ConnectionTokenIssuanceOutcome, + errorCode: string, + details: Record = {}, + ) => { + await recordConnectionTokenIssuance({ + companyId: connection.companyId, + applicationId: connection.applicationId, + connectionId: connection.id, + agentId: input.agentId, + runId: input.runId, + issueId: runContext.issueId, + projectId: runContext.projectId, + responsibleUserId: runContext.responsibleUserId, + path, + requestedScope, + issuedScope, + ttlSeconds: outcome === "use_env_lease" ? null : ttlSeconds, + expiresAt: null, + tokenHash: null, + outcome, + errorCode, + metadata: details, + }); + await auditConnectionTokenIssuance({ + companyId: connection.companyId, + connectionId: connection.id, + agentId: input.agentId, + runId: input.runId, + path, + outcome, + reasonCode: errorCode, + details, + }); + }; + + const fail = async ( + status: number, + message: string, + outcome: ConnectionTokenIssuanceOutcome, + errorCode: string, + details: Record = {}, + ): Promise => { + await recordFailure(outcome, errorCode, details); + throw new HttpError(status, message, { + code: errorCode, + path, + ...details, + }); + }; + + const [install] = await db + .select({ id: toolConnectionInstalls.id }) + .from(toolConnectionInstalls) + .where( + and( + eq(toolConnectionInstalls.companyId, connection.companyId), + eq(toolConnectionInstalls.connectionId, connection.id), + sql`((${toolConnectionInstalls.targetType} = 'company' and ${toolConnectionInstalls.targetId} = ${connection.companyId}) or (${toolConnectionInstalls.targetType} = 'agent' and ${toolConnectionInstalls.targetId} = ${input.agentId}))`, + ), + ) + .limit(1); + if (!install) { + await fail( + 403, + `Connection ${connection.name} must be installed for this agent before it can mint a token`, + "denied", + "installation_required", + { + connection: { + id: connection.id, + uid: connection.uid, + name: connection.name, + }, + agentId: input.agentId, + remediation: { + action: "install_connection", + targetType: "agent", + targetId: input.agentId, + }, + }, + ); + } + + if ( + runContext.run.activeIdentityContextId && + (connection.config.sourceTemplateKey === "github" || + connection.transportConfig?.sourceTemplateKey === "github") + ) { + await fail( + 409, + "Use managed git, gh, or GitHub tools for this run", + "denied", + "managed_github_invocation_required", + ); + } + const requestedSubject = input.body.subject; + if ( + requestedSubject?.type === "user" && + requestedSubject.userId !== runContext.responsibleUserId + ) { + await fail( + 403, + "The agent run cannot act as the requested user", + "denied", + "subject_not_permitted", + { + connection: { uid: connection.uid }, + subject: requestedSubject, + }, + ); + } + + const actingUserId = runContext.responsibleUserId; + const autonomous = + runContext.run.invocationSource === "automation" || + runContext.run.invocationSource === "timer"; + let subject: { type: "app" } | { type: "user"; userId: string } = + connection.credentialPolicy === "shared" || + connection.credentialPolicy === "per_agent" || + !actingUserId + ? { type: "app" as const } + : { type: "user" as const, userId: actingUserId }; + let grant: typeof connectionGrants.$inferSelect | undefined; + if (connection.credentialPolicy === "per_agent") { + [grant] = await db + .select() + .from(connectionGrants) + .where( + and( + eq(connectionGrants.companyId, connection.companyId), + eq(connectionGrants.connectionId, connection.id), + eq(connectionGrants.kind, "agent"), + eq(connectionGrants.subjectAgentId, input.agentId), + ), + ) + .limit(1); + if (!grant) { + await fail( + 409, + "This agent's dedicated authorization is required", + "denied", + "agent_authorization_required", + { + connection: { uid: connection.uid }, + agentId: input.agentId, + remediation: { + action: "start_agent_authorization", + agentId: input.agentId, + }, + }, + ); + } + } else if (connection.credentialPolicy !== "shared" && actingUserId) { + // Installation targets express the grant owner's consent to agent use; + // the control-plane-resolved responsible user selects whose grant is in + // force. A separate standing delegation is required only when no such + // responsible user exists for unattended work. + const [membership] = await db + .select({ id: companyMemberships.id }) + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, connection.companyId), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.principalId, actingUserId), + eq(companyMemberships.status, "active"), + ), + ) + .limit(1); + if (!membership) { + await fail( + 403, + "The personal grant owner is not an active company member", + "denied", + "grant_owner_membership_inactive", + { + connection: { + id: connection.id, + uid: connection.uid, + name: connection.name, + }, + subject: { type: "user", userId: actingUserId }, + remediation: { action: "restore_membership_or_reconnect" }, + }, + ); + } + [grant] = await db + .select() + .from(connectionGrants) + .where( + and( + eq(connectionGrants.companyId, connection.companyId), + eq(connectionGrants.connectionId, connection.id), + eq(connectionGrants.kind, "user"), + eq(connectionGrants.subjectUserId, actingUserId), + ), + ) + .limit(1); + } else if (connection.credentialPolicy === "per_user" && autonomous) { + const delegated = await db + .select({ grant: connectionGrants }) + .from(connectionGrantDelegations) + .innerJoin( + connectionGrants, + and( + eq(connectionGrants.id, connectionGrantDelegations.grantId), + eq( + connectionGrants.companyId, + connectionGrantDelegations.companyId, + ), + ), + ) + .where( + and( + eq(connectionGrantDelegations.companyId, connection.companyId), + eq(connectionGrantDelegations.agentId, input.agentId), + eq(connectionGrants.connectionId, connection.id), + eq(connectionGrants.kind, "user"), + ), + ) + .limit(2); + if (delegated.length > 1) { + await fail( + 409, + "More than one delegated personal authorization matches this run", + "denied", + "subject_not_permitted", + { + connection: { uid: connection.uid }, + agentId: input.agentId, + }, + ); + } + grant = delegated[0]?.grant; + if (grant?.subjectUserId) + subject = { type: "user", userId: grant.subjectUserId }; + } + if (!grant && connection.credentialPolicy === "per_user") { + await fail( + 409, + "User authorization is required", + "denied", + "user_authorization_required", + { + connection: { uid: connection.uid }, + subject: actingUserId + ? { type: "user", userId: actingUserId } + : { type: "app" }, + remediation: { action: "start_authorization" }, + }, + ); + } + if (!grant) { + grant = await ensureDefaultOrganizationGrant(connection); + } + + if ( + grant.kind === "user" && + grant.subjectUserId && + grant.subjectUserId !== actingUserId + ) { + const [membership] = await db + .select({ id: companyMemberships.id }) + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, connection.companyId), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.principalId, grant.subjectUserId), + eq(companyMemberships.status, "active"), + ), + ) + .limit(1); + if (!membership) { + await fail( + 403, + "The delegated personal grant owner is not an active company member", + "denied", + "grant_owner_membership_inactive", + { + connection: { + id: connection.id, + uid: connection.uid, + name: connection.name, + }, + subject, + remediation: { action: "restore_membership_or_reconnect" }, + }, + ); + } + } + + if (grant.kind === "organization") { + const activeAudienceMember = actingUserId + ? await db + .select({ id: companyMemberships.id }) + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, connection.companyId), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.principalId, actingUserId), + eq(companyMemberships.status, "active"), + ), + ) + .limit(1) + .then((rows) => rows[0] ?? null) + : null; + const audience = await db + .select({ subjectId: connectionGrantMembers.subjectId }) + .from(connectionGrantMembers) + .where( + and( + eq(connectionGrantMembers.companyId, connection.companyId), + eq(connectionGrantMembers.grantId, grant.id), + eq(connectionGrantMembers.subjectType, "user"), + ), + ); + if ( + (actingUserId !== null && !activeAudienceMember) || + (audience.length > 0 && + (!actingUserId || + !audience.some((member) => member.subjectId === actingUserId))) + ) { + await fail( + 403, + "The acting user is not in this grant's audience", + "denied", + "grant_audience_denied", + { + connection: { uid: connection.uid }, + subject, + grantId: grant.id, + }, + ); + } + } + + if (grant.status !== "active") { + const code = + grant.status === "needs_reauthorization" + ? "needs_reauthorization" + : "grant_revoked"; + await fail( + 409, + "The selected connection grant is not active", + "denied", + code, + { + connection: { uid: connection.uid }, + subject, + grantId: grant.id, + remediation: { action: "reauthorize" }, + }, + ); + } + const requestedScopeSelectors = new Set(requestedScope); + const matchingScopedRefs = grant.credentialSecretRefs.filter( + (ref) => ref.keyScope && requestedScopeSelectors.has(ref.keyScope), + ); + const selectedCredentialSecretRefs = + matchingScopedRefs.length > 0 + ? grant.credentialSecretRefs.filter( + (ref) => + !ref.keyScope || requestedScopeSelectors.has(ref.keyScope), + ) + : grant.credentialSecretRefs.filter((ref) => !ref.keyScope); + const rotateBefore = Date.now() + 14 * 24 * 60 * 60 * 1000; + const expiringRef = selectedCredentialSecretRefs.find( + (ref) => ref.expiresAt && Date.parse(ref.expiresAt) <= rotateBefore, + ); + if (expiringRef && connection.healthStatus !== "degraded") { + await db + .update(toolConnections) + .set({ + healthStatus: "degraded", + healthMessage: `Rotate ${expiringRef.label ?? expiringRef.configPath} before it expires.`, + updatedAt: new Date(), + }) + .where(eq(toolConnections.id, connection.id)); + } + const credentialConnection = { + ...connection, + credentialSecretRefs: selectedCredentialSecretRefs, + }; + + if (!connection.enabled || connection.status !== "active") { + await fail( + 409, + "Connection is not active", + "denied", + "connection_not_active", + { + connectionStatus: connection.status, + enabled: connection.enabled, + }, + ); + } + if ( + ["failed", "error", "missing_secret"].includes(connection.healthStatus) + ) { + await fail( + 409, + "Connection credential needs attention", + "denied", + "credential_revoked", + { + healthStatus: connection.healthStatus, + healthMessage: connection.healthMessage ?? null, + }, + ); + } + if (!brokerEnabled) { + await fail( + 403, + "Connection token broker is not enabled for this connection", + "denied", + "broker_not_enabled", + { + reason: + "Connections must explicitly opt in with tokenBroker.enabled before agents can request brokered tokens.", + }, + ); + } + try { + assertScopeSubset({ requestedScope: issuedScope, parentScopes }); + } catch { + await fail( + 403, + "Requested token scope exceeds the connection parent scope", + "denied", + "scope_exceeds_parent", + { + parentScopeCount: parentScopes.length, + }, + ); + } + + const hasBrokerGrant = await hasExplicitConnectionTokenMintProfileGrant({ + companyId: connection.companyId, + agentId: input.agentId, + issueId: runContext.issueId, + projectId: runContext.projectId, + routineId: runContext.routineId, + }); + if (!hasBrokerGrant) { + await fail( + 403, + "Connection token minting requires an explicit broker profile grant", + "denied", + "broker_mint_not_granted", + { + reason: + "A connection-level profile grant is not sufficient for connection_token.mint.", + }, + ); + } + + const decisionInput = { + companyId: connection.companyId, + actor: { + actorType: "agent" as const, + actorId: input.agentId, + agentId: input.agentId, + }, + runContext: { + heartbeatRunId: input.runId, + issueId: runContext.issueId, + projectId: runContext.projectId, + routineId: runContext.routineId, + }, + request: { + applicationId: connection.applicationId, + connectionId: connection.id, + providerType: "connection_token_broker", + applicationKey: application?.applicationKey ?? null, + upstreamToolName: CONNECTION_TOKEN_MINT_TOOL_NAME, + riskLevel: "write", + toolName: CONNECTION_TOKEN_MINT_TOOL_NAME, + arguments: { + path, + scope: issuedScope, + requestedTtlSeconds: input.body.requestedTtlSeconds ?? null, + }, + }, + consumeRateLimit: true, + }; + const decision = await policySvc.decide(decisionInput); + await policySvc.writeAudit(decisionInput, decision); + if (!decision.allowed) { + await fail( + decision.decision === "rate_limited" ? 429 : 403, + decision.explanation, + decision.decision === "rate_limited" ? "rate_limited" : "denied", + decision.reasonCode, + { + decision: decision.decision, + effectiveProfileIds: decision.effectiveProfileIds, + matchedPolicyIds: decision.matchedPolicyIds, + rateLimitState: decision.rateLimitState ?? null, + }, + ); + } + + try { + await enforceDefaultConnectionTokenRateLimit({ + connection, + agentId: input.agentId, + path, + }); + } catch (error) { + if (error instanceof HttpError && error.status === 429) { + await fail( + 429, + error.message, + "rate_limited", + "rate_limited", + asRecord(error.details), + ); + } + throw error; + } + + if (path === "static") { + await recordFailure("use_env_lease", "use_env_lease", { + reason: + "Connection uses durable static credentials; broker token delivery is refused.", + }); + return { + status: "use_env_lease", + code: "use_env_lease", + connectionId: connection.id, + connection: { id: connection.id, uid: connection.uid }, + grantId: grant.id, + path: "static", + message: + "This connection uses static credentials. Use an audited environment lease projection instead.", + scope: issuedScope, + attribution, + }; + } + if (path === "oauth_access") { + await fail( + 422, + "OAuth access-token projection is disabled; configure a short-lived exchange mint path instead", + "denied", + "oauth_access_projection_disabled", + { + reason: + "The broker must not return stored upstream OAuth bearer tokens directly.", + }, + ); + } + + const selectedGrant = grant; + try { + const mintResult = await db.transaction(async (tx) => { + await lockAuthorizedBrokerResponsibleMembership( + { + companyId: connection.companyId, + responsibleUserId: runContext.responsibleUserId, + }, + tx, + ); + const minted = await mintExchangeConnectionToken( + { + connection: credentialConnection, + application, + agentId: input.agentId, + runId: input.runId, + issueId: runContext.issueId, + responsibleUserId: runContext.responsibleUserId, + scope: issuedScope, + ttlSeconds, + }, + secretService(tx), + ); + const expiresAt = minted.expiresAt; + const mintedScope = "scope" in minted ? minted.scope : issuedScope; + const effectiveTtlSeconds = Math.max( + 1, + Math.min( + 900, + Math.ceil((expiresAt.getTime() - now().getTime()) / 1000), + ), + ); + const tokenHash = bearerTokenHash(minted.token); + await recordConnectionTokenIssuance( + { + companyId: connection.companyId, + applicationId: connection.applicationId, + connectionId: connection.id, + agentId: input.agentId, + runId: input.runId, + issueId: runContext.issueId, + projectId: runContext.projectId, + responsibleUserId: runContext.responsibleUserId, + path, + requestedScope, + issuedScope: mintedScope, + ttlSeconds: effectiveTtlSeconds, + expiresAt, + tokenHash, + outcome: "success", + metadata: { tokenRef: tokenHash, tokenType: minted.tokenType }, + }, + tx, + ); + await tx + .update(connectionGrants) + .set({ lastUsedAt: new Date(), updatedAt: new Date() }) + .where(eq(connectionGrants.id, selectedGrant.id)); + return { + minted, + expiresAt, + mintedScope, + effectiveTtlSeconds, + tokenHash, + }; + }); + await auditConnectionTokenIssuance({ + companyId: connection.companyId, + connectionId: connection.id, + agentId: input.agentId, + runId: input.runId, + path, + outcome: "success", + details: { + ttlSeconds: mintResult.effectiveTtlSeconds, + scopeCount: mintResult.mintedScope.length, + tokenRef: mintResult.tokenHash, + }, + }); + return { + status: "minted", + connectionId: connection.id, + connection: { id: connection.id, uid: connection.uid }, + grantId: selectedGrant.id, + providerTenantId: selectedGrant.providerTenant?.externalId, + path: "exchange", + token: mintResult.minted.token, + tokenType: mintResult.minted.tokenType, + expiresAt: mintResult.expiresAt.toISOString(), + ttlSeconds: mintResult.effectiveTtlSeconds, + scope: mintResult.mintedScope, + attribution, + }; + } catch (error) { + const details = + error instanceof HttpError && asRecord(error.details).code + ? asRecord(error.details) + : {}; + const errorCode = + typeof details.code === "string" ? details.code : "mint_failed"; + const outcome: ConnectionTokenIssuanceOutcome = + errorCode === "upstream_error" || + errorCode === "upstream_token_missing" + ? "upstream_error" + : "failure"; + await recordFailure(outcome, errorCode, { + ...details, + message: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + }; +}