UNPKG

shipdeck

Version:

Ship MVPs in 48 hours. Fix bugs in 30 seconds. The command deck for developers who ship.

763 lines (642 loc) • 23.5 kB
/** * Tier 3 Human Approval Workflow * * Handles 12 critical Tier 3 violations requiring human approval: * - Architecture changes * - Security vulnerabilities * - Breaking changes * - Database migrations * - Performance regressions * * Provides clear risk assessment and approval interfaces. */ const EventEmitter = require('events'); class TierThreeApproval extends EventEmitter { constructor(config = {}) { super(); this.config = { // Approval settings approvalTimeout: config.approvalTimeout || 3600000, // 1 hour requireMultipleApprovers: config.requireMultipleApprovers || false, minimumApprovers: config.minimumApprovers || 1, allowSelfApproval: config.allowSelfApproval || false, // Risk assessment riskThresholds: { low: config.riskThresholds?.low || 0.3, medium: config.riskThresholds?.medium || 0.6, high: config.riskThresholds?.high || 0.8, critical: config.riskThresholds?.critical || 0.9 }, // Notification settings notificationChannels: config.notificationChannels || ['console'], escalationDelay: config.escalationDelay || 1800000, // 30 minutes ...config }; // Active approval requests this.pendingApprovals = new Map(); this.approvalHistory = []; // Risk assessment models this.riskAssessors = this._initializeRiskAssessors(); // Statistics this.stats = { totalRequests: 0, approvedRequests: 0, deniedRequests: 0, timedOutRequests: 0, averageApprovalTime: 0, riskDistribution: { low: 0, medium: 0, high: 0, critical: 0 } }; } /** * Request human approval for critical violations */ async requestApproval(artifact, violations, context = {}) { const startTime = Date.now(); const approvalId = this._generateApprovalId(); console.log(`āš ļø Requesting human approval for ${violations.length} critical violations: ${approvalId}`); try { // Perform comprehensive risk assessment const riskAssessment = await this._performRiskAssessment(artifact, violations, context); // Create approval request const approvalRequest = { id: approvalId, artifact, violations, context, riskAssessment, createdAt: startTime, status: 'pending', approvers: [], comments: [], expiresAt: startTime + this.config.approvalTimeout }; this.pendingApprovals.set(approvalId, approvalRequest); this.stats.totalRequests++; // Generate approval presentation const approvalPresentation = this._generateApprovalPresentation(approvalRequest); // Send notifications await this._sendApprovalNotifications(approvalRequest, approvalPresentation); // Log approval request console.log(`šŸ“‹ Approval request created: ${approvalId}`); console.log(` Risk Level: ${riskAssessment.level.toUpperCase()}`); console.log(` Violations: ${violations.map(v => v.rule.name).join(', ')}`); this.emit('approval:requested', { approvalId, riskLevel: riskAssessment.level, violationCount: violations.length, expiresAt: approvalRequest.expiresAt }); // For automated testing or immediate processing, check for auto-approval conditions if (this._shouldAutoProcess(riskAssessment, context)) { return this._processAutoApproval(approvalRequest); } // Return pending status for manual approval return { success: false, requiresApproval: true, approvalId, riskAssessment, approvalPresentation, expiresAt: approvalRequest.expiresAt, artifact, approvalCount: 0, deniedCount: 0, pendingCount: 1, processingTime: Date.now() - startTime }; } catch (error) { console.error(`āŒ Approval request failed: ${error.message}`); this.emit('approval:failed', { approvalId, error: error.message }); return { success: false, error: error.message, artifact, approvalCount: 0, deniedCount: 1, pendingCount: 0 }; } } /** * Submit approval decision */ async submitApprovalDecision(approvalId, decision) { const approval = this.pendingApprovals.get(approvalId); if (!approval) { throw new Error(`Approval request not found: ${approvalId}`); } if (approval.status !== 'pending') { throw new Error(`Approval request already processed: ${approvalId} (${approval.status})`); } if (Date.now() > approval.expiresAt) { approval.status = 'expired'; this.stats.timedOutRequests++; throw new Error(`Approval request expired: ${approvalId}`); } // Validate decision const validatedDecision = this._validateDecision(decision, approval); // Record the decision approval.approvers.push({ approver: validatedDecision.approver, decision: validatedDecision.approved, reason: validatedDecision.reason, timestamp: Date.now(), modifications: validatedDecision.modifications || [] }); if (validatedDecision.comment) { approval.comments.push({ author: validatedDecision.approver, comment: validatedDecision.comment, timestamp: Date.now() }); } // Check if we have enough approvers const approvedCount = approval.approvers.filter(a => a.decision === true).length; const deniedCount = approval.approvers.filter(a => a.decision === false).length; let finalDecision = null; if (deniedCount > 0) { // Any denial blocks approval approval.status = 'denied'; finalDecision = 'denied'; this.stats.deniedRequests++; } else if (approvedCount >= this.config.minimumApprovers) { // Sufficient approvals approval.status = 'approved'; finalDecision = 'approved'; this.stats.approvedRequests++; } if (finalDecision) { approval.completedAt = Date.now(); const approvalTime = approval.completedAt - approval.createdAt; this._updateAverageApprovalTime(approvalTime); // Move to history this.approvalHistory.push(approval); this.pendingApprovals.delete(approvalId); console.log(`āœ… Approval ${finalDecision}: ${approvalId} (${approvalTime}ms)`); this.emit('approval:completed', { approvalId, decision: finalDecision, approvalTime, approverCount: approval.approvers.length }); return { success: finalDecision === 'approved', decision: finalDecision, approvalId, artifact: this._applyApprovedModifications(approval), modifications: this._collectModifications(approval), approvers: approval.approvers, comments: approval.comments }; } // Still pending more approvers console.log(`ā³ Approval pending: ${approvalId} (${approvedCount}/${this.config.minimumApprovers} approvers)`); return { success: false, pending: true, approvalId, approvedCount, deniedCount, requiredApprovers: this.config.minimumApprovers }; } /** * Get approval status */ getApprovalStatus(approvalId) { const approval = this.pendingApprovals.get(approvalId); if (!approval) { // Check history const historical = this.approvalHistory.find(a => a.id === approvalId); if (historical) { return { found: true, status: historical.status, completed: true, approvers: historical.approvers, comments: historical.comments }; } return { found: false }; } return { found: true, status: approval.status, pending: approval.status === 'pending', expired: Date.now() > approval.expiresAt, riskLevel: approval.riskAssessment.level, violationCount: approval.violations.length, approvers: approval.approvers, comments: approval.comments, expiresAt: approval.expiresAt, timeRemaining: Math.max(0, approval.expiresAt - Date.now()) }; } /** * Cancel pending approval */ cancelApproval(approvalId, reason = 'Cancelled') { const approval = this.pendingApprovals.get(approvalId); if (!approval) { throw new Error(`Approval request not found: ${approvalId}`); } approval.status = 'cancelled'; approval.cancellationReason = reason; approval.completedAt = Date.now(); this.approvalHistory.push(approval); this.pendingApprovals.delete(approvalId); console.log(`🚫 Approval cancelled: ${approvalId} - ${reason}`); this.emit('approval:cancelled', { approvalId, reason }); } /** * Perform comprehensive risk assessment */ async _performRiskAssessment(artifact, violations, context) { console.log('šŸ” Performing risk assessment...'); const assessments = await Promise.allSettled([ this._assessSecurityRisk(violations, context), this._assessBreakingChangeRisk(violations, context), this._assessPerformanceRisk(violations, context), this._assessArchitecturalRisk(violations, context), this._assessDataIntegrityRisk(violations, context) ]); const risks = assessments.map((result, index) => { if (result.status === 'fulfilled') { return result.value; } else { console.warn(`Risk assessment ${index} failed: ${result.reason}`); return { score: 0.5, factors: ['assessment_failed'] }; } }); // Calculate overall risk score (weighted average) const weights = [0.3, 0.25, 0.2, 0.15, 0.1]; // Security gets highest weight const overallScore = risks.reduce((sum, risk, index) => sum + (risk.score * weights[index]), 0); // Determine risk level let level = 'low'; if (overallScore >= this.config.riskThresholds.critical) level = 'critical'; else if (overallScore >= this.config.riskThresholds.high) level = 'high'; else if (overallScore >= this.config.riskThresholds.medium) level = 'medium'; // Collect all risk factors const allFactors = risks.reduce((factors, risk) => [...factors, ...risk.factors], []); const uniqueFactors = [...new Set(allFactors)]; // Update statistics this.stats.riskDistribution[level]++; const assessment = { level, score: overallScore, factors: uniqueFactors, breakdown: { security: risks[0], breakingChanges: risks[1], performance: risks[2], architectural: risks[3], dataIntegrity: risks[4] }, recommendations: this._generateRiskRecommendations(overallScore, uniqueFactors), mitigations: this._suggestMitigations(violations, uniqueFactors) }; console.log(`šŸ“Š Risk assessment complete: ${level.toUpperCase()} (${Math.round(overallScore * 100)}%)`); return assessment; } // Risk assessment implementations async _assessSecurityRisk(violations, context) { const securityViolations = violations.filter(v => v.rule.category === 'security' || v.rule.severity === 'critical' ); const factors = []; let score = 0; securityViolations.forEach(violation => { switch (violation.rule.name) { case 'no-secrets-in-code': score += 0.8; factors.push('hardcoded_secrets'); break; case 'auth-middleware': score += 0.6; factors.push('missing_authentication'); break; case 'csrf-protection': score += 0.4; factors.push('csrf_vulnerability'); break; case 'prepared-statements': score += 0.7; factors.push('sql_injection_risk'); break; default: score += 0.3; factors.push('security_violation'); } }); return { score: Math.min(1, score), factors: factors.length > 0 ? factors : ['no_security_issues'] }; } async _assessBreakingChangeRisk(violations, context) { const breakingViolations = violations.filter(v => v.rule.name.includes('breaking') || v.rule.description.toLowerCase().includes('breaking') ); const factors = []; let score = 0; if (breakingViolations.length > 0) { score = 0.8; factors.push('api_breaking_changes'); } // Check for database schema changes if (violations.some(v => v.rule.name.includes('migration'))) { score = Math.max(score, 0.6); factors.push('database_schema_changes'); } return { score, factors: factors.length > 0 ? factors : ['no_breaking_changes'] }; } async _assessPerformanceRisk(violations, context) { const performanceViolations = violations.filter(v => v.rule.category === 'performance' || v.rule.severity === 'performance' ); let score = performanceViolations.length * 0.2; const factors = performanceViolations.map(v => v.rule.name); return { score: Math.min(1, score), factors: factors.length > 0 ? factors : ['no_performance_issues'] }; } async _assessArchitecturalRisk(violations, context) { const archViolations = violations.filter(v => v.rule.category === 'architecture' || v.rule.name.includes('architecture') ); let score = archViolations.length * 0.3; const factors = archViolations.map(v => v.rule.name); return { score: Math.min(1, score), factors: factors.length > 0 ? factors : ['no_architectural_issues'] }; } async _assessDataIntegrityRisk(violations, context) { const dataViolations = violations.filter(v => v.rule.category === 'database' && v.rule.severity === 'critical' ); let score = dataViolations.length * 0.4; const factors = dataViolations.map(v => v.rule.name); return { score: Math.min(1, score), factors: factors.length > 0 ? factors : ['no_data_integrity_issues'] }; } /** * Generate approval presentation */ _generateApprovalPresentation(approvalRequest) { const { violations, riskAssessment, context } = approvalRequest; return { title: `Quality Gate Approval Required - ${riskAssessment.level.toUpperCase()} Risk`, summary: { violationCount: violations.length, riskLevel: riskAssessment.level, riskScore: Math.round(riskAssessment.score * 100), expiresIn: Math.round((approvalRequest.expiresAt - Date.now()) / 60000) // minutes }, violations: violations.map(v => ({ rule: v.rule.name, category: v.rule.category, severity: v.rule.severity, message: v.message, suggestion: v.suggestion, location: v.position ? `Line ${v.position}` : 'Unknown' })), riskFactors: riskAssessment.factors, recommendations: riskAssessment.recommendations, mitigations: riskAssessment.mitigations, codePreview: this._generateCodePreview(approvalRequest.artifact, violations), approvalActions: this._generateApprovalActions(riskAssessment) }; } /** * Send approval notifications */ async _sendApprovalNotifications(approvalRequest, presentation) { const { id, riskAssessment } = approvalRequest; // Console notification (always enabled) console.log(`\nšŸ”” APPROVAL REQUIRED: ${id}`); console.log(` Risk Level: ${riskAssessment.level.toUpperCase()}`); console.log(` Violations: ${approvalRequest.violations.length}`); console.log(` Expires: ${new Date(approvalRequest.expiresAt).toLocaleString()}`); console.log(` View details: http://localhost:3000/approvals/${id}\n`); // Additional notification channels would be implemented here for (const channel of this.config.notificationChannels) { try { await this._sendNotificationToChannel(channel, approvalRequest, presentation); } catch (error) { console.warn(`āš ļø Failed to send notification to ${channel}: ${error.message}`); } } } async _sendNotificationToChannel(channel, approvalRequest, presentation) { switch (channel) { case 'email': // await this._sendEmailNotification(approvalRequest, presentation); break; case 'slack': // await this._sendSlackNotification(approvalRequest, presentation); break; case 'webhook': // await this._sendWebhookNotification(approvalRequest, presentation); break; case 'console': // Already handled above break; } } // Utility methods _initializeRiskAssessors() { return { security: this._assessSecurityRisk.bind(this), breakingChanges: this._assessBreakingChangeRisk.bind(this), performance: this._assessPerformanceRisk.bind(this), architectural: this._assessArchitecturalRisk.bind(this), dataIntegrity: this._assessDataIntegrityRisk.bind(this) }; } _generateApprovalId() { return `approval-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; } _shouldAutoProcess(riskAssessment, context) { // Auto-approve low risk items in dev/test environments return ( riskAssessment.level === 'low' && (context.environment === 'development' || context.environment === 'test') && this.config.allowAutoApproval ); } _processAutoApproval(approvalRequest) { console.log(`šŸ¤– Auto-approving low-risk request: ${approvalRequest.id}`); approvalRequest.status = 'approved'; approvalRequest.completedAt = Date.now(); approvalRequest.approvers.push({ approver: 'system', decision: true, reason: 'Auto-approved: low risk in development environment', timestamp: Date.now() }); this.stats.approvedRequests++; this.approvalHistory.push(approvalRequest); return { success: true, autoApproved: true, approvalId: approvalRequest.id, artifact: approvalRequest.artifact, approvalCount: 1, deniedCount: 0, pendingCount: 0 }; } _validateDecision(decision, approval) { if (!decision.approver) { throw new Error('Approver identity required'); } if (typeof decision.approved !== 'boolean') { throw new Error('Decision must be boolean (approved: true/false)'); } if (!decision.reason) { throw new Error('Approval reason required'); } return decision; } _applyApprovedModifications(approval) { let artifact = approval.artifact; // Apply any modifications from approvers approval.approvers.forEach(approver => { if (approver.modifications && approver.modifications.length > 0) { artifact = this._applyModifications(artifact, approver.modifications); } }); return artifact; } _applyModifications(artifact, modifications) { // Apply code modifications approved by reviewers return artifact; // Placeholder - would apply actual modifications } _collectModifications(approval) { return approval.approvers .filter(a => a.modifications && a.modifications.length > 0) .flatMap(a => a.modifications); } _generateRiskRecommendations(score, factors) { const recommendations = []; if (score >= 0.8) { recommendations.push('Consider breaking this change into smaller, less risky parts'); recommendations.push('Require additional code review from security team'); } if (factors.includes('hardcoded_secrets')) { recommendations.push('Move all secrets to environment variables'); } if (factors.includes('sql_injection_risk')) { recommendations.push('Use parameterized queries or ORM methods'); } if (factors.includes('api_breaking_changes')) { recommendations.push('Version the API and maintain backward compatibility'); } return recommendations; } _suggestMitigations(violations, factors) { const mitigations = []; factors.forEach(factor => { switch (factor) { case 'hardcoded_secrets': mitigations.push('Use environment variables and secret management'); break; case 'sql_injection_risk': mitigations.push('Implement prepared statements'); break; case 'missing_authentication': mitigations.push('Add authentication middleware'); break; case 'csrf_vulnerability': mitigations.push('Implement CSRF tokens'); break; default: mitigations.push(`Address ${factor}`); } }); return [...new Set(mitigations)]; // Remove duplicates } _generateCodePreview(artifact, violations) { const code = typeof artifact === 'string' ? artifact : artifact.content || ''; const lines = code.split('\n'); // Show context around violations const previews = violations.slice(0, 3).map(violation => { if (violation.line) { const start = Math.max(0, violation.line - 3); const end = Math.min(lines.length, violation.line + 3); return { rule: violation.rule.name, lines: lines.slice(start, end), violationLine: violation.line - start - 1 }; } return null; }).filter(Boolean); return previews; } _generateApprovalActions(riskAssessment) { const actions = [ { id: 'approve', label: 'Approve', variant: 'primary' }, { id: 'deny', label: 'Deny', variant: 'danger' } ]; if (riskAssessment.level === 'medium' || riskAssessment.level === 'low') { actions.push({ id: 'approve_with_changes', label: 'Approve with Changes', variant: 'warning' }); } return actions; } _updateAverageApprovalTime(newTime) { const totalApprovals = this.stats.approvedRequests + this.stats.deniedRequests; if (totalApprovals === 1) { this.stats.averageApprovalTime = newTime; } else { const currentTotal = this.stats.averageApprovalTime * (totalApprovals - 1); this.stats.averageApprovalTime = (currentTotal + newTime) / totalApprovals; } } /** * Get approval system statistics */ getStatistics() { return { ...this.stats, pendingApprovals: this.pendingApprovals.size, totalHistory: this.approvalHistory.length, config: { approvalTimeout: this.config.approvalTimeout, minimumApprovers: this.config.minimumApprovers, riskThresholds: this.config.riskThresholds } }; } /** * Get all pending approvals */ getPendingApprovals() { return Array.from(this.pendingApprovals.values()).map(approval => ({ id: approval.id, status: approval.status, riskLevel: approval.riskAssessment.level, violationCount: approval.violations.length, createdAt: approval.createdAt, expiresAt: approval.expiresAt, timeRemaining: Math.max(0, approval.expiresAt - Date.now()), approvers: approval.approvers.length })); } } module.exports = { TierThreeApproval };