feat(housekeeping): correlate command audit evidence
This commit is contained in:
1 parent
7c93d3e766
commit
89dec9da05
4 files changed
+226
-4
No files matched your search
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type {
|
||||||
|
AuditEntry,
|
||||||
|
HousekeepingAuditTransaction,
|
||||||
|
HousekeepingAuditWriter,
|
||||||
|
} from "@/lib/services/audit";
|
||||||
|
|
||||||
|
type AuditOutcome = Exclude<NonNullable<AuditEntry["outcome"]>, "intent">;
|
||||||
|
|
||||||
|
export async function writeIntent(
|
||||||
|
writer: HousekeepingAuditWriter,
|
||||||
|
entry: AuditEntry,
|
||||||
|
transaction?: HousekeepingAuditTransaction,
|
||||||
|
): Promise<void> {
|
||||||
|
await writer.write({ ...entry, outcome: "intent" }, transaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeOutcome(
|
||||||
|
writer: HousekeepingAuditWriter,
|
||||||
|
entry: AuditEntry,
|
||||||
|
outcome: AuditOutcome,
|
||||||
|
transaction?: HousekeepingAuditTransaction,
|
||||||
|
): Promise<void> {
|
||||||
|
await writer.write({ ...entry, outcome }, transaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runWithAuditIntent<T>(
|
||||||
|
writer: HousekeepingAuditWriter,
|
||||||
|
entry: AuditEntry,
|
||||||
|
operation: () => Promise<T>,
|
||||||
|
transaction?: HousekeepingAuditTransaction,
|
||||||
|
): Promise<T> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -87,6 +87,57 @@ describe("logAudit", () => {
|
|||||||
expect(JSON.parse(data.diff).username).toEqual({ from: "foo", to: "bar" });
|
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 () => {
|
it("omits diff when only before or after is missing", async () => {
|
||||||
insertValues.mockResolvedValue({ id: 1 });
|
insertValues.mockResolvedValue({ id: 1 });
|
||||||
await logAudit({
|
await logAudit({
|
||||||
|
|||||||
@@ -1,13 +1,28 @@
|
|||||||
import { count, desc, inArray, like, or } from "drizzle-orm";
|
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;
|
userId: number;
|
||||||
action: string;
|
action: string;
|
||||||
target: string;
|
target: string;
|
||||||
targetId?: number;
|
targetId?: number;
|
||||||
before?: Record<string, unknown>;
|
before?: Record<string, unknown>;
|
||||||
after?: 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 =
|
const SENSITIVE_KEY_RE =
|
||||||
@@ -47,7 +62,10 @@ function computeDiff(
|
|||||||
return Object.keys(diff).length > 0 ? diff : null;
|
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
|
const sanitizedBefore = entry.before
|
||||||
? (sanitizeAuditPayload(entry.before) as Record<string, unknown>)
|
? (sanitizeAuditPayload(entry.before) as Record<string, unknown>)
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -56,7 +74,8 @@ export async function logAudit(entry: AuditEntry): Promise<void> {
|
|||||||
: undefined;
|
: undefined;
|
||||||
const diff = computeDiff(sanitizedBefore, sanitizedAfter);
|
const diff = computeDiff(sanitizedBefore, sanitizedAfter);
|
||||||
|
|
||||||
await db.insert(AdminAuditLog).values({
|
const auditWriter: HousekeepingAuditTransaction = transaction ?? db;
|
||||||
|
await auditWriter.insert(AdminAuditLog).values({
|
||||||
userId: entry.userId,
|
userId: entry.userId,
|
||||||
action: entry.action,
|
action: entry.action,
|
||||||
target: entry.target,
|
target: entry.target,
|
||||||
@@ -64,10 +83,19 @@ export async function logAudit(entry: AuditEntry): Promise<void> {
|
|||||||
before: sanitizedBefore ? JSON.stringify(sanitizedBefore) : null,
|
before: sanitizedBefore ? JSON.stringify(sanitizedBefore) : null,
|
||||||
after: sanitizedAfter ? JSON.stringify(sanitizedAfter) : null,
|
after: sanitizedAfter ? JSON.stringify(sanitizedAfter) : null,
|
||||||
diff: diff ? JSON.stringify(diff) : 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(),
|
createdAt: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const housekeepingAuditWriter: HousekeepingAuditWriter = {
|
||||||
|
write: logAudit,
|
||||||
|
};
|
||||||
|
|
||||||
interface GetLogsOptions {
|
interface GetLogsOptions {
|
||||||
search?: string;
|
search?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
|
|||||||
Reference in new issue
Block a user