feat(housekeeping): correlate command audit evidence

This commit is contained in:
Simo committed 2026-08-26 21:56:51 +02:00
1 parent 7c93d3e766
commit 89dec9da05
4 files changed
+226 -4

No files matched your search

+51
View File
@@ -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({
+32 -4
View File
@@ -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<string, unknown>;
after?: Record<string, unknown>;
correlationId?: string;
outcome?: "intent" | "success" | "failure" | "partial" | "denied";
reason?: string;
domain?: HousekeepingDomainId;
ipAddress?: string;
}
export type HousekeepingAuditTransaction = Pick<Db, "insert">;
export interface HousekeepingAuditWriter {
write(
entry: AuditEntry,
transaction?: HousekeepingAuditTransaction,
): Promise<void>;
}
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<void> {
export async function logAudit(
entry: AuditEntry,
transaction?: HousekeepingAuditTransaction,
): Promise<void> {
const sanitizedBefore = entry.before
? (sanitizeAuditPayload(entry.before) as Record<string, unknown>)
: undefined;
@@ -56,7 +74,8 @@ export async function logAudit(entry: AuditEntry): Promise<void> {
: 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<void> {
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;