feat(diagnostics): correlate staff operations errors and audit records
CI / check (push) Successful in 56s
CI / deploy (push) Successful in 19s
CI / publish-container (push) Successful in 1m15s

This commit is contained in:
Simo committed 2026-09-11 00:34:49 +02:00
1 parent 440ce4e556
commit a2954e4408
14 files changed
+473 -110

No files matched your search

+3 -1
View File
@@ -36,8 +36,10 @@ describe("audit query filters", () => {
expect(query.sql).not.toContain("Alice");
expect(query.sql).toContain("`admin_audit_log`.`user_id` in (select");
expect(query.sql).toContain("`admin_audit_log`.`created_at` >= ?");
expect(query.sql).toContain("`admin_audit_log`.`details` like ?");
expect(query.sql).toContain("`admin_audit_log`.`created_at` < ?");
expect(query.params.slice(0, 6)).toEqual([
expect(query.params.slice(0, 7)).toEqual([
"%user%",
"%user%",
"%user%",
"update",
+59
View File
@@ -49,6 +49,12 @@ vi.mock("@/lib/db", () => ({
vi.mock("@/env", () => ({ env: {} }));
import {
createStore,
runWithStore,
setContextUserId,
} from "@/lib/foundation/request-context";
import type { IpAddress, UserId } from "@/lib/foundation/types";
import { getAuditLogs, logAudit } from "./audit";
beforeEach(() => {
@@ -56,6 +62,25 @@ beforeEach(() => {
});
describe("logAudit", () => {
it("stores the same trusted operation ID across asynchronous audit writes", async () => {
const store = createStore("test" as IpAddress);
await runWithStore(store, async () => {
setContextUserId(42 as UserId);
await Promise.resolve();
await logAudit({
userId: 42,
action: "update",
target: "user",
after: { name: "Alice" },
});
});
expect(JSON.parse(insertValues.mock.calls[0][0].details)).toEqual({
operationId: store.requestId,
userId: 42,
});
await logAudit({ userId: 42, action: "outside", target: "user" });
expect(JSON.parse(insertValues.mock.calls[1][0].details)).toEqual({});
});
it("creates an audit entry", async () => {
insertValues.mockResolvedValue({ id: 1 });
await logAudit({
@@ -189,6 +214,40 @@ describe("getAuditLogs", () => {
});
describe("audit response privacy", () => {
it("exposes only a validated operation ID, never arbitrary stored details", async () => {
selectRows.mockResolvedValue([
{
id: 1,
userId: 42,
diff: null,
before: null,
after: null,
operationDetails: JSON.stringify({
operationId: "op-123",
token: "private-token",
ip: "private-ip",
}),
},
{
id: 2,
userId: 42,
diff: null,
before: null,
after: null,
operationDetails: JSON.stringify({
operationId: "../../other?token=private",
}),
},
]);
selectCount.mockResolvedValue([{ value: 2 }]);
selectUsers.mockResolvedValue([]);
const result = await getAuditLogs();
expect(result.rows[0].operationId).toBe("op-123");
expect(result.rows[1].operationId).toBeNull();
expect(JSON.stringify(result.rows)).not.toContain("private-token");
expect(JSON.stringify(result.rows)).not.toContain("private-ip");
expect(result.rows[0]).not.toHaveProperty("operationDetails");
});
it("does not serialize legacy snapshot secrets to the client", async () => {
selectRows.mockResolvedValue([
{
+16 -1
View File
@@ -1,5 +1,6 @@
import { and, count, desc, eq, gte, inArray, like, lt, or } from "drizzle-orm";
import { AdminAuditLog, db, User } from "@/lib/db";
import { getOperationContext } from "@/lib/foundation/request-context";
import { readAuditChanges } from "./audit-diff";
import { type AuditFilters, normalizeAuditFilters } from "./audit-filters";
@@ -60,6 +61,7 @@ export async function logAudit(entry: AuditEntry): Promise<void> {
await db.insert(AdminAuditLog).values({
userId: entry.userId,
details: JSON.stringify(getOperationContext()),
action: entry.action,
target: entry.target,
targetId: entry.targetId,
@@ -79,6 +81,7 @@ export async function getAuditLogs(options: AuditFilters = {}) {
? or(
like(AdminAuditLog.action, `%${search}%`),
like(AdminAuditLog.target, `%${search}%`),
like(AdminAuditLog.details, `%${search}%`),
)
: undefined,
action ? eq(AdminAuditLog.action, action) : undefined,
@@ -105,6 +108,7 @@ export async function getAuditLogs(options: AuditFilters = {}) {
target: AdminAuditLog.target,
targetId: AdminAuditLog.targetId,
diff: AdminAuditLog.diff,
operationDetails: AdminAuditLog.details,
before: AdminAuditLog.before,
after: AdminAuditLog.after,
createdAt: AdminAuditLog.createdAt,
@@ -131,8 +135,19 @@ export async function getAuditLogs(options: AuditFilters = {}) {
const enrichedRows = rows.map((r) => {
const details = readAuditChanges(r.diff, r.before, r.after);
let operationId: string | null = null;
try {
const meta = JSON.parse(r.operationDetails ?? "{}");
if (
typeof meta.operationId === "string" &&
/^[a-zA-Z0-9-]{1,100}$/.test(meta.operationId)
)
operationId = meta.operationId;
} catch {}
const { operationDetails: _privateDetails, ...safeRow } = r;
return {
...r,
...safeRow,
operationId,
// Only sanitized changes may cross the server/client boundary.
before: null,
after: null,