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
JavaScript
/**
* 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