UNPKG

@ithena-one/mcp-governance

Version:

Governance layer (Identity, RBAC, Credentials, Audit, Logging, Tracing) for Model Context Protocol (MCP) servers.

611 lines 33.6 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.GovernancePipeline = void 0; /* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable no-console */ /* eslint-disable @typescript-eslint/no-explicit-any */ const types_js_1 = require("@modelcontextprotocol/sdk/types.js"); const index_js_1 = require("../errors/index.js"); const error_mapper_js_1 = require("../utils/error-mapper.js"); /** * Contains the logic for executing the governance pipeline for requests and notifications. */ class GovernancePipeline { constructor(options, // Use Processed type here requestHandlers, notificationHandlers) { this.options = options; this.requestHandlers = requestHandlers; this.notificationHandlers = notificationHandlers; } /** Executes the governance pipeline for a request. */ async executeRequestPipeline(request, baseExtra, operationContext, auditRecord) { const logger = operationContext.logger; const startTime = operationContext.timestamp.getTime(); let outcomeStatus = 'failure'; let pipelineError = null; let handlerResult = undefined; // Create an immutable copy of the original headers and transport context // NOTE: Context Immutability: The pipeline uses Object.freeze and Proxies // to enforce shallow immutability on context objects passed between steps. // This helps prevent unintended side effects where one step modifies context // needed by a later step. // - Headers specifically are wrapped in a Proxy that silently ignores mutations. // - Other context objects are shallowly frozen using Object.freeze. // Caveats: True deep immutability is not achieved. Mutations to nested objects // within the context might still be possible. The Proxy approach for headers // might obscure bugs if components attempt mutations, as they will fail silently. // Consider potential performance overhead, although likely negligible. const originalHeaders = Object.freeze({ ...(operationContext.transportContext.headers ?? {}) }); // Create an immutable proxy for headers that silently ignores all mutations const headersProxy = new Proxy({ ...originalHeaders }, { get(obj, key) { return obj[key]; }, set() { // Silently ignore all mutations return true; }, deleteProperty() { // Silently ignore all deletions return true; }, defineProperty() { // Silently ignore property definitions return true; }, setPrototypeOf() { // Silently ignore prototype changes return true; }, isExtensible() { return false; }, preventExtensions() { return true; } }); // Create immutable transport context with headers proxy const transportContextProxy = new Proxy({ ...operationContext.transportContext, headers: headersProxy }, { get(target, prop) { return target[prop]; }, set(target, prop, value) { if (prop !== 'headers') { // Allow setting non-headers properties target[prop] = value; } // Always return true to avoid throwing return true; } }); // Create a frozen base context incorporating the immutable transport context. // Subsequent steps will build upon this, freezing each new context layer. // Create a clean base context that will be used for each step const baseContext = Object.freeze({ ...operationContext, transportContext: transportContextProxy }); try { logger.debug("Executing request pipeline steps..."); let identity = null; let roles = undefined; let derivedPermission = null; let resolvedCredentials = undefined; // Initialize audit record structure auditRecord.outcome = { status: 'failure' }; auditRecord.authorization = { decision: 'not_applicable' }; auditRecord.credentialResolution = { status: 'not_configured' }; // 2. Identity Resolution if (this.options.identityResolver) { try { logger.debug("[Pipeline Phase] Starting Identity Resolution..."); identity = await this.options.identityResolver.resolveIdentity(baseContext); auditRecord.identity = identity; logger.debug("Identity resolved", { hasIdentity: !!identity }); logger.debug("[Pipeline Phase] Identity Resolution completed.", { identity: identity ? 'resolved' : 'null' }); } catch (err) { logger.error("Identity resolution failed", { error: err }); logger.debug("[Pipeline Phase] Identity Resolution failed."); const authError = err instanceof Error ? new index_js_1.AuthenticationError(err.message) : new index_js_1.AuthenticationError("Identity resolution failed"); throw new types_js_1.McpError(types_js_1.ErrorCode.InvalidRequest, authError.message, { type: 'AuthenticationError', originalError: err }); } } else { logger.debug("No identity resolver configured"); logger.debug("[Pipeline Phase] Skipping Identity Resolution (no resolver)."); } // Freeze context after identity resolution. // Create context with identity for next steps const identityContext = Object.freeze({ ...baseContext, ...(identity !== null && { identity }) }); // 3. RBAC if (this.options.enableRbac) { auditRecord.authorization.decision = 'denied'; logger.debug("[Pipeline Phase] Starting RBAC check..."); if (identity === null) { auditRecord.authorization.denialReason = 'identity'; const authzError = new index_js_1.AuthorizationError('identity', "Identity required for authorization but none was resolved."); logger.debug("[Pipeline Phase] RBAC failed: Identity required but none resolved."); throw new types_js_1.McpError(-32001, authzError.message, { type: 'AuthorizationError', reason: 'identity' }); } if (!this.options.roleStore || !this.options.permissionStore) { const govError = new index_js_1.GovernanceError("RBAC enabled but RoleStore or PermissionStore is missing."); throw new types_js_1.McpError(types_js_1.ErrorCode.InternalError, govError.message, { type: 'GovernanceError' }); } // Call derivePermission with the transport context proxy derivedPermission = this.options.derivePermission?.(operationContext.mcpMessage, transportContextProxy) ?? null; logger.debug("[Pipeline Phase] Permission derived.", { derivedPermission }); auditRecord.authorization.permissionAttempted = derivedPermission; if (derivedPermission === null) { auditRecord.authorization.decision = 'granted'; logger.debug("Permission check not applicable (null permission derived)"); logger.debug("[Pipeline Phase] RBAC completed: Granted (no permission required)."); } else { try { roles = await this.options.roleStore.getRoles(identity, identityContext); logger.debug("[Pipeline Phase] Roles retrieved.", { roles }); auditRecord.authorization.roles = roles; // Early check for empty roles array if (!roles || roles.length === 0) { auditRecord.authorization.denialReason = 'permission'; const authzError = new index_js_1.AuthorizationError('permission', `No roles assigned to check permission: ${derivedPermission}`); throw new types_js_1.McpError(-32001, authzError.message, { type: 'AuthorizationError', reason: 'permission' }); } // Check roles sequentially and stop on first grant let hasPermission = false; for (const role of roles) { logger.debug("[Pipeline Phase] Checking permission for role.", { role, permission: derivedPermission }); try { if (await this.options.permissionStore.hasPermission(role, derivedPermission, baseContext)) { hasPermission = true; logger.debug("[Pipeline Phase] Permission granted by role.", { role, permission: derivedPermission }); break; // Stop checking once we find a role that grants permission } } catch (err) { // Wrap permission store errors in GovernanceError const govError = new index_js_1.GovernanceError("Error checking permissions", { originalError: err }); throw new types_js_1.McpError(types_js_1.ErrorCode.InternalError, govError.message, { type: 'GovernanceError', originalError: err }); } } if (!hasPermission) { auditRecord.authorization.denialReason = 'permission'; const authzError = new index_js_1.AuthorizationError('permission', `Missing required permission: ${derivedPermission}`); logger.debug("[Pipeline Phase] RBAC failed: Permission denied.", { permission: derivedPermission, rolesChecked: roles }); throw new types_js_1.McpError(-32001, authzError.message, { type: 'AuthorizationError', reason: 'permission' }); } auditRecord.authorization.decision = 'granted'; logger.debug("[Pipeline Phase] RBAC completed: Granted.", { permission: derivedPermission, grantedByRoles: roles }); logger.debug("Authorization granted", { permission: derivedPermission, roles }); } catch (err) { logger.debug("[Pipeline Phase] RBAC check encountered an error.", { error: err }); // If it's already an McpError (from permission checks or previous wrapping), rethrow if (err instanceof types_js_1.McpError) throw err; // Otherwise wrap in GovernanceError and map to McpError const govError = new index_js_1.GovernanceError("Error checking permissions", { originalError: err }); throw new types_js_1.McpError(types_js_1.ErrorCode.InternalError, govError.message, { type: 'GovernanceError', originalError: err }); } } } else { logger.debug("[Pipeline Phase] Skipping RBAC (disabled)."); // Even when RBAC is disabled, we still want to call derivePermission // if configured, for testing purposes if (this.options.derivePermission) { derivedPermission = this.options.derivePermission(operationContext.mcpMessage, transportContextProxy); logger.debug("[Pipeline Phase] Permission derived (RBAC disabled).", { derivedPermission }); } } // Freeze context after RBAC checks. // Create context with RBAC results for next steps const rbacContext = Object.freeze({ ...identityContext, ...(derivedPermission !== undefined && { derivedPermission }), ...(roles && { roles }) }); // 4. Credentials if (this.options.credentialResolver) { try { logger.debug("[Pipeline Phase] Starting Credential Resolution..."); logger.debug("Resolving credentials"); resolvedCredentials = await this.options.credentialResolver.resolveCredentials(identity ?? null, rbacContext); auditRecord.credentialResolution = { status: 'success' }; logger.debug("Credentials resolution successful"); logger.debug("[Pipeline Phase] Credential Resolution completed.", { resolved: resolvedCredentials !== undefined && resolvedCredentials !== null }); } catch (err) { auditRecord.credentialResolution = { status: 'failure', error: { message: err instanceof Error ? err.message : String(err), type: err?.constructor?.name } }; logger.error("Credential resolution failed", { error: err }); logger.debug("[Pipeline Phase] Credential Resolution failed."); if (this.options.failOnCredentialResolutionError) { const credError = err instanceof Error ? new index_js_1.CredentialResolutionError(err.message) : new index_js_1.CredentialResolutionError("Credential resolution failed"); throw new types_js_1.McpError(types_js_1.ErrorCode.InternalError, credError.message, { type: 'CredentialResolutionError', originalError: err }); } else { logger.warn("Credential resolution failed, but proceeding as failOnCredentialResolutionError=false"); } } } else { logger.debug("No credential resolver configured"); logger.debug("[Pipeline Phase] Skipping Credential Resolution (no resolver)."); } // Freeze the final context before passing to hooks and handlers. // Create final context with all results const finalContext = Object.freeze({ ...rbacContext, ...(resolvedCredentials !== undefined && { resolvedCredentials }) }); // 5. Post-Authorization Hook if (this.options.postAuthorizationHook && identity && (auditRecord.authorization.decision === 'granted' || auditRecord.authorization.decision === 'not_applicable')) { try { logger.debug("[Pipeline Phase] Starting Post-Authorization Hook..."); logger.debug("Executing post-authorization hook"); await this.options.postAuthorizationHook(identity, { ...baseContext, ...(roles && { roles }), ...(resolvedCredentials !== undefined && { resolvedCredentials }) }); logger.debug("[Pipeline Phase] Post-Authorization Hook completed successfully."); } catch (err) { logger.debug("[Pipeline Phase] Post-Authorization Hook failed.", { error: err }); const govError = new index_js_1.GovernanceError("Post-authorization hook failed", { originalError: err }); throw new types_js_1.McpError(types_js_1.ErrorCode.InternalError, govError.message, { type: 'GovernanceError', originalError: err }); } } // 6. Execute User Handler const handlerInfo = this.requestHandlers.get(request.method); logger.debug("[Pipeline Phase] Starting Handler Execution Lookup..."); if (!handlerInfo) { logger.warn(`No governed handler registered for method: ${request.method}`); logger.debug("[Pipeline Phase] Handler Execution failed: Method not found.", { method: request.method }); throw new types_js_1.McpError(types_js_1.ErrorCode.MethodNotFound, `Method not found: ${request.method}`); } const { handler: userHandler, schema: requestSchema } = handlerInfo; logger.debug("[Pipeline Phase] Handler found, validating schema...", { method: request.method }); const parseResult = requestSchema.safeParse(request); if (!parseResult.success) { logger.error("Request failed schema validation before handler execution", { error: parseResult.error, method: request.method }); logger.debug("[Pipeline Phase] Handler Execution failed: Invalid schema.", { method: request.method, error: parseResult.error }); throw new types_js_1.McpError(types_js_1.ErrorCode.InvalidParams, `Invalid request structure: ${parseResult.error.message}`); } const parsedRequest = parseResult.data; logger.debug("[Pipeline Phase] Schema valid, preparing handler context...", { method: request.method }); const extra = { signal: baseExtra.signal, sessionId: baseExtra.sessionId, eventId: finalContext.eventId, logger: finalContext.logger, identity: identity ?? null, roles: roles, resolvedCredentials: resolvedCredentials ?? undefined, traceContext: finalContext.traceContext, transportContext: transportContextProxy, }; try { logger.debug("Executing user request handler"); logger.debug("[Pipeline Phase] Executing user handler...", { method: request.method }); handlerResult = await userHandler(parsedRequest, extra); outcomeStatus = 'success'; auditRecord.outcome.status = 'success'; auditRecord.outcome.mcpResponse = { result: handlerResult }; logger.debug("User request handler completed successfully"); logger.debug("[Pipeline Phase] Handler Execution completed successfully.", { method: request.method }); } catch (handlerErr) { logger.debug("[Pipeline Phase] Handler Execution failed.", { method: request.method, error: handlerErr }); // If the handler threw an McpError, propagate it directly if (handlerErr instanceof types_js_1.McpError) { throw handlerErr; } // Otherwise wrap it as a handler error const handlerError = new index_js_1.HandlerError("Handler execution failed", handlerErr); throw new types_js_1.McpError(types_js_1.ErrorCode.InternalError, handlerError.message, { type: 'HandlerError', originalError: handlerErr }); } return handlerResult; } catch (pipeErr) { pipelineError = pipeErr; if (pipeErr instanceof index_js_1.AuthorizationError) { outcomeStatus = 'denied'; logger.debug("[Pipeline Phase] Overall Outcome: Denied (AuthorizationError)"); } else if (pipeErr instanceof index_js_1.AuthenticationError || pipeErr instanceof index_js_1.CredentialResolutionError || pipeErr instanceof index_js_1.HandlerError || pipeErr instanceof index_js_1.GovernanceError) { outcomeStatus = 'failure'; logger.debug("[Pipeline Phase] Overall Outcome: Failure (AuthenticationError, CredentialResolutionError, HandlerError, or GovernanceError)"); } else if (pipeErr instanceof types_js_1.McpError) { const errorData = pipeErr.data; outcomeStatus = (errorData?.type === 'AuthorizationError') ? 'denied' : 'failure'; logger.debug(`[Pipeline Phase] Overall Outcome: ${outcomeStatus} (McpError: ${errorData?.type || 'Unknown'})`); } else { outcomeStatus = 'failure'; logger.debug("[Pipeline Phase] Overall Outcome: Failure (Unknown Error)"); } auditRecord.outcome.status = outcomeStatus; throw pipeErr; } finally { // --- Build Audit Record Outcome --- const endTime = Date.now(); const durationMs = endTime - startTime; auditRecord.timestamp = new Date(endTime).toISOString(); auditRecord.durationMs = durationMs; if (pipelineError) { auditRecord.outcome.error = (0, error_mapper_js_1.mapErrorToAuditPayload)(pipelineError); } // Ensure MCP fields are present auditRecord.mcp = { type: 'request', method: request.method, id: request.id, params: operationContext.mcpMessage.params // Use original params }; // Use the original headers in the audit record auditRecord.transport = { ...transportContextProxy, headers: { ...originalHeaders } }; // --- Auditing --- const shouldAudit = outcomeStatus !== 'denied' || this.options.auditDeniedRequests; if (shouldAudit && this.options.auditStore && !auditRecord.logged) { // At this point, auditRecord should have all required fields const baseRecord = auditRecord; let sanitizedRecord = baseRecord; let sanitizationSucceeded = true; // Try to sanitize the record if a sanitizer is configured if (this.options.sanitizeForAudit) { try { const sanitized = this.options.sanitizeForAudit(baseRecord); if (sanitized) { sanitizedRecord = sanitized; } } catch (sanitizeErr) { sanitizationSucceeded = false; logger.error("Audit record sanitization failed", { error: sanitizeErr, auditEventId: baseRecord.eventId }); console.error(`!!! FAILED TO SANITIZE AUDIT RECORD ${baseRecord.eventId} !!!`, baseRecord, sanitizeErr); } } // Log the record (sanitized or original) if sanitization succeeded or no sanitizer configured if (sanitizationSucceeded || !this.options.sanitizeForAudit) { logger.debug("Logging audit record", { eventId: baseRecord.eventId }); try { await this.options.auditStore.log(sanitizedRecord); auditRecord.logged = true; } catch (auditErr) { logger.error("Audit logging failed", { error: auditErr, auditEventId: baseRecord.eventId }); } } } else { logger.debug("Skipping audit log based on configuration", { eventId: auditRecord.eventId, outcome: outcomeStatus }); } } } /** Executes the governance pipeline for a notification. */ async executeNotificationPipeline(notification, baseExtra, operationContext, // Assume pre-built context passed in auditRecord // Assume pre-built base record passed in ) { const logger = operationContext.logger; const startTime = operationContext.timestamp.getTime(); // Get start time from context let outcomeStatus = 'failure'; let handlerError = null; let identity = null; // Create a proxy for the transport context to prevent header mutations const originalHeaders = Object.freeze({ ...(operationContext.transportContext.headers ?? {}) }); // Create an immutable proxy for headers that silently ignores all mutations const headersProxy = new Proxy({ ...originalHeaders }, { get(obj, key) { return obj[key]; }, set() { // Silently ignore all mutations return true; }, deleteProperty() { // Silently ignore all deletions return true; }, defineProperty() { // Silently ignore property definitions return true; }, setPrototypeOf() { // Silently ignore prototype changes return true; }, isExtensible() { return false; }, preventExtensions() { return true; } }); // Create immutable transport context with headers proxy const transportContextProxy = new Proxy({ ...operationContext.transportContext, headers: headersProxy }, { get(target, prop) { return target[prop]; }, set(target, prop, value) { if (prop !== 'headers') { // Allow setting non-headers properties target[prop] = value; } // Always return true to avoid throwing return true; } }); // Replace the transport context with the proxy operationContext = { ...operationContext, transportContext: transportContextProxy }; try { logger.debug("Executing notification pipeline steps..."); // 1. Identity Resolution (Optional) if (this.options.identityResolver) { try { identity = await this.options.identityResolver.resolveIdentity(operationContext); operationContext.identity = identity; // Update context for potential handler use auditRecord.identity = identity; logger.debug("Identity resolved for notification", { hasIdentity: !!identity }); } catch (err) { logger.warn("Identity resolution failed during notification processing", { error: err }); // Do not fail pipeline } } // 2. Execute User Handler const handlerInfo = this.notificationHandlers.get(notification.method); if (handlerInfo) { const { handler: userHandler, schema: notificationSchema } = handlerInfo; // NOTE: Similar to requests, there's a known issue where 'params' may be undefined in the // notification object at this point. Schema validation should account for this by making // the params property optional. The original notification data is available in operationContext.mcpMessage. const parseResult = notificationSchema.safeParse(notification); if (!parseResult.success) { logger.error("Notification failed schema validation", { error: parseResult.error, method: notification.method }); outcomeStatus = 'success'; // Treat as ignored } else { const parsedNotification = parseResult.data; const extra = { // Spread baseExtra carefully signal: baseExtra.signal, sessionId: baseExtra.sessionId, eventId: operationContext.eventId, logger: operationContext.logger, identity: identity ?? null, traceContext: operationContext.traceContext, transportContext: operationContext.transportContext, }; try { logger.debug("Executing user notification handler"); await userHandler(parsedNotification, extra); outcomeStatus = 'success'; logger.debug("User notification handler completed successfully"); } catch (err) { handlerError = new index_js_1.HandlerError("Notification handler failed", err); outcomeStatus = 'failure'; logger.error("User notification handler failed", { error: err }); } } } else { outcomeStatus = 'success'; // Ignored logger.debug(`No governed handler for notification ${notification.method}, ignoring.`); } } catch (err) { // Catch errors from context setup phase (less likely) handlerError = err; outcomeStatus = 'failure'; logger.error("Error in notification pipeline setup", { error: err }); } finally { // --- Auditing --- const endTime = Date.now(); const durationMs = endTime - startTime; auditRecord.timestamp = new Date(endTime).toISOString(); auditRecord.durationMs = durationMs; // Add params just before audit logging const finalAuditRecord = { ...auditRecord, // Ensure mcp is an object before spreading mcp: { ...(auditRecord.mcp || { type: 'notification', method: notification.method }), params: operationContext.mcpMessage.params }, // Use original params outcome: { status: outcomeStatus, ...(handlerError ? { error: (0, error_mapper_js_1.mapErrorToAuditPayload)(handlerError) } : {}) }, // Use the original headers in the audit record transport: { ...operationContext.transportContext, headers: { ...originalHeaders } } }; if (this.options.auditNotifications) { try { let canProceedWithAudit = true; // Check for required audit configuration if (!this.options.sanitizeForAudit) { logger.error("Cannot audit notification: sanitizeForAudit is not configured"); canProceedWithAudit = false; } if (!this.options.auditStore) { logger.error("Cannot audit notification: auditStore is not configured"); canProceedWithAudit = false; } if (canProceedWithAudit) { const sanitizedRecord = this.options.sanitizeForAudit(finalAuditRecord); logger.debug("Logging notification audit record", { eventId: finalAuditRecord.eventId }); this.options.auditStore.log(sanitizedRecord).catch((auditErr) => { logger.error("Audit logging failed for notification", { error: auditErr, auditEventId: finalAuditRecord.eventId }); }); } } catch (sanitizeErr) { logger.error("Audit record sanitization failed for notification", { error: sanitizeErr, auditEventId: finalAuditRecord.eventId }); console.error(`!!! FAILED TO SANITIZE NOTIFICATION AUDIT RECORD ${finalAuditRecord.eventId} !!!`, finalAuditRecord, sanitizeErr); } } else { logger.debug("Skipping notification audit log", { eventId: finalAuditRecord.eventId }); } } } } // End GovernancePipeline class exports.GovernancePipeline = GovernancePipeline; //# sourceMappingURL=governance-pipeline.js.map