From 233753e5771e57d89ae107c1d1bfa1b06cea2fd9 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 21 Oct 2026 22:21:42 -0500 Subject: [PATCH] refactor(tools): isolate profile ownership (#17155) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Tool profiles select the actions each agent can use. > - Profile reads, review, mutation and assignment resolution shared a large OAuth and connection service. > - Those paths have their own company checks, lock order and database projections. > - This pull request gives profiles explicit owners while preserving the existing transactions and public API. > - The benefit is a smaller coordinator and a reviewable access boundary. ## Linked Issues or Issue Description Depends on #17147. Stack: #17144 → #17146 → #17147 → this PR. These PRs remain open. **What existing behavior does this improve?** Maintenance of tool profile management and effective agent assignments. **Current behavior** The service entry point contains 19,293 lines. Profile behavior is spread across its closure and returned object. **Proposed behavior** Profile management accepts only a database and clock. Assignment resolution accepts three named capabilities. Projection and shared lock functions have explicit homes. Public methods and responses remain unchanged. **Reason and benefit** Reviewers can follow profile transactions, lightweight reads and precedence without navigating OAuth setup. The entry point falls to 17,752 lines, a reduction of 1,541 lines in this PR. ## What Changed - Extract profile management, projections and effective assignment resolution into three modules. Their sizes are 886, 268 and 216 lines. - Move shared actor/transaction types, identifiers and assignment locks into named modules. Preserve the public lock export. - Keep all database reads, transaction boundaries, lock order and method signatures. Add no cache or background work. - Record the next ownership cuts toward an entry point below 5,000 lines. ## Verification - Before and after: 466 cases in `tool-access-service.test.ts` and 33 cases across profile entry validation, effective assignments and catalog publication. All 499 cases pass with one database worker. - Server TypeScript check and `node scripts/check-module-boundaries.mjs` pass. `git diff --check` passes. - Normalized syntax comparison finds all 329 pre-existing function/method bodies unchanged. Wiring passes the unchanged public service tests. - The previous stack passed full typecheck and build. Its full local test run was interrupted and remains unverified; its complete CI matrix passed. This PR will record its own current-head CI result below. - Current-head CI is green. Current review: Greptile is 5/5 at `0f5c85905e`, with no inline review threads. - CI failure preserved: [general server shard 1](https://github.com/paperclipai/paperclip/actions/runs/39704932041/job/119582710174) failed the unchanged workspace-runtime test that replaces an unhealthy local process. The replacement refused connections for its original 10-second readiness budget. The fixture and runtime service are unchanged and do not import tool-access. One isolated run passed without changes or timeout increases. The failure log is preserved; the cause is not reproduced. Only that failed job was retried; it passed at the same head without code changes. The original failure remains recorded above. ## Risks - Moving profile mutations could change transaction clients or lock order. Both are unchanged and the existing database tests cover publication and assignment behavior. - Effective assignment resolution still uses the same installed-connection loader and full catalog projection. The lightweight path still omits large schema and metadata fields. - No schema, public API or access policy change is intended. Existing tests provide characterization coverage; no new test mirrors the extraction. ## Model Used OpenAI Codex, GPT-6 family, with repository tools and code execution. The exact serving model ID and context-window size are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip --- .../2026-10-21-tool-access-boundaries.md | 47 + server/src/services/tool-access.ts | 1575 +---------------- .../services/tool-access/assignment-locks.ts | 46 + .../src/services/tool-access/identifiers.ts | 16 + .../tool-access/profiles/projection.ts | 270 +++ .../tool-access/profiles/resolution.ts | 216 +++ .../services/tool-access/profiles/service.ts | 887 ++++++++++ server/src/services/tool-access/types.ts | 22 + 8 files changed, 1521 insertions(+), 1558 deletions(-) create mode 100644 server/src/services/tool-access/assignment-locks.ts create mode 100644 server/src/services/tool-access/identifiers.ts create mode 100644 server/src/services/tool-access/profiles/projection.ts create mode 100644 server/src/services/tool-access/profiles/resolution.ts create mode 100644 server/src/services/tool-access/profiles/service.ts create mode 100644 server/src/services/tool-access/types.ts diff --git a/doc/plans/2026-10-21-tool-access-boundaries.md b/doc/plans/2026-10-21-tool-access-boundaries.md index 0d36bc30c6d..a1d6b2cae9c 100644 --- a/doc/plans/2026-10-21-tool-access-boundaries.md +++ b/doc/plans/2026-10-21-tool-access-boundaries.md @@ -108,3 +108,50 @@ Token brokering follows after its authorization, issuance audit, and locking contract is mapped. The large setup and callback functions should shrink through those ownership cuts rather than by distributing their local variables into a shared service context. + +## Continued stack: coordinator below 5,000 lines + +The user authorized continuing the stack after PRs #17144, #17146, and #17147. +All three remain open. Start each dependent PR from its predecessor; do not merge. +The new baseline is `3dc46c132408556ecb31038a9b9621ac20aacefc`, at 19,293 lines. + +The next cuts follow ownership, with a measured target of fewer than 5,000 lines +in the entry point. Reassess the estimates and ordering after each cut. + +| Boundary | Approximate reduction | Owner and contract | State and verification | +| --- | ---: | --- | --- | +| Profile management and effective assignments | 1,500 | Profile service owns scoped queries, mutations, review and summaries; assignment resolver consumes installed connections | Preserve caller transactions, lock ordering, precedence, exclusions, and lightweight reads | +| Connection token broker | 1,700 | Authorized run and issuance request to credential lease; provider exchange separated from admission | Keep run/membership checks, rate limits, scope restriction, issuance audit and cleanup in one flow | +| Runtime administration and diagnostics | 1,500 | Stdio templates, supervisor controls, health and activity projections | Same supervisor instance and live clock; public routes and real runtime controls | +| Connection credentials and refresh | 2,800 | Current grant and actor to usable credentials; separate registration and refresh owners | Preserve process registration flights, per-service refresh flights, database leases and current authority checks | +| OAuth authorization lifecycle | 2,700 | State admission, authorization request and provider-specific callback completion | Keep state consumption and callback finalization ordering; local HTTP OAuth/MCP integration and denial/revocation cases | +| Catalog and provider discovery | 1,900 | Connection to normalized descriptors and reviewed catalog; aggregator inventory has its own owner | Preserve discovery caches, credential access, failure classification and catalog publication transactions | +| Connection grants, installs and removal | 2,300 | Company-scoped connection lifecycle and audience operations | Preserve revocation/assignment lock ordering, current membership, teardown and secret ownership | +| Example installation, if still needed | 600 | Reviewed examples to installed application/profile plus smoke results | Preserve idempotency, approved runtime and policy decisions | + +Shared support is limited to explicit actor/transaction types, stable identifiers, +and assignment-lock functions. It must not become a service-context bag. The +entry point should retain cross-domain app setup and access finalization, while +subsystems own their implementation. Existing public imports remain compatible. + +Run the full public service baseline, then each boundary's meaningful tests +before and after its move. Review normalized bodies and every wiring change. +Run full typecheck/build at the batch handoff and require current-head CI gates. +The prior batch's interrupted local full suite remains unverified; its passing +isolated retry does not replace the missing full-run result. + +### Profile ownership extraction + +Profile management now takes only the database and clock. Pure projections and +lightweight catalog columns live beside it. Effective assignment resolution has +three explicit capabilities: validate the agent, load installed connections, and +project full catalog entries. Shared assignment locks keep their bodies and call +order. Transactions still enclose the same reads, locks and writes. + +The entry point is 17,752 lines / 698,511 bytes. The profile modules contain 1,370 +lines, with the largest at 886. All 329 pre-existing function and method bodies +match normalized syntax across the move. The baseline and post-move runs pass +466 public service cases plus 33 profile validation, assignment and publication +cases. Server typechecking and the module-boundary gate pass. Existing tests +exercise both full catalog DTOs and lightweight assignments; no new behavior or +fixture-only test was added. diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index ed24bf0268e..8057451407c 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -1,3 +1,10 @@ +import { createToolProfileService } from "./tool-access/profiles/service.js"; +import { createToolProfileResolver } from "./tool-access/profiles/resolution.js"; +import { toProfile, toProfileEntry, toProfileBinding, pendingNewToolsForProfile, profileCatalogColumns } from "./tool-access/profiles/projection.js"; +import { lockManagedAppPolicies, lockToolAssignmentAgents, lockToolConnectionRuntimeInputs } from "./tool-access/assignment-locks.js"; +export { lockToolConnectionRuntimeInputs } from "./tool-access/assignment-locks.js"; +import type { ActorInfo, DbTransaction, ToolAccessMutationDb } from "./tool-access/types.js"; +import { normalizeKey, connectionUid } from "./tool-access/identifiers.js"; import { createOAuthEndpointPolicy } from "./tool-access/oauth/endpoint-policy.js"; import { createOAuthDiscovery } from "./tool-access/oauth/discovery.js"; import { createOAuthClientProtocol, storedOAuthTokenEndpointAuthMethod } from "./tool-access/oauth/client.js"; @@ -27,7 +34,7 @@ import { loadGitHubGrantMetadata, loadGitHubTokenRepositories } from "./tool-acc export { loadGitHubGrantMetadata, loadGitHubTokenRepositories } from "./tool-access/github-access-discovery.js"; import { getAppDefinitionForApplicationKey, isConnectorFeatureEnabled } from "@paperclipai/shared"; import { stableHash, descriptorHash } from "./tool-catalog-hash.js"; -import { assertProfileEntryInputs } from "./tool-profile-entry-validation.js"; + import { DAYTONA_TOOLS, daytonaClient, daytonaRisk, isDaytonaConnection, isDaytonaToolName } from "./daytona.js"; import { connectorToolPolicy } from "./connectors/tool-policy.js"; import { chatCredentialMutationLease } from "./chat-credential-mutation-lease.js"; @@ -81,130 +88,8 @@ import { sql, } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; -import { - agents, - connectionGrantMembers, - connectionGrantDelegations, - connectionGrants, - connectionTokenIssuances, - authUsers, - companies, - companyMemberships, - companySecretBindings, - companySecrets, - principalPermissionGrants, - userSecretDefinitions, - userSecretDeclarations, - heartbeatRuns, - issues, - issueThreadInteractions, - plugins, - projects, - routines, - toolAccessAuditEvents, - toolApplications, - toolActionRequests, - toolCatalogEntries, - toolConnectionInstalls, - toolConnectionAppSnapshots, - toolConnectionAppSyncs, - toolConnections, - toolOauthStates, - toolStdioCommandTemplates, - toolCallEvents, - toolInvocations, - toolPolicies, - toolGatewaySessions, - toolMcpGateways, - toolMcpGatewayTokens, - toolProfileBindings, - toolProfileEntries, - toolProfiles, - toolRuntimeMetricCounters, - toolRuntimeSlots, -} from "@paperclipai/db"; -import type { - AppDefinition, - ConnectionGrantKind, - ConnectionTokenIssuanceOutcome, - ConnectionTokenIssuancePath, - ConnectionTokenRequest, - ConnectionTokenResponse, - CreateToolApplication, - CreateToolConnection, - ConnectToolApp, - ConnectToolAppResult, - CreateToolStdioCommandTemplate, - FinishToolApp, - FinishToolAppResult, - FinalizeOAuthAccess, - CreateToolProfileBindingForProfile, - CreateToolProfileEntryForProfile, - CreateToolProfileWithEntries, - DeleteToolProfile, - DeploymentExposure, - DeploymentMode, - DuplicateToolProfile, - ImportMcpJson, - McpConnectionCredentialRef, - McpJsonImportPreview, - ToolApplication, - ToolCatalogEntry, - ToolCatalogRefreshResult, - ToolConnection, - ToolConnectionInstall, - ToolConnectionInstallSnapshot, - ToolConnectionRemovalResult, - ToolConnectionRemovalSummary, - ToolConnectionHealthCheckResult, - ToolConnectionHealthStatus, - ToolConnectionAuthKind, - ToolConnectionCredentialPolicy, - ToolConnectionCredentialSource, - ToolConnectionTransport, - ToolCredentialSecretRef, - ToolOAuthStartResult, - ToolAppsAttentionResponse, - ToolAppMetadataPreflightResult, - ToolActionRequest, - ToolActionRequestListItem, - ToolActionRequestStatus, - ToolConnectionActivityResponse, - ToolAppConnectionActionSummary, - ToolExampleInstallResult, - ToolExampleSmokeCheck, - ToolExampleSmokeResult, - ToolExampleSummary, - ToolCallEvent, - ToolInvocation, - ToolProfile, - ToolProfileBinding, - ToolProfileEffectiveSummary, - ToolProfileEntry, - ToolProfileNewToolReviewItem, - ToolProfileNewToolsReview, - ToolProfileNewToolsReviewResult, - ToolProfileSummary, - ToolProfileWithDetails, - ToolPolicyDecision, - ToolPolicy, - ToolRiskLevel, - ToolRuntimeAlertRecommendation, - ToolRuntimeHealthSummary, - ToolRunDecision, - ToolRunDecisionLookup, - ToolRuntimeSlot, - ToolStdioCommandTemplate, - ReviewToolProfileNewTools, - UpdateToolApplication, - UpdateToolConnection, - PutToolConnectionInstalls, - UpdateToolProfileEntry, - UpdateToolProfileWithEntries, - UnbindToolProfileBinding, - VercelConnectCredentialReference, - VercelConnectGrantReference, -} from "@paperclipai/shared"; +import { agents, connectionGrantMembers, connectionGrantDelegations, connectionGrants, connectionTokenIssuances, authUsers, companyMemberships, companySecretBindings, companySecrets, principalPermissionGrants, userSecretDefinitions, userSecretDeclarations, heartbeatRuns, issues, issueThreadInteractions, plugins, toolAccessAuditEvents, toolApplications, toolActionRequests, toolCatalogEntries, toolConnectionInstalls, toolConnectionAppSnapshots, toolConnectionAppSyncs, toolConnections, toolOauthStates, toolStdioCommandTemplates, toolCallEvents, toolInvocations, toolPolicies, toolGatewaySessions, toolMcpGateways, toolMcpGatewayTokens, toolProfileBindings, toolProfileEntries, toolProfiles, toolRuntimeMetricCounters, toolRuntimeSlots } from "@paperclipai/db"; +import type { AppDefinition, ConnectionGrantKind, ConnectionTokenIssuanceOutcome, ConnectionTokenIssuancePath, ConnectionTokenRequest, ConnectionTokenResponse, CreateToolApplication, CreateToolConnection, ConnectToolApp, ConnectToolAppResult, CreateToolStdioCommandTemplate, FinishToolApp, FinishToolAppResult, FinalizeOAuthAccess, CreateToolProfileBindingForProfile, CreateToolProfileEntryForProfile, DeploymentExposure, DeploymentMode, ImportMcpJson, McpConnectionCredentialRef, McpJsonImportPreview, ToolApplication, ToolCatalogEntry, ToolCatalogRefreshResult, ToolConnection, ToolConnectionInstall, ToolConnectionInstallSnapshot, ToolConnectionRemovalResult, ToolConnectionRemovalSummary, ToolConnectionHealthCheckResult, ToolConnectionHealthStatus, ToolConnectionAuthKind, ToolConnectionCredentialPolicy, ToolConnectionCredentialSource, ToolConnectionTransport, ToolCredentialSecretRef, ToolOAuthStartResult, ToolAppsAttentionResponse, ToolAppMetadataPreflightResult, ToolActionRequest, ToolActionRequestListItem, ToolActionRequestStatus, ToolConnectionActivityResponse, ToolAppConnectionActionSummary, ToolExampleInstallResult, ToolExampleSmokeCheck, ToolExampleSmokeResult, ToolExampleSummary, ToolCallEvent, ToolInvocation, ToolProfileBinding, ToolProfileEntry, ToolPolicyDecision, ToolPolicy, ToolRiskLevel, ToolRuntimeAlertRecommendation, ToolRuntimeHealthSummary, ToolRunDecision, ToolRunDecisionLookup, ToolRuntimeSlot, ToolStdioCommandTemplate, UpdateToolApplication, UpdateToolConnection, PutToolConnectionInstalls, VercelConnectCredentialReference, VercelConnectGrantReference } from "@paperclipai/shared"; import { CLASS3_STATIC_LEASE_ALLOWLIST, GITHUB_CONNECTOR_PROFILES, @@ -238,15 +123,7 @@ import { isUniqueViolation } from "../db-errors.js"; import { logger } from "../middleware/logger.js"; import { logActivity } from "./activity-log.js"; import { isSlackMcpAccessDisabledResponse } from "./slack-mcp-error.js"; -import { - initializeMcpHttpSession, - McpHttpInitializationError, - getMcpHttpSession, - forgetMcpHttpSessions, - readMcpHttpResponse, - mcpHttpRequestHeaders, - parseMcpHttpResponseBody, -} from "./mcp-http.js"; +import { initializeMcpHttpSession, McpHttpInitializationError, getMcpHttpSession, forgetMcpHttpSessions, readMcpHttpResponse, mcpHttpRequestHeaders } from "./mcp-http.js"; import { assertPublicRemoteHttpEndpoint, parseRemoteHttpEndpoint, @@ -272,11 +149,7 @@ import { readSignedToolArgumentsPayload, TOOL_ACTION_REQUEST_SIGNING_GRACE_MS, } from "./tool-content-guards.js"; -import { - effectiveToolProfileBindings, - narrowestScopeBindings, - profileIdsInBindingOrder, -} from "./tool-profile-binding-precedence.js"; +import { narrowestScopeBindings, profileIdsInBindingOrder } from "./tool-profile-binding-precedence.js"; import { googleChatToolDescription } from "./google-chat-tool-policy.js"; import { gmailArchiveToolDescription } from "./gmail-archive-tool-policy.js"; import { @@ -309,20 +182,6 @@ import { type VercelConnectClient, } from "./vercel-connect.js"; -type ActorInfo = { - actorType?: "agent" | "user" | "system" | "plugin"; - actorId?: string | null; - sessionId?: string | null; - actorSource?: - | "local_implicit" - | "session" - | "board_key" - | "agent_key" - | "agent_jwt" - | "mcp_oauth" - | "cloud_tenant"; -}; - const ACTIVE_BROKER_RUN_STATUSES = new Set(["running"]); const REMOTE_HTTP_REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); const MAX_REMOTE_HTTP_REDIRECTS = 5; @@ -371,12 +230,6 @@ type ToolAccessServiceOptions = { vercelConnectClient?: VercelConnectClient | null; }; -type DbTransaction = Parameters[0]>[0]; -type ToolAccessMutationDb = Pick< - Db | DbTransaction, - "select" | "insert" | "update" | "delete" ->; - export type McpToolDescriptor = { name: string; title?: string | null; @@ -813,21 +666,6 @@ function percentile(values: number[], p: number): number | null { return sorted[index] ?? null; } -function normalizeKey(input: string) { - return ( - input - .trim() - .toLowerCase() - .replace(/[^a-z0-9._:-]+/g, "-") - .replace(/^-+|-+$/g, "") - .slice(0, 160) || "tool" - ); -} - -function connectionUid(namespace: string, name: string, connectionId: string) { - return `${normalizeKey(namespace)}/${normalizeKey(name)}-${connectionId.slice(0, 8)}`; -} - /** * The key namespace `connectGalleryApp`, `reconnectGalleryApp` and * `createOrRotateOAuthSecret` mint for credentials a connection owns outright. @@ -1350,60 +1188,6 @@ function denialReasonForDecision( return null; } -function toProfile(row: typeof toolProfiles.$inferSelect): ToolProfile { - return { - id: row.id, - companyId: row.companyId, - profileKey: row.profileKey, - name: row.name, - description: row.description, - status: row.status, - defaultAction: row.defaultAction, - newToolsReviewedAt: row.newToolsReviewedAt, - metadata: row.metadata ?? null, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - }; -} - -function toProfileEntry( - row: typeof toolProfileEntries.$inferSelect, -): ToolProfileEntry { - return { - id: row.id, - companyId: row.companyId, - profileId: row.profileId, - selectorType: row.selectorType, - effect: row.effect, - applicationId: row.applicationId, - connectionId: row.connectionId, - catalogEntryId: row.catalogEntryId, - toolName: row.toolName, - riskLevel: row.riskLevel, - conditions: row.conditions ?? null, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - }; -} - -function toProfileBinding( - row: typeof toolProfileBindings.$inferSelect, -): ToolProfileBinding { - return { - id: row.id, - companyId: row.companyId, - profileId: row.profileId, - targetType: row.targetType, - targetId: row.targetId, - priority: row.priority, - metadata: row.metadata ?? null, - createdByAgentId: row.createdByAgentId, - createdByUserId: row.createdByUserId, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - }; -} - function toPolicy(row: typeof toolPolicies.$inferSelect): ToolPolicy { return { id: row.id, @@ -1423,217 +1207,6 @@ function toPolicy(row: typeof toolPolicies.$inferSelect): ToolPolicy { }; } -// Profile summaries and new-tool counts never read schemas, annotations, or -// provider metadata. Keep those large fields out of these repeated reads. -const profileCatalogColumns = { - id: toolCatalogEntries.id, - applicationId: toolCatalogEntries.applicationId, - connectionId: toolCatalogEntries.connectionId, - toolName: toolCatalogEntries.toolName, - riskLevel: toolCatalogEntries.riskLevel, - status: toolCatalogEntries.status, - firstSeenAt: toolCatalogEntries.firstSeenAt, -}; -const profileReviewCatalogColumns = { - ...profileCatalogColumns, - title: toolCatalogEntries.title, - description: toolCatalogEntries.description, -}; -type ProfileCatalogEntry = Pick - & Partial>; -type ProfileApplication = Pick; -type ProfileConnection = Pick; - -function profileEntryMatchesCatalog( - entry: typeof toolProfileEntries.$inferSelect, - catalogEntry: ProfileCatalogEntry, -): boolean { - if (entry.selectorType === "application") - return entry.applicationId === catalogEntry.applicationId; - if (entry.selectorType === "connection") - return entry.connectionId === catalogEntry.connectionId; - if (entry.selectorType === "catalog_entry") - return entry.catalogEntryId === catalogEntry.id; - if (entry.selectorType === "tool_name") - return entry.toolName === catalogEntry.toolName; - if (entry.selectorType === "risk_level") - return entry.riskLevel === catalogEntry.riskLevel; - return false; -} - -function summarizeProfile(input: { - profile: typeof toolProfiles.$inferSelect; - entries: Array; - bindings: Array; - catalog: ProfileCatalogEntry[]; - agentIds: string[]; -}): ToolProfileSummary { - const includes = input.entries.filter((entry) => entry.effect === "include"); - const excludes = input.entries.filter((entry) => entry.effect === "exclude"); - const allowedCatalogIds = new Set(); - const allowedApplicationIds = new Set(); - const excludedCatalogIds = new Set(); - - for (const catalogEntry of input.catalog) { - const excluded = excludes.some((entry) => - profileEntryMatchesCatalog(entry, catalogEntry), - ); - if (excluded) excludedCatalogIds.add(catalogEntry.id); - if (excluded) continue; - const included = includes.some((entry) => - profileEntryMatchesCatalog(entry, catalogEntry), - ); - if (input.profile.defaultAction === "allow" || included) { - allowedCatalogIds.add(catalogEntry.id); - if (catalogEntry.applicationId) - allowedApplicationIds.add(catalogEntry.applicationId); - } - } - - const isCompanyDefault = input.bindings.some( - (binding) => - binding.targetType === "company" && - binding.targetId === input.profile.companyId, - ); - const appliesToAgents = new Set(); - if (isCompanyDefault) { - for (const agentId of input.agentIds) appliesToAgents.add(agentId); - } else { - const companyAgentIds = new Set(input.agentIds); - for (const binding of input.bindings) { - if ( - binding.targetType === "agent" && - companyAgentIds.has(binding.targetId) - ) { - appliesToAgents.add(binding.targetId); - } - } - } - - return { - accessMode: - input.profile.defaultAction === "allow" ? "all_except" : "selected", - allowedToolCount: allowedCatalogIds.size, - allowedApplicationCount: allowedApplicationIds.size, - excludedToolCount: excludedCatalogIds.size, - totalToolCount: input.catalog.length, - assignmentCount: input.bindings.length, - appliesToAgentCount: appliesToAgents.size, - isCompanyDefault, - }; -} - -function profileCoversCatalogScope(input: { - entry: typeof toolProfileEntries.$inferSelect; - catalogEntry: ProfileCatalogEntry; - catalogById: Map; -}): boolean { - if (input.entry.effect !== "include") return false; - if (input.entry.selectorType === "application") - return input.entry.applicationId === input.catalogEntry.applicationId; - if (input.entry.selectorType === "connection") - return input.entry.connectionId === input.catalogEntry.connectionId; - if ( - input.entry.selectorType !== "catalog_entry" || - !input.entry.catalogEntryId - ) - return false; - const scopedEntry = input.catalogById.get(input.entry.catalogEntryId); - if (!scopedEntry) return false; - if (scopedEntry.connectionId === input.catalogEntry.connectionId) return true; - return Boolean( - scopedEntry.applicationId && - scopedEntry.applicationId === input.catalogEntry.applicationId, - ); -} - -function pendingNewToolsForProfile(input: { - profile: typeof toolProfiles.$inferSelect; - entries: Array; - catalog: ProfileCatalogEntry[]; - applicationsById?: Map; - connectionsById?: Map; -}): ToolProfileNewToolReviewItem[] { - if ( - input.profile.status !== "active" || - input.profile.defaultAction !== "deny" - ) - return []; - const watermark = input.profile.newToolsReviewedAt ?? input.profile.createdAt; - const catalogById = new Map(input.catalog.map((entry) => [entry.id, entry])); - const scopedIncludes = input.entries.filter( - (entry) => - entry.effect === "include" && - (entry.selectorType === "application" || - entry.selectorType === "connection" || - entry.selectorType === "catalog_entry"), - ); - if (scopedIncludes.length === 0) return []; - - return input.catalog - .filter( - (catalogEntry) => - catalogEntry.status === "active" || - catalogEntry.status === "quarantined", - ) - .filter((catalogEntry) => catalogEntry.firstSeenAt > watermark) - .filter((catalogEntry) => - scopedIncludes.some((entry) => - profileCoversCatalogScope({ entry, catalogEntry, catalogById }), - ), - ) - .filter( - (catalogEntry) => - !input.entries.some((entry) => - profileEntryMatchesCatalog(entry, catalogEntry), - ), - ) - .map((catalogEntry) => ({ - catalogEntryId: catalogEntry.id, - applicationId: catalogEntry.applicationId, - applicationName: catalogEntry.applicationId - ? (input.applicationsById?.get(catalogEntry.applicationId)?.name ?? - null) - : null, - connectionId: catalogEntry.connectionId, - connectionName: - input.connectionsById?.get(catalogEntry.connectionId)?.name ?? null, - toolName: catalogEntry.toolName, - title: catalogEntry.title ?? null, - description: catalogEntry.description ?? null, - capability: catalogEntry.riskLevel, - riskLevel: catalogEntry.riskLevel, - addedAt: catalogEntry.firstSeenAt, - firstSeenAt: catalogEntry.firstSeenAt, - })); -} - -function buildProfileDetails(input: { - profile: typeof toolProfiles.$inferSelect; - entries: Array; - bindings: Array; - catalog: ProfileCatalogEntry[]; - agentIds: string[]; - applicationsById?: Map; - connectionsById?: Map; -}): ToolProfileWithDetails { - const pendingNewTools = pendingNewToolsForProfile({ - profile: input.profile, - entries: input.entries, - catalog: input.catalog, - applicationsById: input.applicationsById, - connectionsById: input.connectionsById, - }); - return { - ...toProfile(input.profile), - newToolsPendingCount: pendingNewTools.length, - entries: input.entries.map(toProfileEntry), - bindings: input.bindings.map(toProfileBinding), - summary: summarizeProfile(input), - }; -} - - function connectionSetupMutationFingerprint( row: typeof toolConnections.$inferSelect, ): string { @@ -1653,7 +1226,6 @@ function connectionSetupMutationFingerprint( }); } - function normalizeToolDescriptor(tool: unknown): McpToolDescriptor | null { const record = asRecord(tool); if (typeof record.name !== "string" || record.name.trim().length === 0) @@ -2145,49 +1717,6 @@ async function gitHubReadGrantAccess(db: Db, companyId: string, connectionId: st return { connection, allowed, legacyShared }; } -async function lockManagedAppPolicies(client: ToolAccessMutationDb, companyId: string, connectionId: string) { - await client.select({ id: toolPolicies.id }).from(toolPolicies).where(and( - eq(toolPolicies.companyId, companyId), eq(toolPolicies.policyType, "require_approval"), - sql`${toolPolicies.config}->>'source' = 'app_gallery_finish'`, - sql`${toolPolicies.config}->>'connectionId' = ${connectionId}`, - )).orderBy(toolPolicies.id).for("no key update"); -} - -async function lockToolAssignmentAgents(client: ToolAccessMutationDb, companyId: string, targets: ReadonlyArray<{ targetType: string; targetId: string }>) { - const allAgents = targets.some(target => target.targetType === "company" && target.targetId === companyId); - const ids = targets.filter(target => target.targetType === "agent").map(target => target.targetId); - if (!allAgents && !ids.length) return; - await client.select({ id: agents.id }).from(agents) - .where(and(eq(agents.companyId, companyId), sql`${agents.status} NOT IN ('pending_approval', 'terminated')`, - allAgents ? undefined : inArray(agents.id, ids))) - .orderBy(agents.id).for("no key update"); -} - -export async function lockToolConnectionRuntimeInputs(client: ToolAccessMutationDb, companyId: string, connectionId: string) { - await client.select({ id: toolApplications.id }).from(toolApplications) - .innerJoin(toolConnections, and(eq(toolConnections.applicationId, toolApplications.id), eq(toolConnections.companyId, toolApplications.companyId))) - .where(and(eq(toolConnections.id, connectionId), eq(toolConnections.companyId, companyId))) - .for("update", { of: toolApplications }); - await client.select({ id: toolConnections.id }).from(toolConnections) - .where(and(eq(toolConnections.id, connectionId), eq(toolConnections.companyId, companyId))).for("update"); - await client.select({ id: toolProfiles.id }).from(toolProfiles) - .where(and(eq(toolProfiles.companyId, companyId), eq(toolProfiles.profileKey, `app:${connectionId}`))) - .for("no key update"); - await lockManagedAppPolicies(client, companyId, connectionId); -} - -async function lockProfileEntryReferences(client: ToolAccessMutationDb, companyId: string, entries: Pick[]) { - const applicationIds = [...new Set(entries.flatMap(entry => entry.applicationId ? [entry.applicationId] : []))]; - const connectionIds = [...new Set(entries.flatMap(entry => entry.connectionId ? [entry.connectionId] : []))]; - const catalogIds = [...new Set(entries.flatMap(entry => entry.catalogEntryId ? [entry.catalogEntryId] : []))]; - if (applicationIds.length) await client.select({ id: toolApplications.id }).from(toolApplications) - .where(and(eq(toolApplications.companyId, companyId), inArray(toolApplications.id, applicationIds))).orderBy(toolApplications.id).for("key share"); - if (connectionIds.length) await client.select({ id: toolConnections.id }).from(toolConnections) - .where(and(eq(toolConnections.companyId, companyId), inArray(toolConnections.id, connectionIds))).orderBy(toolConnections.id).for("key share"); - if (catalogIds.length) await client.select({ id: toolCatalogEntries.id }).from(toolCatalogEntries) - .where(and(eq(toolCatalogEntries.companyId, companyId), inArray(toolCatalogEntries.id, catalogIds))).orderBy(toolCatalogEntries.id).for("key share"); -} - export function toolAccessService( db: Db, options: ToolAccessServiceOptions = {}, @@ -2248,6 +1777,9 @@ export function toolAccessService( } const policySvc = toolAccessPolicyService(db); const now = options.now ?? (() => new Date()); + const profileService = createToolProfileService(db, now); + const profileDetails = profileService.getProfile; + const profileResolver = createToolProfileResolver(db, { assertOptionalAgent, resolveInstalledConnectionsForAgent, toCatalogEntry }); const configuredCloudConnector = options.paperclipCloudConnector ?? options.paperclipIdGmailConnector; const connectorWasProvided = @@ -4509,84 +4041,6 @@ export function toolAccessService( ); } - async function assertTargetExists( - companyId: string, - targetType: CreateToolProfileBindingForProfile["targetType"], - targetId: string, - dbClient: ToolAccessMutationDb = db, - ) { - if (targetType === "company") { - if (targetId !== companyId) - throw unprocessable( - "Company profile bindings must target the same company id", - ); - return; - } - if (targetType === "agent") { - const [row] = await dbClient - .select({ id: agents.id }) - .from(agents) - .where(and(eq(agents.id, targetId), eq(agents.companyId, companyId))); - if (!row) - throw unprocessable( - "Tool profile agent binding target must belong to the same company", - ); - return; - } - if (targetType === "project") { - const [row] = await dbClient - .select({ id: projects.id }) - .from(projects) - .where( - and(eq(projects.id, targetId), eq(projects.companyId, companyId)), - ); - if (!row) - throw unprocessable( - "Tool profile project binding target must belong to the same company", - ); - return; - } - if (targetType === "routine") { - const [row] = await dbClient - .select({ id: routines.id }) - .from(routines) - .where( - and(eq(routines.id, targetId), eq(routines.companyId, companyId)), - ); - if (!row) - throw unprocessable( - "Tool profile routine binding target must belong to the same company", - ); - return; - } - if (targetType === "issue") { - const [row] = await dbClient - .select({ id: issues.id }) - .from(issues) - .where(and(eq(issues.id, targetId), eq(issues.companyId, companyId))); - if (!row) - throw unprocessable( - "Tool profile issue binding target must belong to the same company", - ); - return; - } - if (targetType === "gateway") { - const [row] = await dbClient - .select({ id: toolMcpGateways.id }) - .from(toolMcpGateways) - .where( - and( - eq(toolMcpGateways.id, targetId), - eq(toolMcpGateways.companyId, companyId), - ), - ); - if (!row) - throw unprocessable( - "Tool profile gateway binding target must belong to the same company", - ); - } - } - async function appProfileForConnection( dbClient: Pick, connection: typeof toolConnections.$inferSelect, @@ -5010,274 +4464,6 @@ export function toolAccessService( return created; } - async function getProfileRow(profileId: string, companyId?: string, dbClient: ToolAccessMutationDb = db) { - const where = companyId - ? and( - eq(toolProfiles.id, profileId), - eq(toolProfiles.companyId, companyId), - ) - : eq(toolProfiles.id, profileId); - const [row] = await dbClient.select().from(toolProfiles).where(where); - if (!row) throw notFound("Tool profile not found"); - return row; - } - - async function profileDetails( - profileId: string, - companyId?: string, - dbClient: ToolAccessMutationDb = db, - ): Promise { - const profile = await getProfileRow(profileId, companyId, dbClient); - const [ - entries, - bindings, - catalog, - companyAgents, - applications, - connections, - ] = await Promise.all([ - dbClient - .select() - .from(toolProfileEntries) - .where( - and( - eq(toolProfileEntries.companyId, profile.companyId), - eq(toolProfileEntries.profileId, profile.id), - ), - ) - .orderBy(asc(toolProfileEntries.createdAt)), - dbClient - .select() - .from(toolProfileBindings) - .where( - and( - eq(toolProfileBindings.companyId, profile.companyId), - eq(toolProfileBindings.profileId, profile.id), - ), - ) - .orderBy( - asc(toolProfileBindings.priority), - asc(toolProfileBindings.createdAt), - ), - dbClient - .select(profileCatalogColumns) - .from(toolCatalogEntries) - .where( - and( - eq(toolCatalogEntries.companyId, profile.companyId), - eq(toolCatalogEntries.status, "active"), - ), - ), - dbClient - .select({ id: agents.id }) - .from(agents) - .where(eq(agents.companyId, profile.companyId)), - dbClient - .select({ id: toolApplications.id, name: toolApplications.name }) - .from(toolApplications) - .where(eq(toolApplications.companyId, profile.companyId)), - dbClient - .select({ id: toolConnections.id, name: toolConnections.name }) - .from(toolConnections) - .where(eq(toolConnections.companyId, profile.companyId)), - ]); - return buildProfileDetails({ - profile, - entries, - bindings, - catalog, - agentIds: companyAgents.map((agent) => agent.id), - applicationsById: new Map( - applications.map((application) => [application.id, application]), - ), - connectionsById: new Map( - connections.map((connection) => [connection.id, connection]), - ), - }); - } - - async function listProfileNewTools( - profileId: string, - companyId?: string, - ): Promise { - const profile = await getProfileRow(profileId, companyId); - const [entries, catalog, applications, connections] = await Promise.all([ - db - .select() - .from(toolProfileEntries) - .where( - and( - eq(toolProfileEntries.companyId, profile.companyId), - eq(toolProfileEntries.profileId, profile.id), - ), - ) - .orderBy(asc(toolProfileEntries.createdAt)), - db - .select(profileReviewCatalogColumns) - .from(toolCatalogEntries) - .where( - and( - eq(toolCatalogEntries.companyId, profile.companyId), - eq(toolCatalogEntries.status, "active"), - ), - ) - .orderBy(asc(toolCatalogEntries.toolName)), - db - .select({ id: toolApplications.id, name: toolApplications.name }) - .from(toolApplications) - .where(eq(toolApplications.companyId, profile.companyId)), - db - .select({ id: toolConnections.id, name: toolConnections.name }) - .from(toolConnections) - .where(eq(toolConnections.companyId, profile.companyId)), - ]); - const tools = pendingNewToolsForProfile({ - profile, - entries, - catalog, - applicationsById: new Map( - applications.map((application) => [application.id, application]), - ), - connectionsById: new Map( - connections.map((connection) => [connection.id, connection]), - ), - }); - return { - profileId: profile.id, - reviewedAt: profile.newToolsReviewedAt, - pendingCount: tools.length, - tools, - }; - } - - async function reviewProfileNewTools( - profileId: string, - input: ReviewToolProfileNewTools, - actor?: ActorInfo, - ): Promise { - const profile = await getProfileRow(profileId); - const review = await listProfileNewTools(profile.id, profile.companyId); - if (review.tools.length === 0) - throw badRequest("No new tools are pending review for this profile"); - - const decisionIds = input.decisions.map( - (decision) => decision.catalogEntryId, - ); - if (new Set(decisionIds).size !== decisionIds.length) { - throw badRequest( - "New-tools review decisions must not contain duplicate catalogEntryId values", - ); - } - const pendingIds = new Set(review.tools.map((tool) => tool.catalogEntryId)); - if ( - decisionIds.length !== pendingIds.size || - decisionIds.some((id) => !pendingIds.has(id)) - ) { - throw badRequest( - "New-tools review decisions must cover every currently pending tool exactly once", - ); - } - - const toolById = new Map( - review.tools.map((tool) => [tool.catalogEntryId, tool]), - ); - const allowTools = input.decisions - .filter((decision) => decision.decision === "allow") - .map((decision) => toolById.get(decision.catalogEntryId)) - .filter(Boolean) as ToolProfileNewToolReviewItem[]; - const nowAt = now(); - let createdEntries: ToolProfileEntry[] = []; - if (allowTools.length > 0) { - const rows = await db - .insert(toolProfileEntries) - .values( - allowTools.map((tool) => ({ - companyId: profile.companyId, - profileId: profile.id, - selectorType: "catalog_entry" as const, - effect: "include" as const, - applicationId: tool.applicationId, - connectionId: tool.connectionId, - catalogEntryId: tool.catalogEntryId, - })), - ) - .returning(); - createdEntries = rows.map(toProfileEntry); - } - - await db - .update(toolCatalogEntries) - .set({ - reviewedAt: nowAt, - reviewedByAgentId: - actor?.actorType === "agent" ? (actor.actorId ?? null) : null, - reviewedByUserId: - actor?.actorType === "user" ? (actor.actorId ?? null) : null, - updatedAt: nowAt, - }) - .where( - and( - eq(toolCatalogEntries.companyId, profile.companyId), - inArray(toolCatalogEntries.id, decisionIds), - ), - ); - await db - .update(toolProfiles) - .set({ newToolsReviewedAt: nowAt, updatedAt: nowAt }) - .where(eq(toolProfiles.id, profile.id)); - - return { - profile: await profileDetails(profile.id, profile.companyId), - reviewedAt: nowAt, - allowedCount: allowTools.length, - keptBlockedCount: input.decisions.length - allowTools.length, - entriesCreated: createdEntries, - reviewedCatalogEntryIds: decisionIds, - }; - } - - async function createProfileEntries( - companyId: string, - profileId: string, - entries: CreateToolProfileEntryForProfile[], - dbClient: ToolAccessMutationDb = db, - ) { - await assertProfileEntryInputs(dbClient, companyId, entries); - if (entries.length === 0) return; - await dbClient.insert(toolProfileEntries).values( - entries.map((entry) => ({ - companyId, - profileId, - selectorType: entry.selectorType, - effect: entry.effect ?? "include", - applicationId: entry.applicationId ?? null, - connectionId: entry.connectionId ?? null, - catalogEntryId: entry.catalogEntryId ?? null, - toolName: entry.toolName ?? null, - riskLevel: entry.riskLevel ?? null, - conditions: entry.conditions ?? null, - })), - ); - } - - async function replaceProfileEntries( - companyId: string, - profileId: string, - entries: CreateToolProfileEntryForProfile[], - dbClient: ToolAccessMutationDb = db, - ) { - await assertProfileEntryInputs(dbClient, companyId, entries); - await dbClient - .delete(toolProfileEntries) - .where( - and( - eq(toolProfileEntries.companyId, companyId), - eq(toolProfileEntries.profileId, profileId), - ), - ); - await createProfileEntries(companyId, profileId, entries, dbClient); - } - /** * @param grantSecretRefs Secret refs held by a grant rather than the connection * row. A personal credential lives only on its user grant (PAP-17835), so it @@ -15278,178 +14464,6 @@ export function toolAccessService( return preflightConnectionMetadata(app, method, method.defaults, serverUrl, () => options.now?.() ?? new Date()); } - async function resolveEffectiveProfiles( - companyId: string, - agentId: string, - loadCatalog: () => PromiseLike, - toAllowedTool: (entry: TEntry) => TAllowedTool, - ): Promise & { allowedTools: TAllowedTool[] }> { - await assertOptionalAgent( - companyId, - agentId, - "Tool profile effective agent", - ); - const allBindings = await db - .select() - .from(toolProfileBindings) - .where(eq(toolProfileBindings.companyId, companyId)) - .orderBy( - asc(toolProfileBindings.priority), - asc(toolProfileBindings.createdAt), - ); - const matchingBindings = allBindings.filter( - (binding) => - (binding.targetType === "company" && - binding.targetId === companyId) || - (binding.targetType === "agent" && binding.targetId === agentId), - ); - if (matchingBindings.length === 0) { - return { - agentId, - profiles: [], - entries: [], - bindings: [], - allowedTools: [], - allowedToolNames: [], - installedConnections: await resolveInstalledConnectionsForAgent( - companyId, - agentId, - ), - }; - } - const candidateProfileIds = profileIdsInBindingOrder(matchingBindings); - const candidateProfiles = await db - .select() - .from(toolProfiles) - .where( - and( - eq(toolProfiles.companyId, companyId), - inArray(toolProfiles.id, candidateProfileIds), - ), - ); - const bindings = effectiveToolProfileBindings( - matchingBindings, - candidateProfiles, - ); - const profileIds = profileIdsInBindingOrder(bindings); - const profilesById = new Map( - candidateProfiles.map((profile) => [profile.id, profile]), - ); - const activeProfiles = profileIds - .map((profileId) => profilesById.get(profileId) ?? null) - .filter((profile): profile is typeof toolProfiles.$inferSelect => - Boolean(profile && profile.status === "active"), - ); - if (activeProfiles.length === 0) { - return { - agentId, - profiles: [], - entries: [], - bindings: bindings.map(toProfileBinding), - allowedTools: [], - allowedToolNames: [], - installedConnections: await resolveInstalledConnectionsForAgent( - companyId, - agentId, - ), - }; - } - const activeProfileIds = activeProfiles.map((profile) => profile.id); - const [entries, catalog, companyAgents] = await Promise.all([ - db - .select() - .from(toolProfileEntries) - .where( - and( - eq(toolProfileEntries.companyId, companyId), - inArray(toolProfileEntries.profileId, activeProfileIds), - ), - ) - .orderBy(asc(toolProfileEntries.createdAt)), - loadCatalog(), - db - .select({ id: agents.id }) - .from(agents) - .where(eq(agents.companyId, companyId)), - ]); - const entriesByProfile = new Map< - string, - Array - >(); - for (const entry of entries) { - const list = entriesByProfile.get(entry.profileId) ?? []; - list.push(entry); - entriesByProfile.set(entry.profileId, list); - } - const allowedCatalogIds = new Set(); - const allowedToolNames = new Set(); - for (const profile of activeProfiles) { - const profileEntries = entriesByProfile.get(profile.id) ?? []; - const includes = profileEntries.filter( - (entry) => entry.effect === "include", - ); - const excludes = profileEntries.filter( - (entry) => entry.effect === "exclude", - ); - for (const catalogEntry of catalog) { - if ( - excludes.some((entry) => - profileEntryMatchesCatalog(entry, catalogEntry), - ) - ) - continue; - if ( - profile.defaultAction === "allow" || - includes.some((entry) => - profileEntryMatchesCatalog(entry, catalogEntry), - ) - ) { - allowedCatalogIds.add(catalogEntry.id); - allowedToolNames.add(catalogEntry.toolName); - } - } - for (const entry of includes.filter( - (item) => item.selectorType === "tool_name" && item.toolName, - )) { - const matchingExclude = excludes.some( - (item) => - item.selectorType === "tool_name" && - item.toolName === entry.toolName, - ); - if (!matchingExclude) allowedToolNames.add(entry.toolName!); - } - } - const agentIds = companyAgents.map((agent) => agent.id); - const details: ToolProfileWithDetails[] = activeProfiles.map((profile) => - buildProfileDetails({ - profile, - entries: entriesByProfile.get(profile.id) ?? [], - bindings: bindings.filter( - (binding) => binding.profileId === profile.id, - ), - catalog, - agentIds, - }), - ); - const allowedTools = catalog - .filter((entry) => allowedCatalogIds.has(entry.id)) - .map(toAllowedTool); - return { - agentId, - profiles: details, - entries: entries.map(toProfileEntry), - bindings: bindings.map(toProfileBinding), - allowedTools, - allowedToolNames: [...allowedToolNames].sort((a, b) => - a.localeCompare(b), - ), - installedConnections: await resolveInstalledConnectionsForAgent( - companyId, - agentId, - ), - }; - } - return { /** Identity-only verification: no bot installation/repository access required. */ verifyPersonalGitHubIdentity: async (companyId: string, connectionId: string, userId: string) => { @@ -17742,563 +16756,8 @@ export function toolAccessService( }; }); }, - - listProfiles: async ( - companyId: string, - ): Promise => { - const profiles = await db - .select() - .from(toolProfiles) - .where(eq(toolProfiles.companyId, companyId)) - .orderBy(desc(toolProfiles.updatedAt)); - if (profiles.length === 0) return []; - const profileIds = profiles.map((profile) => profile.id); - const [ - entries, - bindings, - catalog, - companyAgents, - applications, - connections, - ] = await Promise.all([ - db - .select() - .from(toolProfileEntries) - .where( - and( - eq(toolProfileEntries.companyId, companyId), - inArray(toolProfileEntries.profileId, profileIds), - ), - ) - .orderBy(asc(toolProfileEntries.createdAt)), - db - .select() - .from(toolProfileBindings) - .where( - and( - eq(toolProfileBindings.companyId, companyId), - inArray(toolProfileBindings.profileId, profileIds), - ), - ) - .orderBy( - asc(toolProfileBindings.priority), - asc(toolProfileBindings.createdAt), - ), - db - .select(profileCatalogColumns) - .from(toolCatalogEntries) - .where( - and( - eq(toolCatalogEntries.companyId, companyId), - eq(toolCatalogEntries.status, "active"), - ), - ), - db - .select({ id: agents.id }) - .from(agents) - .where(eq(agents.companyId, companyId)), - db - .select({ id: toolApplications.id, name: toolApplications.name }) - .from(toolApplications) - .where(eq(toolApplications.companyId, companyId)), - db - .select({ id: toolConnections.id, name: toolConnections.name }) - .from(toolConnections) - .where(eq(toolConnections.companyId, companyId)), - ]); - const entriesByProfile = new Map< - string, - Array - >(); - const bindingsByProfile = new Map< - string, - Array - >(); - for (const entry of entries) { - const list = entriesByProfile.get(entry.profileId) ?? []; - list.push(entry); - entriesByProfile.set(entry.profileId, list); - } - for (const binding of bindings) { - const list = bindingsByProfile.get(binding.profileId) ?? []; - list.push(binding); - bindingsByProfile.set(binding.profileId, list); - } - const agentIds = companyAgents.map((agent) => agent.id); - const applicationsById = new Map( - applications.map((application) => [application.id, application]), - ); - const connectionsById = new Map( - connections.map((connection) => [connection.id, connection]), - ); - return profiles.map((profile) => - buildProfileDetails({ - profile, - entries: entriesByProfile.get(profile.id) ?? [], - bindings: bindingsByProfile.get(profile.id) ?? [], - catalog, - agentIds, - applicationsById, - connectionsById, - }), - ); - }, - - createProfile: async ( - companyId: string, - input: CreateToolProfileWithEntries, - ): Promise => { - await assertProfileEntryInputs(db, companyId, input.entries ?? []); - const [row] = await db - .insert(toolProfiles) - .values({ - companyId, - profileKey: input.profileKey, - name: input.name, - description: input.description ?? null, - status: input.status ?? "active", - defaultAction: input.defaultAction ?? "deny", - metadata: input.metadata ?? {}, - }) - .returning(); - await createProfileEntries(companyId, row.id, input.entries ?? []); - return profileDetails(row.id, companyId); - }, - - getProfile: profileDetails, - - listProfileNewTools, - - reviewProfileNewTools, - - updateProfile: async ( - profileId: string, - input: UpdateToolProfileWithEntries, - ): Promise => { - const existing = await getProfileRow(profileId); - if (input.entries) { - await assertProfileEntryInputs(db, existing.companyId, input.entries); - } - const referencesChanged = new Error("Tool profile references changed"); - for (let attempt = 0; attempt < 3; attempt++) { - try { - await db.transaction(async tx => { - const db = tx as unknown as Db; - const readReferences = () => tx.select({ applicationId: toolProfileEntries.applicationId, connectionId: toolProfileEntries.connectionId, catalogEntryId: toolProfileEntries.catalogEntryId }) - .from(toolProfileEntries).where(and(eq(toolProfileEntries.companyId, existing.companyId), eq(toolProfileEntries.profileId, profileId))) - .orderBy(toolProfileEntries.id); - const oldReferences = input.entries ? await readReferences() : []; - await lockProfileEntryReferences(tx, existing.companyId, [...oldReferences, ...(input.entries ?? [])]); - const [current] = await tx.select().from(toolProfiles).where(eq(toolProfiles.id, profileId)).for("no key update"); - if (!current) throw notFound("Tool profile not found"); - if (input.entries && JSON.stringify(await readReferences()) !== JSON.stringify(oldReferences)) throw referencesChanged; - await db - .update(toolProfiles) - .set({ - profileKey: input.profileKey ?? current.profileKey, - name: input.name ?? current.name, - description: input.description ?? current.description, - status: input.status ?? current.status, - defaultAction: input.defaultAction ?? current.defaultAction, - metadata: input.metadata ?? current.metadata, - updatedAt: new Date(), - }) - .where(eq(toolProfiles.id, profileId)); - if (input.entries) { - await replaceProfileEntries( - existing.companyId, - profileId, - input.entries, - tx, - ); - } - }); - break; - } catch (error) { - if (error !== referencesChanged) throw error; - if (attempt >= 2) throw conflict("Tool profile entries changed during replacement", { retryable: true }); - } - } - return profileDetails(profileId, existing.companyId); - }, - - duplicateProfile: async ( - profileId: string, - input: DuplicateToolProfile, - ): Promise => { - const existing = await getProfileRow(profileId); - const [entries, bindings] = await Promise.all([ - db - .select() - .from(toolProfileEntries) - .where( - and( - eq(toolProfileEntries.companyId, existing.companyId), - eq(toolProfileEntries.profileId, existing.id), - ), - ) - .orderBy(asc(toolProfileEntries.createdAt)), - db - .select() - .from(toolProfileBindings) - .where( - and( - eq(toolProfileBindings.companyId, existing.companyId), - eq(toolProfileBindings.profileId, existing.id), - ), - ) - .orderBy( - asc(toolProfileBindings.priority), - asc(toolProfileBindings.createdAt), - ), - ]); - const [created] = await db - .insert(toolProfiles) - .values({ - companyId: existing.companyId, - profileKey: normalizeKey(`${input.name}-${randomUUID().slice(0, 8)}`), - name: input.name, - description: existing.description, - status: "active", - defaultAction: existing.defaultAction, - newToolsReviewedAt: existing.newToolsReviewedAt, - metadata: existing.metadata ?? {}, - }) - .returning(); - if (entries.length > 0) { - await db.insert(toolProfileEntries).values( - entries.map((entry) => ({ - companyId: entry.companyId, - profileId: created.id, - selectorType: entry.selectorType, - effect: entry.effect, - applicationId: entry.applicationId, - connectionId: entry.connectionId, - catalogEntryId: entry.catalogEntryId, - toolName: entry.toolName, - riskLevel: entry.riskLevel, - conditions: entry.conditions, - })), - ); - } - if (input.includeAssignments && bindings.length > 0) { - await db.insert(toolProfileBindings).values( - bindings.map((binding) => ({ - companyId: binding.companyId, - profileId: created.id, - targetType: binding.targetType, - targetId: binding.targetId, - priority: binding.priority, - metadata: binding.metadata ?? {}, - createdByAgentId: binding.createdByAgentId, - createdByUserId: binding.createdByUserId, - })), - ); - } - return profileDetails(created.id, existing.companyId); - }, - - deleteProfile: async ( - profileId: string, - input: DeleteToolProfile, - ): Promise<{ - profile: ToolProfile; - summary: ToolProfileSummary; - reassignedToProfileId: string | null; - reassignedBindingCount: number; - }> => db.transaction(async tx => { - const existing = await getProfileRow(profileId, undefined, tx); - const db = tx as unknown as Db; - await tx.select({ id: toolProfiles.id }).from(toolProfiles) - .where(and(eq(toolProfiles.companyId, existing.companyId), inArray(toolProfiles.id, [profileId, ...(input.reassignToProfileId ? [input.reassignToProfileId] : [])]))) - .orderBy(toolProfiles.id).for("update"); - await lockToolAssignmentAgents(tx, existing.companyId, [{ targetType: "company", targetId: existing.companyId }]); - if (input.force && input.reassignToProfileId) { - throw badRequest( - "Use either force or reassignToProfileId when deleting a tool profile, not both", - ); - } - const details = await profileDetails(existing.id, existing.companyId, tx); - if ( - details.summary.isCompanyDefault && - !input.force && - !input.reassignToProfileId - ) { - throw unprocessable( - "Cannot delete the company default tool profile. Reassign the default profile or pass force=true to delete it.", - { summary: details.summary }, - ); - } - - let reassignedBindingCount = 0; - if (input.reassignToProfileId) { - if (input.reassignToProfileId === existing.id) { - throw badRequest( - "reassignToProfileId must reference a different tool profile", - ); - } - const target = await getProfileRow( - input.reassignToProfileId, - existing.companyId, - tx, - ); - if (target.status !== "active") { - throw unprocessable( - "Tool profile assignments can only be reassigned to an active profile", - ); - } - const targetBindings = await db - .select() - .from(toolProfileBindings) - .where( - and( - eq(toolProfileBindings.companyId, existing.companyId), - eq(toolProfileBindings.profileId, target.id), - ), - ); - const targetKeys = new Set( - targetBindings.map( - (binding) => `${binding.targetType}:${binding.targetId}`, - ), - ); - const copiedBindings = details.bindings.filter( - (binding) => - !targetKeys.has(`${binding.targetType}:${binding.targetId}`), - ); - if (copiedBindings.length > 0) { - await db.insert(toolProfileBindings).values( - copiedBindings.map((binding) => ({ - companyId: binding.companyId, - profileId: target.id, - targetType: binding.targetType, - targetId: binding.targetId, - priority: binding.priority, - metadata: binding.metadata ?? {}, - createdByAgentId: binding.createdByAgentId, - createdByUserId: binding.createdByUserId, - })), - ); - reassignedBindingCount = copiedBindings.length; - await db - .update(toolProfiles) - .set({ updatedAt: new Date() }) - .where(eq(toolProfiles.id, target.id)); - } - } - - const [deleted] = await db - .delete(toolProfiles) - .where(eq(toolProfiles.id, existing.id)) - .returning(); - if (!deleted) throw notFound("Tool profile not found"); - return { - profile: toProfile(deleted), - summary: details.summary, - reassignedToProfileId: input.reassignToProfileId ?? null, - reassignedBindingCount, - }; - }), - - addProfileEntry: async ( - profileId: string, - input: CreateToolProfileEntryForProfile, - ): Promise => db.transaction(async tx => { - const db = tx as unknown as Db; - const profile = await getProfileRow(profileId, undefined, tx); - await assertProfileEntryInputs(tx, profile.companyId, [input]); - await lockProfileEntryReferences(tx, profile.companyId, [input]); - await tx.select({ id: toolProfiles.id }).from(toolProfiles).where(eq(toolProfiles.id, profile.id)).for("no key update"); - const [row] = await db - .insert(toolProfileEntries) - .values({ - companyId: profile.companyId, - profileId: profile.id, - selectorType: input.selectorType, - effect: input.effect ?? "include", - applicationId: input.applicationId ?? null, - connectionId: input.connectionId ?? null, - catalogEntryId: input.catalogEntryId ?? null, - toolName: input.toolName ?? null, - riskLevel: input.riskLevel ?? null, - conditions: input.conditions ?? null, - }) - .returning(); - await db - .update(toolProfiles) - .set({ updatedAt: new Date() }) - .where(eq(toolProfiles.id, profile.id)); - return toProfileEntry(row); - }), - - getProfileEntry: async (entryId: string): Promise => { - const [row] = await db - .select() - .from(toolProfileEntries) - .where(eq(toolProfileEntries.id, entryId)); - if (!row) throw notFound("Tool profile entry not found"); - return toProfileEntry(row); - }, - - updateProfileEntry: async ( - entryId: string, - input: UpdateToolProfileEntry, - ): Promise => db.transaction(async tx => { - const db = tx as unknown as Db; - const [existing] = await db - .select() - .from(toolProfileEntries) - .where(eq(toolProfileEntries.id, entryId)); - if (!existing) throw notFound("Tool profile entry not found"); - const next: CreateToolProfileEntryForProfile = { - selectorType: input.selectorType ?? existing.selectorType, - effect: input.effect ?? existing.effect, - applicationId: input.applicationId ?? existing.applicationId, - connectionId: input.connectionId ?? existing.connectionId, - catalogEntryId: input.catalogEntryId ?? existing.catalogEntryId, - toolName: input.toolName ?? existing.toolName, - riskLevel: input.riskLevel ?? existing.riskLevel, - conditions: input.conditions ?? existing.conditions, - }; - await assertProfileEntryInputs(tx, existing.companyId, [next]); - await lockProfileEntryReferences(tx, existing.companyId, [next]); - await tx.select({ id: toolProfiles.id }).from(toolProfiles).where(eq(toolProfiles.id, existing.profileId)).for("no key update"); - const [row] = await db - .update(toolProfileEntries) - .set({ - selectorType: next.selectorType, - effect: next.effect ?? "include", - applicationId: next.applicationId ?? null, - connectionId: next.connectionId ?? null, - catalogEntryId: next.catalogEntryId ?? null, - toolName: next.toolName ?? null, - riskLevel: next.riskLevel ?? null, - conditions: next.conditions ?? null, - updatedAt: new Date(), - }) - .where(eq(toolProfileEntries.id, entryId)) - .returning(); - await db - .update(toolProfiles) - .set({ updatedAt: new Date() }) - .where(eq(toolProfiles.id, existing.profileId)); - return toProfileEntry(row); - }), - - deleteProfileEntry: async (entryId: string): Promise => db.transaction(async tx => { - const db = tx as unknown as Db; - const [existing] = await tx.select().from(toolProfileEntries).where(eq(toolProfileEntries.id, entryId)); - if (!existing) throw notFound("Tool profile entry not found"); - await tx.select({ id: toolProfiles.id }).from(toolProfiles).where(eq(toolProfiles.id, existing.profileId)).for("no key update"); - const [row] = await db - .delete(toolProfileEntries) - .where(eq(toolProfileEntries.id, entryId)) - .returning(); - if (!row) throw notFound("Tool profile entry not found"); - await db - .update(toolProfiles) - .set({ updatedAt: new Date() }) - .where(eq(toolProfiles.id, row.profileId)); - return toProfileEntry(row); - }), - - bindProfile: async ( - profileId: string, - input: CreateToolProfileBindingForProfile, - actor?: ActorInfo, - ): Promise => db.transaction(async tx => { - const db = tx as unknown as Db; - const [profile] = await tx.select().from(toolProfiles).where(eq(toolProfiles.id, profileId)).for("no key update"); - if (!profile) throw notFound("Tool profile not found"); - await assertTargetExists( - profile.companyId, - input.targetType, - input.targetId, - tx, - ); - const [row] = await db - .insert(toolProfileBindings) - .values({ - companyId: profile.companyId, - profileId: profile.id, - targetType: input.targetType, - targetId: input.targetId, - priority: input.priority ?? 100, - metadata: input.metadata ?? {}, - createdByAgentId: - actor?.actorType === "agent" ? (actor.actorId ?? null) : null, - createdByUserId: - actor?.actorType === "user" ? (actor.actorId ?? null) : null, - }) - .returning(); - await db - .update(toolProfiles) - .set({ updatedAt: new Date() }) - .where(eq(toolProfiles.id, profile.id)); - return toProfileBinding(row); - }), - - unbindProfile: async ( - profileId: string, - input: UnbindToolProfileBinding, - ): Promise<{ unbound: number }> => db.transaction(async tx => { - const db = tx as unknown as Db; - const [profile] = await tx.select().from(toolProfiles).where(eq(toolProfiles.id, profileId)).for("no key update"); - if (!profile) throw notFound("Tool profile not found"); - await assertTargetExists( - profile.companyId, - input.targetType, - input.targetId, - tx, - ); - const rows = await db - .delete(toolProfileBindings) - .where( - and( - eq(toolProfileBindings.companyId, profile.companyId), - eq(toolProfileBindings.profileId, profile.id), - eq(toolProfileBindings.targetType, input.targetType), - eq(toolProfileBindings.targetId, input.targetId), - ), - ) - .returning({ id: toolProfileBindings.id }); - if (rows.length > 0) { - await db - .update(toolProfiles) - .set({ updatedAt: new Date() }) - .where(eq(toolProfiles.id, profile.id)); - } - return { unbound: rows.length }; - }), - - getEffectiveProfilesForAgent: (companyId: string, agentId: string) => - resolveEffectiveProfiles( - companyId, - agentId, - () => db - .select() - .from(toolCatalogEntries) - .where(and( - eq(toolCatalogEntries.companyId, companyId), - eq(toolCatalogEntries.status, "active"), - )) - .orderBy(asc(toolCatalogEntries.toolName)), - toCatalogEntry, - ), - - getEffectiveToolAssignmentsForAgent: (companyId: string, agentId: string) => - resolveEffectiveProfiles( - companyId, - agentId, - () => db - .select(profileCatalogColumns) - .from(toolCatalogEntries) - .where(and( - eq(toolCatalogEntries.companyId, companyId), - eq(toolCatalogEntries.status, "active"), - )) - .orderBy(asc(toolCatalogEntries.toolName)), - ({ id, applicationId, connectionId, toolName }) => ({ id, applicationId, connectionId, toolName }), - ), + ...profileService, + ...profileResolver, mintConnectionTokenForAgent: async (input: { connectionId: string; diff --git a/server/src/services/tool-access/assignment-locks.ts b/server/src/services/tool-access/assignment-locks.ts new file mode 100644 index 00000000000..28712b463ca --- /dev/null +++ b/server/src/services/tool-access/assignment-locks.ts @@ -0,0 +1,46 @@ +import { and, eq, inArray, sql } from "drizzle-orm"; +import { agents, toolApplications, toolCatalogEntries, toolConnections, toolPolicies, toolProfiles } from "@paperclipai/db"; +import type { CreateToolProfileEntryForProfile } from "@paperclipai/shared"; +import type { ToolAccessMutationDb } from "./types.js"; +export async function lockManagedAppPolicies(client: ToolAccessMutationDb, companyId: string, connectionId: string) { + await client.select({ id: toolPolicies.id }).from(toolPolicies).where(and( + eq(toolPolicies.companyId, companyId), eq(toolPolicies.policyType, "require_approval"), + sql`${toolPolicies.config}->>'source' = 'app_gallery_finish'`, + sql`${toolPolicies.config}->>'connectionId' = ${connectionId}`, + )).orderBy(toolPolicies.id).for("no key update"); +} + +export async function lockToolAssignmentAgents(client: ToolAccessMutationDb, companyId: string, targets: ReadonlyArray<{ targetType: string; targetId: string }>) { + const allAgents = targets.some(target => target.targetType === "company" && target.targetId === companyId); + const ids = targets.filter(target => target.targetType === "agent").map(target => target.targetId); + if (!allAgents && !ids.length) return; + await client.select({ id: agents.id }).from(agents) + .where(and(eq(agents.companyId, companyId), sql`${agents.status} NOT IN ('pending_approval', 'terminated')`, + allAgents ? undefined : inArray(agents.id, ids))) + .orderBy(agents.id).for("no key update"); +} + +export async function lockToolConnectionRuntimeInputs(client: ToolAccessMutationDb, companyId: string, connectionId: string) { + await client.select({ id: toolApplications.id }).from(toolApplications) + .innerJoin(toolConnections, and(eq(toolConnections.applicationId, toolApplications.id), eq(toolConnections.companyId, toolApplications.companyId))) + .where(and(eq(toolConnections.id, connectionId), eq(toolConnections.companyId, companyId))) + .for("update", { of: toolApplications }); + await client.select({ id: toolConnections.id }).from(toolConnections) + .where(and(eq(toolConnections.id, connectionId), eq(toolConnections.companyId, companyId))).for("update"); + await client.select({ id: toolProfiles.id }).from(toolProfiles) + .where(and(eq(toolProfiles.companyId, companyId), eq(toolProfiles.profileKey, `app:${connectionId}`))) + .for("no key update"); + await lockManagedAppPolicies(client, companyId, connectionId); +} + +export async function lockProfileEntryReferences(client: ToolAccessMutationDb, companyId: string, entries: Pick[]) { + const applicationIds = [...new Set(entries.flatMap(entry => entry.applicationId ? [entry.applicationId] : []))]; + const connectionIds = [...new Set(entries.flatMap(entry => entry.connectionId ? [entry.connectionId] : []))]; + const catalogIds = [...new Set(entries.flatMap(entry => entry.catalogEntryId ? [entry.catalogEntryId] : []))]; + if (applicationIds.length) await client.select({ id: toolApplications.id }).from(toolApplications) + .where(and(eq(toolApplications.companyId, companyId), inArray(toolApplications.id, applicationIds))).orderBy(toolApplications.id).for("key share"); + if (connectionIds.length) await client.select({ id: toolConnections.id }).from(toolConnections) + .where(and(eq(toolConnections.companyId, companyId), inArray(toolConnections.id, connectionIds))).orderBy(toolConnections.id).for("key share"); + if (catalogIds.length) await client.select({ id: toolCatalogEntries.id }).from(toolCatalogEntries) + .where(and(eq(toolCatalogEntries.companyId, companyId), inArray(toolCatalogEntries.id, catalogIds))).orderBy(toolCatalogEntries.id).for("key share"); +} diff --git a/server/src/services/tool-access/identifiers.ts b/server/src/services/tool-access/identifiers.ts new file mode 100644 index 00000000000..33f8bfcc4d1 --- /dev/null +++ b/server/src/services/tool-access/identifiers.ts @@ -0,0 +1,16 @@ + + +export function normalizeKey(input: string) { + return ( + input + .trim() + .toLowerCase() + .replace(/[^a-z0-9._:-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 160) || "tool" + ); +} + +export function connectionUid(namespace: string, name: string, connectionId: string) { + return `${normalizeKey(namespace)}/${normalizeKey(name)}-${connectionId.slice(0, 8)}`; +} diff --git a/server/src/services/tool-access/profiles/projection.ts b/server/src/services/tool-access/profiles/projection.ts new file mode 100644 index 00000000000..50b77ae3a79 --- /dev/null +++ b/server/src/services/tool-access/profiles/projection.ts @@ -0,0 +1,270 @@ +import { toolApplications, toolCatalogEntries, toolConnections, toolProfileBindings, toolProfileEntries, toolProfiles } from "@paperclipai/db"; +import type { ToolProfile, ToolProfileBinding, ToolProfileEntry, ToolProfileNewToolReviewItem, ToolProfileSummary, ToolProfileWithDetails } from "@paperclipai/shared"; + +export function toProfile(row: typeof toolProfiles.$inferSelect): ToolProfile { + return { + id: row.id, + companyId: row.companyId, + profileKey: row.profileKey, + name: row.name, + description: row.description, + status: row.status, + defaultAction: row.defaultAction, + newToolsReviewedAt: row.newToolsReviewedAt, + metadata: row.metadata ?? null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +export function toProfileEntry( + row: typeof toolProfileEntries.$inferSelect, +): ToolProfileEntry { + return { + id: row.id, + companyId: row.companyId, + profileId: row.profileId, + selectorType: row.selectorType, + effect: row.effect, + applicationId: row.applicationId, + connectionId: row.connectionId, + catalogEntryId: row.catalogEntryId, + toolName: row.toolName, + riskLevel: row.riskLevel, + conditions: row.conditions ?? null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +export function toProfileBinding( + row: typeof toolProfileBindings.$inferSelect, +): ToolProfileBinding { + return { + id: row.id, + companyId: row.companyId, + profileId: row.profileId, + targetType: row.targetType, + targetId: row.targetId, + priority: row.priority, + metadata: row.metadata ?? null, + createdByAgentId: row.createdByAgentId, + createdByUserId: row.createdByUserId, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +// Profile summaries and new-tool counts exclude schemas, annotations, and +// provider metadata from these repeated reads. +export const profileCatalogColumns = { + id: toolCatalogEntries.id, + applicationId: toolCatalogEntries.applicationId, + connectionId: toolCatalogEntries.connectionId, + toolName: toolCatalogEntries.toolName, + riskLevel: toolCatalogEntries.riskLevel, + status: toolCatalogEntries.status, + firstSeenAt: toolCatalogEntries.firstSeenAt, +}; + +export const profileReviewCatalogColumns = { + ...profileCatalogColumns, + title: toolCatalogEntries.title, + description: toolCatalogEntries.description, +}; + +export type ProfileCatalogEntry = Pick + & Partial>; + +export type ProfileApplication = Pick; + +export type ProfileConnection = Pick; + +export function profileEntryMatchesCatalog( + entry: typeof toolProfileEntries.$inferSelect, + catalogEntry: ProfileCatalogEntry, +): boolean { + if (entry.selectorType === "application") + return entry.applicationId === catalogEntry.applicationId; + if (entry.selectorType === "connection") + return entry.connectionId === catalogEntry.connectionId; + if (entry.selectorType === "catalog_entry") + return entry.catalogEntryId === catalogEntry.id; + if (entry.selectorType === "tool_name") + return entry.toolName === catalogEntry.toolName; + if (entry.selectorType === "risk_level") + return entry.riskLevel === catalogEntry.riskLevel; + return false; +} + +export function summarizeProfile(input: { + profile: typeof toolProfiles.$inferSelect; + entries: Array; + bindings: Array; + catalog: ProfileCatalogEntry[]; + agentIds: string[]; +}): ToolProfileSummary { + const includes = input.entries.filter((entry) => entry.effect === "include"); + const excludes = input.entries.filter((entry) => entry.effect === "exclude"); + const allowedCatalogIds = new Set(); + const allowedApplicationIds = new Set(); + const excludedCatalogIds = new Set(); + + for (const catalogEntry of input.catalog) { + const excluded = excludes.some((entry) => + profileEntryMatchesCatalog(entry, catalogEntry), + ); + if (excluded) excludedCatalogIds.add(catalogEntry.id); + if (excluded) continue; + const included = includes.some((entry) => + profileEntryMatchesCatalog(entry, catalogEntry), + ); + if (input.profile.defaultAction === "allow" || included) { + allowedCatalogIds.add(catalogEntry.id); + if (catalogEntry.applicationId) + allowedApplicationIds.add(catalogEntry.applicationId); + } + } + + const isCompanyDefault = input.bindings.some( + (binding) => + binding.targetType === "company" && + binding.targetId === input.profile.companyId, + ); + const appliesToAgents = new Set(); + if (isCompanyDefault) { + for (const agentId of input.agentIds) appliesToAgents.add(agentId); + } else { + const companyAgentIds = new Set(input.agentIds); + for (const binding of input.bindings) { + if ( + binding.targetType === "agent" && + companyAgentIds.has(binding.targetId) + ) { + appliesToAgents.add(binding.targetId); + } + } + } + + return { + accessMode: + input.profile.defaultAction === "allow" ? "all_except" : "selected", + allowedToolCount: allowedCatalogIds.size, + allowedApplicationCount: allowedApplicationIds.size, + excludedToolCount: excludedCatalogIds.size, + totalToolCount: input.catalog.length, + assignmentCount: input.bindings.length, + appliesToAgentCount: appliesToAgents.size, + isCompanyDefault, + }; +} + +export function profileCoversCatalogScope(input: { + entry: typeof toolProfileEntries.$inferSelect; + catalogEntry: ProfileCatalogEntry; + catalogById: Map; +}): boolean { + if (input.entry.effect !== "include") return false; + if (input.entry.selectorType === "application") + return input.entry.applicationId === input.catalogEntry.applicationId; + if (input.entry.selectorType === "connection") + return input.entry.connectionId === input.catalogEntry.connectionId; + if ( + input.entry.selectorType !== "catalog_entry" || + !input.entry.catalogEntryId + ) + return false; + const scopedEntry = input.catalogById.get(input.entry.catalogEntryId); + if (!scopedEntry) return false; + if (scopedEntry.connectionId === input.catalogEntry.connectionId) return true; + return Boolean( + scopedEntry.applicationId && + scopedEntry.applicationId === input.catalogEntry.applicationId, + ); +} + +export function pendingNewToolsForProfile(input: { + profile: typeof toolProfiles.$inferSelect; + entries: Array; + catalog: ProfileCatalogEntry[]; + applicationsById?: Map; + connectionsById?: Map; +}): ToolProfileNewToolReviewItem[] { + if ( + input.profile.status !== "active" || + input.profile.defaultAction !== "deny" + ) + return []; + const watermark = input.profile.newToolsReviewedAt ?? input.profile.createdAt; + const catalogById = new Map(input.catalog.map((entry) => [entry.id, entry])); + const scopedIncludes = input.entries.filter( + (entry) => + entry.effect === "include" && + (entry.selectorType === "application" || + entry.selectorType === "connection" || + entry.selectorType === "catalog_entry"), + ); + if (scopedIncludes.length === 0) return []; + + return input.catalog + .filter( + (catalogEntry) => + catalogEntry.status === "active" || + catalogEntry.status === "quarantined", + ) + .filter((catalogEntry) => catalogEntry.firstSeenAt > watermark) + .filter((catalogEntry) => + scopedIncludes.some((entry) => + profileCoversCatalogScope({ entry, catalogEntry, catalogById }), + ), + ) + .filter( + (catalogEntry) => + !input.entries.some((entry) => + profileEntryMatchesCatalog(entry, catalogEntry), + ), + ) + .map((catalogEntry) => ({ + catalogEntryId: catalogEntry.id, + applicationId: catalogEntry.applicationId, + applicationName: catalogEntry.applicationId + ? (input.applicationsById?.get(catalogEntry.applicationId)?.name ?? + null) + : null, + connectionId: catalogEntry.connectionId, + connectionName: + input.connectionsById?.get(catalogEntry.connectionId)?.name ?? null, + toolName: catalogEntry.toolName, + title: catalogEntry.title ?? null, + description: catalogEntry.description ?? null, + capability: catalogEntry.riskLevel, + riskLevel: catalogEntry.riskLevel, + addedAt: catalogEntry.firstSeenAt, + firstSeenAt: catalogEntry.firstSeenAt, + })); +} + +export function buildProfileDetails(input: { + profile: typeof toolProfiles.$inferSelect; + entries: Array; + bindings: Array; + catalog: ProfileCatalogEntry[]; + agentIds: string[]; + applicationsById?: Map; + connectionsById?: Map; +}): ToolProfileWithDetails { + const pendingNewTools = pendingNewToolsForProfile({ + profile: input.profile, + entries: input.entries, + catalog: input.catalog, + applicationsById: input.applicationsById, + connectionsById: input.connectionsById, + }); + return { + ...toProfile(input.profile), + newToolsPendingCount: pendingNewTools.length, + entries: input.entries.map(toProfileEntry), + bindings: input.bindings.map(toProfileBinding), + summary: summarizeProfile(input), + }; +} diff --git a/server/src/services/tool-access/profiles/resolution.ts b/server/src/services/tool-access/profiles/resolution.ts new file mode 100644 index 00000000000..80aa6870681 --- /dev/null +++ b/server/src/services/tool-access/profiles/resolution.ts @@ -0,0 +1,216 @@ +import { and, asc, eq, inArray } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { agents, toolCatalogEntries, toolProfileBindings, toolProfileEntries, toolProfiles } from "@paperclipai/db"; +import type { ToolCatalogEntry, ToolConnection, ToolProfileEffectiveSummary, ToolProfileWithDetails } from "@paperclipai/shared"; +import { effectiveToolProfileBindings, profileIdsInBindingOrder } from "../../tool-profile-binding-precedence.js"; +import { buildProfileDetails, profileCatalogColumns, profileEntryMatchesCatalog, toProfileBinding, toProfileEntry, type ProfileCatalogEntry } from "./projection.js"; +type ProfileAssignmentDependencies = { + assertOptionalAgent(companyId: string, agentId: string | null | undefined, label: string): Promise; + resolveInstalledConnectionsForAgent(companyId: string, agentId: string): Promise; + toCatalogEntry(row: typeof toolCatalogEntries.$inferSelect): ToolCatalogEntry; +}; + +export function createToolProfileResolver(db: Db, { assertOptionalAgent, resolveInstalledConnectionsForAgent, toCatalogEntry }: ProfileAssignmentDependencies) { + async function resolveEffectiveProfiles( + companyId: string, + agentId: string, + loadCatalog: () => PromiseLike, + toAllowedTool: (entry: TEntry) => TAllowedTool, + ): Promise & { allowedTools: TAllowedTool[] }> { + await assertOptionalAgent( + companyId, + agentId, + "Tool profile effective agent", + ); + const allBindings = await db + .select() + .from(toolProfileBindings) + .where(eq(toolProfileBindings.companyId, companyId)) + .orderBy( + asc(toolProfileBindings.priority), + asc(toolProfileBindings.createdAt), + ); + const matchingBindings = allBindings.filter( + (binding) => + (binding.targetType === "company" && + binding.targetId === companyId) || + (binding.targetType === "agent" && binding.targetId === agentId), + ); + if (matchingBindings.length === 0) { + return { + agentId, + profiles: [], + entries: [], + bindings: [], + allowedTools: [], + allowedToolNames: [], + installedConnections: await resolveInstalledConnectionsForAgent( + companyId, + agentId, + ), + }; + } + const candidateProfileIds = profileIdsInBindingOrder(matchingBindings); + const candidateProfiles = await db + .select() + .from(toolProfiles) + .where( + and( + eq(toolProfiles.companyId, companyId), + inArray(toolProfiles.id, candidateProfileIds), + ), + ); + const bindings = effectiveToolProfileBindings( + matchingBindings, + candidateProfiles, + ); + const profileIds = profileIdsInBindingOrder(bindings); + const profilesById = new Map( + candidateProfiles.map((profile) => [profile.id, profile]), + ); + const activeProfiles = profileIds + .map((profileId) => profilesById.get(profileId) ?? null) + .filter((profile): profile is typeof toolProfiles.$inferSelect => + Boolean(profile && profile.status === "active"), + ); + if (activeProfiles.length === 0) { + return { + agentId, + profiles: [], + entries: [], + bindings: bindings.map(toProfileBinding), + allowedTools: [], + allowedToolNames: [], + installedConnections: await resolveInstalledConnectionsForAgent( + companyId, + agentId, + ), + }; + } + const activeProfileIds = activeProfiles.map((profile) => profile.id); + const [entries, catalog, companyAgents] = await Promise.all([ + db + .select() + .from(toolProfileEntries) + .where( + and( + eq(toolProfileEntries.companyId, companyId), + inArray(toolProfileEntries.profileId, activeProfileIds), + ), + ) + .orderBy(asc(toolProfileEntries.createdAt)), + loadCatalog(), + db + .select({ id: agents.id }) + .from(agents) + .where(eq(agents.companyId, companyId)), + ]); + const entriesByProfile = new Map< + string, + Array + >(); + for (const entry of entries) { + const list = entriesByProfile.get(entry.profileId) ?? []; + list.push(entry); + entriesByProfile.set(entry.profileId, list); + } + const allowedCatalogIds = new Set(); + const allowedToolNames = new Set(); + for (const profile of activeProfiles) { + const profileEntries = entriesByProfile.get(profile.id) ?? []; + const includes = profileEntries.filter( + (entry) => entry.effect === "include", + ); + const excludes = profileEntries.filter( + (entry) => entry.effect === "exclude", + ); + for (const catalogEntry of catalog) { + if ( + excludes.some((entry) => + profileEntryMatchesCatalog(entry, catalogEntry), + ) + ) + continue; + if ( + profile.defaultAction === "allow" || + includes.some((entry) => + profileEntryMatchesCatalog(entry, catalogEntry), + ) + ) { + allowedCatalogIds.add(catalogEntry.id); + allowedToolNames.add(catalogEntry.toolName); + } + } + for (const entry of includes.filter( + (item) => item.selectorType === "tool_name" && item.toolName, + )) { + const matchingExclude = excludes.some( + (item) => + item.selectorType === "tool_name" && + item.toolName === entry.toolName, + ); + if (!matchingExclude) allowedToolNames.add(entry.toolName!); + } + } + const agentIds = companyAgents.map((agent) => agent.id); + const details: ToolProfileWithDetails[] = activeProfiles.map((profile) => + buildProfileDetails({ + profile, + entries: entriesByProfile.get(profile.id) ?? [], + bindings: bindings.filter( + (binding) => binding.profileId === profile.id, + ), + catalog, + agentIds, + }), + ); + const allowedTools = catalog + .filter((entry) => allowedCatalogIds.has(entry.id)) + .map(toAllowedTool); + return { + agentId, + profiles: details, + entries: entries.map(toProfileEntry), + bindings: bindings.map(toProfileBinding), + allowedTools, + allowedToolNames: [...allowedToolNames].sort((a, b) => + a.localeCompare(b), + ), + installedConnections: await resolveInstalledConnectionsForAgent( + companyId, + agentId, + ), + }; + } + return { + getEffectiveProfilesForAgent: (companyId: string, agentId: string) => + resolveEffectiveProfiles( + companyId, + agentId, + () => db + .select() + .from(toolCatalogEntries) + .where(and( + eq(toolCatalogEntries.companyId, companyId), + eq(toolCatalogEntries.status, "active"), + )) + .orderBy(asc(toolCatalogEntries.toolName)), + toCatalogEntry, + ), + + getEffectiveToolAssignmentsForAgent: (companyId: string, agentId: string) => + resolveEffectiveProfiles( + companyId, + agentId, + () => db + .select(profileCatalogColumns) + .from(toolCatalogEntries) + .where(and( + eq(toolCatalogEntries.companyId, companyId), + eq(toolCatalogEntries.status, "active"), + )) + .orderBy(asc(toolCatalogEntries.toolName)), + ({ id, applicationId, connectionId, toolName }) => ({ id, applicationId, connectionId, toolName }), + ) + }; +} diff --git a/server/src/services/tool-access/profiles/service.ts b/server/src/services/tool-access/profiles/service.ts new file mode 100644 index 00000000000..ed24716410c --- /dev/null +++ b/server/src/services/tool-access/profiles/service.ts @@ -0,0 +1,887 @@ +import { assertProfileEntryInputs } from "../../tool-profile-entry-validation.js"; +import { randomUUID } from "node:crypto"; +import { and, asc, desc, eq, inArray } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { agents, issues, projects, routines, toolApplications, toolCatalogEntries, toolConnections, toolMcpGateways, toolProfileBindings, toolProfileEntries, toolProfiles } from "@paperclipai/db"; +import type { CreateToolProfileBindingForProfile, CreateToolProfileEntryForProfile, CreateToolProfileWithEntries, DeleteToolProfile, DuplicateToolProfile, ToolProfile, ToolProfileBinding, ToolProfileEntry, ToolProfileNewToolReviewItem, ToolProfileNewToolsReview, ToolProfileNewToolsReviewResult, ToolProfileSummary, ToolProfileWithDetails, ReviewToolProfileNewTools, UpdateToolProfileEntry, UpdateToolProfileWithEntries, UnbindToolProfileBinding } from "@paperclipai/shared"; +import { badRequest, conflict, notFound, unprocessable } from "../../../errors.js"; +import type { ActorInfo, ToolAccessMutationDb } from "../types.js"; +import { lockProfileEntryReferences, lockToolAssignmentAgents } from "../assignment-locks.js"; +import { normalizeKey } from "../identifiers.js"; +import { toProfile, toProfileEntry, toProfileBinding, profileCatalogColumns, profileReviewCatalogColumns, pendingNewToolsForProfile, buildProfileDetails } from "./projection.js"; + +export function createToolProfileService(db: Db, now: () => Date) { + async function getProfileRow(profileId: string, companyId?: string, dbClient: ToolAccessMutationDb = db) { + const where = companyId + ? and( + eq(toolProfiles.id, profileId), + eq(toolProfiles.companyId, companyId), + ) + : eq(toolProfiles.id, profileId); + const [row] = await dbClient.select().from(toolProfiles).where(where); + if (!row) throw notFound("Tool profile not found"); + return row; + } + + async function profileDetails( + profileId: string, + companyId?: string, + dbClient: ToolAccessMutationDb = db, + ): Promise { + const profile = await getProfileRow(profileId, companyId, dbClient); + const [ + entries, + bindings, + catalog, + companyAgents, + applications, + connections, + ] = await Promise.all([ + dbClient + .select() + .from(toolProfileEntries) + .where( + and( + eq(toolProfileEntries.companyId, profile.companyId), + eq(toolProfileEntries.profileId, profile.id), + ), + ) + .orderBy(asc(toolProfileEntries.createdAt)), + dbClient + .select() + .from(toolProfileBindings) + .where( + and( + eq(toolProfileBindings.companyId, profile.companyId), + eq(toolProfileBindings.profileId, profile.id), + ), + ) + .orderBy( + asc(toolProfileBindings.priority), + asc(toolProfileBindings.createdAt), + ), + dbClient + .select(profileCatalogColumns) + .from(toolCatalogEntries) + .where( + and( + eq(toolCatalogEntries.companyId, profile.companyId), + eq(toolCatalogEntries.status, "active"), + ), + ), + dbClient + .select({ id: agents.id }) + .from(agents) + .where(eq(agents.companyId, profile.companyId)), + dbClient + .select({ id: toolApplications.id, name: toolApplications.name }) + .from(toolApplications) + .where(eq(toolApplications.companyId, profile.companyId)), + dbClient + .select({ id: toolConnections.id, name: toolConnections.name }) + .from(toolConnections) + .where(eq(toolConnections.companyId, profile.companyId)), + ]); + return buildProfileDetails({ + profile, + entries, + bindings, + catalog, + agentIds: companyAgents.map((agent) => agent.id), + applicationsById: new Map( + applications.map((application) => [application.id, application]), + ), + connectionsById: new Map( + connections.map((connection) => [connection.id, connection]), + ), + }); + } + + async function listProfileNewTools( + profileId: string, + companyId?: string, + ): Promise { + const profile = await getProfileRow(profileId, companyId); + const [entries, catalog, applications, connections] = await Promise.all([ + db + .select() + .from(toolProfileEntries) + .where( + and( + eq(toolProfileEntries.companyId, profile.companyId), + eq(toolProfileEntries.profileId, profile.id), + ), + ) + .orderBy(asc(toolProfileEntries.createdAt)), + db + .select(profileReviewCatalogColumns) + .from(toolCatalogEntries) + .where( + and( + eq(toolCatalogEntries.companyId, profile.companyId), + eq(toolCatalogEntries.status, "active"), + ), + ) + .orderBy(asc(toolCatalogEntries.toolName)), + db + .select({ id: toolApplications.id, name: toolApplications.name }) + .from(toolApplications) + .where(eq(toolApplications.companyId, profile.companyId)), + db + .select({ id: toolConnections.id, name: toolConnections.name }) + .from(toolConnections) + .where(eq(toolConnections.companyId, profile.companyId)), + ]); + const tools = pendingNewToolsForProfile({ + profile, + entries, + catalog, + applicationsById: new Map( + applications.map((application) => [application.id, application]), + ), + connectionsById: new Map( + connections.map((connection) => [connection.id, connection]), + ), + }); + return { + profileId: profile.id, + reviewedAt: profile.newToolsReviewedAt, + pendingCount: tools.length, + tools, + }; + } + + async function reviewProfileNewTools( + profileId: string, + input: ReviewToolProfileNewTools, + actor?: ActorInfo, + ): Promise { + const profile = await getProfileRow(profileId); + const review = await listProfileNewTools(profile.id, profile.companyId); + if (review.tools.length === 0) + throw badRequest("No new tools are pending review for this profile"); + + const decisionIds = input.decisions.map( + (decision) => decision.catalogEntryId, + ); + if (new Set(decisionIds).size !== decisionIds.length) { + throw badRequest( + "New-tools review decisions must not contain duplicate catalogEntryId values", + ); + } + const pendingIds = new Set(review.tools.map((tool) => tool.catalogEntryId)); + if ( + decisionIds.length !== pendingIds.size || + decisionIds.some((id) => !pendingIds.has(id)) + ) { + throw badRequest( + "New-tools review decisions must cover every currently pending tool exactly once", + ); + } + + const toolById = new Map( + review.tools.map((tool) => [tool.catalogEntryId, tool]), + ); + const allowTools = input.decisions + .filter((decision) => decision.decision === "allow") + .map((decision) => toolById.get(decision.catalogEntryId)) + .filter(Boolean) as ToolProfileNewToolReviewItem[]; + const nowAt = now(); + let createdEntries: ToolProfileEntry[] = []; + if (allowTools.length > 0) { + const rows = await db + .insert(toolProfileEntries) + .values( + allowTools.map((tool) => ({ + companyId: profile.companyId, + profileId: profile.id, + selectorType: "catalog_entry" as const, + effect: "include" as const, + applicationId: tool.applicationId, + connectionId: tool.connectionId, + catalogEntryId: tool.catalogEntryId, + })), + ) + .returning(); + createdEntries = rows.map(toProfileEntry); + } + + await db + .update(toolCatalogEntries) + .set({ + reviewedAt: nowAt, + reviewedByAgentId: + actor?.actorType === "agent" ? (actor.actorId ?? null) : null, + reviewedByUserId: + actor?.actorType === "user" ? (actor.actorId ?? null) : null, + updatedAt: nowAt, + }) + .where( + and( + eq(toolCatalogEntries.companyId, profile.companyId), + inArray(toolCatalogEntries.id, decisionIds), + ), + ); + await db + .update(toolProfiles) + .set({ newToolsReviewedAt: nowAt, updatedAt: nowAt }) + .where(eq(toolProfiles.id, profile.id)); + + return { + profile: await profileDetails(profile.id, profile.companyId), + reviewedAt: nowAt, + allowedCount: allowTools.length, + keptBlockedCount: input.decisions.length - allowTools.length, + entriesCreated: createdEntries, + reviewedCatalogEntryIds: decisionIds, + }; + } + + async function createProfileEntries( + companyId: string, + profileId: string, + entries: CreateToolProfileEntryForProfile[], + dbClient: ToolAccessMutationDb = db, + ) { + await assertProfileEntryInputs(dbClient, companyId, entries); + if (entries.length === 0) return; + await dbClient.insert(toolProfileEntries).values( + entries.map((entry) => ({ + companyId, + profileId, + selectorType: entry.selectorType, + effect: entry.effect ?? "include", + applicationId: entry.applicationId ?? null, + connectionId: entry.connectionId ?? null, + catalogEntryId: entry.catalogEntryId ?? null, + toolName: entry.toolName ?? null, + riskLevel: entry.riskLevel ?? null, + conditions: entry.conditions ?? null, + })), + ); + } + + async function replaceProfileEntries( + companyId: string, + profileId: string, + entries: CreateToolProfileEntryForProfile[], + dbClient: ToolAccessMutationDb = db, + ) { + await assertProfileEntryInputs(dbClient, companyId, entries); + await dbClient + .delete(toolProfileEntries) + .where( + and( + eq(toolProfileEntries.companyId, companyId), + eq(toolProfileEntries.profileId, profileId), + ), + ); + await createProfileEntries(companyId, profileId, entries, dbClient); + } + + async function assertTargetExists( + companyId: string, + targetType: CreateToolProfileBindingForProfile["targetType"], + targetId: string, + dbClient: ToolAccessMutationDb = db, + ) { + if (targetType === "company") { + if (targetId !== companyId) + throw unprocessable( + "Company profile bindings must target the same company id", + ); + return; + } + if (targetType === "agent") { + const [row] = await dbClient + .select({ id: agents.id }) + .from(agents) + .where(and(eq(agents.id, targetId), eq(agents.companyId, companyId))); + if (!row) + throw unprocessable( + "Tool profile agent binding target must belong to the same company", + ); + return; + } + if (targetType === "project") { + const [row] = await dbClient + .select({ id: projects.id }) + .from(projects) + .where( + and(eq(projects.id, targetId), eq(projects.companyId, companyId)), + ); + if (!row) + throw unprocessable( + "Tool profile project binding target must belong to the same company", + ); + return; + } + if (targetType === "routine") { + const [row] = await dbClient + .select({ id: routines.id }) + .from(routines) + .where( + and(eq(routines.id, targetId), eq(routines.companyId, companyId)), + ); + if (!row) + throw unprocessable( + "Tool profile routine binding target must belong to the same company", + ); + return; + } + if (targetType === "issue") { + const [row] = await dbClient + .select({ id: issues.id }) + .from(issues) + .where(and(eq(issues.id, targetId), eq(issues.companyId, companyId))); + if (!row) + throw unprocessable( + "Tool profile issue binding target must belong to the same company", + ); + return; + } + if (targetType === "gateway") { + const [row] = await dbClient + .select({ id: toolMcpGateways.id }) + .from(toolMcpGateways) + .where( + and( + eq(toolMcpGateways.id, targetId), + eq(toolMcpGateways.companyId, companyId), + ), + ); + if (!row) + throw unprocessable( + "Tool profile gateway binding target must belong to the same company", + ); + } + } + return { + listProfiles: async ( + companyId: string, + ): Promise => { + const profiles = await db + .select() + .from(toolProfiles) + .where(eq(toolProfiles.companyId, companyId)) + .orderBy(desc(toolProfiles.updatedAt)); + if (profiles.length === 0) return []; + const profileIds = profiles.map((profile) => profile.id); + const [ + entries, + bindings, + catalog, + companyAgents, + applications, + connections, + ] = await Promise.all([ + db + .select() + .from(toolProfileEntries) + .where( + and( + eq(toolProfileEntries.companyId, companyId), + inArray(toolProfileEntries.profileId, profileIds), + ), + ) + .orderBy(asc(toolProfileEntries.createdAt)), + db + .select() + .from(toolProfileBindings) + .where( + and( + eq(toolProfileBindings.companyId, companyId), + inArray(toolProfileBindings.profileId, profileIds), + ), + ) + .orderBy( + asc(toolProfileBindings.priority), + asc(toolProfileBindings.createdAt), + ), + db + .select(profileCatalogColumns) + .from(toolCatalogEntries) + .where( + and( + eq(toolCatalogEntries.companyId, companyId), + eq(toolCatalogEntries.status, "active"), + ), + ), + db + .select({ id: agents.id }) + .from(agents) + .where(eq(agents.companyId, companyId)), + db + .select({ id: toolApplications.id, name: toolApplications.name }) + .from(toolApplications) + .where(eq(toolApplications.companyId, companyId)), + db + .select({ id: toolConnections.id, name: toolConnections.name }) + .from(toolConnections) + .where(eq(toolConnections.companyId, companyId)), + ]); + const entriesByProfile = new Map< + string, + Array + >(); + const bindingsByProfile = new Map< + string, + Array + >(); + for (const entry of entries) { + const list = entriesByProfile.get(entry.profileId) ?? []; + list.push(entry); + entriesByProfile.set(entry.profileId, list); + } + for (const binding of bindings) { + const list = bindingsByProfile.get(binding.profileId) ?? []; + list.push(binding); + bindingsByProfile.set(binding.profileId, list); + } + const agentIds = companyAgents.map((agent) => agent.id); + const applicationsById = new Map( + applications.map((application) => [application.id, application]), + ); + const connectionsById = new Map( + connections.map((connection) => [connection.id, connection]), + ); + return profiles.map((profile) => + buildProfileDetails({ + profile, + entries: entriesByProfile.get(profile.id) ?? [], + bindings: bindingsByProfile.get(profile.id) ?? [], + catalog, + agentIds, + applicationsById, + connectionsById, + }), + ); + }, + + createProfile: async ( + companyId: string, + input: CreateToolProfileWithEntries, + ): Promise => { + await assertProfileEntryInputs(db, companyId, input.entries ?? []); + const [row] = await db + .insert(toolProfiles) + .values({ + companyId, + profileKey: input.profileKey, + name: input.name, + description: input.description ?? null, + status: input.status ?? "active", + defaultAction: input.defaultAction ?? "deny", + metadata: input.metadata ?? {}, + }) + .returning(); + await createProfileEntries(companyId, row.id, input.entries ?? []); + return profileDetails(row.id, companyId); + }, + + getProfile: profileDetails, + + listProfileNewTools, + + reviewProfileNewTools, + + updateProfile: async ( + profileId: string, + input: UpdateToolProfileWithEntries, + ): Promise => { + const existing = await getProfileRow(profileId); + if (input.entries) { + await assertProfileEntryInputs(db, existing.companyId, input.entries); + } + const referencesChanged = new Error("Tool profile references changed"); + for (let attempt = 0; attempt < 3; attempt++) { + try { + await db.transaction(async tx => { + const db = tx as unknown as Db; + const readReferences = () => tx.select({ applicationId: toolProfileEntries.applicationId, connectionId: toolProfileEntries.connectionId, catalogEntryId: toolProfileEntries.catalogEntryId }) + .from(toolProfileEntries).where(and(eq(toolProfileEntries.companyId, existing.companyId), eq(toolProfileEntries.profileId, profileId))) + .orderBy(toolProfileEntries.id); + const oldReferences = input.entries ? await readReferences() : []; + await lockProfileEntryReferences(tx, existing.companyId, [...oldReferences, ...(input.entries ?? [])]); + const [current] = await tx.select().from(toolProfiles).where(eq(toolProfiles.id, profileId)).for("no key update"); + if (!current) throw notFound("Tool profile not found"); + if (input.entries && JSON.stringify(await readReferences()) !== JSON.stringify(oldReferences)) throw referencesChanged; + await db + .update(toolProfiles) + .set({ + profileKey: input.profileKey ?? current.profileKey, + name: input.name ?? current.name, + description: input.description ?? current.description, + status: input.status ?? current.status, + defaultAction: input.defaultAction ?? current.defaultAction, + metadata: input.metadata ?? current.metadata, + updatedAt: new Date(), + }) + .where(eq(toolProfiles.id, profileId)); + if (input.entries) { + await replaceProfileEntries( + existing.companyId, + profileId, + input.entries, + tx, + ); + } + }); + break; + } catch (error) { + if (error !== referencesChanged) throw error; + if (attempt >= 2) throw conflict("Tool profile entries changed during replacement", { retryable: true }); + } + } + return profileDetails(profileId, existing.companyId); + }, + + duplicateProfile: async ( + profileId: string, + input: DuplicateToolProfile, + ): Promise => { + const existing = await getProfileRow(profileId); + const [entries, bindings] = await Promise.all([ + db + .select() + .from(toolProfileEntries) + .where( + and( + eq(toolProfileEntries.companyId, existing.companyId), + eq(toolProfileEntries.profileId, existing.id), + ), + ) + .orderBy(asc(toolProfileEntries.createdAt)), + db + .select() + .from(toolProfileBindings) + .where( + and( + eq(toolProfileBindings.companyId, existing.companyId), + eq(toolProfileBindings.profileId, existing.id), + ), + ) + .orderBy( + asc(toolProfileBindings.priority), + asc(toolProfileBindings.createdAt), + ), + ]); + const [created] = await db + .insert(toolProfiles) + .values({ + companyId: existing.companyId, + profileKey: normalizeKey(`${input.name}-${randomUUID().slice(0, 8)}`), + name: input.name, + description: existing.description, + status: "active", + defaultAction: existing.defaultAction, + newToolsReviewedAt: existing.newToolsReviewedAt, + metadata: existing.metadata ?? {}, + }) + .returning(); + if (entries.length > 0) { + await db.insert(toolProfileEntries).values( + entries.map((entry) => ({ + companyId: entry.companyId, + profileId: created.id, + selectorType: entry.selectorType, + effect: entry.effect, + applicationId: entry.applicationId, + connectionId: entry.connectionId, + catalogEntryId: entry.catalogEntryId, + toolName: entry.toolName, + riskLevel: entry.riskLevel, + conditions: entry.conditions, + })), + ); + } + if (input.includeAssignments && bindings.length > 0) { + await db.insert(toolProfileBindings).values( + bindings.map((binding) => ({ + companyId: binding.companyId, + profileId: created.id, + targetType: binding.targetType, + targetId: binding.targetId, + priority: binding.priority, + metadata: binding.metadata ?? {}, + createdByAgentId: binding.createdByAgentId, + createdByUserId: binding.createdByUserId, + })), + ); + } + return profileDetails(created.id, existing.companyId); + }, + + deleteProfile: async ( + profileId: string, + input: DeleteToolProfile, + ): Promise<{ + profile: ToolProfile; + summary: ToolProfileSummary; + reassignedToProfileId: string | null; + reassignedBindingCount: number; + }> => db.transaction(async tx => { + const existing = await getProfileRow(profileId, undefined, tx); + const db = tx as unknown as Db; + await tx.select({ id: toolProfiles.id }).from(toolProfiles) + .where(and(eq(toolProfiles.companyId, existing.companyId), inArray(toolProfiles.id, [profileId, ...(input.reassignToProfileId ? [input.reassignToProfileId] : [])]))) + .orderBy(toolProfiles.id).for("update"); + await lockToolAssignmentAgents(tx, existing.companyId, [{ targetType: "company", targetId: existing.companyId }]); + if (input.force && input.reassignToProfileId) { + throw badRequest( + "Use either force or reassignToProfileId when deleting a tool profile, not both", + ); + } + const details = await profileDetails(existing.id, existing.companyId, tx); + if ( + details.summary.isCompanyDefault && + !input.force && + !input.reassignToProfileId + ) { + throw unprocessable( + "Cannot delete the company default tool profile. Reassign the default profile or pass force=true to delete it.", + { summary: details.summary }, + ); + } + + let reassignedBindingCount = 0; + if (input.reassignToProfileId) { + if (input.reassignToProfileId === existing.id) { + throw badRequest( + "reassignToProfileId must reference a different tool profile", + ); + } + const target = await getProfileRow( + input.reassignToProfileId, + existing.companyId, + tx, + ); + if (target.status !== "active") { + throw unprocessable( + "Tool profile assignments can only be reassigned to an active profile", + ); + } + const targetBindings = await db + .select() + .from(toolProfileBindings) + .where( + and( + eq(toolProfileBindings.companyId, existing.companyId), + eq(toolProfileBindings.profileId, target.id), + ), + ); + const targetKeys = new Set( + targetBindings.map( + (binding) => `${binding.targetType}:${binding.targetId}`, + ), + ); + const copiedBindings = details.bindings.filter( + (binding) => + !targetKeys.has(`${binding.targetType}:${binding.targetId}`), + ); + if (copiedBindings.length > 0) { + await db.insert(toolProfileBindings).values( + copiedBindings.map((binding) => ({ + companyId: binding.companyId, + profileId: target.id, + targetType: binding.targetType, + targetId: binding.targetId, + priority: binding.priority, + metadata: binding.metadata ?? {}, + createdByAgentId: binding.createdByAgentId, + createdByUserId: binding.createdByUserId, + })), + ); + reassignedBindingCount = copiedBindings.length; + await db + .update(toolProfiles) + .set({ updatedAt: new Date() }) + .where(eq(toolProfiles.id, target.id)); + } + } + + const [deleted] = await db + .delete(toolProfiles) + .where(eq(toolProfiles.id, existing.id)) + .returning(); + if (!deleted) throw notFound("Tool profile not found"); + return { + profile: toProfile(deleted), + summary: details.summary, + reassignedToProfileId: input.reassignToProfileId ?? null, + reassignedBindingCount, + }; + }), + + addProfileEntry: async ( + profileId: string, + input: CreateToolProfileEntryForProfile, + ): Promise => db.transaction(async tx => { + const db = tx as unknown as Db; + const profile = await getProfileRow(profileId, undefined, tx); + await assertProfileEntryInputs(tx, profile.companyId, [input]); + await lockProfileEntryReferences(tx, profile.companyId, [input]); + await tx.select({ id: toolProfiles.id }).from(toolProfiles).where(eq(toolProfiles.id, profile.id)).for("no key update"); + const [row] = await db + .insert(toolProfileEntries) + .values({ + companyId: profile.companyId, + profileId: profile.id, + selectorType: input.selectorType, + effect: input.effect ?? "include", + applicationId: input.applicationId ?? null, + connectionId: input.connectionId ?? null, + catalogEntryId: input.catalogEntryId ?? null, + toolName: input.toolName ?? null, + riskLevel: input.riskLevel ?? null, + conditions: input.conditions ?? null, + }) + .returning(); + await db + .update(toolProfiles) + .set({ updatedAt: new Date() }) + .where(eq(toolProfiles.id, profile.id)); + return toProfileEntry(row); + }), + + getProfileEntry: async (entryId: string): Promise => { + const [row] = await db + .select() + .from(toolProfileEntries) + .where(eq(toolProfileEntries.id, entryId)); + if (!row) throw notFound("Tool profile entry not found"); + return toProfileEntry(row); + }, + + updateProfileEntry: async ( + entryId: string, + input: UpdateToolProfileEntry, + ): Promise => db.transaction(async tx => { + const db = tx as unknown as Db; + const [existing] = await db + .select() + .from(toolProfileEntries) + .where(eq(toolProfileEntries.id, entryId)); + if (!existing) throw notFound("Tool profile entry not found"); + const next: CreateToolProfileEntryForProfile = { + selectorType: input.selectorType ?? existing.selectorType, + effect: input.effect ?? existing.effect, + applicationId: input.applicationId ?? existing.applicationId, + connectionId: input.connectionId ?? existing.connectionId, + catalogEntryId: input.catalogEntryId ?? existing.catalogEntryId, + toolName: input.toolName ?? existing.toolName, + riskLevel: input.riskLevel ?? existing.riskLevel, + conditions: input.conditions ?? existing.conditions, + }; + await assertProfileEntryInputs(tx, existing.companyId, [next]); + await lockProfileEntryReferences(tx, existing.companyId, [next]); + await tx.select({ id: toolProfiles.id }).from(toolProfiles).where(eq(toolProfiles.id, existing.profileId)).for("no key update"); + const [row] = await db + .update(toolProfileEntries) + .set({ + selectorType: next.selectorType, + effect: next.effect ?? "include", + applicationId: next.applicationId ?? null, + connectionId: next.connectionId ?? null, + catalogEntryId: next.catalogEntryId ?? null, + toolName: next.toolName ?? null, + riskLevel: next.riskLevel ?? null, + conditions: next.conditions ?? null, + updatedAt: new Date(), + }) + .where(eq(toolProfileEntries.id, entryId)) + .returning(); + await db + .update(toolProfiles) + .set({ updatedAt: new Date() }) + .where(eq(toolProfiles.id, existing.profileId)); + return toProfileEntry(row); + }), + + deleteProfileEntry: async (entryId: string): Promise => db.transaction(async tx => { + const db = tx as unknown as Db; + const [existing] = await tx.select().from(toolProfileEntries).where(eq(toolProfileEntries.id, entryId)); + if (!existing) throw notFound("Tool profile entry not found"); + await tx.select({ id: toolProfiles.id }).from(toolProfiles).where(eq(toolProfiles.id, existing.profileId)).for("no key update"); + const [row] = await db + .delete(toolProfileEntries) + .where(eq(toolProfileEntries.id, entryId)) + .returning(); + if (!row) throw notFound("Tool profile entry not found"); + await db + .update(toolProfiles) + .set({ updatedAt: new Date() }) + .where(eq(toolProfiles.id, row.profileId)); + return toProfileEntry(row); + }), + + bindProfile: async ( + profileId: string, + input: CreateToolProfileBindingForProfile, + actor?: ActorInfo, + ): Promise => db.transaction(async tx => { + const db = tx as unknown as Db; + const [profile] = await tx.select().from(toolProfiles).where(eq(toolProfiles.id, profileId)).for("no key update"); + if (!profile) throw notFound("Tool profile not found"); + await assertTargetExists( + profile.companyId, + input.targetType, + input.targetId, + tx, + ); + const [row] = await db + .insert(toolProfileBindings) + .values({ + companyId: profile.companyId, + profileId: profile.id, + targetType: input.targetType, + targetId: input.targetId, + priority: input.priority ?? 100, + metadata: input.metadata ?? {}, + createdByAgentId: + actor?.actorType === "agent" ? (actor.actorId ?? null) : null, + createdByUserId: + actor?.actorType === "user" ? (actor.actorId ?? null) : null, + }) + .returning(); + await db + .update(toolProfiles) + .set({ updatedAt: new Date() }) + .where(eq(toolProfiles.id, profile.id)); + return toProfileBinding(row); + }), + + unbindProfile: async ( + profileId: string, + input: UnbindToolProfileBinding, + ): Promise<{ unbound: number }> => db.transaction(async tx => { + const db = tx as unknown as Db; + const [profile] = await tx.select().from(toolProfiles).where(eq(toolProfiles.id, profileId)).for("no key update"); + if (!profile) throw notFound("Tool profile not found"); + await assertTargetExists( + profile.companyId, + input.targetType, + input.targetId, + tx, + ); + const rows = await db + .delete(toolProfileBindings) + .where( + and( + eq(toolProfileBindings.companyId, profile.companyId), + eq(toolProfileBindings.profileId, profile.id), + eq(toolProfileBindings.targetType, input.targetType), + eq(toolProfileBindings.targetId, input.targetId), + ), + ) + .returning({ id: toolProfileBindings.id }); + if (rows.length > 0) { + await db + .update(toolProfiles) + .set({ updatedAt: new Date() }) + .where(eq(toolProfiles.id, profile.id)); + } + return { unbound: rows.length }; + }) + }; +} diff --git a/server/src/services/tool-access/types.ts b/server/src/services/tool-access/types.ts new file mode 100644 index 00000000000..7574d10258b --- /dev/null +++ b/server/src/services/tool-access/types.ts @@ -0,0 +1,22 @@ +import type { Db } from "@paperclipai/db"; + +export type ActorInfo = { + actorType?: "agent" | "user" | "system" | "plugin"; + actorId?: string | null; + sessionId?: string | null; + actorSource?: + | "local_implicit" + | "session" + | "board_key" + | "agent_key" + | "agent_jwt" + | "mcp_oauth" + | "cloud_tenant"; +}; + +export type DbTransaction = Parameters[0]>[0]; + +export type ToolAccessMutationDb = Pick< + Db | DbTransaction, + "select" | "insert" | "update" | "delete" +>;