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>
462 lines
11 KiB
JavaScript
462 lines
11 KiB
JavaScript
/**
|
|
* AuditLog Model
|
|
*
|
|
* Stores governance enforcement decisions and boundary checks
|
|
* Replaces filesystem-based .memory/audit/decisions-YYYY-MM-DD.jsonl
|
|
*
|
|
* Benefits over JSONL files:
|
|
* - Fast time-range queries (indexed by timestamp)
|
|
* - Aggregation for analytics dashboard
|
|
* - Filter by sessionId, action, allowed status
|
|
* - Join with GovernanceRule for violation analysis
|
|
* - Automatic expiration with TTL index
|
|
*/
|
|
|
|
const mongoose = require('mongoose');
|
|
|
|
const auditLogSchema = new mongoose.Schema({
|
|
// Core identification
|
|
sessionId: {
|
|
type: String,
|
|
required: true,
|
|
index: true,
|
|
description: 'Session identifier for tracing related decisions'
|
|
},
|
|
|
|
action: {
|
|
type: String,
|
|
required: true,
|
|
index: true,
|
|
description: 'Type of action being audited (e.g., boundary_enforcement, content_generation)'
|
|
},
|
|
|
|
// Decision outcome
|
|
allowed: {
|
|
type: Boolean,
|
|
required: true,
|
|
index: true,
|
|
description: 'Whether the action was allowed or blocked'
|
|
},
|
|
|
|
// Governance context
|
|
rulesChecked: {
|
|
type: [String],
|
|
default: [],
|
|
description: 'IDs of rules that were evaluated (e.g., [inst_016, inst_017])'
|
|
},
|
|
|
|
violations: {
|
|
type: [{
|
|
ruleId: String,
|
|
rulText: String,
|
|
severity: {
|
|
type: String,
|
|
enum: ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'],
|
|
default: 'MEDIUM'
|
|
},
|
|
details: String
|
|
}],
|
|
default: [],
|
|
description: 'Rules that were violated (if any)'
|
|
},
|
|
|
|
// Metadata
|
|
metadata: {
|
|
type: mongoose.Schema.Types.Mixed,
|
|
default: {},
|
|
description: 'Additional context (boundary, domain, tractatus_section, etc.)'
|
|
},
|
|
|
|
// Classification
|
|
domain: {
|
|
type: String,
|
|
enum: ['STRATEGIC', 'OPERATIONAL', 'TACTICAL', 'SYSTEM', 'UNKNOWN'],
|
|
default: 'UNKNOWN',
|
|
index: true,
|
|
description: 'Domain of the decision'
|
|
},
|
|
|
|
boundary: {
|
|
type: String,
|
|
default: null,
|
|
description: 'Boundary that was checked (if applicable)'
|
|
},
|
|
|
|
tractatus_section: {
|
|
type: String,
|
|
default: null,
|
|
index: true,
|
|
description: 'Tractatus framework section that governed this decision'
|
|
},
|
|
|
|
// Performance tracking
|
|
durationMs: {
|
|
type: Number,
|
|
default: null,
|
|
description: 'How long the enforcement check took (milliseconds)'
|
|
},
|
|
|
|
// Service tracking
|
|
service: {
|
|
type: String,
|
|
default: 'BoundaryEnforcer',
|
|
index: true,
|
|
description: 'Which service performed the audit (BoundaryEnforcer, BlogCuration, etc.)'
|
|
},
|
|
|
|
// Environment tracking (for cross-environment research)
|
|
environment: {
|
|
type: String,
|
|
enum: ['development', 'production', 'staging'],
|
|
default: 'development',
|
|
index: true,
|
|
description: 'Environment where this decision was made'
|
|
},
|
|
|
|
synced_at: {
|
|
type: Date,
|
|
default: null,
|
|
description: 'When this log was synced from another environment (null if local)'
|
|
},
|
|
|
|
is_local: {
|
|
type: Boolean,
|
|
default: true,
|
|
description: 'True if created in this environment, false if synced from another'
|
|
},
|
|
|
|
sync_metadata: {
|
|
original_id: {
|
|
type: mongoose.Schema.Types.ObjectId,
|
|
default: null,
|
|
description: 'Original _id from source environment (if synced)'
|
|
},
|
|
synced_from: {
|
|
type: String,
|
|
enum: ['production', 'development', 'staging', null],
|
|
default: null,
|
|
description: 'Source environment (if synced)'
|
|
},
|
|
sync_batch: {
|
|
type: Date,
|
|
default: null,
|
|
description: 'Batch timestamp from export (for tracking)'
|
|
},
|
|
sanitized: {
|
|
type: Boolean,
|
|
default: false,
|
|
description: 'Whether this log was privacy-sanitized before sync'
|
|
}
|
|
},
|
|
|
|
// User context (if applicable)
|
|
userId: {
|
|
type: mongoose.Schema.Types.ObjectId,
|
|
ref: 'User',
|
|
default: null,
|
|
description: 'User who triggered the action (if applicable)'
|
|
},
|
|
|
|
// IP and request context
|
|
ipAddress: {
|
|
type: String,
|
|
default: null,
|
|
description: 'IP address of request (if applicable)'
|
|
},
|
|
|
|
userAgent: {
|
|
type: String,
|
|
default: null,
|
|
description: 'User agent string (if applicable)'
|
|
},
|
|
|
|
// Timestamp (auto-created by timestamps: true, but explicit for clarity)
|
|
// Note: Index is defined separately with TTL (line 149), not here
|
|
timestamp: {
|
|
type: Date,
|
|
default: Date.now,
|
|
description: 'When this decision was made'
|
|
}
|
|
|
|
}, {
|
|
timestamps: true, // Adds createdAt and updatedAt
|
|
collection: 'auditLogs'
|
|
});
|
|
|
|
// Indexes for common queries
|
|
auditLogSchema.index({ timestamp: -1 }); // Most recent first
|
|
auditLogSchema.index({ sessionId: 1, timestamp: -1 }); // Session timeline
|
|
auditLogSchema.index({ allowed: 1, timestamp: -1 }); // Violations timeline
|
|
auditLogSchema.index({ service: 1, timestamp: -1 }); // Service-specific logs
|
|
auditLogSchema.index({ 'violations.ruleId': 1 }, { sparse: true }); // Violation analysis
|
|
auditLogSchema.index({ environment: 1, timestamp: -1 }); // Environment-specific queries
|
|
auditLogSchema.index({ 'sync_metadata.original_id': 1 }, { sparse: true }); // Deduplication
|
|
|
|
// TTL index - automatically delete logs older than 90 days
|
|
auditLogSchema.index({ timestamp: 1 }, { expireAfterSeconds: 90 * 24 * 60 * 60 });
|
|
|
|
// Virtual for violation count
|
|
auditLogSchema.virtual('violationCount').get(function() {
|
|
return this.violations ? this.violations.length : 0;
|
|
});
|
|
|
|
// Static methods
|
|
|
|
/**
|
|
* Find recent decisions
|
|
*/
|
|
auditLogSchema.statics.findRecent = function(limit = 100) {
|
|
return this.find()
|
|
.sort({ timestamp: -1 })
|
|
.limit(limit);
|
|
};
|
|
|
|
/**
|
|
* Find decisions by session
|
|
*/
|
|
auditLogSchema.statics.findBySession = function(sessionId, options = {}) {
|
|
const query = { sessionId };
|
|
|
|
return this.find(query)
|
|
.sort({ timestamp: options.ascending ? 1 : -1 })
|
|
.limit(options.limit || 0);
|
|
};
|
|
|
|
/**
|
|
* Find decisions by date range
|
|
*/
|
|
auditLogSchema.statics.findByDateRange = function(startDate, endDate, options = {}) {
|
|
const query = {
|
|
timestamp: {
|
|
$gte: startDate,
|
|
$lte: endDate
|
|
}
|
|
};
|
|
|
|
if (options.allowed !== undefined) {
|
|
query.allowed = options.allowed;
|
|
}
|
|
|
|
if (options.service) {
|
|
query.service = options.service;
|
|
}
|
|
|
|
return this.find(query)
|
|
.sort({ timestamp: -1 })
|
|
.limit(options.limit || 0);
|
|
};
|
|
|
|
/**
|
|
* Find all violations
|
|
*/
|
|
auditLogSchema.statics.findViolations = function(options = {}) {
|
|
const query = {
|
|
allowed: false,
|
|
'violations.0': { $exists: true } // Has at least one violation
|
|
};
|
|
|
|
if (options.ruleId) {
|
|
query['violations.ruleId'] = options.ruleId;
|
|
}
|
|
|
|
if (options.startDate && options.endDate) {
|
|
query.timestamp = {
|
|
$gte: options.startDate,
|
|
$lte: options.endDate
|
|
};
|
|
}
|
|
|
|
return this.find(query)
|
|
.sort({ timestamp: -1 })
|
|
.limit(options.limit || 0);
|
|
};
|
|
|
|
/**
|
|
* Get statistics for dashboard
|
|
*/
|
|
auditLogSchema.statics.getStatistics = async function(startDate, endDate) {
|
|
const matchStage = {};
|
|
|
|
if (startDate && endDate) {
|
|
matchStage.timestamp = { $gte: startDate, $lte: endDate };
|
|
}
|
|
|
|
const stats = await this.aggregate([
|
|
{ $match: matchStage },
|
|
{
|
|
$group: {
|
|
_id: null,
|
|
totalDecisions: { $sum: 1 },
|
|
allowed: {
|
|
$sum: { $cond: ['$allowed', 1, 0] }
|
|
},
|
|
blocked: {
|
|
$sum: { $cond: ['$allowed', 0, 1] }
|
|
},
|
|
totalViolations: {
|
|
$sum: { $size: { $ifNull: ['$violations', []] } }
|
|
},
|
|
avgDuration: {
|
|
$avg: '$durationMs'
|
|
},
|
|
uniqueSessions: {
|
|
$addToSet: '$sessionId'
|
|
},
|
|
serviceBreakdown: {
|
|
$push: '$service'
|
|
}
|
|
}
|
|
},
|
|
{
|
|
$project: {
|
|
_id: 0,
|
|
totalDecisions: 1,
|
|
allowed: 1,
|
|
blocked: 1,
|
|
totalViolations: 1,
|
|
avgDuration: { $round: ['$avgDuration', 2] },
|
|
uniqueSessionCount: { $size: '$uniqueSessions' },
|
|
allowedRate: {
|
|
$multiply: [
|
|
{ $divide: ['$allowed', '$totalDecisions'] },
|
|
100
|
|
]
|
|
},
|
|
services: '$serviceBreakdown' // Simplified - just return array for now
|
|
}
|
|
}
|
|
]);
|
|
|
|
return stats[0] || null;
|
|
};
|
|
|
|
/**
|
|
* Get violation breakdown by rule
|
|
*/
|
|
auditLogSchema.statics.getViolationBreakdown = async function(startDate, endDate) {
|
|
const matchStage = {
|
|
allowed: false,
|
|
'violations.0': { $exists: true }
|
|
};
|
|
|
|
if (startDate && endDate) {
|
|
matchStage.timestamp = { $gte: startDate, $lte: endDate };
|
|
}
|
|
|
|
const breakdown = await this.aggregate([
|
|
{ $match: matchStage },
|
|
{ $unwind: '$violations' },
|
|
{
|
|
$group: {
|
|
_id: '$violations.ruleId',
|
|
count: { $sum: 1 },
|
|
severity: { $first: '$violations.severity' },
|
|
examples: {
|
|
$push: {
|
|
sessionId: '$sessionId',
|
|
timestamp: '$timestamp',
|
|
details: '$violations.details'
|
|
}
|
|
}
|
|
}
|
|
},
|
|
{
|
|
$project: {
|
|
_id: 0,
|
|
ruleId: '$_id',
|
|
count: 1,
|
|
severity: 1,
|
|
recentExamples: { $slice: ['$examples', 5] } // Last 5 examples
|
|
}
|
|
},
|
|
{ $sort: { count: -1 } }
|
|
]);
|
|
|
|
return breakdown;
|
|
};
|
|
|
|
/**
|
|
* Get timeline data (for charts)
|
|
*/
|
|
auditLogSchema.statics.getTimeline = async function(startDate, endDate, intervalHours = 1) {
|
|
const timeline = await this.aggregate([
|
|
{
|
|
$match: {
|
|
timestamp: { $gte: startDate, $lte: endDate }
|
|
}
|
|
},
|
|
{
|
|
$group: {
|
|
_id: {
|
|
$dateTrunc: {
|
|
date: '$timestamp',
|
|
unit: 'hour',
|
|
binSize: intervalHours
|
|
}
|
|
},
|
|
total: { $sum: 1 },
|
|
allowed: { $sum: { $cond: ['$allowed', 1, 0] } },
|
|
blocked: { $sum: { $cond: ['$allowed', 0, 1] } },
|
|
violations: {
|
|
$sum: { $size: { $ifNull: ['$violations', []] } }
|
|
}
|
|
}
|
|
},
|
|
{ $sort: { _id: 1 } },
|
|
{
|
|
$project: {
|
|
_id: 0,
|
|
timestamp: '$_id',
|
|
total: 1,
|
|
allowed: 1,
|
|
blocked: 1,
|
|
violations: 1,
|
|
allowedRate: {
|
|
$multiply: [
|
|
{ $divide: ['$allowed', '$total'] },
|
|
100
|
|
]
|
|
}
|
|
}
|
|
}
|
|
]);
|
|
|
|
return timeline;
|
|
};
|
|
|
|
// Instance methods
|
|
|
|
/**
|
|
* Add a violation to this log entry
|
|
*/
|
|
auditLogSchema.methods.addViolation = function(violation) {
|
|
this.violations.push(violation);
|
|
this.allowed = false; // Violations mean action blocked
|
|
return this.save();
|
|
};
|
|
|
|
/**
|
|
* Check if this decision was blocked
|
|
*/
|
|
auditLogSchema.methods.isBlocked = function() {
|
|
return !this.allowed;
|
|
};
|
|
|
|
/**
|
|
* Get human-readable summary
|
|
*/
|
|
auditLogSchema.methods.getSummary = function() {
|
|
return {
|
|
timestamp: this.timestamp.toISOString(),
|
|
sessionId: this.sessionId,
|
|
action: this.action,
|
|
result: this.allowed ? 'ALLOWED' : 'BLOCKED',
|
|
violationCount: this.violationCount,
|
|
service: this.service,
|
|
domain: this.domain
|
|
};
|
|
};
|
|
|
|
const AuditLog = mongoose.model('AuditLog', auditLogSchema);
|
|
|
|
module.exports = AuditLog;
|