tractatus/src/controllers/audit.controller.js
TheFlow d854ac85e2 feat(research): add cross-environment audit log sync infrastructure
Implements privacy-preserving synchronization of production audit logs
to development for comprehensive governance research analysis.

Backend Components:
- SyncMetadata.model.js: Track sync state and statistics
- audit-sanitizer.util.js: Privacy sanitization utility
  - Redacts credentials, API keys, user identities
  - Sanitizes file paths and violation content
  - Preserves statistical patterns for research
- sync-prod-audit-logs.js: CLI sync script
  - Incremental sync with deduplication
  - Dry-run mode for testing
  - Configurable date range
- AuditLog.model.js: Enhanced schema with environment tracking
  - environment field (development/production/staging)
  - sync_metadata tracking (original_id, synced_from, etc.)
  - New indexes for cross-environment queries
- audit.controller.js: New /api/admin/audit-export endpoint
  - Privacy-sanitized export for cross-environment sync
  - Environment filter support in getAuditLogs
- MemoryProxy.service.js: Environment tagging in auditDecision()
  - Tags new logs with NODE_ENV or override
  - Sets is_local flag for tracking

Frontend Components:
- audit-analytics.html: Environment filter dropdown
- audit-analytics.js: Environment filter query parameter handling

Research Benefits:
- Combine dev and prod governance statistics
- Longitudinal analysis across environments
- Validate framework consistency
- Privacy-preserving data sharing

Security:
- API-based export (not direct DB access)
- Admin-only endpoints with JWT authentication
- Comprehensive credential redaction
- One-way sync (production → development)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 12:11:16 +13:00

580 lines
18 KiB
JavaScript

/*
* Copyright 2025 John G Stroh
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Audit Controller
* Serves audit logs from MemoryProxy for analytics dashboard
*/
const fs = require('fs').promises;
const path = require('path');
const logger = require('../utils/logger.util');
// Default cost factors (user-configurable)
const DEFAULT_COST_FACTORS = {
CRITICAL: { amount: 50000, currency: 'USD', rationale: 'Average cost of critical security incident' },
HIGH: { amount: 10000, currency: 'USD', rationale: 'Average cost of high-impact client-facing error' },
MEDIUM: { amount: 2000, currency: 'USD', rationale: 'Average cost of medium-impact issue requiring hotfix' },
LOW: { amount: 500, currency: 'USD', rationale: 'Average developer time cost to fix low-impact issue' }
};
let userCostFactors = { ...DEFAULT_COST_FACTORS };
/**
* Get audit logs for analytics
* GET /api/admin/audit-logs?days=7&environment=production
*/
async function getAuditLogs(req, res) {
try {
const { days = 7, limit = 10000, environment } = req.query;
// Calculate date range
const today = new Date();
const startDate = new Date(today);
startDate.setDate(today.getDate() - parseInt(days));
// Build query
const query = { timestamp: { $gte: startDate } };
// Add environment filter if specified
if (environment && environment !== 'all') {
query.environment = environment;
}
// Read from MongoDB instead of JSONL files
const db = require('../utils/db.util');
const collection = await db.getCollection('auditLogs');
const decisions = await collection
.find(query)
.sort({ timestamp: -1 })
.limit(parseInt(limit))
.toArray();
res.json({
success: true,
decisions,
total: decisions.length,
limited: decisions.length,
environment: environment || 'all',
dateRange: {
start: startDate.toISOString(),
end: today.toISOString()
}
});
} catch (error) {
logger.error('Error fetching audit logs:', error);
res.status(500).json({
success: false,
error: error.message
});
}
}
/**
* Get audit analytics summary
* GET /api/admin/audit-analytics
*/
async function getAuditAnalytics(req, res) {
try {
const { days = 7 } = req.query;
// Get audit logs
const auditLogsResponse = await getAuditLogs(req, { json: data => data });
const decisions = auditLogsResponse.decisions;
// Calculate analytics
const blockedDecisions = decisions.filter(d => !d.allowed);
const allowedDecisions = decisions.filter(d => d.allowed);
const analytics = {
total: decisions.length,
allowed: allowedDecisions.length,
blocked: blockedDecisions.length,
violations: decisions.filter(d => d.violations && d.violations.length > 0).length,
// Block metrics
blockRate: decisions.length > 0 ? ((blockedDecisions.length / decisions.length) * 100).toFixed(1) : 0,
allowedRate: decisions.length > 0 ? ((allowedDecisions.length / decisions.length) * 100).toFixed(1) : 0,
// Block breakdown
blocksByReason: {},
blocksBySeverity: {},
frameworkSaves: [], // High severity blocks
byAction: {},
byService: {},
bySession: {},
byDate: {},
timeline: [],
topViolations: [],
// Business intelligence metrics
byActivityType: {},
byRiskLevel: {},
byStakeholderImpact: {},
riskHeatmap: [],
businessImpactTotal: 0,
roiProjections: {},
dateRange: auditLogsResponse.dateRange
};
// Group by action
decisions.forEach(d => {
const action = d.action || 'unknown';
analytics.byAction[action] = (analytics.byAction[action] || 0) + 1;
});
// Group by service
decisions.forEach(d => {
const service = d.service || 'unknown';
analytics.byService[service] = (analytics.byService[service] || 0) + 1;
});
// Group by session
decisions.forEach(d => {
const session = d.sessionId || 'unknown';
analytics.bySession[session] = (analytics.bySession[session] || 0) + 1;
});
// Group by date
decisions.forEach(d => {
const date = new Date(d.timestamp).toISOString().split('T')[0];
analytics.byDate[date] = (analytics.byDate[date] || 0) + 1;
});
// Timeline (last 24 hours by hour)
const hourCounts = {};
decisions.forEach(d => {
const hour = new Date(d.timestamp).getHours();
hourCounts[hour] = (hourCounts[hour] || 0) + 1;
});
for (let i = 0; i < 24; i++) {
analytics.timeline.push({
hour: i,
count: hourCounts[i] || 0
});
}
// Analyze blocks by reason and severity
blockedDecisions.forEach(d => {
// Extract reason from violations or metadata
let reason = 'Unknown';
let severity = 'MEDIUM';
if (d.violations && d.violations.length > 0) {
// Use first violation's rule ID as reason
reason = d.violations[0].ruleId || d.violations[0].ruleText || 'Unknown';
severity = d.violations[0].severity || 'MEDIUM';
// Count by severity
analytics.blocksBySeverity[severity] = (analytics.blocksBySeverity[severity] || 0) + 1;
// Track high severity blocks as "Framework Saves"
if (severity === 'HIGH' || severity === 'CRITICAL') {
analytics.frameworkSaves.push({
timestamp: d.timestamp,
reason: d.violations[0].ruleText || d.violations[0].details,
ruleId: d.violations[0].ruleId,
severity: severity,
file: d.metadata?.filePath || d.metadata?.file || 'N/A',
service: d.service
});
}
} else if (d.metadata?.reason) {
reason = d.metadata.reason;
}
// Count by reason
analytics.blocksByReason[reason] = (analytics.blocksByReason[reason] || 0) + 1;
});
// Sort framework saves by timestamp (most recent first)
analytics.frameworkSaves.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
analytics.frameworkSaves = analytics.frameworkSaves.slice(0, 10); // Top 10 saves
// Top violations (enhanced with more detail)
const violationCounts = {};
decisions.forEach(d => {
if (d.violations && d.violations.length > 0) {
d.violations.forEach(v => {
const key = v.ruleId || v.ruleText || 'unknown';
if (!violationCounts[key]) {
violationCounts[key] = {
ruleId: v.ruleId,
ruleText: v.ruleText,
severity: v.severity,
count: 0
};
}
violationCounts[key].count++;
});
}
});
analytics.topViolations = Object.values(violationCounts)
.sort((a, b) => b.count - a.count)
.slice(0, 10);
// === BUSINESS INTELLIGENCE METRICS ===
// Activity type breakdown
decisions.forEach(d => {
const activityType = d.activityType || 'Unknown';
if (!analytics.byActivityType[activityType]) {
analytics.byActivityType[activityType] = {
total: 0,
allowed: 0,
blocked: 0,
blockRate: 0
};
}
analytics.byActivityType[activityType].total++;
if (d.allowed) {
analytics.byActivityType[activityType].allowed++;
} else {
analytics.byActivityType[activityType].blocked++;
}
});
// Calculate block rates for each activity type
Object.keys(analytics.byActivityType).forEach(type => {
const data = analytics.byActivityType[type];
data.blockRate = data.total > 0 ? ((data.blocked / data.total) * 100).toFixed(1) : '0.0';
});
// Risk level distribution
decisions.forEach(d => {
const riskLevel = d.riskLevel || 'low';
if (!analytics.byRiskLevel[riskLevel]) {
analytics.byRiskLevel[riskLevel] = {
total: 0,
blocked: 0
};
}
analytics.byRiskLevel[riskLevel].total++;
if (!d.allowed) {
analytics.byRiskLevel[riskLevel].blocked++;
}
});
// Stakeholder impact distribution
decisions.forEach(d => {
const impact = d.stakeholderImpact || 'individual';
if (!analytics.byStakeholderImpact[impact]) {
analytics.byStakeholderImpact[impact] = {
total: 0,
blocked: 0,
businessImpact: 0
};
}
analytics.byStakeholderImpact[impact].total++;
if (!d.allowed) {
analytics.byStakeholderImpact[impact].blocked++;
analytics.byStakeholderImpact[impact].businessImpact += (d.businessImpact || 0);
}
});
// Risk heatmap: Activity Type vs Risk Level
const heatmapData = {};
decisions.forEach(d => {
const activityType = d.activityType || 'Unknown';
const riskLevel = d.riskLevel || 'low';
const key = `${activityType}|${riskLevel}`;
if (!heatmapData[key]) {
heatmapData[key] = {
activityType,
riskLevel,
total: 0,
blocked: 0
};
}
heatmapData[key].total++;
if (!d.allowed) {
heatmapData[key].blocked++;
}
});
analytics.riskHeatmap = Object.values(heatmapData);
// Business impact total
analytics.businessImpactTotal = blockedDecisions.reduce((sum, d) => sum + (d.businessImpact || 0), 0);
// === COST AVOIDANCE CALCULATIONS ===
// Calculate cost avoided based on user-configured factors
let totalCostAvoided = 0;
const costBreakdown = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 };
blockedDecisions.forEach(d => {
if (d.violations && d.violations.length > 0) {
d.violations.forEach(v => {
const severity = v.severity || 'LOW';
const costFactor = userCostFactors[severity];
if (costFactor) {
totalCostAvoided += costFactor.amount;
costBreakdown[severity] += costFactor.amount;
}
});
}
});
analytics.costAvoidance = {
total: totalCostAvoided,
currency: userCostFactors.CRITICAL.currency,
breakdown: costBreakdown,
costFactors: userCostFactors
};
// === AI vs HUMAN PERFORMANCE ===
// Detect AI vs Human based on service patterns
// AI = FileEditHook, Framework services
// Human = Manual overrides, direct database operations
const aiDecisions = decisions.filter(d =>
d.service === 'FileEditHook' ||
d.service === 'BoundaryEnforcer' ||
d.service === 'ContextPressureMonitor' ||
d.service === 'MetacognitiveVerifier'
);
const humanDecisions = decisions.filter(d =>
!aiDecisions.includes(d)
);
const aiBlocked = aiDecisions.filter(d => !d.allowed).length;
const humanBlocked = humanDecisions.filter(d => !d.allowed).length;
analytics.teamComparison = {
ai: {
total: aiDecisions.length,
blocked: aiBlocked,
blockRate: aiDecisions.length > 0 ? ((aiBlocked / aiDecisions.length) * 100).toFixed(1) : '0.0',
label: 'AI Assistant'
},
human: {
total: humanDecisions.length,
blocked: humanBlocked,
blockRate: humanDecisions.length > 0 ? ((humanBlocked / humanDecisions.length) * 100).toFixed(1) : '0.0',
label: 'Human Direct'
}
};
// Framework Maturity Score (0-100)
// Based on: block rate trend, severity distribution, learning curve
const recentDays = 7;
const recentDate = new Date();
recentDate.setDate(recentDate.getDate() - recentDays);
const recentDecisions = decisions.filter(d => new Date(d.timestamp) >= recentDate);
const recentBlocked = recentDecisions.filter(d => !d.allowed).length;
const recentBlockRate = recentDecisions.length > 0 ? (recentBlocked / recentDecisions.length) : 0;
// Lower block rate = higher maturity (framework teaching good habits)
const blockRateScore = Math.max(0, 100 - (recentBlockRate * 1000));
// More AI decisions with low block rate = higher maturity
const aiAdoptionScore = aiDecisions.length > 0 ? ((aiDecisions.length / decisions.length) * 100) : 0;
// Fewer critical/high severity blocks = higher maturity
const criticalCount = blockedDecisions.filter(d =>
d.violations && d.violations.some(v => v.severity === 'CRITICAL' || v.severity === 'HIGH')
).length;
const severityScore = Math.max(0, 100 - (criticalCount / decisions.length * 1000));
analytics.frameworkMaturity = {
score: Math.round((blockRateScore + aiAdoptionScore + severityScore) / 3),
components: {
blockRateScore: Math.round(blockRateScore),
aiAdoptionScore: Math.round(aiAdoptionScore),
severityScore: Math.round(severityScore)
},
trend: recentBlockRate < avgBlockRate ? 'improving' : 'stable',
message: recentBlockRate < 0.05 ? 'Excellent - Framework teaching good practices' :
recentBlockRate < 0.10 ? 'Good - Team adapting well to governance' :
'Learning - Framework actively preventing violations'
};
// ROI Projections
const totalDecisions = decisions.length;
const totalBlocks = blockedDecisions.length;
const avgBlockRate = totalDecisions > 0 ? (totalBlocks / totalDecisions) : 0;
analytics.roiProjections = {
// Current period metrics
decisionsPerDay: days > 0 ? (totalDecisions / days).toFixed(1) : '0',
blocksPerDay: days > 0 ? (totalBlocks / days).toFixed(1) : '0',
// Projections for scaled deployment
projections: [
{
userCount: 1000,
decisionsPerMonth: Math.round((totalDecisions / days) * 30 * 1000 / decisions.length * 10), // Estimated
blocksPerMonth: Math.round((totalBlocks / days) * 30 * 1000 / decisions.length * 10),
highSeverityBlocks: Math.round(analytics.frameworkSaves.length / days * 30 * 1000 / decisions.length * 10),
businessImpactSaved: Math.round(analytics.businessImpactTotal / days * 30 * 1000 / decisions.length * 10)
},
{
userCount: 10000,
decisionsPerMonth: Math.round((totalDecisions / days) * 30 * 10000 / decisions.length * 10),
blocksPerMonth: Math.round((totalBlocks / days) * 30 * 10000 / decisions.length * 10),
highSeverityBlocks: Math.round(analytics.frameworkSaves.length / days * 30 * 10000 / decisions.length * 10),
businessImpactSaved: Math.round(analytics.businessImpactTotal / days * 30 * 10000 / decisions.length * 10)
},
{
userCount: 70000,
decisionsPerMonth: Math.round((totalDecisions / days) * 30 * 70000 / decisions.length * 10),
blocksPerMonth: Math.round((totalBlocks / days) * 30 * 70000 / decisions.length * 10),
highSeverityBlocks: Math.round(analytics.frameworkSaves.length / days * 30 * 70000 / decisions.length * 10),
businessImpactSaved: Math.round(analytics.businessImpactTotal / days * 30 * 70000 / decisions.length * 10)
}
]
};
res.json({
success: true,
analytics
});
} catch (error) {
logger.error('Error calculating audit analytics:', error);
res.status(500).json({
success: false,
error: error.message
});
}
}
/**
* Get cost configuration
* GET /api/admin/cost-config
*/
async function getCostConfig(req, res) {
try {
res.json({
success: true,
costFactors: userCostFactors
});
} catch (error) {
logger.error('Error fetching cost config:', error);
res.status(500).json({
success: false,
error: error.message
});
}
}
/**
* Update cost configuration
* POST /api/admin/cost-config
*/
async function updateCostConfig(req, res) {
try {
const { costFactors } = req.body;
if (!costFactors) {
return res.status(400).json({
success: false,
error: 'costFactors required'
});
}
// Validate and update
['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'].forEach(severity => {
if (costFactors[severity]) {
userCostFactors[severity] = {
amount: parseFloat(costFactors[severity].amount) || userCostFactors[severity].amount,
currency: costFactors[severity].currency || userCostFactors[severity].currency,
rationale: costFactors[severity].rationale || userCostFactors[severity].rationale
};
}
});
logger.info('Cost factors updated by', req.user?.username || 'admin');
res.json({
success: true,
costFactors: userCostFactors
});
} catch (error) {
logger.error('Error updating cost config:', error);
res.status(500).json({
success: false,
error: error.message
});
}
}
/**
* Export audit logs for cross-environment research (privacy-preserving)
* GET /api/admin/audit-export?since=YYYY-MM-DD
*/
async function exportAuditLogs(req, res) {
try {
const { since } = req.query;
if (!since) {
return res.status(400).json({
success: false,
error: 'since parameter required (ISO date format)'
});
}
// Parse date
const sinceDate = new Date(since);
if (isNaN(sinceDate.getTime())) {
return res.status(400).json({
success: false,
error: 'Invalid date format for since parameter'
});
}
// Fetch logs since date
const logs = await AuditLog.find({
timestamp: { $gte: sinceDate }
}).sort({ timestamp: 1 }).lean();
// Sanitize for privacy
const { sanitizeBatch } = require('../utils/audit-sanitizer.util');
const sanitized = sanitizeBatch(logs);
logger.info(`Exported ${sanitized.length} audit logs since ${since} for ${req.user?.username || 'unknown'}`);
res.json({
success: true,
count: sanitized.length,
since: sinceDate,
exported_at: new Date(),
logs: sanitized
});
} catch (error) {
logger.error('Error exporting audit logs:', error);
res.status(500).json({
success: false,
error: error.message
});
}
}
module.exports = {
getAuditLogs,
getAuditAnalytics,
getCostConfig,
updateCostConfig,
exportAuditLogs
};