mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-25 16:42:16 +03:00
fix(config-audit): persist config audit log to SQLite with retention (#11103)
Validated on the combined batch board over release/v3.8.50 tip d91238b7: static gates clean, typecheck:core clean, focused tests green.
Config-audit survives restarts with bounded growth (migration 161 + OR IGNORE seed, retention wired into runAutoCleanup); route cabling deliberately out of scope. Thank you @maxmad64bis!
This commit is contained in:
@@ -13,6 +13,8 @@
|
||||
* - Optional human notes
|
||||
*/
|
||||
|
||||
import { getDbInstance } from "../lib/db/core";
|
||||
|
||||
/** Types of configuration entities that can be audited */
|
||||
export type AuditTarget = "provider" | "combo" | "policy" | "connection" | "settings";
|
||||
|
||||
@@ -72,10 +74,8 @@ export interface ConfigSnapshot {
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ── In-memory store ──────────────────────────────────────────────────────────
|
||||
// In production, persist to SQLite alongside other domain state.
|
||||
// ── SQLite-backed store ───────────────────────────────────────────────────────
|
||||
|
||||
let auditLog: ConfigAuditEntry[] = [];
|
||||
let idCounter = 0;
|
||||
|
||||
function generateId(): string {
|
||||
@@ -85,6 +85,40 @@ function generateId(): string {
|
||||
return `audit-${ts}-${seq}`;
|
||||
}
|
||||
|
||||
function db() {
|
||||
return getDbInstance();
|
||||
}
|
||||
|
||||
interface ConfigAuditRow {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
action: string;
|
||||
target: string;
|
||||
target_id: string;
|
||||
target_name: string;
|
||||
before_json: string | null;
|
||||
after_json: string | null;
|
||||
diff_json: string;
|
||||
source: string;
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
function rowToEntry(row: ConfigAuditRow): ConfigAuditEntry {
|
||||
return {
|
||||
id: row.id,
|
||||
timestamp: row.timestamp,
|
||||
action: row.action as AuditAction,
|
||||
target: row.target as AuditTarget,
|
||||
targetId: row.target_id,
|
||||
targetName: row.target_name,
|
||||
before: row.before_json === null ? null : (JSON.parse(row.before_json) as Record<string, unknown> | null),
|
||||
after: row.after_json === null ? null : (JSON.parse(row.after_json) as Record<string, unknown> | null),
|
||||
source: row.source as AuditSource,
|
||||
diff: JSON.parse(row.diff_json) as ConfigDiff,
|
||||
note: row.note,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a structured diff between two configuration states.
|
||||
*/
|
||||
@@ -159,12 +193,24 @@ export function recordChange(
|
||||
note: note ?? null,
|
||||
};
|
||||
|
||||
auditLog.push(entry);
|
||||
|
||||
// Keep log bounded (max 1000 entries in memory)
|
||||
if (auditLog.length > 1000) {
|
||||
auditLog = auditLog.slice(-1000);
|
||||
}
|
||||
db().prepare(
|
||||
`INSERT INTO config_audit_log
|
||||
(id, timestamp, action, target, target_id, target_name, before_json, after_json, diff_json, source, note)
|
||||
VALUES
|
||||
(@id, @timestamp, @action, @target, @targetId, @targetName, @beforeJson, @afterJson, @diffJson, @source, @note)`
|
||||
).run({
|
||||
id: entry.id,
|
||||
timestamp: entry.timestamp,
|
||||
action: entry.action,
|
||||
target: entry.target,
|
||||
targetId: entry.targetId,
|
||||
targetName: entry.targetName,
|
||||
beforeJson: before === null ? null : JSON.stringify(before),
|
||||
afterJson: after === null ? null : JSON.stringify(after),
|
||||
diffJson: JSON.stringify(entry.diff),
|
||||
source: entry.source,
|
||||
note: entry.note,
|
||||
});
|
||||
|
||||
return entry;
|
||||
}
|
||||
@@ -181,42 +227,57 @@ export function getAuditLog(options?: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): { entries: ConfigAuditEntry[]; total: number } {
|
||||
let filtered = auditLog;
|
||||
const where: string[] = [];
|
||||
const params: Record<string, unknown> = {};
|
||||
|
||||
if (options?.target) {
|
||||
filtered = filtered.filter((e) => e.target === options.target);
|
||||
where.push("target = @target");
|
||||
params.target = options.target;
|
||||
}
|
||||
if (options?.targetId) {
|
||||
filtered = filtered.filter((e) => e.targetId === options.targetId);
|
||||
where.push("target_id = @targetId");
|
||||
params.targetId = options.targetId;
|
||||
}
|
||||
if (options?.action) {
|
||||
filtered = filtered.filter((e) => e.action === options.action);
|
||||
where.push("action = @action");
|
||||
params.action = options.action;
|
||||
}
|
||||
if (options?.source) {
|
||||
filtered = filtered.filter((e) => e.source === options.source);
|
||||
where.push("source = @source");
|
||||
params.source = options.source;
|
||||
}
|
||||
if (options?.since) {
|
||||
filtered = filtered.filter((e) => e.timestamp >= options.since!);
|
||||
where.push("timestamp >= @since");
|
||||
params.since = options.since;
|
||||
}
|
||||
|
||||
const total = filtered.length;
|
||||
const whereSql = where.length > 0 ? `WHERE ${where.join(" AND ")}` : "";
|
||||
|
||||
// Sort newest first
|
||||
filtered = [...filtered].sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
||||
const totalRow = db()
|
||||
.prepare(`SELECT COUNT(*) AS c FROM config_audit_log ${whereSql}`)
|
||||
.get(params) as { c: number };
|
||||
const total = totalRow.c;
|
||||
|
||||
// Paginate
|
||||
const offset = options?.offset ?? 0;
|
||||
const limit = options?.limit ?? 50;
|
||||
filtered = filtered.slice(offset, offset + limit);
|
||||
|
||||
return { entries: filtered, total };
|
||||
const rows = db()
|
||||
.prepare(
|
||||
`SELECT * FROM config_audit_log ${whereSql} ORDER BY datetime(timestamp) DESC, id DESC LIMIT @limit OFFSET @offset`
|
||||
)
|
||||
.all({ ...params, limit, offset }) as ConfigAuditRow[];
|
||||
|
||||
return { entries: rows.map(rowToEntry), total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific audit entry by ID.
|
||||
*/
|
||||
export function getAuditEntry(id: string): ConfigAuditEntry | null {
|
||||
return auditLog.find((e) => e.id === id) ?? null;
|
||||
const row = db()
|
||||
.prepare("SELECT * FROM config_audit_log WHERE id = @id")
|
||||
.get({ id }) as ConfigAuditRow | undefined;
|
||||
return row ? rowToEntry(row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -260,19 +321,23 @@ export function getAuditSummary(): {
|
||||
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;
|
||||
const rows = db()
|
||||
.prepare("SELECT * FROM config_audit_log ORDER BY datetime(timestamp) DESC, id DESC")
|
||||
.all() as ConfigAuditRow[];
|
||||
|
||||
for (const row of rows) {
|
||||
byTarget[row.target] = (byTarget[row.target] || 0) + 1;
|
||||
byAction[row.action] = (byAction[row.action] || 0) + 1;
|
||||
bySource[row.source] = (bySource[row.source] || 0) + 1;
|
||||
}
|
||||
|
||||
return {
|
||||
totalEntries: auditLog.length,
|
||||
totalEntries: rows.length,
|
||||
byTarget,
|
||||
byAction,
|
||||
bySource,
|
||||
oldestEntry: auditLog.length > 0 ? auditLog[0].timestamp : null,
|
||||
newestEntry: auditLog.length > 0 ? auditLog[auditLog.length - 1].timestamp : null,
|
||||
oldestEntry: rows.length > 0 ? rows[rows.length - 1].timestamp : null,
|
||||
newestEntry: rows.length > 0 ? rows[0].timestamp : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -280,6 +345,6 @@ export function getAuditSummary(): {
|
||||
* Reset the audit log. Useful for testing.
|
||||
*/
|
||||
export function resetAuditLog(): void {
|
||||
auditLog = [];
|
||||
db().prepare("DELETE FROM config_audit_log").run();
|
||||
idCounter = 0;
|
||||
}
|
||||
|
||||
@@ -193,6 +193,31 @@ export async function cleanupMcpAudit(): Promise<CleanupResult> {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old config_audit_log based on retention settings.
|
||||
*/
|
||||
export async function cleanupConfigAudit(retentionDays = getRetentionSettings().configAudit): Promise<CleanupResult> {
|
||||
const db = getDbInstance();
|
||||
const result: CleanupResult = { deleted: 0, errors: 0 };
|
||||
|
||||
try {
|
||||
const stmt = db.prepare(
|
||||
"DELETE FROM config_audit_log WHERE datetime(timestamp) < datetime('now', '-' || ? || ' days')"
|
||||
);
|
||||
const runResult = stmt.run(String(retentionDays));
|
||||
result.deleted = runResult.changes;
|
||||
|
||||
console.log(
|
||||
`[Cleanup] Deleted ${result.deleted} config_audit_log older than ${retentionDays} days`
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
console.error("[Cleanup] Error cleaning config_audit_log:", err);
|
||||
result.errors++;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old a2a_task_events based on retention settings.
|
||||
*/
|
||||
@@ -420,6 +445,7 @@ export async function runAutoCleanup(): Promise<{
|
||||
usageHistory: await cleanupUsageHistory(),
|
||||
compressionAnalytics: await cleanupCompressionAnalytics(),
|
||||
mcpAudit: await cleanupMcpAudit(),
|
||||
configAudit: await cleanupConfigAudit(),
|
||||
a2aEvents: await cleanupA2aEvents(),
|
||||
memoryEntries: await cleanupMemoryEntries(),
|
||||
domainCostHistory: await cleanupDomainCostHistory(),
|
||||
|
||||
@@ -46,6 +46,7 @@ const LEGACY_FLAT_KEYS: {
|
||||
quotaSnapshots: ["quotaSnapshots"],
|
||||
compressionAnalytics: ["compressionAnalytics"],
|
||||
mcpAudit: ["mcpAudit"],
|
||||
configAudit: ["configAudit"],
|
||||
a2aEvents: ["a2aEvents"],
|
||||
callLogs: ["callLogs"],
|
||||
usageHistory: ["usageHistory"],
|
||||
|
||||
@@ -25,6 +25,7 @@ INSERT OR IGNORE INTO key_value (namespace, key, value) VALUES ('databaseSetting
|
||||
INSERT OR IGNORE INTO key_value (namespace, key, value) VALUES ('databaseSettings', 'quotaSnapshots', '90');
|
||||
INSERT OR IGNORE INTO key_value (namespace, key, value) VALUES ('databaseSettings', 'compressionAnalytics', '30');
|
||||
INSERT OR IGNORE INTO key_value (namespace, key, value) VALUES ('databaseSettings', 'mcpAudit', '30');
|
||||
INSERT OR IGNORE INTO key_value (namespace, key, value) VALUES ('databaseSettings', 'configAudit', '30');
|
||||
INSERT OR IGNORE INTO key_value (namespace, key, value) VALUES ('databaseSettings', 'a2aEvents', '30');
|
||||
INSERT OR IGNORE INTO key_value (namespace, key, value) VALUES ('databaseSettings', 'callLogs', '90');
|
||||
INSERT OR IGNORE INTO key_value (namespace, key, value) VALUES ('databaseSettings', 'usageHistory', '365');
|
||||
|
||||
15
src/lib/db/migrations/161_config_audit_log.sql
Normal file
15
src/lib/db/migrations/161_config_audit_log.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE IF NOT EXISTS config_audit_log (
|
||||
id TEXT PRIMARY KEY,
|
||||
timestamp TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
target_id TEXT NOT NULL,
|
||||
target_name TEXT NOT NULL,
|
||||
before_json TEXT,
|
||||
after_json TEXT,
|
||||
diff_json TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
note TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_config_audit_log_target_created ON config_audit_log(target, timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_config_audit_log_created ON config_audit_log(timestamp);
|
||||
@@ -44,6 +44,7 @@ export interface DatabaseSettings {
|
||||
quotaSnapshots: number;
|
||||
compressionAnalytics: number;
|
||||
mcpAudit: number;
|
||||
configAudit: number;
|
||||
a2aEvents: number;
|
||||
callLogs: number;
|
||||
usageHistory: number;
|
||||
@@ -114,6 +115,7 @@ export const DEFAULT_DATABASE_SETTINGS: Omit<DatabaseSettings, "location" | "sta
|
||||
quotaSnapshots: 7,
|
||||
compressionAnalytics: 30,
|
||||
mcpAudit: 30,
|
||||
configAudit: 30,
|
||||
a2aEvents: 30,
|
||||
callLogs: 30,
|
||||
usageHistory: 30,
|
||||
|
||||
Reference in New Issue
Block a user