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