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; + } + } + }; +}