UNPKG

okta-mcp-server

Version:

Model Context Protocol (MCP) server for Okta API operations with support for bulk operations and caching

489 lines (485 loc) 18.8 kB
/** * SQLite-based audit logger with tamper resistance and high performance */ import Database from 'better-sqlite3'; import { randomUUID } from 'crypto'; import { createHash } from 'crypto'; import path from 'path'; import { createDefaultPIIMasker } from './pii-masker.js'; import { logger } from '../../utils/logger.js'; export class AuditLogger { db; eventBus; piiMasker; retentionPolicy; enableIntegrityChecks; performanceTracking; batchSize; pendingEntries = []; flushTimer; preparedStatements = {}; constructor(options = {}) { const { dbPath = path.join(process.cwd(), 'audit.db'), inMemory = false, eventBus, retentionPolicy = { retentionDays: 90 }, piiMasker = createDefaultPIIMasker(), enableIntegrityChecks = true, performanceTracking = true, batchSize = 100, flushInterval = 1000, } = options; this.eventBus = eventBus; this.piiMasker = piiMasker; this.retentionPolicy = retentionPolicy; this.enableIntegrityChecks = enableIntegrityChecks; this.performanceTracking = performanceTracking; this.batchSize = batchSize; // Initialize database this.db = new Database(inMemory ? ':memory:' : dbPath); // Configure for write-heavy workload if (!inMemory) { this.db.pragma('journal_mode = WAL'); this.db.pragma('synchronous = NORMAL'); this.db.pragma('cache_size = 10000'); this.db.pragma('temp_store = MEMORY'); this.db.pragma('mmap_size = 30000000000'); // 30GB mmap } // Initialize schema this.initializeSchema(); // Prepare statements this.prepareStatements(); // Start flush timer if (flushInterval > 0) { this.flushTimer = setInterval(() => { this.flush().catch((err) => logger.error('Failed to flush audit entries:', err)); }, flushInterval); } // Start retention cleanup if (retentionPolicy.retentionDays > 0) { setInterval(() => { this.cleanupOldEntries().catch((err) => logger.error('Failed to cleanup old audit entries:', err)); }, 24 * 60 * 60 * 1000); // Daily } } initializeSchema() { this.db.exec(` -- Main audit log table CREATE TABLE IF NOT EXISTS audit_log ( id TEXT PRIMARY KEY, timestamp INTEGER NOT NULL, actor_id TEXT NOT NULL, actor_type TEXT NOT NULL, action_type TEXT NOT NULL, action_method TEXT NOT NULL, action_result TEXT NOT NULL, resource_type TEXT, resource_id TEXT, request_id TEXT NOT NULL, correlation_id TEXT, duration INTEGER, ip_address TEXT, user_agent TEXT, error_code TEXT, error_message TEXT, request_params TEXT, -- JSON, sanitized response_status INTEGER, metadata TEXT, -- JSON hash TEXT NOT NULL, created_at INTEGER NOT NULL ); -- Indexes for efficient querying CREATE INDEX IF NOT EXISTS idx_timestamp ON audit_log(timestamp); CREATE INDEX IF NOT EXISTS idx_actor_id ON audit_log(actor_id); CREATE INDEX IF NOT EXISTS idx_action_type ON audit_log(action_type); CREATE INDEX IF NOT EXISTS idx_action_result ON audit_log(action_result); CREATE INDEX IF NOT EXISTS idx_resource_type_id ON audit_log(resource_type, resource_id); CREATE INDEX IF NOT EXISTS idx_request_id ON audit_log(request_id); CREATE INDEX IF NOT EXISTS idx_correlation_id ON audit_log(correlation_id); CREATE INDEX IF NOT EXISTS idx_created_at ON audit_log(created_at); -- Table for integrity checks CREATE TABLE IF NOT EXISTS audit_integrity ( id INTEGER PRIMARY KEY AUTOINCREMENT, check_timestamp INTEGER NOT NULL, entries_checked INTEGER NOT NULL, corrupted_count INTEGER NOT NULL, corrupted_ids TEXT, -- JSON array check_hash TEXT NOT NULL ); -- Table for audit statistics (materialized view) CREATE TABLE IF NOT EXISTS audit_stats_cache ( id INTEGER PRIMARY KEY AUTOINCREMENT, period_start INTEGER NOT NULL, period_end INTEGER NOT NULL, total_entries INTEGER NOT NULL, success_count INTEGER NOT NULL, failure_count INTEGER NOT NULL, error_count INTEGER NOT NULL, avg_duration REAL, top_actors TEXT, -- JSON top_actions TEXT, -- JSON top_errors TEXT, -- JSON created_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_stats_period ON audit_stats_cache(period_start, period_end); `); } prepareStatements() { // Insert statement this.preparedStatements.insert = this.db.prepare(` INSERT INTO audit_log ( id, timestamp, actor_id, actor_type, action_type, action_method, action_result, resource_type, resource_id, request_id, correlation_id, duration, ip_address, user_agent, error_code, error_message, request_params, response_status, metadata, hash, created_at ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ) `); // Query statement (will be built dynamically) // Count statement this.preparedStatements.count = this.db.prepare(` SELECT COUNT(*) as count FROM audit_log WHERE timestamp >= ? AND timestamp <= ? `); // Delete old entries this.preparedStatements.deleteOld = this.db.prepare(` DELETE FROM audit_log WHERE created_at < ? `); // Integrity check this.preparedStatements.checkIntegrity = this.db.prepare(` SELECT id, hash, timestamp, actor_id, action_type, action_result FROM audit_log WHERE created_at >= ? ORDER BY created_at DESC LIMIT ? `); } /** * Log an audit entry */ async log(entry) { const fullEntry = { ...entry, id: randomUUID(), hash: '', // Will be calculated }; // Sanitize PII if (fullEntry.request?.parameters) { fullEntry.request.parameters = this.piiMasker.maskObject(fullEntry.request.parameters); } if (fullEntry.metadata) { fullEntry.metadata = this.piiMasker.maskObject(fullEntry.metadata); } // Calculate hash for integrity if (this.enableIntegrityChecks) { fullEntry.hash = this.calculateEntryHash(fullEntry); } // Add to pending batch this.pendingEntries.push(fullEntry); // Flush if batch is full if (this.pendingEntries.length >= this.batchSize) { await this.flush(); } // Emit event this.eventBus?.emit('audit:logged', { entry: fullEntry }); } /** * Flush pending entries to database */ async flush() { if (this.pendingEntries.length === 0) { return; } const entries = [...this.pendingEntries]; this.pendingEntries = []; const transaction = this.db.transaction(() => { const stmt = this.preparedStatements.insert; for (const entry of entries) { stmt.run(entry.id, entry.timestamp.getTime(), entry.actor.id, entry.actor.type, entry.action.type, entry.action.method, entry.action.result, entry.resource?.type || null, entry.resource?.id || null, entry.context.requestId, entry.context.correlationId || null, entry.performance?.duration || null, entry.actor.ipAddress || null, entry.actor.userAgent || null, entry.action.errorCode || null, entry.action.errorMessage || null, entry.request ? JSON.stringify(entry.request) : null, entry.response?.status || null, entry.metadata ? JSON.stringify(entry.metadata) : null, entry.hash || '', Date.now()); } }); try { transaction(); logger.debug(`Flushed ${entries.length} audit entries`); } catch (error) { logger.error('Failed to flush audit entries:', error); // Re-add entries to pending for retry this.pendingEntries.unshift(...entries); throw error; } } /** * Query audit logs */ async query(options = {}) { await this.flush(); // Ensure all pending entries are written const { startTime = new Date(Date.now() - 24 * 60 * 60 * 1000), // Last 24 hours endTime = new Date(), actors = [], actions = [], resources = [], status = [], limit = 1000, offset = 0, orderBy = 'timestamp', orderDirection = 'desc', } = options; let query = 'SELECT * FROM audit_log WHERE timestamp >= ? AND timestamp <= ?'; const params = [startTime.getTime(), endTime.getTime()]; // Build dynamic WHERE clauses if (actors.length > 0) { query += ` AND actor_id IN (${actors.map(() => '?').join(',')})`; params.push(...actors); } if (actions.length > 0) { query += ` AND action_type IN (${actions.map(() => '?').join(',')})`; params.push(...actions); } if (resources.length > 0) { query += ` AND resource_id IN (${resources.map(() => '?').join(',')})`; params.push(...resources); } if (status.length > 0) { query += ` AND action_result IN (${status.map(() => '?').join(',')})`; params.push(...status); } // Add ordering and pagination query += ` ORDER BY ${orderBy} ${orderDirection.toUpperCase()} LIMIT ? OFFSET ?`; params.push(limit, offset); const rows = this.db.prepare(query).all(...params); return rows.map((row) => this.rowToAuditEntry(row)); } /** * Get audit statistics */ async getStatistics(startTime, endTime) { await this.flush(); const stats = this.db .prepare(` SELECT COUNT(*) as total, COUNT(CASE WHEN action_result = 'success' THEN 1 END) as success_count, COUNT(CASE WHEN action_result = 'failure' THEN 1 END) as failure_count, COUNT(CASE WHEN action_result = 'error' THEN 1 END) as error_count, AVG(duration) as avg_duration FROM audit_log WHERE timestamp >= ? AND timestamp <= ? `) .get(startTime.getTime(), endTime.getTime()); // Get top actors const topActors = this.db .prepare(` SELECT actor_id, COUNT(*) as count FROM audit_log WHERE timestamp >= ? AND timestamp <= ? GROUP BY actor_id ORDER BY count DESC LIMIT 10 `) .all(startTime.getTime(), endTime.getTime()); // Get top actions const topActions = this.db .prepare(` SELECT action_type, COUNT(*) as count FROM audit_log WHERE timestamp >= ? AND timestamp <= ? GROUP BY action_type ORDER BY count DESC LIMIT 10 `) .all(startTime.getTime(), endTime.getTime()); // Get top errors const topErrors = this.db .prepare(` SELECT error_code, COUNT(*) as count FROM audit_log WHERE timestamp >= ? AND timestamp <= ? AND error_code IS NOT NULL GROUP BY error_code ORDER BY count DESC LIMIT 10 `) .all(startTime.getTime(), endTime.getTime()); return { totalEntries: stats.total, successCount: stats.success_count, failureCount: stats.failure_count, errorCount: stats.error_count, averageDuration: stats.avg_duration || 0, topActors: topActors.map((row) => ({ actor: row.actor_id, count: row.count })), topActions: topActions.map((row) => ({ action: row.action_type, count: row.count })), topErrors: topErrors.map((row) => ({ error: row.error_code, count: row.count })), timeRange: { start: startTime, end: endTime }, }; } /** * Export audit logs */ async export(options) { const entries = await this.query({ startTime: options.startTime, endTime: options.endTime, ...options.filters, }); switch (options.format) { case 'json': return JSON.stringify(entries, null, 2); case 'csv': return this.exportToCSV(entries, options.includeHeaders); case 'siem': return this.exportToSIEM(entries); default: throw new Error(`Unsupported export format: ${options.format}`); } } /** * Check integrity of audit logs */ async checkIntegrity(hoursBack = 24) { const checkTime = Date.now() - hoursBack * 60 * 60 * 1000; const rows = this.preparedStatements.checkIntegrity.all(checkTime, 10000); const errors = []; const corrupted = []; for (const row of rows) { const expectedHash = this.calculateHashFromRow(row); if (row.hash !== expectedHash) { corrupted.push(row.id); errors.push(`Entry ${row.id} has invalid hash`); } } const result = { isValid: corrupted.length === 0, errors: errors.length > 0 ? errors : undefined, lastChecked: new Date(), entriesChecked: rows.length, corruptedEntries: corrupted.length > 0 ? corrupted : undefined, }; // Log integrity check this.db .prepare(` INSERT INTO audit_integrity ( check_timestamp, entries_checked, corrupted_count, corrupted_ids, check_hash ) VALUES (?, ?, ?, ?, ?) `) .run(Date.now(), result.entriesChecked, corrupted.length, JSON.stringify(corrupted), createHash('sha256').update(JSON.stringify(result)).digest('hex')); return result; } /** * Clean up old entries based on retention policy */ async cleanupOldEntries() { const cutoffTime = Date.now() - this.retentionPolicy.retentionDays * 24 * 60 * 60 * 1000; const result = this.preparedStatements.deleteOld.run(cutoffTime); if (result.changes > 0) { logger.info(`Cleaned up ${result.changes} old audit entries`); this.eventBus?.emit('audit:cleanup', { deleted: result.changes }); } } /** * Calculate hash for an audit entry */ calculateEntryHash(entry) { const data = `${entry.timestamp.getTime()}|${entry.actor.id}|${entry.action.type}|${entry.action.result}|${entry.context.requestId}`; return createHash('sha256').update(data).digest('hex'); } /** * Calculate hash from database row */ calculateHashFromRow(row) { const data = `${row.timestamp}|${row.actor_id}|${row.action_type}|${row.action_result}|${row.request_id}`; return createHash('sha256').update(data).digest('hex'); } /** * Convert database row to AuditEntry */ rowToAuditEntry(row) { return { id: row.id, timestamp: new Date(row.timestamp), actor: { id: row.actor_id, type: row.actor_type, ipAddress: row.ip_address, userAgent: row.user_agent, }, action: { type: row.action_type, method: row.action_method, result: row.action_result, errorCode: row.error_code, errorMessage: row.error_message, }, resource: row.resource_type ? { type: row.resource_type, id: row.resource_id, } : undefined, request: row.request_params ? JSON.parse(row.request_params) : undefined, response: row.response_status ? { status: row.response_status, } : undefined, performance: row.duration ? { duration: row.duration, } : undefined, context: { requestId: row.request_id, correlationId: row.correlation_id, }, metadata: row.metadata ? JSON.parse(row.metadata) : undefined, hash: row.hash, }; } /** * Export to CSV format */ exportToCSV(entries, includeHeaders = true) { const rows = []; if (includeHeaders) { rows.push('ID,Timestamp,Actor,Action,Result,Resource,Duration,IP Address,Error'); } for (const entry of entries) { rows.push([ entry.id, entry.timestamp.toISOString(), entry.actor.id, entry.action.type, entry.action.result, entry.resource ? `${entry.resource.type}:${entry.resource.id}` : '', entry.performance?.duration || '', entry.actor.ipAddress || '', entry.action.errorMessage || '', ].join(',')); } return rows.join('\n'); } /** * Export to SIEM format (CEF) */ exportToSIEM(entries) { const lines = []; for (const entry of entries) { const severity = entry.action.result === 'error' ? 7 : entry.action.result === 'failure' ? 5 : 3; lines.push(`CEF:0|Okta|MCP Server|1.0|${entry.action.type}|${entry.action.method}|${severity}|` + `rt=${entry.timestamp.getTime()} ` + `src=${entry.actor.ipAddress || 'unknown'} ` + `suser=${entry.actor.id} ` + `outcome=${entry.action.result} ` + `cs1Label=RequestID cs1=${entry.context.requestId} ` + `cs2Label=ResourceID cs2=${entry.resource?.id || 'none'}`); } return lines.join('\n'); } /** * Close the database connection */ close() { if (this.flushTimer) { clearInterval(this.flushTimer); } // Final flush this.flush().catch((err) => logger.error('Failed to flush during close:', err)); this.db.close(); } /** * Optimize the database */ async optimize() { try { await this.flush(); this.db.exec('VACUUM'); this.db.exec('ANALYZE'); logger.info('Audit database optimized'); } catch (error) { logger.error('Failed to optimize audit database:', error); } } } //# sourceMappingURL=audit-logger.js.map