mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
Merge pull request #796 from igormorais123/feat/config-audit-trail
feat(domain): add configuration audit trail with diff detection and rollback
This commit is contained in:
285
src/domain/configAudit.ts
Normal file
285
src/domain/configAudit.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* Configuration Audit Trail
|
||||
*
|
||||
* Records every change to provider connections, combos, and routing
|
||||
* policies with before/after snapshots and diff detection.
|
||||
* Enables rollback to previous configurations when changes cause issues.
|
||||
*
|
||||
* Each entry captures:
|
||||
* - What changed (target type + ID)
|
||||
* - Who/what triggered the change (source)
|
||||
* - Before/after state snapshots
|
||||
* - Computed diff summary
|
||||
* - Optional human notes
|
||||
*/
|
||||
|
||||
/** Types of configuration entities that can be audited */
|
||||
export type AuditTarget = "provider" | "combo" | "policy" | "connection" | "settings";
|
||||
|
||||
/** How the change was triggered */
|
||||
export type AuditSource = "dashboard" | "api" | "sync" | "auto-healing" | "cli" | "mcp";
|
||||
|
||||
/** Type of change */
|
||||
export type AuditAction = "create" | "update" | "delete" | "enable" | "disable";
|
||||
|
||||
/** A single audit log entry */
|
||||
export interface ConfigAuditEntry {
|
||||
/** Unique entry ID */
|
||||
id: string;
|
||||
/** ISO timestamp of the change */
|
||||
timestamp: string;
|
||||
/** Type of change */
|
||||
action: AuditAction;
|
||||
/** What type of entity was changed */
|
||||
target: AuditTarget;
|
||||
/** ID of the changed entity */
|
||||
targetId: string;
|
||||
/** Human-readable name of the entity */
|
||||
targetName: string;
|
||||
/** State before the change (null for creates) */
|
||||
before: Record<string, unknown> | null;
|
||||
/** State after the change (null for deletes) */
|
||||
after: Record<string, unknown> | null;
|
||||
/** How the change was triggered */
|
||||
source: AuditSource;
|
||||
/** Computed diff summary */
|
||||
diff: ConfigDiff;
|
||||
/** Optional human note */
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
/** Computed diff between two states */
|
||||
export interface ConfigDiff {
|
||||
/** Keys that were added */
|
||||
added: string[];
|
||||
/** Keys that were removed */
|
||||
removed: string[];
|
||||
/** Keys whose values changed */
|
||||
changed: Array<{ key: string; from: unknown; to: unknown }>;
|
||||
/** True if the states are identical */
|
||||
isEmpty: boolean;
|
||||
}
|
||||
|
||||
/** Configuration snapshot for export/import */
|
||||
export interface ConfigSnapshot {
|
||||
/** ISO timestamp when snapshot was taken */
|
||||
timestamp: string;
|
||||
/** Semantic version tag */
|
||||
version: string;
|
||||
/** Description of the snapshot */
|
||||
description: string;
|
||||
/** Full configuration data */
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ── In-memory store ──────────────────────────────────────────────────────────
|
||||
// In production, persist to SQLite alongside other domain state.
|
||||
|
||||
let auditLog: ConfigAuditEntry[] = [];
|
||||
let idCounter = 0;
|
||||
|
||||
function generateId(): string {
|
||||
idCounter++;
|
||||
const ts = Date.now().toString(36);
|
||||
const seq = idCounter.toString(36).padStart(4, "0");
|
||||
return `audit-${ts}-${seq}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a structured diff between two configuration states.
|
||||
*/
|
||||
export function computeDiff(
|
||||
before: Record<string, unknown> | null,
|
||||
after: Record<string, unknown> | null
|
||||
): ConfigDiff {
|
||||
const beforeKeys = new Set(before ? Object.keys(before) : []);
|
||||
const afterKeys = new Set(after ? Object.keys(after) : []);
|
||||
|
||||
const added: string[] = [];
|
||||
const removed: string[] = [];
|
||||
const changed: Array<{ key: string; from: unknown; to: unknown }> = [];
|
||||
|
||||
// Keys in after but not in before → added
|
||||
for (const key of afterKeys) {
|
||||
if (!beforeKeys.has(key)) {
|
||||
added.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Keys in before but not in after → removed
|
||||
for (const key of beforeKeys) {
|
||||
if (!afterKeys.has(key)) {
|
||||
removed.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Keys in both → check for changes
|
||||
for (const key of beforeKeys) {
|
||||
if (afterKeys.has(key)) {
|
||||
const beforeVal = before![key];
|
||||
const afterVal = after![key];
|
||||
if (JSON.stringify(beforeVal) !== JSON.stringify(afterVal)) {
|
||||
changed.push({ key, from: beforeVal, to: afterVal });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
added,
|
||||
removed,
|
||||
changed,
|
||||
isEmpty: added.length === 0 && removed.length === 0 && changed.length === 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a configuration change in the audit log.
|
||||
*/
|
||||
export function recordChange(
|
||||
action: AuditAction,
|
||||
target: AuditTarget,
|
||||
targetId: string,
|
||||
targetName: string,
|
||||
before: Record<string, unknown> | null,
|
||||
after: Record<string, unknown> | null,
|
||||
source: AuditSource,
|
||||
note?: string | null
|
||||
): ConfigAuditEntry {
|
||||
const entry: ConfigAuditEntry = {
|
||||
id: generateId(),
|
||||
timestamp: new Date().toISOString(),
|
||||
action,
|
||||
target,
|
||||
targetId,
|
||||
targetName,
|
||||
before,
|
||||
after,
|
||||
source,
|
||||
diff: computeDiff(before, after),
|
||||
note: note ?? null,
|
||||
};
|
||||
|
||||
auditLog.push(entry);
|
||||
|
||||
// Keep log bounded (max 1000 entries in memory)
|
||||
if (auditLog.length > 1000) {
|
||||
auditLog = auditLog.slice(-1000);
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get audit entries, optionally filtered.
|
||||
*/
|
||||
export function getAuditLog(options?: {
|
||||
target?: AuditTarget;
|
||||
targetId?: string;
|
||||
action?: AuditAction;
|
||||
source?: AuditSource;
|
||||
since?: string; // ISO date
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): { entries: ConfigAuditEntry[]; total: number } {
|
||||
let filtered = auditLog;
|
||||
|
||||
if (options?.target) {
|
||||
filtered = filtered.filter((e) => e.target === options.target);
|
||||
}
|
||||
if (options?.targetId) {
|
||||
filtered = filtered.filter((e) => e.targetId === options.targetId);
|
||||
}
|
||||
if (options?.action) {
|
||||
filtered = filtered.filter((e) => e.action === options.action);
|
||||
}
|
||||
if (options?.source) {
|
||||
filtered = filtered.filter((e) => e.source === options.source);
|
||||
}
|
||||
if (options?.since) {
|
||||
filtered = filtered.filter((e) => e.timestamp >= options.since!);
|
||||
}
|
||||
|
||||
const total = filtered.length;
|
||||
|
||||
// Sort newest first
|
||||
filtered = [...filtered].sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
||||
|
||||
// Paginate
|
||||
const offset = options?.offset ?? 0;
|
||||
const limit = options?.limit ?? 50;
|
||||
filtered = filtered.slice(offset, offset + limit);
|
||||
|
||||
return { entries: filtered, total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific audit entry by ID.
|
||||
*/
|
||||
export function getAuditEntry(id: string): ConfigAuditEntry | null {
|
||||
return auditLog.find((e) => e.id === id) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the state of an entity before a specific audit entry.
|
||||
* Enables rollback by returning the `before` snapshot.
|
||||
*/
|
||||
export function getRollbackState(entryId: string): Record<string, unknown> | null {
|
||||
const entry = getAuditEntry(entryId);
|
||||
if (!entry) return null;
|
||||
return entry.before;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a full configuration snapshot for export.
|
||||
*/
|
||||
export function createSnapshot(
|
||||
version: string,
|
||||
description: string,
|
||||
configData: Record<string, unknown>
|
||||
): ConfigSnapshot {
|
||||
return {
|
||||
timestamp: new Date().toISOString(),
|
||||
version,
|
||||
description,
|
||||
data: JSON.parse(JSON.stringify(configData)), // deep clone
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get summary statistics of the audit log.
|
||||
*/
|
||||
export function getAuditSummary(): {
|
||||
totalEntries: number;
|
||||
byTarget: Record<string, number>;
|
||||
byAction: Record<string, number>;
|
||||
bySource: Record<string, number>;
|
||||
oldestEntry: string | null;
|
||||
newestEntry: string | null;
|
||||
} {
|
||||
const byTarget: Record<string, number> = {};
|
||||
const byAction: Record<string, number> = {};
|
||||
const bySource: Record<string, number> = {};
|
||||
|
||||
for (const entry of auditLog) {
|
||||
byTarget[entry.target] = (byTarget[entry.target] || 0) + 1;
|
||||
byAction[entry.action] = (byAction[entry.action] || 0) + 1;
|
||||
bySource[entry.source] = (bySource[entry.source] || 0) + 1;
|
||||
}
|
||||
|
||||
return {
|
||||
totalEntries: auditLog.length,
|
||||
byTarget,
|
||||
byAction,
|
||||
bySource,
|
||||
oldestEntry: auditLog.length > 0 ? auditLog[0].timestamp : null,
|
||||
newestEntry: auditLog.length > 0 ? auditLog[auditLog.length - 1].timestamp : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the audit log. Useful for testing.
|
||||
*/
|
||||
export function resetAuditLog(): void {
|
||||
auditLog = [];
|
||||
idCounter = 0;
|
||||
}
|
||||
Reference in New Issue
Block a user