tractatus/src/models/AuditLog.model.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

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;