diff --git a/doc/plans/2026-10-21-tool-access-boundaries.md b/doc/plans/2026-10-21-tool-access-boundaries.md index d1090a78b19..83c66555014 100644 --- a/doc/plans/2026-10-21-tool-access-boundaries.md +++ b/doc/plans/2026-10-21-tool-access-boundaries.md @@ -208,3 +208,17 @@ boundaries pass. No new supervisor, background loop or runtime cache is created. - Separate guarded remote HTTP, descriptor risk policy, discovery, publication, default access, cached reads, health, attention and aggregator account synchronization. - `tool-access.ts`: 8,997 → 6,418 lines. New owners range from 39 to 492 lines. Remote options remain live; catalog flights and aggregator sync jobs retain per-service lifetime. Publication locks and transaction-scoped default access remain unchanged. - Before/after: 470 public service/HTTP integration cases plus 44 catalog-publication, HTTP rebinding and aggregator-sync cases pass. Server typecheck and module-boundary checks pass. All 157 previous function/method bodies match by normalized syntax comparison. + +### Connection access and removal; final target + +- Extract credential-reference validation, grant audiences/delegations, installations and removal. Each owner keeps its locks, audit writes and cleanup order. Removal uses the existing supervisor and secret client, retains the Slack mutation lease, and keeps access revocation with resumable secret cleanup. +- `tool-access.ts`: 6,418 → **4,820 lines / 198,002 bytes**. The full stack started at 21,525 lines / 829,519 bytes. The four new modules have 75, 603, 350 and 658 lines. +- Removal, grant resolution and the strengthened real HTTP OAuth journey passed before extraction (13 cases). The final combined public-path run passes 525 cases across seven suites. Full repository typecheck and build pass. Module-boundary and whitespace checks pass; all 94 previous function/method bodies match by normalized syntax comparison. +- The earlier local full-suite attempt remains interrupted and unverified. PR descriptions retain that limitation and original CI failures alongside current-head CI outcomes. +- The requested size target is met. App setup, finish/reconnect orchestration, local template definitions and examples remain in the coordinator; further extraction is outside this batch. + +### Merge integration + +- The first eight PRs landed on master. The catalog extraction was replayed after the Braintrust connector policy change in #17141. Risk classification and publication retain that policy in their extracted owners; setup retains its policy call. +- The final entry point is 4,821 lines / 198,065 bytes. All 157 catalog and 94 connection-lifecycle method bodies match their updated bases by normalized syntax comparison. +- The integrated stack passes 601 targeted cases across nine suites, including Braintrust lifecycle and connector-policy coverage. Server typecheck, module-boundary and whitespace checks pass. Fresh CI and reviews are required for both updated PR heads before merge. diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index 5f3f5e4f51d..7bee173fc66 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -1,8 +1,12 @@ +import { createConnectionCredentialValidation } from "./tool-access/connections/credential-validation.js"; +import { createConnectionGrants } from "./tool-access/connections/grants.js"; +import { createConnectionInstalls } from "./tool-access/connections/installs.js"; +import { createConnectionRemoval } from "./tool-access/connections/removal.js"; import { createToolRemoteHttp } from "./tool-access/remote-http.js"; import { classifyRisk, normalizeToolDescriptor, type McpToolDescriptor } from "./tool-access/catalog/descriptors.js"; export { classifyRisk, isGmailToolPermanentlyBlocked, isGoogleWorkspaceToolAllowed, type McpToolDescriptor } from "./tool-access/catalog/descriptors.js"; import { toCatalogEntry } from "./tool-access/catalog/projection.js"; -import { toConnection, toConnectionGrant, toConnectionInstall } from "./tool-access/connections/projection.js"; +import { toConnection, toConnectionInstall } from "./tool-access/connections/projection.js"; import { isOAuthEndpointRejection } from "./tool-access/catalog/failures.js"; import { createCatalogDefaultAccess, toPolicy } from "./tool-access/catalog/default-access.js"; import { createToolCatalogDiscovery } from "./tool-access/catalog/discovery.js"; @@ -24,7 +28,7 @@ import { createDirectOAuthCallback } from "./tool-access/oauth/direct-callback.j import { createCloudOAuthCallback } from "./tool-access/oauth/cloud-callback.js"; import { createVercelOAuthCallback } from "./tool-access/oauth/vercel-callback.js"; import { createOAuthAccessFinalization } from "./tool-access/oauth/access-finalization.js"; -import { createVercelCredentialService, vercelConnectHttpError, vercelConnectResourcesFor } from "./tool-access/vercel-credentials.js"; +import { createVercelCredentialService, vercelConnectHttpError } from "./tool-access/vercel-credentials.js"; import { createConnectionCredentialAccess } from "./tool-access/credential-access.js"; import { createOAuthCredentialStore, actorForSecret } from "./tool-access/oauth/credential-store.js"; @@ -44,7 +48,7 @@ import { createConnectionTokenBroker } from "./tool-access/token-broker/service. import { createToolProfileService } from "./tool-access/profiles/service.js"; import { createToolProfileResolver } from "./tool-access/profiles/resolution.js"; import { toProfile, toProfileEntry, toProfileBinding } from "./tool-access/profiles/projection.js"; -import { lockManagedAppPolicies, lockToolAssignmentAgents, lockToolConnectionRuntimeInputs } from "./tool-access/assignment-locks.js"; +import { lockManagedAppPolicies, lockToolAssignmentAgents } from "./tool-access/assignment-locks.js"; export { lockToolConnectionRuntimeInputs } from "./tool-access/assignment-locks.js"; import type { ActorInfo } from "./tool-access/types.js"; import { normalizeKey, connectionUid } from "./tool-access/identifiers.js"; @@ -52,8 +56,6 @@ import { createOAuthEndpointPolicy } from "./tool-access/oauth/endpoint-policy.j import { createOAuthDiscovery } from "./tool-access/oauth/discovery.js"; import { createOAuthClientProtocol } from "./tool-access/oauth/client.js"; -import { oauthConfig } from "./tool-access/oauth/metadata.js"; - import type { OAuthClientRegistrationSource } from "./tool-access/oauth/types.js"; export { OAUTH_CLIENT_ID_METADATA_DOCUMENT_PATH, resolveOAuthClientIdMetadataDocumentUrl, oauthClientIdMetadataDocument } from "./tool-access/oauth/client-metadata.js"; import { connectionMethodFor, connectionMethodForConnection, credentialFieldsFor, normalizeConnectionMethodConfig, projectedConnectionToolInputSchema } from "./tool-access/connection-methods.js"; @@ -72,15 +74,13 @@ import { stableHash } from "./tool-catalog-hash.js"; import { isDaytonaConnection } from "./daytona.js"; import { connectorToolPolicy } from "./connectors/tool-policy.js"; -import { chatCredentialMutationLease } from "./chat-credential-mutation-lease.js"; + import { notifyChatEndpointWork } from "./chat-work-notifications.js"; -import { removeSlackRegistration } from "./chat-slack-registration-cleanup.js"; -import { chatEndpoints } from "@paperclipai/db"; import { defaultConnectionAgentInstructions } from "@paperclipai/shared"; import { isBrowserUseConnection } from "./browser-use-client.js"; -import { browserUseService } from "./browser-use.js"; + import { COGNEE_STDIO_TEMPLATE } from "./cognee-connection.js"; import { isRemoteMcpConnectorMethod } from "@paperclipai/shared"; @@ -96,11 +96,11 @@ import { measureRunPhase } from "./execution-preparation-timing.js"; import { randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; -import { and, asc, desc, eq, inArray, isNull, max, ne, notInArray, sql } from "drizzle-orm"; +import { and, asc, desc, eq, inArray, max, ne, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; -import { agents, connectionGrantMembers, connectionGrantDelegations, connectionGrants, connectionTokenIssuances, authUsers, companyMemberships, companySecretBindings, companySecrets, principalPermissionGrants, userSecretDefinitions, userSecretDeclarations, plugins, toolAccessAuditEvents, toolApplications, toolCatalogEntries, toolConnectionInstalls, toolConnections, toolOauthStates, toolStdioCommandTemplates, toolCallEvents, toolPolicies, toolGatewaySessions, toolMcpGateways, toolMcpGatewayTokens, toolProfileBindings, toolProfileEntries, toolProfiles, toolRuntimeSlots } from "@paperclipai/db"; -import type { AppDefinition, ConnectionGrantKind, CreateToolApplication, CreateToolConnection, ConnectToolApp, ConnectToolAppResult, CreateToolStdioCommandTemplate, FinishToolApp, FinishToolAppResult, CreateToolProfileBindingForProfile, CreateToolProfileEntryForProfile, DeploymentExposure, DeploymentMode, ImportMcpJson, McpConnectionCredentialRef, McpJsonImportPreview, ToolApplication, ToolCatalogEntry, ToolCatalogRefreshResult, ToolConnection, ToolConnectionInstall, ToolConnectionInstallSnapshot, ToolConnectionRemovalResult, ToolConnectionRemovalSummary, ToolConnectionHealthCheckResult, ToolConnectionAuthKind, ToolConnectionCredentialPolicy, ToolConnectionCredentialSource, ToolConnectionTransport, ToolAppMetadataPreflightResult, ToolExampleInstallResult, ToolExampleSmokeCheck, ToolExampleSmokeResult, ToolExampleSummary, ToolProfileBinding, ToolProfileEntry, ToolPolicyDecision, ToolStdioCommandTemplate, UpdateToolApplication, UpdateToolConnection, PutToolConnectionInstalls, VercelConnectCredentialReference } from "@paperclipai/shared"; -import { CLASS3_STATIC_LEASE_ALLOWLIST, getAppDefinitionForUrl, getAvailableConnectionMethods, getConnectableAppDefinition, recommendedDefaultsForApp } from "@paperclipai/shared"; +import { agents, connectionGrantMembers, connectionGrants, companyMemberships, principalPermissionGrants, userSecretDefinitions, plugins, toolAccessAuditEvents, toolApplications, toolCatalogEntries, toolConnectionInstalls, toolConnections, toolStdioCommandTemplates, toolCallEvents, toolPolicies, toolProfileBindings, toolProfileEntries, toolProfiles } from "@paperclipai/db"; +import type { AppDefinition, ConnectionGrantKind, CreateToolApplication, CreateToolConnection, ConnectToolApp, ConnectToolAppResult, CreateToolStdioCommandTemplate, FinishToolApp, FinishToolAppResult, CreateToolProfileBindingForProfile, CreateToolProfileEntryForProfile, DeploymentExposure, DeploymentMode, ImportMcpJson, McpConnectionCredentialRef, McpJsonImportPreview, ToolApplication, ToolCatalogEntry, ToolCatalogRefreshResult, ToolConnection, ToolConnectionInstall, ToolConnectionHealthCheckResult, ToolConnectionAuthKind, ToolConnectionCredentialPolicy, ToolConnectionCredentialSource, ToolConnectionTransport, ToolAppMetadataPreflightResult, ToolExampleInstallResult, ToolExampleSmokeCheck, ToolExampleSmokeResult, ToolExampleSummary, ToolProfileBinding, ToolProfileEntry, ToolPolicyDecision, ToolStdioCommandTemplate, UpdateToolApplication, UpdateToolConnection, VercelConnectCredentialReference } from "@paperclipai/shared"; +import { getAppDefinitionForUrl, getAvailableConnectionMethods, getConnectableAppDefinition, recommendedDefaultsForApp } from "@paperclipai/shared"; import { checkMcpRemoteHeaderName, checkMcpRemoteHeaderValue, @@ -116,8 +116,6 @@ import { unprocessable, } from "../errors.js"; import { isUniqueViolation } from "../db-errors.js"; -import { logger } from "../middleware/logger.js"; -import { logActivity } from "./activity-log.js"; import { forgetMcpHttpSessions } from "./mcp-http.js"; import { type RemoteHttpEndpointLookup } from "./remote-http-endpoint-guard.js"; @@ -128,7 +126,7 @@ import { splitRemoteUrlCredential, } from "./remote-url-credentials.js"; import { secretService } from "./secrets.js"; -import { connectionCredentialConfigPath as credentialRefConfigPath, connectionSecretsUsedByOtherConsumers, validateConnectionGrantSecretOwnership, writeConnectionCredential } from "./connection-credentials.js"; +import { connectionCredentialConfigPath as credentialRefConfigPath, validateConnectionGrantSecretOwnership, writeConnectionCredential } from "./connection-credentials.js"; import type { ConfigureRailwaySsh, RailwaySshSetup } from "@paperclipai/shared"; import { generateRailwaySshKey, RAILWAY_SSH_SECRET_PATH, validateRailwayKnownHosts } from "./railway-ssh.js"; @@ -141,7 +139,7 @@ import { gmailArchiveToolDescription } from "./gmail-archive-tool-policy.js"; import { createToolRuntimeSupervisor } from "./tool-runtime-supervisor.js"; import { appWithPaperclipCloudConnectorAvailability, paperclipCloudConnectorCapabilitiesFromEnv, createPaperclipCloudConnector, isPaperclipCloudConnectorStrategy, paperclipCloudConnectorConfigFromEnv, type PaperclipCloudConnector } from "./paperclip-cloud-connector.js"; -import { createVercelConnectClient, deriveVercelConnectSubject, vercelConnectIntegrationStatus, vercelTokenRequest, type VercelConnectClient } from "./vercel-connect.js"; +import { createVercelConnectClient, deriveVercelConnectSubject, vercelConnectIntegrationStatus, type VercelConnectClient } from "./vercel-connect.js"; type ToolAccessServiceOptions = { deploymentMode?: DeploymentMode; @@ -573,87 +571,6 @@ function isToolConnectionForeignKeyViolation(error: unknown): boolean { }); } -/** - * The key namespace `connectGalleryApp`, `reconnectGalleryApp` and - * `createOrRotateOAuthSecret` mint for credentials a connection owns outright. - * Nothing else writes this prefix, which is what lets removal tell a dedicated - * app credential apart from a secret the operator manages by hand — see - * `classifyConnectionSecrets`. Matched as a prefix on purpose: `secrets.remove` - * suffixes `__deleted__` onto the key before it deletes the row, so a - * removal that is retried after a provider failure must still recognise it. - */ -const CONNECTION_OWNED_SECRET_KEY_PREFIX = "tool_app."; - -/** Token fields a legacy row may have inlined into `config.oauth`. */ -const INLINE_OAUTH_TOKEN_FIELDS = [ - "access_token", - "refresh_token", - "accessToken", - "refreshToken", - "client_secret", - "clientSecret", -]; - -/** - * Drop inline OAuth token material from a connection config, keeping the - * non-secret identity (client id, issuer, registration source) a later - * reconnect reuses. Current code stores tokens as secret refs, never in the - * config; this exists so a row written by an older build cannot keep a usable - * token after the operator removed the app. - */ -function withoutInlineOAuthTokens( - config: Record, -): Record { - const oauth = config.oauth; - if (!oauth || typeof oauth !== "object" || Array.isArray(oauth)) - return config; - const next = { ...(oauth as Record) }; - let changed = false; - for (const field of INLINE_OAUTH_TOKEN_FIELDS) { - if (field in next) { - delete next[field]; - changed = true; - } - } - return changed ? { ...config, oauth: next } : config; -} - -function assertClass3ToolCredentialRefAllowed(ref: { - configPath?: string | null; - projectionClass?: string | null; - projectionAllowlistKey?: string | null; -}) { - const projectionClass = ref.projectionClass ?? "unclassified"; - if (projectionClass !== "class_3_static_lease") return; - if (!ref.configPath?.trim() || !ref.projectionAllowlistKey?.trim()) { - throw unprocessable( - "Class-3 static lease tool credentials require an allowlist key and config path", - { - code: "class_3_static_lease_allowlist_required", - targetType: "tool_connection", - configPath: ref.configPath ?? null, - }, - ); - } - const allowed = CLASS3_STATIC_LEASE_ALLOWLIST.some( - (entry) => - entry.key === ref.projectionAllowlistKey && - entry.targetType === "tool_connection" && - entry.configPath === ref.configPath, - ); - if (!allowed) { - throw unprocessable( - "Class-3 static lease tool credential is outside the approved allowlist", - { - code: "class_3_static_lease_not_allowed", - allowlistKey: ref.projectionAllowlistKey, - targetType: "tool_connection", - configPath: ref.configPath, - }, - ); - } -} - function toCatalogEntryForConnection( row: typeof toolCatalogEntries.$inferSelect, connection: typeof toolConnections.$inferSelect, @@ -818,6 +735,9 @@ export function toolAccessService( }); const policySvc = toolAccessPolicyService(db); const now = options.now ?? (() => new Date()); + const assertSecretRefs = createConnectionCredentialValidation(db); + const connectionInstalls = createConnectionInstalls(db, { now, getConnectionRow, assertOptionalAgent }); + const { resolveInstalledConnectionsForAgent, listConnectionInstalls } = connectionInstalls; const audit = createToolAccessAudit(db); const ensureDefaultOrganizationGrant = createOrganizationGrantStore(db); const { mintConnectionTokenForAgent, loadBrokerRunContext } = createConnectionTokenBroker(db, { now, secrets, policySvc, audit, getConnectionRow, ensureDefaultOrganizationGrant }); @@ -853,6 +773,8 @@ export function toolAccessService( ? createVercelConnectClient() : options.vercelConnectClient; const runtimeSupervisor = createToolRuntimeSupervisor(db, options); + const connectionGrantsService = createConnectionGrants(db, { getConnectionRow, assertSecretRefs, vercelConnect }); + const removeConnection = createConnectionRemoval(db, { options, secrets, runtimeSupervisor, vercelConnect, getConnectionRow }); const credentialStore = createOAuthCredentialStore(db, secrets); const { syncCredentialBindings, annotateSavedOAuthClientSecret, oauthClientForConnection, createOrRotateOAuthSecret, getOAuthGrantRow, resolveOAuthGrantSecret } = credentialStore; const ensureOAuthClient = createOAuthClientRegistration(db, { now, options, store: credentialStore, getConnectionRow, requestOAuthClientRegistration }); @@ -994,37 +916,6 @@ export function toolAccessService( if (!row) throw unprocessable("Tool application plugin was not found"); } - async function assertSecretRefs( - companyId: string, - refs: Array<{ - secretId: string; - configPath?: string | null; - projectionClass?: string | null; - projectionAllowlistKey?: string | null; - }>, - ) { - if (refs.length === 0) return; - for (const ref of refs) { - assertClass3ToolCredentialRefAllowed(ref); - } - const secretIds = [...new Set(refs.map((ref) => ref.secretId))]; - for (const secretId of secretIds) { - const [secret] = await db - .select({ id: companySecrets.id }) - .from(companySecrets) - .where( - and( - eq(companySecrets.id, secretId), - eq(companySecrets.companyId, companyId), - ), - ); - if (!secret) - throw unprocessable( - "Tool connection credential secrets must belong to the same company", - ); - } - } - async function assertGoogleSheetsSpreadsheetOwnership( companyId: string, config: Record, @@ -1087,130 +978,6 @@ export function toolAccessService( ); } - async function appProfileForConnection( - dbClient: Pick, - connection: typeof toolConnections.$inferSelect, - ) { - const profileKey = `app:${connection.id}`; - let [profile] = await dbClient - .select() - .from(toolProfiles) - .where( - and( - eq(toolProfiles.companyId, connection.companyId), - eq(toolProfiles.profileKey, profileKey), - ), - ) - .limit(1); - if (!profile) { - const [sameName] = await dbClient - .select({ id: toolProfiles.id }) - .from(toolProfiles) - .where( - and( - eq(toolProfiles.companyId, connection.companyId), - eq(toolProfiles.name, connection.name), - ), - ) - .limit(1); - const profileName = sameName - ? `${connection.name} (${connection.id.replace(/-/g, "").slice(0, 8)})` - : connection.name; - [profile] = await dbClient - .insert(toolProfiles) - .values({ - companyId: connection.companyId, - profileKey, - name: profileName, - description: `Access profile for ${connection.name}.`, - status: "active", - defaultAction: "deny", - metadata: { - source: "tool_connection_install", - connectionId: connection.id, - }, - }) - .returning(); - } - // Installation controls where a connection is exposed, not which actions - // it grants. The app wizard's catalog-entry includes are the authority for - // action selection, so remove the legacy connection-wide include that used - // to silently turn every installed action on. - await dbClient - .delete(toolProfileEntries) - .where( - and( - eq(toolProfileEntries.companyId, connection.companyId), - eq(toolProfileEntries.profileId, profile.id), - eq(toolProfileEntries.selectorType, "connection"), - eq(toolProfileEntries.effect, "include"), - eq(toolProfileEntries.connectionId, connection.id), - ), - ); - return profile; - } - - async function listConnectionInstalls( - connectionId: string, - companyId?: string, - ): Promise { - const connection = await getConnectionRow(connectionId, companyId); - const rows = await db - .select() - .from(toolConnectionInstalls) - .where( - and( - eq(toolConnectionInstalls.companyId, connection.companyId), - eq(toolConnectionInstalls.connectionId, connection.id), - ), - ) - .orderBy( - asc(toolConnectionInstalls.targetType), - asc(toolConnectionInstalls.targetId), - ); - return rows.map(toConnectionInstall); - } - - async function resolveInstalledConnectionsForAgent( - companyId: string, - agentId: string, - ): Promise { - await assertOptionalAgent( - companyId, - agentId, - "Tool connection install agent", - ); - const installRows = await db - .select() - .from(toolConnectionInstalls) - .where( - and( - eq(toolConnectionInstalls.companyId, companyId), - sql`((${toolConnectionInstalls.targetType} = 'company' and ${toolConnectionInstalls.targetId} = ${companyId}) or (${toolConnectionInstalls.targetType} = 'agent' and ${toolConnectionInstalls.targetId} = ${agentId}))`, - ), - ); - if (installRows.length === 0) return []; - const connectionIds = [ - ...new Set(installRows.map((install) => install.connectionId)), - ]; - const rows = await db - .select() - .from(toolConnections) - .where( - and( - eq(toolConnections.companyId, companyId), - inArray(toolConnections.id, connectionIds), - ), - ) - .orderBy(asc(toolConnections.name)); - return rows.map((row) => ({ - ...toConnection(row), - installs: installRows - .filter((install) => install.connectionId === row.id) - .map(toConnectionInstall), - })); - } - async function getConnectionRow(idOrUid: string, companyId?: string) { const identifier = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test( @@ -1226,581 +993,6 @@ export function toolAccessService( return row; } - /** - * Split the secrets a connection points at into the ones it owns outright and - * the ones another consumer still depends on. - * - * Removing an app is a credential revocation boundary (PAP-17119), but it must - * never destroy a secret the operator manages by hand or shares with another - * target. Two independent tests have to agree before a secret is destroyed: - * - * 1. Provenance — the key sits in the `tool_app.` namespace only the - * connect/reconnect/OAuth paths mint, or its user-secret definition does. - * Personal definitions must also have no declarations on other targets. - * 2. Exclusivity — nothing outside this connection references it: no - * `company_secret_bindings` row from another target, and no other - * connection or connection grant naming the same secret id. - * - * A secret failing either test is reported as retained; removal still drops - * this connection's binding and ref, so the connection loses the credential - * either way. Retaining a secret nobody can reach is a leak of an unused row; - * deleting one another target still resolves is an outage, so the ambiguous - * case fails towards retention. - */ - async function classifyConnectionSecrets( - connection: typeof toolConnections.$inferSelect, - secretIds: string[], - ): Promise<{ owned: string[]; retained: string[] }> { - const unique = [ - ...new Set( - secretIds.filter((id) => typeof id === "string" && id.length > 0), - ), - ]; - if (unique.length === 0) return { owned: [], retained: [] }; - - const secretRows = await db - .select({ - id: companySecrets.id, - key: companySecrets.key, - scope: companySecrets.scope, - userSecretDefinitionId: companySecrets.userSecretDefinitionId, - }) - .from(companySecrets) - .where( - and( - eq(companySecrets.companyId, connection.companyId), - inArray(companySecrets.id, unique), - ), - ); - const byId = new Map(secretRows.map((row) => [row.id, row])); - - const referencedElsewhere = await connectionSecretsUsedByOtherConsumers(db, unique); - const foreignBindings = await db - .select({ secretId: companySecretBindings.secretId }) - .from(companySecretBindings) - .where( - and( - eq(companySecretBindings.companyId, connection.companyId), - inArray(companySecretBindings.secretId, unique), - sql`not (${companySecretBindings.targetType} = 'tool_connection' and ${companySecretBindings.targetId} = ${connection.id})`, - ), - ); - for (const row of foreignBindings) referencedElsewhere.add(row.secretId); - - // Bindings are the authority, but read the sibling refs too: a row written - // before `syncCredentialBindings` existed — or by hand — can reference a - // secret with no binding to prove it. - const siblingConnections = await db - .select({ - credentialRefs: toolConnections.credentialRefs, - credentialSecretRefs: toolConnections.credentialSecretRefs, - }) - .from(toolConnections) - .where( - and( - eq(toolConnections.companyId, connection.companyId), - ne(toolConnections.id, connection.id), - ), - ); - for (const row of siblingConnections) { - for (const ref of row.credentialRefs ?? []) - referencedElsewhere.add(ref.secretId); - for (const ref of row.credentialSecretRefs ?? []) - referencedElsewhere.add(ref.secretId); - } - const siblingGrants = await db - .select({ credentialSecretRefs: connectionGrants.credentialSecretRefs }) - .from(connectionGrants) - .where( - and( - eq(connectionGrants.companyId, connection.companyId), - ne(connectionGrants.connectionId, connection.id), - ), - ); - for (const row of siblingGrants) { - for (const ref of row.credentialSecretRefs ?? []) - referencedElsewhere.add(ref.secretId); - } - - const definitionIds = secretRows.flatMap((row) => row.userSecretDefinitionId ? [row.userSecretDefinitionId] : []); - const definitions = definitionIds.length ? await db.select().from(userSecretDefinitions) - .where(and(eq(userSecretDefinitions.companyId, connection.companyId), inArray(userSecretDefinitions.id, definitionIds))) : []; - const dedicatedDefinitions = new Set(definitions.filter((row) => row.key.startsWith(CONNECTION_OWNED_SECRET_KEY_PREFIX) || row.key.startsWith(`tool_oauth.${connection.id}.`)).map((row) => row.id)); - const foreignDeclarations = definitionIds.length ? await db.select().from(userSecretDeclarations).where(and( - inArray(userSecretDeclarations.userSecretDefinitionId, definitionIds), - sql`not (${userSecretDeclarations.targetType} = 'tool_connection' and ${userSecretDeclarations.targetId} = ${connection.id})`, - )) : []; - const sharedDefinitions = new Set(foreignDeclarations.map((row) => row.userSecretDefinitionId)); - const owned: string[] = []; - const retained: string[] = []; - for (const secretId of unique) { - const row = byId.get(secretId); - // No row means an earlier pass of this same removal already deleted it. - // Hand it back as owned so a retry re-runs the (idempotent) revocation - // instead of reporting a credential this connection never shared. - if (!row) { - owned.push(secretId); - continue; - } - const dedicated = row.scope === "user" && row.userSecretDefinitionId - ? dedicatedDefinitions.has(row.userSecretDefinitionId) && !sharedDefinitions.has(row.userSecretDefinitionId) - : row.scope === "company" && row.userSecretDefinitionId === null - && row.key.startsWith(CONNECTION_OWNED_SECRET_KEY_PREFIX); - if (dedicated && !referencedElsewhere.has(secretId)) owned.push(secretId); - else retained.push(secretId); - } - return { owned, retained }; - } - - /** - * Remove an app: a credential-revoking teardown, not a status flip (PAP-17119). - * - * Order is the security property. Every database-side access path closes - * first — grants, installs, the app-managed profile, gateway tokens minted - * against it, outstanding OAuth state, the catalog, and the connection itself - * — so the app is already undispatchable before the first call out to a secret - * provider. Secret revocation runs last, and each secret's ref survives until - * that secret is gone, so a provider that errors leaves the operation failed - * closed and resumable: the credential is already unresolvable (its row is - * marked deleted first), and retrying the same removal finishes the job. - * - * What stays behind is deliberate: the connection and application rows, their - * ids, names and activity keep working so a later reconnect reuses the same - * identity — but with no credential, no install and no profile, so - * reconnecting has to ask for fresh authentication and rebuild access. - */ - async function removeConnection( - connectionId: string, - companyId?: string, - actor?: ActorInfo, - ): Promise { - const connection = await getConnectionRow(connectionId, companyId); - const [endpoint] = await db.select().from(chatEndpoints).where(and(eq(chatEndpoints.connectionId, connection.id), eq(chatEndpoints.companyId, connection.companyId), eq(chatEndpoints.provider, "slack"))); - if (!endpoint) return removeConnectionUnlocked(connectionId, companyId, actor); - return chatCredentialMutationLease(db)(endpoint, async lease => { - await db.transaction(async tx => { - await lease.assertOwned(tx); - await tx.update(chatEndpoints).set({ status: "archived", updatedAt: new Date() }).where(eq(chatEndpoints.id, endpoint.id)); - await tx.update(toolConnections).set({ status: "archived", enabled: false, updatedAt: new Date() }).where(eq(toolConnections.id, connection.id)); - }); - await removeSlackRegistration(db, endpoint.id, lease); - return removeConnectionUnlocked(connectionId, companyId, actor); - }); - } - - async function removeConnectionUnlocked(connectionId: string, companyId?: string, actor?: ActorInfo): Promise { - const connection = await getConnectionRow(connectionId, companyId); - forgetMcpHttpSessions(connection.id); - const now = new Date(); - const binding = actorBinding(actor); - - // Grants are read before they are revoked: a retried removal must still see - // the credential refs of a grant an earlier pass already marked revoked. - const grantRows = await db - .select({ - id: connectionGrants.id, - status: connectionGrants.status, - kind: connectionGrants.kind, - subjectUserId: connectionGrants.subjectUserId, - credentialSecretRefs: connectionGrants.credentialSecretRefs, - externalCredential: connectionGrants.externalCredential, - }) - .from(connectionGrants) - .where( - and( - eq(connectionGrants.companyId, connection.companyId), - eq(connectionGrants.connectionId, connection.id), - ), - ); - const grantsToRevoke = grantRows.filter((row) => row.status !== "revoked"); - if (grantsToRevoke.length > 0) { - await db - .update(connectionGrants) - .set({ - status: "revoked", - isDefault: false, - revokedAt: now, - revokedByAgentId: - binding.actorType === "agent" ? binding.actorId : null, - revokedByUserId: - binding.actorType === "user" ? binding.actorId : null, - updatedAt: now, - }) - .where( - inArray( - connectionGrants.id, - grantsToRevoke.map((row) => row.id), - ), - ); - } - let externalCredentialCleanup: ToolConnectionRemovalSummary["externalCredentialCleanup"] = - null; - if ( - connection.credentialSource === "vercel_connect" && - connection.externalCredential - ) { - let attempted = 0; - let revoked = 0; - let failures = 0; - let appSubjectCleanup: "not_applicable" | "manage_in_vercel" = - "not_applicable"; - for (const grant of grantRows) { - const request = vercelTokenRequest({ - credential: connection.externalCredential, - grant, - connectionId: connection.id, - companyId: connection.companyId, - resources: vercelConnectResourcesFor(connection), - }); - vercelConnect?.evict(request); - if (grant.externalCredential?.subjectType === "app") { - appSubjectCleanup = "manage_in_vercel"; - continue; - } - if (grant.externalCredential?.subjectType !== "user") continue; - attempted += 1; - try { - if (!vercelConnect) throw new Error("Vercel Connect unavailable"); - await vercelConnect.revoke(request); - revoked += 1; - } catch { - failures += 1; - } - } - externalCredentialCleanup = { - provider: "vercel_connect", - userSubjectsAttempted: attempted, - userSubjectsRevoked: revoked, - userSubjectFailures: failures, - appSubjectCleanup, - manageUrl: vercelConnectIntegrationStatus().manageUrl, - }; - } - - // Bindings are how a credential reaches a runtime, so they go before the - // provider round-trip rather than after it. They are also not needed to - // finish the job: the refs on the connection row are what a resumed removal - // reads to find the secrets it still owes a revocation. - const removedSecretBindings = await db - .delete(companySecretBindings) - .where( - and( - eq(companySecretBindings.companyId, connection.companyId), - eq(companySecretBindings.targetType, "tool_connection"), - eq(companySecretBindings.targetId, connection.id), - ), - ) - .returning({ id: companySecretBindings.id }); - - const removedInstalls = await db - .delete(toolConnectionInstalls) - .where( - and( - eq(toolConnectionInstalls.companyId, connection.companyId), - eq(toolConnectionInstalls.connectionId, connection.id), - ), - ) - .returning({ id: toolConnectionInstalls.id }); - - // The app-managed profile exists only to carry this connection's action - // selection, so it goes with the connection. Operator-authored profiles that - // happen to mention the connection are left alone — the archived connection - // is denied by the policy engine regardless. - const [appProfile] = await db - .select({ id: toolProfiles.id }) - .from(toolProfiles) - .where( - and( - eq(toolProfiles.companyId, connection.companyId), - eq(toolProfiles.profileKey, `app:${connection.id}`), - ), - ) - .limit(1); - let appProfileOutcome: ToolConnectionRemovalSummary["appProfile"] = - "absent"; - let appProfileEntriesRemoved = 0; - let appProfileBindingsRemoved = 0; - let gatewayTokensRevoked = 0; - let gatewaySessionsRevoked = 0; - if (appProfile) { - appProfileEntriesRemoved = await db.transaction(async tx => { - await tx.select({ id: toolProfiles.id }).from(toolProfiles) - .where(and(eq(toolProfiles.companyId, connection.companyId), eq(toolProfiles.id, appProfile.id))) - .for("no key update"); - return (await tx.delete(toolProfileEntries) - .where(and(eq(toolProfileEntries.companyId, connection.companyId), eq(toolProfileEntries.profileId, appProfile.id))) - .returning({ id: toolProfileEntries.id })).length; - }); - appProfileBindingsRemoved = ( - await db - .delete(toolProfileBindings) - .where( - and( - eq(toolProfileBindings.companyId, connection.companyId), - eq(toolProfileBindings.profileId, appProfile.id), - ), - ) - .returning({ id: toolProfileBindings.id }) - ).length; - - const gatewayRows = await db - .select({ id: toolMcpGateways.id }) - .from(toolMcpGateways) - .where( - and( - eq(toolMcpGateways.companyId, connection.companyId), - eq(toolMcpGateways.profileId, appProfile.id), - ), - ); - if (gatewayRows.length === 0) { - await db.delete(toolProfiles).where(eq(toolProfiles.id, appProfile.id)); - appProfileOutcome = "deleted"; - } else { - // `tool_mcp_gateways.profile_id` is ON DELETE RESTRICT, so a gateway - // pointing here keeps the row alive. Archive it instead — the policy - // engine only consults `active` profiles, and it has no entries left — - // and revoke the tokens those gateways already handed out, which are the - // one credential a caller could still present. - await db - .update(toolProfiles) - .set({ status: "archived", defaultAction: "deny", updatedAt: now }) - .where(eq(toolProfiles.id, appProfile.id)); - appProfileOutcome = "archived"; - const revokedTokens = await db - .update(toolMcpGatewayTokens) - .set({ revokedAt: now, updatedAt: now }) - .where( - and( - eq(toolMcpGatewayTokens.companyId, connection.companyId), - inArray( - toolMcpGatewayTokens.gatewayId, - gatewayRows.map((row) => row.id), - ), - isNull(toolMcpGatewayTokens.revokedAt), - ), - ) - .returning({ id: toolMcpGatewayTokens.id }); - gatewayTokensRevoked = revokedTokens.length; - if (revokedTokens.length > 0) { - gatewaySessionsRevoked = ( - await db - .update(toolGatewaySessions) - .set({ revokedAt: now, updatedAt: now }) - .where( - and( - eq(toolGatewaySessions.companyId, connection.companyId), - inArray( - toolGatewaySessions.gatewayTokenId, - revokedTokens.map((row) => row.id), - ), - isNull(toolGatewaySessions.revokedAt), - ), - ) - .returning({ id: toolGatewaySessions.id }) - ).length; - } - } - } - - // A local runtime already holds the injected credential inside a live child - // process, so archiving rows is not enough — the process itself is an access - // path. Stopping is best effort on purpose: if the supervisor cannot be - // reached, revoking the credential anyway (so nothing can start again) beats - // abandoning the teardown, and the warning says which slot was left running. - let runtimeSlotsStopped = 0; - const runtimeSlotRows = await db - .select({ id: toolRuntimeSlots.id, status: toolRuntimeSlots.status }) - .from(toolRuntimeSlots) - .where( - and( - eq(toolRuntimeSlots.companyId, connection.companyId), - eq(toolRuntimeSlots.connectionId, connection.id), - ), - ); - for (const slot of runtimeSlotRows) { - if (slot.status === "stopped") continue; - try { - await runtimeSupervisor.stopSlot({ - companyId: connection.companyId, - slotId: slot.id, - reason: "connection_removed", - }); - runtimeSlotsStopped += 1; - } catch (error) { - logger.warn( - { - err: error, - companyId: connection.companyId, - connectionId: connection.id, - slotId: slot.id, - }, - "tool connection removal could not stop a runtime slot", - ); - } - } - - // The catalog stays as history, but `removed` is the status that stops a - // standing trust rule from auto-allowing one of these actions again. - const removedCatalogEntries = await db - .update(toolCatalogEntries) - .set({ status: "removed", updatedAt: now }) - .where( - and( - eq(toolCatalogEntries.companyId, connection.companyId), - eq(toolCatalogEntries.connectionId, connection.id), - ne(toolCatalogEntries.status, "removed"), - ), - ) - .returning({ id: toolCatalogEntries.id }); - - // An authorization already in flight would otherwise come back and mint a - // fresh token for an app the operator just removed. - const discardedOAuthStates = await db - .delete(toolOauthStates) - .where( - and( - eq(toolOauthStates.companyId, connection.companyId), - eq(toolOauthStates.connectionId, connection.id), - ), - ) - .returning({ state: toolOauthStates.state }); - - // Token-derived material in the issuance ledger is not an access path — - // nothing validates against it — but there is no reason to keep a hash of a - // credential the operator asked us to revoke. Path, outcome, actor and time - // stay, so the usage history survives. - const clearedIssuanceHashes = await db - .update(connectionTokenIssuances) - .set({ tokenHash: null }) - .where( - and( - eq(connectionTokenIssuances.companyId, connection.companyId), - eq(connectionTokenIssuances.connectionId, connection.id), - sql`${connectionTokenIssuances.tokenHash} is not null`, - ), - ) - .returning({ id: connectionTokenIssuances.id }); - - const archived = await db.transaction(async (tx) => { - await tx.select({ id: toolApplications.id }).from(toolApplications) - .where(and(eq(toolApplications.id, connection.applicationId), eq(toolApplications.companyId, connection.companyId))) - .for("update"); - const [updatedConnection] = await tx - .update(toolConnections) - .set({ status: "archived", enabled: false, updatedAt: now }) - .where(eq(toolConnections.id, connection.id)) - .returning(); - if (!updatedConnection) throw notFound("Tool connection not found"); - - const remainingConnections = await tx - .select({ id: toolConnections.id }) - .from(toolConnections) - .where( - and( - eq(toolConnections.applicationId, updatedConnection.applicationId), - ne(toolConnections.status, "archived"), - ), - ) - .limit(1); - - let applicationArchived = false; - if (remainingConnections.length === 0) { - const [application] = await tx - .update(toolApplications) - .set({ status: "archived", archivedAt: now, updatedAt: now }) - .where( - and( - eq(toolApplications.id, updatedConnection.applicationId), - ne(toolApplications.status, "archived"), - ), - ) - .returning({ id: toolApplications.id }); - applicationArchived = Boolean(application); - } - - return { connection: updatedConnection, applicationArchived }; - }); - emitConnectionUpdated(archived.connection, connection, "archive"); - - // Hosted work needs its credential to stop. Agent/viewer access is already - // revoked above; retain cleanup authority until provider shutdown confirms. - if (isBrowserUseConnection(connection)) { - await browserUseService(db, options.remoteHttpRequest).stopBeforeCredentialRemoval(connection.companyId, connection.id); - } - - // Only now, with every access path closed, revoke the credentials. Each - // `secrets.remove` marks the row deleted before it calls the provider, so a - // provider error leaves an unresolvable secret and a resumable removal - // rather than a half-open app. - const candidateSecretIds = [ - ...Object.values(asRecord(connection.config.aggregatorDiscovery)).flatMap(value => typeof asRecord(value).secretId === "string" ? [asRecord(value).secretId as string] : []), - ...connection.credentialRefs.map((ref) => ref.secretId), - ...connection.credentialSecretRefs.map((ref) => ref.secretId), - ...grantRows.flatMap((grant) => - (grant.credentialSecretRefs ?? []).map((ref) => ref.secretId), - ), - ]; - const { owned, retained } = await classifyConnectionSecrets( - connection, - candidateSecretIds, - ); - let secretsRevoked = 0; - for (const secretId of owned) { - const removed = await secrets.remove(secretId); - if (removed) secretsRevoked += 1; - } - - const credentialRefsCleared = - connection.credentialRefs.length + connection.credentialSecretRefs.length; - const [cleared] = await db - .update(toolConnections) - .set({ - credentialRefs: [], - credentialSecretRefs: [], - config: withoutInlineOAuthTokens(connection.config), - transportConfig: withoutInlineOAuthTokens(connection.transportConfig), - updatedAt: now, - }) - .where(eq(toolConnections.id, connection.id)) - .returning(); - if ( - grantRows.some((grant) => (grant.credentialSecretRefs ?? []).length > 0) - ) { - await db - .update(connectionGrants) - .set({ credentialSecretRefs: [], updatedAt: now }) - .where( - and( - eq(connectionGrants.companyId, connection.companyId), - eq(connectionGrants.connectionId, connection.id), - ), - ); - } - - return { - connection: toConnection(cleared ?? archived.connection), - removal: { - secretsRevoked, - secretsRetainedShared: retained.length, - credentialRefsCleared, - secretBindingsRemoved: removedSecretBindings.length, - grantsRevoked: grantsToRevoke.length, - installsRemoved: removedInstalls.length, - appProfile: appProfileOutcome, - appProfileEntriesRemoved, - appProfileBindingsRemoved, - catalogEntriesMarkedRemoved: removedCatalogEntries.length, - oauthStatesDiscarded: discardedOAuthStates.length, - tokenIssuanceHashesCleared: clearedIssuanceHashes.length, - runtimeSlotsStopped, - gatewayTokensRevoked, - gatewaySessionsRevoked, - applicationArchived: archived.applicationArchived, - externalCredentialCleanup, - }, - }; - } - async function localTools( connection: typeof toolConnections.$inferSelect, ): Promise { @@ -5361,801 +4553,11 @@ export function toolAccessService( await annotateSavedOAuthClientSecret([connection], viewerUserId); return connection; }, - - listConnectionGrants: async (idOrUid: string, companyId?: string) => { - const connection = await getConnectionRow(idOrUid, companyId); - const grants = await db - .select() - .from(connectionGrants) - .where( - and( - eq(connectionGrants.companyId, connection.companyId), - eq(connectionGrants.connectionId, connection.id), - ), - ) - .orderBy( - desc(connectionGrants.isDefault), - desc(connectionGrants.updatedAt), - ); - const grantIds = grants.map((grant) => grant.id); - const [members, delegations] = - grantIds.length === 0 - ? [[], []] - : await Promise.all([ - db - .select() - .from(connectionGrantMembers) - .where( - and( - eq(connectionGrantMembers.companyId, connection.companyId), - inArray(connectionGrantMembers.grantId, grantIds), - ), - ), - db - .select() - .from(connectionGrantDelegations) - .where( - and( - eq( - connectionGrantDelegations.companyId, - connection.companyId, - ), - inArray(connectionGrantDelegations.grantId, grantIds), - ), - ), - ]); - return { - connection: { id: connection.id, uid: connection.uid }, - grants: grants.map((grant) => ({ - ...toConnectionGrant(grant), - members: members.filter((member) => member.grantId === grant.id), - delegations: delegations.filter( - (delegation) => delegation.grantId === grant.id, - ), - })), - }; - }, - - /** - * Company members eligible to appear in an organization grant's audience. - * The audience editor needs display names, and the client must not have to - * cross-reference a second endpoint to render "12 selected members". - */ - listConnectionAudienceMembers: async (companyId: string) => { - const rows = await db - .select({ - userId: companyMemberships.principalId, - name: authUsers.name, - email: authUsers.email, - }) - .from(companyMemberships) - .leftJoin(authUsers, eq(authUsers.id, companyMemberships.principalId)) - .where( - and( - eq(companyMemberships.companyId, companyId), - eq(companyMemberships.principalType, "user"), - eq(companyMemberships.status, "active"), - ), - ); - return rows - .map((row) => ({ - userId: row.userId, - name: row.name ?? null, - email: row.email ?? null, - })) - .sort((a, b) => - (a.name ?? a.email ?? a.userId).localeCompare( - b.name ?? b.email ?? b.userId, - ), - ); - }, - - /** - * Replace an organization grant's audience atomically (PAP-17835). - * - * An empty `memberUserIds` persists as zero rows, which the resolver already - * treats as "every organization member". Replacement is delete-then-insert - * inside one transaction so a partially-applied audience can never widen or - * narrow access, and every member id is checked against active company - * membership first so an audience cannot name an outsider. Existing audience - * members are locked too: membership cleanup deliberately retains a sole - * inactive audience row, and a concurrent empty replacement must not erase - * that fail-closed marker after cleanup wins. Once cleanup has retained that - * marker, callers must restore the member or replace it with an active named - * audience before widening access to the whole company. - */ - replaceConnectionGrantMembers: async ( - idOrUid: string, - grantId: string, - memberUserIds: string[], - actor?: ActorInfo, - ) => { - const connection = await getConnectionRow(idOrUid); - const [grant] = await db - .select() - .from(connectionGrants) - .where( - and( - eq(connectionGrants.id, grantId), - eq(connectionGrants.companyId, connection.companyId), - eq(connectionGrants.connectionId, connection.id), - ), - ) - .limit(1); - if (!grant) throw notFound("Connection grant not found"); - if (grant.kind !== "organization") { - throw badRequest( - "Only an organization identity has an audience; a personal identity belongs to its owner", - ); - } - const requested = [ - ...new Set(memberUserIds.map((id) => id.trim()).filter(Boolean)), - ]; - const binding = actorBinding(actor); - const members = await db.transaction(async (tx) => { - // Serialize replacements before taking the current audience snapshot. - // Otherwise a second replacement could read the old rows, wait behind - // an in-flight replacement, and then miss locking a newly-added member. - await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended(${"paperclip.connection_grant_audience:" + grant.id}, 0))`); - const existingAudience = await tx - .select() - .from(connectionGrantMembers) - .where( - and( - eq(connectionGrantMembers.companyId, connection.companyId), - eq(connectionGrantMembers.grantId, grant.id), - eq(connectionGrantMembers.subjectType, "user"), - ), - ); - const existingUserIds = [ - ...new Set(existingAudience.map((row) => row.subjectId)), - ]; - const membershipUserIds = [ - ...new Set([...existingUserIds, ...requested]), - ]; - if (membershipUserIds.length > 0) { - // Membership suspension/archive/removal takes the same row lock before - // sweeping grant audiences. Lock both the old and new audience so an - // empty replacement also serializes with cleanup of its existing sole - // member. Whichever transaction wins is then authoritative. - const memberships = await tx - .select({ - principalId: companyMemberships.principalId, - status: companyMemberships.status, - }) - .from(companyMemberships) - .where( - and( - eq(companyMemberships.companyId, connection.companyId), - eq(companyMemberships.principalType, "user"), - inArray(companyMemberships.principalId, membershipUserIds), - ), - ) - .orderBy(asc(companyMemberships.id)) - .for("update"); - const active = new Set( - memberships - .filter((row) => row.status === "active") - .map((row) => row.principalId), - ); - const unknown = requested.filter((id) => !active.has(id)); - if (unknown.length > 0) { - throw unprocessable( - "Every audience member must be an active member of this company", - { - code: "audience_member_not_in_company", - unknownUserIds: unknown, - }, - ); - } - const inactiveExisting = existingUserIds.filter( - (id) => !active.has(id), - ); - if (requested.length === 0 && inactiveExisting.length > 0) { - throw conflict( - "Replace inactive audience members before widening access to the whole company", - { - code: "audience_widening_blocked", - inactiveUserIds: inactiveExisting, - }, - ); - } - } - await tx - .select({ id: connectionGrants.id }) - .from(connectionGrants) - .where( - and( - eq(connectionGrants.id, grant.id), - eq(connectionGrants.companyId, connection.companyId), - eq(connectionGrants.connectionId, connection.id), - ), - ) - .for("update"); - const existingUserSet = new Set(existingUserIds); - if (requested.length === existingUserSet.size && requested.every(id => existingUserSet.has(id))) return existingAudience; - await tx - .delete(connectionGrantMembers) - .where( - and( - eq(connectionGrantMembers.companyId, connection.companyId), - eq(connectionGrantMembers.grantId, grant.id), - ), - ); - const inserted = - requested.length === 0 - ? [] - : await tx - .insert(connectionGrantMembers) - .values( - requested.map((subjectId) => ({ - companyId: connection.companyId, - grantId: grant.id, - subjectType: "user" as const, - subjectId, - })), - ) - .returning(); - await tx - .update(connectionGrants) - .set({ updatedAt: new Date() }) - .where(eq(connectionGrants.id, grant.id)); - return inserted; - }); - await db.insert(toolAccessAuditEvents).values({ - companyId: connection.companyId, - connectionId: connection.id, - actorType: binding.actorType ?? "system", - actorId: binding.actorId, - action: "connection_grant.audience_replaced", - outcome: "success", - reasonCode: "audience_replaced", - details: { - grantId: grant.id, - memberCount: members.length, - memberUserIds: requested, - }, - }); - return { ...toConnectionGrant(grant), members }; - }, - - createConnectionGrantDelegation: async ( - idOrUid: string, - grantId: string, - agentId: string, - ownerUserId: string, - ) => { - const connection = await getConnectionRow(idOrUid); - if (connection.connectionPurpose === "ai") { - throw badRequest("AI credentials use the connection's human access settings, not agent delegation"); - } - return db.transaction(async (tx) => { - // Membership removal/suspension takes this same row lock before sweeping - // personal grants. Whichever operation wins is therefore authoritative: - // removal deletes a delegation committed first, while creation that runs - // second observes the inactive membership and fails closed. - const [membership] = await tx - .select({ id: companyMemberships.id }) - .from(companyMemberships) - .where( - and( - eq(companyMemberships.companyId, connection.companyId), - eq(companyMemberships.principalType, "user"), - eq(companyMemberships.principalId, ownerUserId), - eq(companyMemberships.status, "active"), - ), - ) - .for("update") - .limit(1); - if (!membership) { - throw forbidden( - "Only an active company member can delegate their personal grant", - ); - } - - const [grant] = await tx - .select() - .from(connectionGrants) - .where( - and( - eq(connectionGrants.id, grantId), - eq(connectionGrants.companyId, connection.companyId), - eq(connectionGrants.connectionId, connection.id), - eq(connectionGrants.kind, "user"), - eq(connectionGrants.subjectUserId, ownerUserId), - eq(connectionGrants.status, "active"), - ), - ) - .for("update") - .limit(1); - if (!grant) - throw forbidden( - "Only the active personal grant owner can create a delegation", - ); - await tx.select({ id: toolConnections.id }).from(toolConnections) - .where(eq(toolConnections.id, connection.id)).for("key share"); - const [targetAgent] = await tx - .select({ id: agents.id }) - .from(agents) - .where( - and( - eq(agents.id, agentId), - eq(agents.companyId, connection.companyId), - ), - ) - .for("no key update") - .limit(1); - if (!targetAgent) throw notFound("Agent not found"); - const [existing] = await tx - .select() - .from(connectionGrantDelegations) - .where( - and( - eq(connectionGrantDelegations.grantId, grant.id), - eq(connectionGrantDelegations.agentId, agentId), - ), - ) - .limit(1); - if (existing) return existing; - const [delegation] = await tx - .insert(connectionGrantDelegations) - .values({ - companyId: connection.companyId, - grantId: grant.id, - agentId, - createdByUserId: ownerUserId, - }) - .returning(); - await tx.insert(toolAccessAuditEvents).values({ - companyId: connection.companyId, - connectionId: connection.id, - actorType: "user", - actorId: ownerUserId, - action: "connection_grant.delegated", - outcome: "success", - reasonCode: "delegation_created", - details: { grantId: grant.id, delegationId: delegation!.id, agentId }, - }); - return delegation!; - }); - }, - - revokeConnectionGrantDelegation: async ( - idOrUid: string, - grantId: string, - delegationId: string, - actor?: ActorInfo, - ) => { - const connection = await getConnectionRow(idOrUid); - const delegation = await db.transaction(async tx => { - const [grant] = await tx.select({ id: connectionGrants.id }).from(connectionGrants) - .where(and(eq(connectionGrants.id, grantId), eq(connectionGrants.companyId, connection.companyId), eq(connectionGrants.connectionId, connection.id))) - .for("update"); - if (!grant) return undefined; - const [target] = await tx.select({ agentId: connectionGrantDelegations.agentId }).from(connectionGrantDelegations) - .where(and(eq(connectionGrantDelegations.id, delegationId), eq(connectionGrantDelegations.companyId, connection.companyId), eq(connectionGrantDelegations.grantId, grantId))); - if (!target) return undefined; - await tx.select({ id: agents.id }).from(agents) - .where(and(eq(agents.companyId, connection.companyId), eq(agents.id, target.agentId))).for("no key update"); - const [removed] = await tx - .delete(connectionGrantDelegations) - .where( - and( - eq(connectionGrantDelegations.id, delegationId), - eq(connectionGrantDelegations.companyId, connection.companyId), - eq(connectionGrantDelegations.grantId, grantId), - ), - ) - .returning(); - return removed; - }); - if (!delegation) throw notFound("Connection grant delegation not found"); - const binding = actorBinding(actor); - await db.insert(toolAccessAuditEvents).values({ - companyId: connection.companyId, - connectionId: connection.id, - actorType: binding.actorType ?? "system", - actorId: binding.actorId, - action: "connection_grant.delegation_revoked", - outcome: "success", - reasonCode: "delegation_revoked", - details: { grantId, delegationId, agentId: delegation.agentId }, - }); - return delegation; - }, - - addConnectionInstallation: async ( - idOrUid: string, - input: { - providerTenant?: { name?: string; externalId?: string }; - credentialSecretRefs?: typeof connectionGrants.$inferInsert.credentialSecretRefs; - isDefault?: boolean; - }, - actor?: ActorInfo, - ) => { - const connection = await getConnectionRow(idOrUid); - await assertSecretRefs( - connection.companyId, - input.credentialSecretRefs ?? [], - ); - if (input.isDefault) { - await db - .update(connectionGrants) - .set({ isDefault: false, updatedAt: new Date() }) - .where( - and( - eq(connectionGrants.connectionId, connection.id), - eq(connectionGrants.kind, "organization"), - ), - ); - } - const binding = actorBinding(actor); - const [grant] = await db - .insert(connectionGrants) - .values({ - companyId: connection.companyId, - connectionId: connection.id, - kind: "organization", - providerTenant: input.providerTenant, - credentialSecretRefs: input.credentialSecretRefs ?? [], - status: "active", - isDefault: input.isDefault ?? false, - createdByAgentId: - binding.actorType === "agent" ? binding.actorId : null, - createdByUserId: - binding.actorType === "user" ? binding.actorId : null, - }) - .returning(); - if (!grant) throw new Error("Failed to create connection installation"); - await db.insert(toolAccessAuditEvents).values({ - companyId: connection.companyId, - connectionId: connection.id, - actorType: binding.actorType ?? "system", - actorId: binding.actorId, - action: "connection_grant.created", - outcome: "success", - reasonCode: "grant_created", - details: { - grantId: grant.id, - kind: grant.kind, - isDefault: grant.isDefault, - }, - }); - return toConnectionGrant(grant); - }, - - revokeConnectionGrant: async ( - idOrUid: string, - grantId: string, - actor?: ActorInfo, - ) => { - const connection = await getConnectionRow(idOrUid); - const binding = actorBinding(actor); - const [currentGrant] = await db - .select() - .from(connectionGrants) - .where( - and( - eq(connectionGrants.id, grantId), - eq(connectionGrants.companyId, connection.companyId), - eq(connectionGrants.connectionId, connection.id), - ), - ) - .limit(1); - if (!currentGrant) throw notFound("Connection grant not found"); - let providerRevocation = "not_applicable"; - if ( - connection.credentialSource === "vercel_connect" && - connection.externalCredential - ) { - const request = vercelTokenRequest({ - credential: connection.externalCredential, - grant: currentGrant, - connectionId: connection.id, - companyId: connection.companyId, - resources: vercelConnectResourcesFor(connection), - }); - vercelConnect?.evict(request); - if (currentGrant.externalCredential?.subjectType === "app") { - providerRevocation = "manage_in_vercel"; - } else if (currentGrant.externalCredential?.subjectType === "user") { - try { - if (!vercelConnect) throw new Error("Vercel Connect unavailable"); - await vercelConnect.revoke(request); - providerRevocation = "success"; - } catch { - providerRevocation = "failed"; - } - } - } else if ( - isPaperclipCloudConnectorStrategy(oauthConfig(connection).strategy) - ) { - // Google revocation is client-wide for a user. The managed Workspace - // profiles intentionally share one Paperclip-owned client, so revoking - // one token here could invalidate unrelated Gmail, Drive, and Calendar - // grants. A per-profile removal is therefore local-only. A future - // provider-level disconnect must warn that it removes every profile. - providerRevocation = "local_only_shared_client"; - } - const grant = await db.transaction(async (tx) => { - const [lockedGrant] = await tx.select({ id: connectionGrants.id }).from(connectionGrants) - .where(and(eq(connectionGrants.id, grantId), eq(connectionGrants.companyId, connection.companyId), eq(connectionGrants.connectionId, connection.id))) - .for("update"); - if (!lockedGrant) throw notFound("Connection grant not found"); - await tx.select({ id: agents.id }).from(agents) - .where(and(eq(agents.companyId, connection.companyId), notInArray(agents.status, ["pending_approval", "terminated"]))) - .orderBy(agents.id).for("no key update"); - const removedDelegations = await tx - .delete(connectionGrantDelegations) - .where( - and( - eq(connectionGrantDelegations.companyId, connection.companyId), - eq(connectionGrantDelegations.grantId, grantId), - ), - ) - .returning(); - const [updated] = await tx - .update(connectionGrants) - .set({ - status: "revoked", - isDefault: false, - revokedAt: new Date(), - revokedByAgentId: - binding.actorType === "agent" ? binding.actorId : null, - revokedByUserId: - binding.actorType === "user" ? binding.actorId : null, - updatedAt: new Date(), - }) - .where( - and( - eq(connectionGrants.id, grantId), - eq(connectionGrants.companyId, connection.companyId), - eq(connectionGrants.connectionId, connection.id), - ), - ) - .returning(); - if (!updated) throw notFound("Connection grant not found"); - if (removedDelegations.length > 0) { - await tx.insert(toolAccessAuditEvents).values( - removedDelegations.map((delegation) => ({ - companyId: connection.companyId, - connectionId: connection.id, - actorType: binding.actorType ?? "system", - actorId: binding.actorId, - action: "connection_grant.delegation_revoked", - outcome: "success", - reasonCode: "grant_revoked", - details: { - grantId, - delegationId: delegation.id, - agentId: delegation.agentId, - }, - })), - ); - } - return updated; - }); - if (!grant) throw notFound("Connection grant not found"); - await db.insert(toolAccessAuditEvents).values({ - companyId: connection.companyId, - connectionId: connection.id, - actorType: binding.actorType ?? "system", - actorId: binding.actorId, - action: "connection_grant.revoked", - outcome: "success", - reasonCode: "grant_revoked", - details: { grantId: grant.id, kind: grant.kind, providerRevocation }, - }); - return toConnectionGrant(grant); - }, + ...connectionGrantsService, ...toolActivity, listConnectionInstalls, - - putConnectionInstalls: async ( - connectionId: string, - input: PutToolConnectionInstalls, - actor?: ActorInfo, - ): Promise => { - const connection = await getConnectionRow(connectionId); - const requested = new Map( - input.installs.map((install) => [ - `${install.targetType}:${install.targetId}`, - install, - ]), - ); - for (const install of requested.values()) { - if (install.targetType === "company") { - if (install.targetId !== connection.companyId) - throw unprocessable( - "Company installs must target the connection company", - ); - } else { - await assertOptionalAgent( - connection.companyId, - install.targetId, - "Tool connection install agent", - ); - } - } - const markMcpAccessConfigured = connection.transport === "mcp_remote" - && connection.config.mcpAgentAccessConfigured !== true; - const accessExtensions: Array<{ - targetType: "company" | "agent"; - targetId: string; - profileId: string; - }> = []; - await db.transaction(async (tx) => { - await lockToolConnectionRuntimeInputs(tx, connection.companyId, connection.id); - if (markMcpAccessConfigured) { - // Preserve an explicit zero-agent selection through OAuth. Merge the - // marker in SQL so concurrent credential/config changes are retained. - await tx.update(toolConnections).set({ - config: sql`${toolConnections.config} || '{"mcpAgentAccessConfigured":true}'::jsonb`, - transportConfig: sql`${toolConnections.transportConfig} || '{"mcpAgentAccessConfigured":true}'::jsonb`, - updatedAt: now(), - }).where(and( - eq(toolConnections.companyId, connection.companyId), - eq(toolConnections.id, connection.id), - )); - } - const existing = await tx - .select() - .from(toolConnectionInstalls) - .where( - and( - eq(toolConnectionInstalls.companyId, connection.companyId), - eq(toolConnectionInstalls.connectionId, connection.id), - ), - ); - const existingKeys = new Set( - existing.map( - (install) => `${install.targetType}:${install.targetId}`, - ), - ); - const bindings = await tx.select({ targetType: toolProfileBindings.targetType, targetId: toolProfileBindings.targetId }) - .from(toolProfileBindings).innerJoin(toolProfiles, eq(toolProfiles.id, toolProfileBindings.profileId)) - .where(and(eq(toolProfileBindings.companyId, connection.companyId), eq(toolProfiles.companyId, connection.companyId), eq(toolProfiles.profileKey, `app:${connection.id}`))); - await lockToolAssignmentAgents(tx, connection.companyId, [...existing, ...requested.values(), ...bindings]); - const removals = existing.filter( - (install) => - !requested.has(`${install.targetType}:${install.targetId}`), - ); - const removeIds = removals.map((install) => install.id); - if (removeIds.length > 0) { - await tx - .delete(toolConnectionInstalls) - .where(inArray(toolConnectionInstalls.id, removeIds)); - // Uninstalling must also drop the binding this path created. Installing - // writes both an install row and a profile binding, so deleting only the - // install row leaves a binding that no surface can see or remove. The - // install row is the reach gate (`mintConnectionTokenForAgent` fails with - // `installation_required`, and the heartbeat only hands over installed - // connections), so a stale binding grants no reach on its own — but it - // still makes `finishApp`, which rebuilds `access` from the bindings, - // read a target the operator already removed. - // - // Only bindings tagged `source: "tool_connection_install"` are removed. - // A binding the operator authored through the access model carries a - // different source and must survive an uninstall. - const [installProfile] = await tx - .select({ id: toolProfiles.id }) - .from(toolProfiles) - .where( - and( - eq(toolProfiles.companyId, connection.companyId), - eq(toolProfiles.profileKey, `app:${connection.id}`), - ), - ) - .limit(1); - if (installProfile) { - for (const install of removals) { - await tx - .delete(toolProfileBindings) - .where( - and( - eq(toolProfileBindings.companyId, connection.companyId), - eq(toolProfileBindings.profileId, installProfile.id), - eq(toolProfileBindings.targetType, install.targetType), - eq(toolProfileBindings.targetId, install.targetId), - sql`${toolProfileBindings.metadata}->>'source' = 'tool_connection_install'`, - ), - ); - } - } - } - const additions = [...requested.entries()] - .filter(([key]) => !existingKeys.has(key)) - .map(([, install]) => install); - if (additions.length > 0) { - await tx.insert(toolConnectionInstalls).values( - additions.map((install) => ({ - companyId: connection.companyId, - connectionId: connection.id, - targetType: install.targetType, - targetId: install.targetId, - createdByAgentId: - actor?.actorType === "agent" ? (actor.actorId ?? null) : null, - createdByUserId: - actor?.actorType === "user" ? (actor.actorId ?? null) : null, - })), - ); - } - if (requested.size > 0 && connection.connectionPurpose !== "ai") { - const profile = await appProfileForConnection(tx, connection); - for (const install of requested.values()) { - const [binding] = await tx - .insert(toolProfileBindings) - .values({ - companyId: connection.companyId, - profileId: profile.id, - targetType: install.targetType, - targetId: install.targetId, - priority: 100, - metadata: { - source: "tool_connection_install", - connectionId: connection.id, - }, - createdByAgentId: - actor?.actorType === "agent" ? (actor.actorId ?? null) : null, - createdByUserId: - actor?.actorType === "user" ? (actor.actorId ?? null) : null, - }) - .onConflictDoNothing() - .returning({ id: toolProfileBindings.id }); - if (binding) - accessExtensions.push({ - targetType: install.targetType, - targetId: install.targetId, - profileId: profile.id, - }); - } - } - if (markMcpAccessConfigured || removeIds.length > 0 || additions.length > 0) { - const binding = actorBinding(actor); - await tx.insert(toolAccessAuditEvents).values({ - companyId: connection.companyId, - connectionId: connection.id, - actorType: binding.actorType ?? "system", - actorId: binding.actorId, - action: "connection_installs.changed", - outcome: "success", - reasonCode: "installs_changed", - details: { - ...(markMcpAccessConfigured ? { mcpAgentAccessConfigured: true } : {}), - added: additions.map((install) => ({ - targetType: install.targetType, - targetId: install.targetId, - })), - removed: existing - .filter((install) => removeIds.includes(install.id)) - .map((install) => ({ - targetType: install.targetType, - targetId: install.targetId, - })), - }, - }); - } - }); - for (const extension of accessExtensions) { - await logActivity(db, { - companyId: connection.companyId, - actorType: actor?.actorType ?? "system", - actorId: actor?.actorId ?? "system", - action: "tool_connection.install_access_extended", - entityType: "tool_connection", - entityId: connection.id, - details: extension, - }); - } - return { - connectionId: connection.id, - installs: await listConnectionInstalls( - connection.id, - connection.companyId, - ), - }; - }, + putConnectionInstalls: connectionInstalls.putConnectionInstalls, updateConnection: async ( connectionId: string, diff --git a/server/src/services/tool-access/connections/credential-validation.ts b/server/src/services/tool-access/connections/credential-validation.ts new file mode 100644 index 00000000000..97b8ed980a8 --- /dev/null +++ b/server/src/services/tool-access/connections/credential-validation.ts @@ -0,0 +1,75 @@ +import { and, eq } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { companySecrets } from "@paperclipai/db"; +import { CLASS3_STATIC_LEASE_ALLOWLIST } from "@paperclipai/shared"; +import { unprocessable } from "../../../errors.js"; + +function assertClass3ToolCredentialRefAllowed(ref: { + configPath?: string | null; + projectionClass?: string | null; + projectionAllowlistKey?: string | null; +}) { + const projectionClass = ref.projectionClass ?? "unclassified"; + if (projectionClass !== "class_3_static_lease") return; + if (!ref.configPath?.trim() || !ref.projectionAllowlistKey?.trim()) { + throw unprocessable( + "Class-3 static lease tool credentials require an allowlist key and config path", + { + code: "class_3_static_lease_allowlist_required", + targetType: "tool_connection", + configPath: ref.configPath ?? null, + }, + ); + } + const allowed = CLASS3_STATIC_LEASE_ALLOWLIST.some( + (entry) => + entry.key === ref.projectionAllowlistKey && + entry.targetType === "tool_connection" && + entry.configPath === ref.configPath, + ); + if (!allowed) { + throw unprocessable( + "Class-3 static lease tool credential is outside the approved allowlist", + { + code: "class_3_static_lease_not_allowed", + allowlistKey: ref.projectionAllowlistKey, + targetType: "tool_connection", + configPath: ref.configPath, + }, + ); + } +} + +export function createConnectionCredentialValidation(db: Db) { + async function assertSecretRefs( + companyId: string, + refs: Array<{ + secretId: string; + configPath?: string | null; + projectionClass?: string | null; + projectionAllowlistKey?: string | null; + }>, + ) { + if (refs.length === 0) return; + for (const ref of refs) { + assertClass3ToolCredentialRefAllowed(ref); + } + const secretIds = [...new Set(refs.map((ref) => ref.secretId))]; + for (const secretId of secretIds) { + const [secret] = await db + .select({ id: companySecrets.id }) + .from(companySecrets) + .where( + and( + eq(companySecrets.id, secretId), + eq(companySecrets.companyId, companyId), + ), + ); + if (!secret) + throw unprocessable( + "Tool connection credential secrets must belong to the same company", + ); + } + } + return assertSecretRefs; +} diff --git a/server/src/services/tool-access/connections/grants.ts b/server/src/services/tool-access/connections/grants.ts new file mode 100644 index 00000000000..c6b5aea2706 --- /dev/null +++ b/server/src/services/tool-access/connections/grants.ts @@ -0,0 +1,603 @@ +import { toConnectionGrant } from "./projection.js"; +import { actorBinding } from "../actor.js"; +import { vercelConnectResourcesFor } from "../vercel-credentials.js"; +import type { ActorInfo } from "../types.js"; +import { oauthConfig } from "../oauth/metadata.js"; +import { and, asc, desc, eq, inArray, notInArray, sql } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { agents, connectionGrantMembers, connectionGrantDelegations, connectionGrants, authUsers, companyMemberships, toolAccessAuditEvents, toolConnections } from "@paperclipai/db"; +import { badRequest, conflict, forbidden, notFound, unprocessable } from "../../../errors.js"; +import { isPaperclipCloudConnectorStrategy } from "../../paperclip-cloud-connector.js"; +import { vercelTokenRequest, type VercelConnectClient } from "../../vercel-connect.js"; + +import type { createConnectionCredentialValidation } from "./credential-validation.js"; +type GrantDependencies = { + getConnectionRow(idOrUid: string, companyId?: string): Promise; + assertSecretRefs: ReturnType; + vercelConnect: VercelConnectClient | null; +}; + +export function createConnectionGrants(db: Db, { getConnectionRow, assertSecretRefs, vercelConnect }: GrantDependencies) { + return { + listConnectionGrants: async (idOrUid: string, companyId?: string) => { + const connection = await getConnectionRow(idOrUid, companyId); + const grants = await db + .select() + .from(connectionGrants) + .where( + and( + eq(connectionGrants.companyId, connection.companyId), + eq(connectionGrants.connectionId, connection.id), + ), + ) + .orderBy( + desc(connectionGrants.isDefault), + desc(connectionGrants.updatedAt), + ); + const grantIds = grants.map((grant) => grant.id); + const [members, delegations] = + grantIds.length === 0 + ? [[], []] + : await Promise.all([ + db + .select() + .from(connectionGrantMembers) + .where( + and( + eq(connectionGrantMembers.companyId, connection.companyId), + inArray(connectionGrantMembers.grantId, grantIds), + ), + ), + db + .select() + .from(connectionGrantDelegations) + .where( + and( + eq( + connectionGrantDelegations.companyId, + connection.companyId, + ), + inArray(connectionGrantDelegations.grantId, grantIds), + ), + ), + ]); + return { + connection: { id: connection.id, uid: connection.uid }, + grants: grants.map((grant) => ({ + ...toConnectionGrant(grant), + members: members.filter((member) => member.grantId === grant.id), + delegations: delegations.filter( + (delegation) => delegation.grantId === grant.id, + ), + })), + }; + }, + /** + * Company members eligible to appear in an organization grant's audience. + * The audience editor needs display names, and the client must not have to + * cross-reference a second endpoint to render "12 selected members". + */ +listConnectionAudienceMembers: async (companyId: string) => { + const rows = await db + .select({ + userId: companyMemberships.principalId, + name: authUsers.name, + email: authUsers.email, + }) + .from(companyMemberships) + .leftJoin(authUsers, eq(authUsers.id, companyMemberships.principalId)) + .where( + and( + eq(companyMemberships.companyId, companyId), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.status, "active"), + ), + ); + return rows + .map((row) => ({ + userId: row.userId, + name: row.name ?? null, + email: row.email ?? null, + })) + .sort((a, b) => + (a.name ?? a.email ?? a.userId).localeCompare( + b.name ?? b.email ?? b.userId, + ), + ); + }, + /** + * Replace an organization grant's audience atomically (PAP-17835). + * + * An empty `memberUserIds` persists as zero rows, which the resolver already + * treats as "every organization member". Replacement is delete-then-insert + * inside one transaction so a partially-applied audience can never widen or + * narrow access, and every member id is checked against active company + * membership first so an audience cannot name an outsider. Existing audience + * members are locked too: membership cleanup deliberately retains a sole + * inactive audience row, and a concurrent empty replacement must not erase + * that fail-closed marker after cleanup wins. Once cleanup has retained that + * marker, callers must restore the member or replace it with an active named + * audience before widening access to the whole company. + */ +replaceConnectionGrantMembers: async ( + idOrUid: string, + grantId: string, + memberUserIds: string[], + actor?: ActorInfo, + ) => { + const connection = await getConnectionRow(idOrUid); + const [grant] = await db + .select() + .from(connectionGrants) + .where( + and( + eq(connectionGrants.id, grantId), + eq(connectionGrants.companyId, connection.companyId), + eq(connectionGrants.connectionId, connection.id), + ), + ) + .limit(1); + if (!grant) throw notFound("Connection grant not found"); + if (grant.kind !== "organization") { + throw badRequest( + "Only an organization identity has an audience; a personal identity belongs to its owner", + ); + } + const requested = [ + ...new Set(memberUserIds.map((id) => id.trim()).filter(Boolean)), + ]; + const binding = actorBinding(actor); + const members = await db.transaction(async (tx) => { + // Serialize replacements before taking the current audience snapshot. + // Otherwise a second replacement could read the old rows, wait behind + // an in-flight replacement, and then miss locking a newly-added member. + await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended(${"paperclip.connection_grant_audience:" + grant.id}, 0))`); + const existingAudience = await tx + .select() + .from(connectionGrantMembers) + .where( + and( + eq(connectionGrantMembers.companyId, connection.companyId), + eq(connectionGrantMembers.grantId, grant.id), + eq(connectionGrantMembers.subjectType, "user"), + ), + ); + const existingUserIds = [ + ...new Set(existingAudience.map((row) => row.subjectId)), + ]; + const membershipUserIds = [ + ...new Set([...existingUserIds, ...requested]), + ]; + if (membershipUserIds.length > 0) { + // Membership suspension/archive/removal takes the same row lock before + // sweeping grant audiences. Lock both the old and new audience so an + // empty replacement also serializes with cleanup of its existing sole + // member. Whichever transaction wins is then authoritative. + const memberships = await tx + .select({ + principalId: companyMemberships.principalId, + status: companyMemberships.status, + }) + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, connection.companyId), + eq(companyMemberships.principalType, "user"), + inArray(companyMemberships.principalId, membershipUserIds), + ), + ) + .orderBy(asc(companyMemberships.id)) + .for("update"); + const active = new Set( + memberships + .filter((row) => row.status === "active") + .map((row) => row.principalId), + ); + const unknown = requested.filter((id) => !active.has(id)); + if (unknown.length > 0) { + throw unprocessable( + "Every audience member must be an active member of this company", + { + code: "audience_member_not_in_company", + unknownUserIds: unknown, + }, + ); + } + const inactiveExisting = existingUserIds.filter( + (id) => !active.has(id), + ); + if (requested.length === 0 && inactiveExisting.length > 0) { + throw conflict( + "Replace inactive audience members before widening access to the whole company", + { + code: "audience_widening_blocked", + inactiveUserIds: inactiveExisting, + }, + ); + } + } + await tx + .select({ id: connectionGrants.id }) + .from(connectionGrants) + .where( + and( + eq(connectionGrants.id, grant.id), + eq(connectionGrants.companyId, connection.companyId), + eq(connectionGrants.connectionId, connection.id), + ), + ) + .for("update"); + const existingUserSet = new Set(existingUserIds); + if (requested.length === existingUserSet.size && requested.every(id => existingUserSet.has(id))) return existingAudience; + await tx + .delete(connectionGrantMembers) + .where( + and( + eq(connectionGrantMembers.companyId, connection.companyId), + eq(connectionGrantMembers.grantId, grant.id), + ), + ); + const inserted = + requested.length === 0 + ? [] + : await tx + .insert(connectionGrantMembers) + .values( + requested.map((subjectId) => ({ + companyId: connection.companyId, + grantId: grant.id, + subjectType: "user" as const, + subjectId, + })), + ) + .returning(); + await tx + .update(connectionGrants) + .set({ updatedAt: new Date() }) + .where(eq(connectionGrants.id, grant.id)); + return inserted; + }); + await db.insert(toolAccessAuditEvents).values({ + companyId: connection.companyId, + connectionId: connection.id, + actorType: binding.actorType ?? "system", + actorId: binding.actorId, + action: "connection_grant.audience_replaced", + outcome: "success", + reasonCode: "audience_replaced", + details: { + grantId: grant.id, + memberCount: members.length, + memberUserIds: requested, + }, + }); + return { ...toConnectionGrant(grant), members }; + }, + createConnectionGrantDelegation: async ( + idOrUid: string, + grantId: string, + agentId: string, + ownerUserId: string, + ) => { + const connection = await getConnectionRow(idOrUid); + if (connection.connectionPurpose === "ai") { + throw badRequest("AI credentials use the connection's human access settings, not agent delegation"); + } + return db.transaction(async (tx) => { + // Membership removal/suspension takes this same row lock before sweeping + // personal grants. Whichever operation wins is therefore authoritative: + // removal deletes a delegation committed first, while creation that runs + // second observes the inactive membership and fails closed. + const [membership] = await tx + .select({ id: companyMemberships.id }) + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, connection.companyId), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.principalId, ownerUserId), + eq(companyMemberships.status, "active"), + ), + ) + .for("update") + .limit(1); + if (!membership) { + throw forbidden( + "Only an active company member can delegate their personal grant", + ); + } + + const [grant] = await tx + .select() + .from(connectionGrants) + .where( + and( + eq(connectionGrants.id, grantId), + eq(connectionGrants.companyId, connection.companyId), + eq(connectionGrants.connectionId, connection.id), + eq(connectionGrants.kind, "user"), + eq(connectionGrants.subjectUserId, ownerUserId), + eq(connectionGrants.status, "active"), + ), + ) + .for("update") + .limit(1); + if (!grant) + throw forbidden( + "Only the active personal grant owner can create a delegation", + ); + await tx.select({ id: toolConnections.id }).from(toolConnections) + .where(eq(toolConnections.id, connection.id)).for("key share"); + const [targetAgent] = await tx + .select({ id: agents.id }) + .from(agents) + .where( + and( + eq(agents.id, agentId), + eq(agents.companyId, connection.companyId), + ), + ) + .for("no key update") + .limit(1); + if (!targetAgent) throw notFound("Agent not found"); + const [existing] = await tx + .select() + .from(connectionGrantDelegations) + .where( + and( + eq(connectionGrantDelegations.grantId, grant.id), + eq(connectionGrantDelegations.agentId, agentId), + ), + ) + .limit(1); + if (existing) return existing; + const [delegation] = await tx + .insert(connectionGrantDelegations) + .values({ + companyId: connection.companyId, + grantId: grant.id, + agentId, + createdByUserId: ownerUserId, + }) + .returning(); + await tx.insert(toolAccessAuditEvents).values({ + companyId: connection.companyId, + connectionId: connection.id, + actorType: "user", + actorId: ownerUserId, + action: "connection_grant.delegated", + outcome: "success", + reasonCode: "delegation_created", + details: { grantId: grant.id, delegationId: delegation!.id, agentId }, + }); + return delegation!; + }); + }, + revokeConnectionGrantDelegation: async ( + idOrUid: string, + grantId: string, + delegationId: string, + actor?: ActorInfo, + ) => { + const connection = await getConnectionRow(idOrUid); + const delegation = await db.transaction(async tx => { + const [grant] = await tx.select({ id: connectionGrants.id }).from(connectionGrants) + .where(and(eq(connectionGrants.id, grantId), eq(connectionGrants.companyId, connection.companyId), eq(connectionGrants.connectionId, connection.id))) + .for("update"); + if (!grant) return undefined; + const [target] = await tx.select({ agentId: connectionGrantDelegations.agentId }).from(connectionGrantDelegations) + .where(and(eq(connectionGrantDelegations.id, delegationId), eq(connectionGrantDelegations.companyId, connection.companyId), eq(connectionGrantDelegations.grantId, grantId))); + if (!target) return undefined; + await tx.select({ id: agents.id }).from(agents) + .where(and(eq(agents.companyId, connection.companyId), eq(agents.id, target.agentId))).for("no key update"); + const [removed] = await tx + .delete(connectionGrantDelegations) + .where( + and( + eq(connectionGrantDelegations.id, delegationId), + eq(connectionGrantDelegations.companyId, connection.companyId), + eq(connectionGrantDelegations.grantId, grantId), + ), + ) + .returning(); + return removed; + }); + if (!delegation) throw notFound("Connection grant delegation not found"); + const binding = actorBinding(actor); + await db.insert(toolAccessAuditEvents).values({ + companyId: connection.companyId, + connectionId: connection.id, + actorType: binding.actorType ?? "system", + actorId: binding.actorId, + action: "connection_grant.delegation_revoked", + outcome: "success", + reasonCode: "delegation_revoked", + details: { grantId, delegationId, agentId: delegation.agentId }, + }); + return delegation; + }, + addConnectionInstallation: async ( + idOrUid: string, + input: { + providerTenant?: { name?: string; externalId?: string }; + credentialSecretRefs?: typeof connectionGrants.$inferInsert.credentialSecretRefs; + isDefault?: boolean; + }, + actor?: ActorInfo, + ) => { + const connection = await getConnectionRow(idOrUid); + await assertSecretRefs( + connection.companyId, + input.credentialSecretRefs ?? [], + ); + if (input.isDefault) { + await db + .update(connectionGrants) + .set({ isDefault: false, updatedAt: new Date() }) + .where( + and( + eq(connectionGrants.connectionId, connection.id), + eq(connectionGrants.kind, "organization"), + ), + ); + } + const binding = actorBinding(actor); + const [grant] = await db + .insert(connectionGrants) + .values({ + companyId: connection.companyId, + connectionId: connection.id, + kind: "organization", + providerTenant: input.providerTenant, + credentialSecretRefs: input.credentialSecretRefs ?? [], + status: "active", + isDefault: input.isDefault ?? false, + createdByAgentId: + binding.actorType === "agent" ? binding.actorId : null, + createdByUserId: + binding.actorType === "user" ? binding.actorId : null, + }) + .returning(); + if (!grant) throw new Error("Failed to create connection installation"); + await db.insert(toolAccessAuditEvents).values({ + companyId: connection.companyId, + connectionId: connection.id, + actorType: binding.actorType ?? "system", + actorId: binding.actorId, + action: "connection_grant.created", + outcome: "success", + reasonCode: "grant_created", + details: { + grantId: grant.id, + kind: grant.kind, + isDefault: grant.isDefault, + }, + }); + return toConnectionGrant(grant); + }, + revokeConnectionGrant: async ( + idOrUid: string, + grantId: string, + actor?: ActorInfo, + ) => { + const connection = await getConnectionRow(idOrUid); + const binding = actorBinding(actor); + const [currentGrant] = await db + .select() + .from(connectionGrants) + .where( + and( + eq(connectionGrants.id, grantId), + eq(connectionGrants.companyId, connection.companyId), + eq(connectionGrants.connectionId, connection.id), + ), + ) + .limit(1); + if (!currentGrant) throw notFound("Connection grant not found"); + let providerRevocation = "not_applicable"; + if ( + connection.credentialSource === "vercel_connect" && + connection.externalCredential + ) { + const request = vercelTokenRequest({ + credential: connection.externalCredential, + grant: currentGrant, + connectionId: connection.id, + companyId: connection.companyId, + resources: vercelConnectResourcesFor(connection), + }); + vercelConnect?.evict(request); + if (currentGrant.externalCredential?.subjectType === "app") { + providerRevocation = "manage_in_vercel"; + } else if (currentGrant.externalCredential?.subjectType === "user") { + try { + if (!vercelConnect) throw new Error("Vercel Connect unavailable"); + await vercelConnect.revoke(request); + providerRevocation = "success"; + } catch { + providerRevocation = "failed"; + } + } + } else if ( + isPaperclipCloudConnectorStrategy(oauthConfig(connection).strategy) + ) { + // Google revocation is client-wide for a user. The managed Workspace + // profiles intentionally share one Paperclip-owned client, so revoking + // one token here could invalidate unrelated Gmail, Drive, and Calendar + // grants. A per-profile removal is therefore local-only. A future + // provider-level disconnect must warn that it removes every profile. + providerRevocation = "local_only_shared_client"; + } + const grant = await db.transaction(async (tx) => { + const [lockedGrant] = await tx.select({ id: connectionGrants.id }).from(connectionGrants) + .where(and(eq(connectionGrants.id, grantId), eq(connectionGrants.companyId, connection.companyId), eq(connectionGrants.connectionId, connection.id))) + .for("update"); + if (!lockedGrant) throw notFound("Connection grant not found"); + await tx.select({ id: agents.id }).from(agents) + .where(and(eq(agents.companyId, connection.companyId), notInArray(agents.status, ["pending_approval", "terminated"]))) + .orderBy(agents.id).for("no key update"); + const removedDelegations = await tx + .delete(connectionGrantDelegations) + .where( + and( + eq(connectionGrantDelegations.companyId, connection.companyId), + eq(connectionGrantDelegations.grantId, grantId), + ), + ) + .returning(); + const [updated] = await tx + .update(connectionGrants) + .set({ + status: "revoked", + isDefault: false, + revokedAt: new Date(), + revokedByAgentId: + binding.actorType === "agent" ? binding.actorId : null, + revokedByUserId: + binding.actorType === "user" ? binding.actorId : null, + updatedAt: new Date(), + }) + .where( + and( + eq(connectionGrants.id, grantId), + eq(connectionGrants.companyId, connection.companyId), + eq(connectionGrants.connectionId, connection.id), + ), + ) + .returning(); + if (!updated) throw notFound("Connection grant not found"); + if (removedDelegations.length > 0) { + await tx.insert(toolAccessAuditEvents).values( + removedDelegations.map((delegation) => ({ + companyId: connection.companyId, + connectionId: connection.id, + actorType: binding.actorType ?? "system", + actorId: binding.actorId, + action: "connection_grant.delegation_revoked", + outcome: "success", + reasonCode: "grant_revoked", + details: { + grantId, + delegationId: delegation.id, + agentId: delegation.agentId, + }, + })), + ); + } + return updated; + }); + if (!grant) throw notFound("Connection grant not found"); + await db.insert(toolAccessAuditEvents).values({ + companyId: connection.companyId, + connectionId: connection.id, + actorType: binding.actorType ?? "system", + actorId: binding.actorId, + action: "connection_grant.revoked", + outcome: "success", + reasonCode: "grant_revoked", + details: { grantId: grant.id, kind: grant.kind, providerRevocation }, + }); + return toConnectionGrant(grant); + } + }; +} diff --git a/server/src/services/tool-access/connections/installs.ts b/server/src/services/tool-access/connections/installs.ts new file mode 100644 index 00000000000..2879cf8af8f --- /dev/null +++ b/server/src/services/tool-access/connections/installs.ts @@ -0,0 +1,350 @@ +import { toConnection, toConnectionInstall } from "./projection.js"; +import { actorBinding } from "../actor.js"; +import { lockToolAssignmentAgents, lockToolConnectionRuntimeInputs } from "../assignment-locks.js"; +import type { ActorInfo } from "../types.js"; +import { and, asc, eq, inArray, sql } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { toolAccessAuditEvents, toolConnectionInstalls, toolConnections, toolProfileBindings, toolProfileEntries, toolProfiles } from "@paperclipai/db"; +import type { ToolConnection, ToolConnectionInstall, ToolConnectionInstallSnapshot, PutToolConnectionInstalls } from "@paperclipai/shared"; +import { unprocessable } from "../../../errors.js"; +import { logActivity } from "../../activity-log.js"; + +type InstallDependencies = { + now: () => Date; + getConnectionRow(idOrUid: string, companyId?: string): Promise; + assertOptionalAgent(companyId: string, agentId: string | null | undefined, label: string): Promise; +}; + +export function createConnectionInstalls(db: Db, { now, getConnectionRow, assertOptionalAgent }: InstallDependencies) { + async function appProfileForConnection( + dbClient: Pick, + connection: typeof toolConnections.$inferSelect, + ) { + const profileKey = `app:${connection.id}`; + let [profile] = await dbClient + .select() + .from(toolProfiles) + .where( + and( + eq(toolProfiles.companyId, connection.companyId), + eq(toolProfiles.profileKey, profileKey), + ), + ) + .limit(1); + if (!profile) { + const [sameName] = await dbClient + .select({ id: toolProfiles.id }) + .from(toolProfiles) + .where( + and( + eq(toolProfiles.companyId, connection.companyId), + eq(toolProfiles.name, connection.name), + ), + ) + .limit(1); + const profileName = sameName + ? `${connection.name} (${connection.id.replace(/-/g, "").slice(0, 8)})` + : connection.name; + [profile] = await dbClient + .insert(toolProfiles) + .values({ + companyId: connection.companyId, + profileKey, + name: profileName, + description: `Access profile for ${connection.name}.`, + status: "active", + defaultAction: "deny", + metadata: { + source: "tool_connection_install", + connectionId: connection.id, + }, + }) + .returning(); + } + // Installation controls where a connection is exposed, not which actions + // it grants. The app wizard's catalog-entry includes are the authority for + // action selection, so remove the legacy connection-wide include that used + // to silently turn every installed action on. + await dbClient + .delete(toolProfileEntries) + .where( + and( + eq(toolProfileEntries.companyId, connection.companyId), + eq(toolProfileEntries.profileId, profile.id), + eq(toolProfileEntries.selectorType, "connection"), + eq(toolProfileEntries.effect, "include"), + eq(toolProfileEntries.connectionId, connection.id), + ), + ); + return profile; + } + + async function listConnectionInstalls( + connectionId: string, + companyId?: string, + ): Promise { + const connection = await getConnectionRow(connectionId, companyId); + const rows = await db + .select() + .from(toolConnectionInstalls) + .where( + and( + eq(toolConnectionInstalls.companyId, connection.companyId), + eq(toolConnectionInstalls.connectionId, connection.id), + ), + ) + .orderBy( + asc(toolConnectionInstalls.targetType), + asc(toolConnectionInstalls.targetId), + ); + return rows.map(toConnectionInstall); + } + + async function resolveInstalledConnectionsForAgent( + companyId: string, + agentId: string, + ): Promise { + await assertOptionalAgent( + companyId, + agentId, + "Tool connection install agent", + ); + const installRows = await db + .select() + .from(toolConnectionInstalls) + .where( + and( + eq(toolConnectionInstalls.companyId, companyId), + sql`((${toolConnectionInstalls.targetType} = 'company' and ${toolConnectionInstalls.targetId} = ${companyId}) or (${toolConnectionInstalls.targetType} = 'agent' and ${toolConnectionInstalls.targetId} = ${agentId}))`, + ), + ); + if (installRows.length === 0) return []; + const connectionIds = [ + ...new Set(installRows.map((install) => install.connectionId)), + ]; + const rows = await db + .select() + .from(toolConnections) + .where( + and( + eq(toolConnections.companyId, companyId), + inArray(toolConnections.id, connectionIds), + ), + ) + .orderBy(asc(toolConnections.name)); + return rows.map((row) => ({ + ...toConnection(row), + installs: installRows + .filter((install) => install.connectionId === row.id) + .map(toConnectionInstall), + })); + } + return { + resolveInstalledConnectionsForAgent, + listConnectionInstalls, + putConnectionInstalls: async ( + connectionId: string, + input: PutToolConnectionInstalls, + actor?: ActorInfo, + ): Promise => { + const connection = await getConnectionRow(connectionId); + const requested = new Map( + input.installs.map((install) => [ + `${install.targetType}:${install.targetId}`, + install, + ]), + ); + for (const install of requested.values()) { + if (install.targetType === "company") { + if (install.targetId !== connection.companyId) + throw unprocessable( + "Company installs must target the connection company", + ); + } else { + await assertOptionalAgent( + connection.companyId, + install.targetId, + "Tool connection install agent", + ); + } + } + const markMcpAccessConfigured = connection.transport === "mcp_remote" + && connection.config.mcpAgentAccessConfigured !== true; + const accessExtensions: Array<{ + targetType: "company" | "agent"; + targetId: string; + profileId: string; + }> = []; + await db.transaction(async (tx) => { + await lockToolConnectionRuntimeInputs(tx, connection.companyId, connection.id); + if (markMcpAccessConfigured) { + // Preserve an explicit zero-agent selection through OAuth. Merge the + // marker in SQL so concurrent credential/config changes are retained. + await tx.update(toolConnections).set({ + config: sql`${toolConnections.config} || '{"mcpAgentAccessConfigured":true}'::jsonb`, + transportConfig: sql`${toolConnections.transportConfig} || '{"mcpAgentAccessConfigured":true}'::jsonb`, + updatedAt: now(), + }).where(and( + eq(toolConnections.companyId, connection.companyId), + eq(toolConnections.id, connection.id), + )); + } + const existing = await tx + .select() + .from(toolConnectionInstalls) + .where( + and( + eq(toolConnectionInstalls.companyId, connection.companyId), + eq(toolConnectionInstalls.connectionId, connection.id), + ), + ); + const existingKeys = new Set( + existing.map( + (install) => `${install.targetType}:${install.targetId}`, + ), + ); + const bindings = await tx.select({ targetType: toolProfileBindings.targetType, targetId: toolProfileBindings.targetId }) + .from(toolProfileBindings).innerJoin(toolProfiles, eq(toolProfiles.id, toolProfileBindings.profileId)) + .where(and(eq(toolProfileBindings.companyId, connection.companyId), eq(toolProfiles.companyId, connection.companyId), eq(toolProfiles.profileKey, `app:${connection.id}`))); + await lockToolAssignmentAgents(tx, connection.companyId, [...existing, ...requested.values(), ...bindings]); + const removals = existing.filter( + (install) => + !requested.has(`${install.targetType}:${install.targetId}`), + ); + const removeIds = removals.map((install) => install.id); + if (removeIds.length > 0) { + await tx + .delete(toolConnectionInstalls) + .where(inArray(toolConnectionInstalls.id, removeIds)); + // Uninstalling must also drop the binding this path created. Installing + // writes both an install row and a profile binding, so deleting only the + // install row leaves a binding that no surface can see or remove. The + // install row is the reach gate (`mintConnectionTokenForAgent` fails with + // `installation_required`, and the heartbeat only hands over installed + // connections), so a stale binding grants no reach on its own — but it + // still makes `finishApp`, which rebuilds `access` from the bindings, + // read a target the operator already removed. + // + // Only bindings tagged `source: "tool_connection_install"` are removed. + // A binding the operator authored through the access model carries a + // different source and must survive an uninstall. + const [installProfile] = await tx + .select({ id: toolProfiles.id }) + .from(toolProfiles) + .where( + and( + eq(toolProfiles.companyId, connection.companyId), + eq(toolProfiles.profileKey, `app:${connection.id}`), + ), + ) + .limit(1); + if (installProfile) { + for (const install of removals) { + await tx + .delete(toolProfileBindings) + .where( + and( + eq(toolProfileBindings.companyId, connection.companyId), + eq(toolProfileBindings.profileId, installProfile.id), + eq(toolProfileBindings.targetType, install.targetType), + eq(toolProfileBindings.targetId, install.targetId), + sql`${toolProfileBindings.metadata}->>'source' = 'tool_connection_install'`, + ), + ); + } + } + } + const additions = [...requested.entries()] + .filter(([key]) => !existingKeys.has(key)) + .map(([, install]) => install); + if (additions.length > 0) { + await tx.insert(toolConnectionInstalls).values( + additions.map((install) => ({ + companyId: connection.companyId, + connectionId: connection.id, + targetType: install.targetType, + targetId: install.targetId, + createdByAgentId: + actor?.actorType === "agent" ? (actor.actorId ?? null) : null, + createdByUserId: + actor?.actorType === "user" ? (actor.actorId ?? null) : null, + })), + ); + } + if (requested.size > 0 && connection.connectionPurpose !== "ai") { + const profile = await appProfileForConnection(tx, connection); + for (const install of requested.values()) { + const [binding] = await tx + .insert(toolProfileBindings) + .values({ + companyId: connection.companyId, + profileId: profile.id, + targetType: install.targetType, + targetId: install.targetId, + priority: 100, + metadata: { + source: "tool_connection_install", + connectionId: connection.id, + }, + createdByAgentId: + actor?.actorType === "agent" ? (actor.actorId ?? null) : null, + createdByUserId: + actor?.actorType === "user" ? (actor.actorId ?? null) : null, + }) + .onConflictDoNothing() + .returning({ id: toolProfileBindings.id }); + if (binding) + accessExtensions.push({ + targetType: install.targetType, + targetId: install.targetId, + profileId: profile.id, + }); + } + } + if (markMcpAccessConfigured || removeIds.length > 0 || additions.length > 0) { + const binding = actorBinding(actor); + await tx.insert(toolAccessAuditEvents).values({ + companyId: connection.companyId, + connectionId: connection.id, + actorType: binding.actorType ?? "system", + actorId: binding.actorId, + action: "connection_installs.changed", + outcome: "success", + reasonCode: "installs_changed", + details: { + ...(markMcpAccessConfigured ? { mcpAgentAccessConfigured: true } : {}), + added: additions.map((install) => ({ + targetType: install.targetType, + targetId: install.targetId, + })), + removed: existing + .filter((install) => removeIds.includes(install.id)) + .map((install) => ({ + targetType: install.targetType, + targetId: install.targetId, + })), + }, + }); + } + }); + for (const extension of accessExtensions) { + await logActivity(db, { + companyId: connection.companyId, + actorType: actor?.actorType ?? "system", + actorId: actor?.actorId ?? "system", + action: "tool_connection.install_access_extended", + entityType: "tool_connection", + entityId: connection.id, + details: extension, + }); + } + return { + connectionId: connection.id, + installs: await listConnectionInstalls( + connection.id, + connection.companyId, + ), + }; + } + }; +} diff --git a/server/src/services/tool-access/connections/removal.ts b/server/src/services/tool-access/connections/removal.ts new file mode 100644 index 00000000000..71ab1767558 --- /dev/null +++ b/server/src/services/tool-access/connections/removal.ts @@ -0,0 +1,658 @@ +import { toConnection } from "./projection.js"; +import { actorBinding } from "../actor.js"; +import { vercelConnectResourcesFor } from "../vercel-credentials.js"; +import type { ActorInfo } from "../types.js"; +import { chatCredentialMutationLease } from "../../chat-credential-mutation-lease.js"; +import { removeSlackRegistration } from "../../chat-slack-registration-cleanup.js"; +import { chatEndpoints } from "@paperclipai/db"; +import { isBrowserUseConnection } from "../../browser-use-client.js"; +import { browserUseService } from "../../browser-use.js"; +import { emitConnectionUpdated } from "../../connector-telemetry.js"; +import { and, eq, inArray, isNull, ne, sql } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { connectionGrants, connectionTokenIssuances, companySecretBindings, companySecrets, userSecretDefinitions, userSecretDeclarations, toolApplications, toolCatalogEntries, toolConnectionInstalls, toolConnections, toolOauthStates, toolGatewaySessions, toolMcpGateways, toolMcpGatewayTokens, toolProfileBindings, toolProfileEntries, toolProfiles, toolRuntimeSlots } from "@paperclipai/db"; +import type { ToolConnectionRemovalResult, ToolConnectionRemovalSummary } from "@paperclipai/shared"; +import { notFound } from "../../../errors.js"; +import { logger } from "../../../middleware/logger.js"; +import { forgetMcpHttpSessions } from "../../mcp-http.js"; +import { secretService } from "../../secrets.js"; +import { connectionSecretsUsedByOtherConsumers } from "../../connection-credentials.js"; +import { createToolRuntimeSupervisor } from "../../tool-runtime-supervisor.js"; +import { vercelConnectIntegrationStatus, vercelTokenRequest, type VercelConnectClient } from "../../vercel-connect.js"; + +function asRecord(value: unknown): Record { + if (value && typeof value === "object" && !Array.isArray(value)) + return value as Record; + return {}; +} + +/** + * The key namespace `connectGalleryApp`, `reconnectGalleryApp` and + * `createOrRotateOAuthSecret` mint for credentials a connection owns outright. + * Nothing else writes this prefix, which is what lets removal tell a dedicated + * app credential apart from a secret the operator manages by hand — see + * `classifyConnectionSecrets`. Matched as a prefix on purpose: `secrets.remove` + * suffixes `__deleted__` onto the key before it deletes the row, so a + * removal that is retried after a provider failure must still recognise it. + */ +const CONNECTION_OWNED_SECRET_KEY_PREFIX = "tool_app."; + +/** Token fields a legacy row may have inlined into `config.oauth`. */ +const INLINE_OAUTH_TOKEN_FIELDS = [ + "access_token", + "refresh_token", + "accessToken", + "refreshToken", + "client_secret", + "clientSecret", +]; + +/** + * Drop inline OAuth token material from a connection config, keeping the + * non-secret identity (client id, issuer, registration source) a later + * reconnect reuses. Current code stores tokens as secret refs, never in the + * config; this exists so a row written by an older build cannot keep a usable + * token after the operator removed the app. + */ +function withoutInlineOAuthTokens( + config: Record, +): Record { + const oauth = config.oauth; + if (!oauth || typeof oauth !== "object" || Array.isArray(oauth)) + return config; + const next = { ...(oauth as Record) }; + let changed = false; + for (const field of INLINE_OAUTH_TOKEN_FIELDS) { + if (field in next) { + delete next[field]; + changed = true; + } + } + return changed ? { ...config, oauth: next } : config; +} + +type RemovalDependencies = { + options: { remoteHttpRequest?: (url: string, init: RequestInit) => Promise }; + secrets: ReturnType; + runtimeSupervisor: Pick, "stopSlot">; + vercelConnect: VercelConnectClient | null; + getConnectionRow(idOrUid: string, companyId?: string): Promise; +}; + +export function createConnectionRemoval(db: Db, { options, secrets, runtimeSupervisor, vercelConnect, getConnectionRow }: RemovalDependencies) { + /** + * Split the secrets a connection points at into the ones it owns outright and + * the ones another consumer still depends on. + * + * Removing an app is a credential revocation boundary (PAP-17119), but it must + * never destroy a secret the operator manages by hand or shares with another + * target. Two independent tests have to agree before a secret is destroyed: + * + * 1. Provenance — the key sits in the `tool_app.` namespace only the + * connect/reconnect/OAuth paths mint, or its user-secret definition does. + * Personal definitions must also have no declarations on other targets. + * 2. Exclusivity — nothing outside this connection references it: no + * `company_secret_bindings` row from another target, and no other + * connection or connection grant naming the same secret id. + * + * A secret failing either test is reported as retained; removal still drops + * this connection's binding and ref, so the connection loses the credential + * either way. Retaining a secret nobody can reach is a leak of an unused row; + * deleting one another target still resolves is an outage, so the ambiguous + * case fails towards retention. + */ +async function classifyConnectionSecrets( + connection: typeof toolConnections.$inferSelect, + secretIds: string[], + ): Promise<{ owned: string[]; retained: string[] }> { + const unique = [ + ...new Set( + secretIds.filter((id) => typeof id === "string" && id.length > 0), + ), + ]; + if (unique.length === 0) return { owned: [], retained: [] }; + + const secretRows = await db + .select({ + id: companySecrets.id, + key: companySecrets.key, + scope: companySecrets.scope, + userSecretDefinitionId: companySecrets.userSecretDefinitionId, + }) + .from(companySecrets) + .where( + and( + eq(companySecrets.companyId, connection.companyId), + inArray(companySecrets.id, unique), + ), + ); + const byId = new Map(secretRows.map((row) => [row.id, row])); + + const referencedElsewhere = await connectionSecretsUsedByOtherConsumers(db, unique); + const foreignBindings = await db + .select({ secretId: companySecretBindings.secretId }) + .from(companySecretBindings) + .where( + and( + eq(companySecretBindings.companyId, connection.companyId), + inArray(companySecretBindings.secretId, unique), + sql`not (${companySecretBindings.targetType} = 'tool_connection' and ${companySecretBindings.targetId} = ${connection.id})`, + ), + ); + for (const row of foreignBindings) referencedElsewhere.add(row.secretId); + + // Bindings are the authority, but read the sibling refs too: a row written + // before `syncCredentialBindings` existed — or by hand — can reference a + // secret with no binding to prove it. + const siblingConnections = await db + .select({ + credentialRefs: toolConnections.credentialRefs, + credentialSecretRefs: toolConnections.credentialSecretRefs, + }) + .from(toolConnections) + .where( + and( + eq(toolConnections.companyId, connection.companyId), + ne(toolConnections.id, connection.id), + ), + ); + for (const row of siblingConnections) { + for (const ref of row.credentialRefs ?? []) + referencedElsewhere.add(ref.secretId); + for (const ref of row.credentialSecretRefs ?? []) + referencedElsewhere.add(ref.secretId); + } + const siblingGrants = await db + .select({ credentialSecretRefs: connectionGrants.credentialSecretRefs }) + .from(connectionGrants) + .where( + and( + eq(connectionGrants.companyId, connection.companyId), + ne(connectionGrants.connectionId, connection.id), + ), + ); + for (const row of siblingGrants) { + for (const ref of row.credentialSecretRefs ?? []) + referencedElsewhere.add(ref.secretId); + } + + const definitionIds = secretRows.flatMap((row) => row.userSecretDefinitionId ? [row.userSecretDefinitionId] : []); + const definitions = definitionIds.length ? await db.select().from(userSecretDefinitions) + .where(and(eq(userSecretDefinitions.companyId, connection.companyId), inArray(userSecretDefinitions.id, definitionIds))) : []; + const dedicatedDefinitions = new Set(definitions.filter((row) => row.key.startsWith(CONNECTION_OWNED_SECRET_KEY_PREFIX) || row.key.startsWith(`tool_oauth.${connection.id}.`)).map((row) => row.id)); + const foreignDeclarations = definitionIds.length ? await db.select().from(userSecretDeclarations).where(and( + inArray(userSecretDeclarations.userSecretDefinitionId, definitionIds), + sql`not (${userSecretDeclarations.targetType} = 'tool_connection' and ${userSecretDeclarations.targetId} = ${connection.id})`, + )) : []; + const sharedDefinitions = new Set(foreignDeclarations.map((row) => row.userSecretDefinitionId)); + const owned: string[] = []; + const retained: string[] = []; + for (const secretId of unique) { + const row = byId.get(secretId); + // No row means an earlier pass of this same removal already deleted it. + // Hand it back as owned so a retry re-runs the (idempotent) revocation + // instead of reporting a credential this connection never shared. + if (!row) { + owned.push(secretId); + continue; + } + const dedicated = row.scope === "user" && row.userSecretDefinitionId + ? dedicatedDefinitions.has(row.userSecretDefinitionId) && !sharedDefinitions.has(row.userSecretDefinitionId) + : row.scope === "company" && row.userSecretDefinitionId === null + && row.key.startsWith(CONNECTION_OWNED_SECRET_KEY_PREFIX); + if (dedicated && !referencedElsewhere.has(secretId)) owned.push(secretId); + else retained.push(secretId); + } + return { owned, retained }; + } + + /** + * Remove an app: a credential-revoking teardown, not a status flip (PAP-17119). + * + * Order is the security property. Every database-side access path closes + * first — grants, installs, the app-managed profile, gateway tokens minted + * against it, outstanding OAuth state, the catalog, and the connection itself + * — so the app is already undispatchable before the first call out to a secret + * provider. Secret revocation runs last, and each secret's ref survives until + * that secret is gone, so a provider that errors leaves the operation failed + * closed and resumable: the credential is already unresolvable (its row is + * marked deleted first), and retrying the same removal finishes the job. + * + * What stays behind is deliberate: the connection and application rows, their + * ids, names and activity keep working so a later reconnect reuses the same + * identity — but with no credential, no install and no profile, so + * reconnecting has to ask for fresh authentication and rebuild access. + */ +async function removeConnection( + connectionId: string, + companyId?: string, + actor?: ActorInfo, + ): Promise { + const connection = await getConnectionRow(connectionId, companyId); + const [endpoint] = await db.select().from(chatEndpoints).where(and(eq(chatEndpoints.connectionId, connection.id), eq(chatEndpoints.companyId, connection.companyId), eq(chatEndpoints.provider, "slack"))); + if (!endpoint) return removeConnectionUnlocked(connectionId, companyId, actor); + return chatCredentialMutationLease(db)(endpoint, async lease => { + await db.transaction(async tx => { + await lease.assertOwned(tx); + await tx.update(chatEndpoints).set({ status: "archived", updatedAt: new Date() }).where(eq(chatEndpoints.id, endpoint.id)); + await tx.update(toolConnections).set({ status: "archived", enabled: false, updatedAt: new Date() }).where(eq(toolConnections.id, connection.id)); + }); + await removeSlackRegistration(db, endpoint.id, lease); + return removeConnectionUnlocked(connectionId, companyId, actor); + }); + } + + async function removeConnectionUnlocked(connectionId: string, companyId?: string, actor?: ActorInfo): Promise { + const connection = await getConnectionRow(connectionId, companyId); + forgetMcpHttpSessions(connection.id); + const now = new Date(); + const binding = actorBinding(actor); + + // Grants are read before they are revoked: a retried removal must still see + // the credential refs of a grant an earlier pass already marked revoked. + const grantRows = await db + .select({ + id: connectionGrants.id, + status: connectionGrants.status, + kind: connectionGrants.kind, + subjectUserId: connectionGrants.subjectUserId, + credentialSecretRefs: connectionGrants.credentialSecretRefs, + externalCredential: connectionGrants.externalCredential, + }) + .from(connectionGrants) + .where( + and( + eq(connectionGrants.companyId, connection.companyId), + eq(connectionGrants.connectionId, connection.id), + ), + ); + const grantsToRevoke = grantRows.filter((row) => row.status !== "revoked"); + if (grantsToRevoke.length > 0) { + await db + .update(connectionGrants) + .set({ + status: "revoked", + isDefault: false, + revokedAt: now, + revokedByAgentId: + binding.actorType === "agent" ? binding.actorId : null, + revokedByUserId: + binding.actorType === "user" ? binding.actorId : null, + updatedAt: now, + }) + .where( + inArray( + connectionGrants.id, + grantsToRevoke.map((row) => row.id), + ), + ); + } + let externalCredentialCleanup: ToolConnectionRemovalSummary["externalCredentialCleanup"] = + null; + if ( + connection.credentialSource === "vercel_connect" && + connection.externalCredential + ) { + let attempted = 0; + let revoked = 0; + let failures = 0; + let appSubjectCleanup: "not_applicable" | "manage_in_vercel" = + "not_applicable"; + for (const grant of grantRows) { + const request = vercelTokenRequest({ + credential: connection.externalCredential, + grant, + connectionId: connection.id, + companyId: connection.companyId, + resources: vercelConnectResourcesFor(connection), + }); + vercelConnect?.evict(request); + if (grant.externalCredential?.subjectType === "app") { + appSubjectCleanup = "manage_in_vercel"; + continue; + } + if (grant.externalCredential?.subjectType !== "user") continue; + attempted += 1; + try { + if (!vercelConnect) throw new Error("Vercel Connect unavailable"); + await vercelConnect.revoke(request); + revoked += 1; + } catch { + failures += 1; + } + } + externalCredentialCleanup = { + provider: "vercel_connect", + userSubjectsAttempted: attempted, + userSubjectsRevoked: revoked, + userSubjectFailures: failures, + appSubjectCleanup, + manageUrl: vercelConnectIntegrationStatus().manageUrl, + }; + } + + // Bindings are how a credential reaches a runtime, so they go before the + // provider round-trip rather than after it. They are also not needed to + // finish the job: the refs on the connection row are what a resumed removal + // reads to find the secrets it still owes a revocation. + const removedSecretBindings = await db + .delete(companySecretBindings) + .where( + and( + eq(companySecretBindings.companyId, connection.companyId), + eq(companySecretBindings.targetType, "tool_connection"), + eq(companySecretBindings.targetId, connection.id), + ), + ) + .returning({ id: companySecretBindings.id }); + + const removedInstalls = await db + .delete(toolConnectionInstalls) + .where( + and( + eq(toolConnectionInstalls.companyId, connection.companyId), + eq(toolConnectionInstalls.connectionId, connection.id), + ), + ) + .returning({ id: toolConnectionInstalls.id }); + + // The app-managed profile exists only to carry this connection's action + // selection, so it goes with the connection. Operator-authored profiles that + // happen to mention the connection are left alone — the archived connection + // is denied by the policy engine regardless. + const [appProfile] = await db + .select({ id: toolProfiles.id }) + .from(toolProfiles) + .where( + and( + eq(toolProfiles.companyId, connection.companyId), + eq(toolProfiles.profileKey, `app:${connection.id}`), + ), + ) + .limit(1); + let appProfileOutcome: ToolConnectionRemovalSummary["appProfile"] = + "absent"; + let appProfileEntriesRemoved = 0; + let appProfileBindingsRemoved = 0; + let gatewayTokensRevoked = 0; + let gatewaySessionsRevoked = 0; + if (appProfile) { + appProfileEntriesRemoved = await db.transaction(async tx => { + await tx.select({ id: toolProfiles.id }).from(toolProfiles) + .where(and(eq(toolProfiles.companyId, connection.companyId), eq(toolProfiles.id, appProfile.id))) + .for("no key update"); + return (await tx.delete(toolProfileEntries) + .where(and(eq(toolProfileEntries.companyId, connection.companyId), eq(toolProfileEntries.profileId, appProfile.id))) + .returning({ id: toolProfileEntries.id })).length; + }); + appProfileBindingsRemoved = ( + await db + .delete(toolProfileBindings) + .where( + and( + eq(toolProfileBindings.companyId, connection.companyId), + eq(toolProfileBindings.profileId, appProfile.id), + ), + ) + .returning({ id: toolProfileBindings.id }) + ).length; + + const gatewayRows = await db + .select({ id: toolMcpGateways.id }) + .from(toolMcpGateways) + .where( + and( + eq(toolMcpGateways.companyId, connection.companyId), + eq(toolMcpGateways.profileId, appProfile.id), + ), + ); + if (gatewayRows.length === 0) { + await db.delete(toolProfiles).where(eq(toolProfiles.id, appProfile.id)); + appProfileOutcome = "deleted"; + } else { + // `tool_mcp_gateways.profile_id` is ON DELETE RESTRICT, so a gateway + // pointing here keeps the row alive. Archive it instead — the policy + // engine only consults `active` profiles, and it has no entries left — + // and revoke the tokens those gateways already handed out, which are the + // one credential a caller could still present. + await db + .update(toolProfiles) + .set({ status: "archived", defaultAction: "deny", updatedAt: now }) + .where(eq(toolProfiles.id, appProfile.id)); + appProfileOutcome = "archived"; + const revokedTokens = await db + .update(toolMcpGatewayTokens) + .set({ revokedAt: now, updatedAt: now }) + .where( + and( + eq(toolMcpGatewayTokens.companyId, connection.companyId), + inArray( + toolMcpGatewayTokens.gatewayId, + gatewayRows.map((row) => row.id), + ), + isNull(toolMcpGatewayTokens.revokedAt), + ), + ) + .returning({ id: toolMcpGatewayTokens.id }); + gatewayTokensRevoked = revokedTokens.length; + if (revokedTokens.length > 0) { + gatewaySessionsRevoked = ( + await db + .update(toolGatewaySessions) + .set({ revokedAt: now, updatedAt: now }) + .where( + and( + eq(toolGatewaySessions.companyId, connection.companyId), + inArray( + toolGatewaySessions.gatewayTokenId, + revokedTokens.map((row) => row.id), + ), + isNull(toolGatewaySessions.revokedAt), + ), + ) + .returning({ id: toolGatewaySessions.id }) + ).length; + } + } + } + + // A local runtime already holds the injected credential inside a live child + // process, so archiving rows is not enough — the process itself is an access + // path. Stopping is best effort on purpose: if the supervisor cannot be + // reached, revoking the credential anyway (so nothing can start again) beats + // abandoning the teardown, and the warning says which slot was left running. + let runtimeSlotsStopped = 0; + const runtimeSlotRows = await db + .select({ id: toolRuntimeSlots.id, status: toolRuntimeSlots.status }) + .from(toolRuntimeSlots) + .where( + and( + eq(toolRuntimeSlots.companyId, connection.companyId), + eq(toolRuntimeSlots.connectionId, connection.id), + ), + ); + for (const slot of runtimeSlotRows) { + if (slot.status === "stopped") continue; + try { + await runtimeSupervisor.stopSlot({ + companyId: connection.companyId, + slotId: slot.id, + reason: "connection_removed", + }); + runtimeSlotsStopped += 1; + } catch (error) { + logger.warn( + { + err: error, + companyId: connection.companyId, + connectionId: connection.id, + slotId: slot.id, + }, + "tool connection removal could not stop a runtime slot", + ); + } + } + + // The catalog stays as history, but `removed` is the status that stops a + // standing trust rule from auto-allowing one of these actions again. + const removedCatalogEntries = await db + .update(toolCatalogEntries) + .set({ status: "removed", updatedAt: now }) + .where( + and( + eq(toolCatalogEntries.companyId, connection.companyId), + eq(toolCatalogEntries.connectionId, connection.id), + ne(toolCatalogEntries.status, "removed"), + ), + ) + .returning({ id: toolCatalogEntries.id }); + + // An authorization already in flight would otherwise come back and mint a + // fresh token for an app the operator just removed. + const discardedOAuthStates = await db + .delete(toolOauthStates) + .where( + and( + eq(toolOauthStates.companyId, connection.companyId), + eq(toolOauthStates.connectionId, connection.id), + ), + ) + .returning({ state: toolOauthStates.state }); + + // Token-derived material in the issuance ledger is not an access path — + // nothing validates against it — but there is no reason to keep a hash of a + // credential the operator asked us to revoke. Path, outcome, actor and time + // stay, so the usage history survives. + const clearedIssuanceHashes = await db + .update(connectionTokenIssuances) + .set({ tokenHash: null }) + .where( + and( + eq(connectionTokenIssuances.companyId, connection.companyId), + eq(connectionTokenIssuances.connectionId, connection.id), + sql`${connectionTokenIssuances.tokenHash} is not null`, + ), + ) + .returning({ id: connectionTokenIssuances.id }); + + const archived = await db.transaction(async (tx) => { + await tx.select({ id: toolApplications.id }).from(toolApplications) + .where(and(eq(toolApplications.id, connection.applicationId), eq(toolApplications.companyId, connection.companyId))) + .for("update"); + const [updatedConnection] = await tx + .update(toolConnections) + .set({ status: "archived", enabled: false, updatedAt: now }) + .where(eq(toolConnections.id, connection.id)) + .returning(); + if (!updatedConnection) throw notFound("Tool connection not found"); + + const remainingConnections = await tx + .select({ id: toolConnections.id }) + .from(toolConnections) + .where( + and( + eq(toolConnections.applicationId, updatedConnection.applicationId), + ne(toolConnections.status, "archived"), + ), + ) + .limit(1); + + let applicationArchived = false; + if (remainingConnections.length === 0) { + const [application] = await tx + .update(toolApplications) + .set({ status: "archived", archivedAt: now, updatedAt: now }) + .where( + and( + eq(toolApplications.id, updatedConnection.applicationId), + ne(toolApplications.status, "archived"), + ), + ) + .returning({ id: toolApplications.id }); + applicationArchived = Boolean(application); + } + + return { connection: updatedConnection, applicationArchived }; + }); + emitConnectionUpdated(archived.connection, connection, "archive"); + + // Hosted work needs its credential to stop. Agent/viewer access is already + // revoked above; retain cleanup authority until provider shutdown confirms. + if (isBrowserUseConnection(connection)) { + await browserUseService(db, options.remoteHttpRequest).stopBeforeCredentialRemoval(connection.companyId, connection.id); + } + + // Only now, with every access path closed, revoke the credentials. Each + // `secrets.remove` marks the row deleted before it calls the provider, so a + // provider error leaves an unresolvable secret and a resumable removal + // rather than a half-open app. + const candidateSecretIds = [ + ...Object.values(asRecord(connection.config.aggregatorDiscovery)).flatMap(value => typeof asRecord(value).secretId === "string" ? [asRecord(value).secretId as string] : []), + ...connection.credentialRefs.map((ref) => ref.secretId), + ...connection.credentialSecretRefs.map((ref) => ref.secretId), + ...grantRows.flatMap((grant) => + (grant.credentialSecretRefs ?? []).map((ref) => ref.secretId), + ), + ]; + const { owned, retained } = await classifyConnectionSecrets( + connection, + candidateSecretIds, + ); + let secretsRevoked = 0; + for (const secretId of owned) { + const removed = await secrets.remove(secretId); + if (removed) secretsRevoked += 1; + } + + const credentialRefsCleared = + connection.credentialRefs.length + connection.credentialSecretRefs.length; + const [cleared] = await db + .update(toolConnections) + .set({ + credentialRefs: [], + credentialSecretRefs: [], + config: withoutInlineOAuthTokens(connection.config), + transportConfig: withoutInlineOAuthTokens(connection.transportConfig), + updatedAt: now, + }) + .where(eq(toolConnections.id, connection.id)) + .returning(); + if ( + grantRows.some((grant) => (grant.credentialSecretRefs ?? []).length > 0) + ) { + await db + .update(connectionGrants) + .set({ credentialSecretRefs: [], updatedAt: now }) + .where( + and( + eq(connectionGrants.companyId, connection.companyId), + eq(connectionGrants.connectionId, connection.id), + ), + ); + } + + return { + connection: toConnection(cleared ?? archived.connection), + removal: { + secretsRevoked, + secretsRetainedShared: retained.length, + credentialRefsCleared, + secretBindingsRemoved: removedSecretBindings.length, + grantsRevoked: grantsToRevoke.length, + installsRemoved: removedInstalls.length, + appProfile: appProfileOutcome, + appProfileEntriesRemoved, + appProfileBindingsRemoved, + catalogEntriesMarkedRemoved: removedCatalogEntries.length, + oauthStatesDiscarded: discardedOAuthStates.length, + tokenIssuanceHashesCleared: clearedIssuanceHashes.length, + runtimeSlotsStopped, + gatewayTokensRevoked, + gatewaySessionsRevoked, + applicationArchived: archived.applicationArchived, + externalCredentialCleanup, + }, + }; + } + return removeConnection; +}