Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
285 changes: 285 additions & 0 deletions src/domain/configAudit.ts
Original file line number Diff line number Diff line change
@@ -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}`;
}
Comment on lines +79 to +86
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current generateId implementation using a simple idCounter is not robust. It's not guaranteed to be unique across application restarts or in a concurrent environment. A better approach is to use a universally unique identifier.

I suggest using crypto.randomUUID(), which is built into modern Node.js versions (v14.17.0+). This removes the need for idCounter and is much more reliable.

Suggested change
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}`;
}
// idCounter is no longer needed when using UUIDs.
function generateId(): string {
// Using crypto.randomUUID() for globally unique identifiers.
// This is more robust than a timestamp-based counter.
// Add `import { randomUUID } from 'node:crypto';` to file imports.
return `audit-${randomUUID()}`;
}


/**
* 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)) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using JSON.stringify for object comparison can be unreliable. The order of object keys is not guaranteed in JavaScript, which can lead to different JSON strings for semantically identical objects (e.g., {a:1, b:2} vs {b:2, a:1}).

For more robust deep object comparison, consider using a library like lodash.isEqual.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use structural compare instead of JSON stringify diff

Diff detection currently uses JSON.stringify equality, which is order-sensitive for object keys. Two semantically identical configs with different key insertion order will be marked as changed, producing noisy or misleading audit entries and potentially triggering unnecessary rollback decisions. Use a stable deep-equality check that ignores key ordering for plain objects.

Useful? React with 👍 / 👎.

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,
Comment on lines +155 to +156
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Clone before/after snapshots when recording changes

recordChange stores before and after by reference, so later mutations to the original config objects rewrite historical audit entries and invalidate rollback data. This breaks the core audit-trail guarantee in common flows where callers reuse mutable config objects (e.g., update object then pass it through additional transformations). Store deep-cloned snapshots at write time so history remains immutable.

Useful? React with 👍 / 👎.

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);
}
Comment on lines +165 to +167
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To keep the log bounded, the current code uses auditLog.slice(-1000). This creates a new array of up to 1000 elements every time an entry is added beyond the limit, which can be inefficient. Since entries are added one by one, a more performant approach is to simply remove the oldest element using auditLog.shift().

Suggested change
if (auditLog.length > 1000) {
auditLog = auditLog.slice(-1000);
}
if (auditLog.length > 1000) {
auditLog.shift();
}


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;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Return a copy from rollback state access

getRollbackState returns the internal entry.before object directly, which lets callers mutate the audit log by editing the returned value. After one rollback attempt that modifies this object, future rollbacks and log inspection can be silently corrupted. Return a cloned snapshot instead of the in-memory reference.

Useful? React with 👍 / 👎.

}

/**
* 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;
}