From 89dec9da053f616db39bacfa162a29d39f1bdbee Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 26 Aug 2026 21:56:51 +0200 Subject: [PATCH] feat(housekeeping): correlate command audit evidence --- .../commands/audit-envelope.test.ts | 99 +++++++++++++++++++ .../foundation/commands/audit-envelope.ts | 44 +++++++++ src/lib/services/audit.test.ts | 51 ++++++++++ src/lib/services/audit.ts | 36 ++++++- 4 files changed, 226 insertions(+), 4 deletions(-) create mode 100644 src/features/housekeeping/foundation/commands/audit-envelope.test.ts create mode 100644 src/features/housekeeping/foundation/commands/audit-envelope.ts diff --git a/src/features/housekeeping/foundation/commands/audit-envelope.test.ts b/src/features/housekeeping/foundation/commands/audit-envelope.test.ts new file mode 100644 index 00000000..14e16508 --- /dev/null +++ b/src/features/housekeeping/foundation/commands/audit-envelope.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it, vi } from "vitest"; +import type { AuditEntry, HousekeepingAuditWriter } from "@/lib/services/audit"; +import { + runWithAuditIntent, + writeIntent, + writeOutcome, +} from "./audit-envelope"; + +const auditEntry: AuditEntry = { + userId: 7, + action: "maintenance.run", + target: "system", + correlationId: "corr-command-7", + domain: "system", + reason: "Scheduled maintenance", +}; + +function createWriter(): HousekeepingAuditWriter { + return { write: vi.fn().mockResolvedValue(undefined) }; +} + +describe("audit command envelope", () => { + it("writes an intent with the original correlation evidence", async () => { + const writer = createWriter(); + + await writeIntent(writer, auditEntry); + + expect(writer.write).toHaveBeenCalledWith( + { ...auditEntry, outcome: "intent" }, + undefined, + ); + }); + + it("writes denied and failure outcomes as durable evidence", async () => { + const writer = createWriter(); + + await writeOutcome(writer, auditEntry, "denied"); + await writeOutcome(writer, auditEntry, "failure"); + + expect(writer.write).toHaveBeenNthCalledWith( + 1, + { ...auditEntry, outcome: "denied" }, + undefined, + ); + expect(writer.write).toHaveBeenNthCalledWith( + 2, + { ...auditEntry, outcome: "failure" }, + undefined, + ); + }); + + it("blocks the operation when intent persistence fails", async () => { + const writer: HousekeepingAuditWriter = { + write: vi.fn().mockRejectedValue(new Error("audit unavailable")), + }; + const operation = vi.fn().mockResolvedValue("external operation result"); + + await expect( + runWithAuditIntent(writer, auditEntry, operation), + ).rejects.toThrow("audit unavailable"); + + expect(operation).not.toHaveBeenCalled(); + }); + + it("writes a success outcome after the operation completes", async () => { + const writer = createWriter(); + + await expect( + runWithAuditIntent(writer, auditEntry, () => Promise.resolve("done")), + ).resolves.toBe("done"); + + expect(writer.write).toHaveBeenNthCalledWith( + 1, + { ...auditEntry, outcome: "intent" }, + undefined, + ); + expect(writer.write).toHaveBeenNthCalledWith( + 2, + { ...auditEntry, outcome: "success" }, + undefined, + ); + }); + + it("writes a failure outcome before rethrowing an operation failure", async () => { + const writer = createWriter(); + + await expect( + runWithAuditIntent(writer, auditEntry, () => + Promise.reject(new Error("operation failed")), + ), + ).rejects.toThrow("operation failed"); + + expect(writer.write).toHaveBeenNthCalledWith( + 2, + { ...auditEntry, outcome: "failure" }, + undefined, + ); + }); +}); diff --git a/src/features/housekeeping/foundation/commands/audit-envelope.ts b/src/features/housekeeping/foundation/commands/audit-envelope.ts new file mode 100644 index 00000000..c2780ec1 --- /dev/null +++ b/src/features/housekeeping/foundation/commands/audit-envelope.ts @@ -0,0 +1,44 @@ +import type { + AuditEntry, + HousekeepingAuditTransaction, + HousekeepingAuditWriter, +} from "@/lib/services/audit"; + +type AuditOutcome = Exclude, "intent">; + +export async function writeIntent( + writer: HousekeepingAuditWriter, + entry: AuditEntry, + transaction?: HousekeepingAuditTransaction, +): Promise { + await writer.write({ ...entry, outcome: "intent" }, transaction); +} + +export async function writeOutcome( + writer: HousekeepingAuditWriter, + entry: AuditEntry, + outcome: AuditOutcome, + transaction?: HousekeepingAuditTransaction, +): Promise { + await writer.write({ ...entry, outcome }, transaction); +} + +export async function runWithAuditIntent( + writer: HousekeepingAuditWriter, + entry: AuditEntry, + operation: () => Promise, + transaction?: HousekeepingAuditTransaction, +): Promise { + await writeIntent(writer, entry, transaction); + + let result: T; + try { + result = await operation(); + } catch (error) { + await writeOutcome(writer, entry, "failure", transaction); + throw error; + } + + await writeOutcome(writer, entry, "success", transaction); + return result; +} diff --git a/src/lib/services/audit.test.ts b/src/lib/services/audit.test.ts index 06181433..5ed9fb75 100644 --- a/src/lib/services/audit.test.ts +++ b/src/lib/services/audit.test.ts @@ -87,6 +87,57 @@ describe("logAudit", () => { expect(JSON.parse(data.diff).username).toEqual({ from: "foo", to: "bar" }); }); + it("redacts sensitive keys recursively in nested objects and arrays", async () => { + insertValues.mockResolvedValue({ id: 1 }); + await logAudit({ + userId: 1, + action: "update", + target: "user", + before: { + profile: { authTicket: "private-ticket" }, + integrations: [{ api_key: "private-key" }], + }, + }); + + const before = JSON.parse(insertValues.mock.calls[0][0].before); + expect(before.profile.authTicket).toBe("[Redacted]"); + expect(before.integrations[0].api_key).toBe("[Redacted]"); + }); + + it("persists correlation, domain, outcome, reason, and IP evidence", async () => { + insertValues.mockResolvedValue({ id: 1 }); + await logAudit({ + userId: 1, + action: "ban", + target: "user", + correlationId: "corr-123", + domain: "people", + outcome: "denied", + reason: "Policy requirement was not met", + ipAddress: "127.0.0.1", + }); + + expect(insertValues.mock.calls[0][0]).toMatchObject({ + correlationId: "corr-123", + domain: "people", + outcome: "denied", + reason: "Policy requirement was not met", + ipAddress: "127.0.0.1", + }); + }); + + it("writes through the injected transaction when one is supplied", async () => { + const transactionValues = vi.fn().mockResolvedValue({ id: 1 }); + const transactionInsert = vi.fn(() => ({ values: transactionValues })); + + await logAudit({ userId: 1, action: "update", target: "settings" }, { + insert: transactionInsert, + } as never); + + expect(transactionInsert).toHaveBeenCalledOnce(); + expect(transactionValues).toHaveBeenCalledOnce(); + expect(insertValues).not.toHaveBeenCalled(); + }); it("omits diff when only before or after is missing", async () => { insertValues.mockResolvedValue({ id: 1 }); await logAudit({ diff --git a/src/lib/services/audit.ts b/src/lib/services/audit.ts index 36e819a9..b3314892 100644 --- a/src/lib/services/audit.ts +++ b/src/lib/services/audit.ts @@ -1,13 +1,28 @@ import { count, desc, inArray, like, or } from "drizzle-orm"; -import { AdminAuditLog, db, User } from "@/lib/db"; +import type { HousekeepingDomainId } from "@/features/housekeeping/migration/types"; +import { AdminAuditLog, type Db, db, User } from "@/lib/db"; -interface AuditEntry { +export interface AuditEntry { userId: number; action: string; target: string; targetId?: number; before?: Record; after?: Record; + correlationId?: string; + outcome?: "intent" | "success" | "failure" | "partial" | "denied"; + reason?: string; + domain?: HousekeepingDomainId; + ipAddress?: string; +} + +export type HousekeepingAuditTransaction = Pick; + +export interface HousekeepingAuditWriter { + write( + entry: AuditEntry, + transaction?: HousekeepingAuditTransaction, + ): Promise; } const SENSITIVE_KEY_RE = @@ -47,7 +62,10 @@ function computeDiff( return Object.keys(diff).length > 0 ? diff : null; } -export async function logAudit(entry: AuditEntry): Promise { +export async function logAudit( + entry: AuditEntry, + transaction?: HousekeepingAuditTransaction, +): Promise { const sanitizedBefore = entry.before ? (sanitizeAuditPayload(entry.before) as Record) : undefined; @@ -56,7 +74,8 @@ export async function logAudit(entry: AuditEntry): Promise { : undefined; const diff = computeDiff(sanitizedBefore, sanitizedAfter); - await db.insert(AdminAuditLog).values({ + const auditWriter: HousekeepingAuditTransaction = transaction ?? db; + await auditWriter.insert(AdminAuditLog).values({ userId: entry.userId, action: entry.action, target: entry.target, @@ -64,10 +83,19 @@ export async function logAudit(entry: AuditEntry): Promise { before: sanitizedBefore ? JSON.stringify(sanitizedBefore) : null, after: sanitizedAfter ? JSON.stringify(sanitizedAfter) : null, diff: diff ? JSON.stringify(diff) : null, + correlationId: entry.correlationId, + outcome: entry.outcome, + reason: entry.reason, + domain: entry.domain, + ipAddress: entry.ipAddress, createdAt: new Date().toISOString(), }); } +export const housekeepingAuditWriter: HousekeepingAuditWriter = { + write: logAudit, +}; + interface GetLogsOptions { search?: string; page?: number;