feat(housekeeping): dispatch audited commands
This commit is contained in:
1 parent
ca666d9f90
commit
6aed71a8b2
8 files changed
+996
No files matched your search
@@ -0,0 +1,395 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
import type { AuditEntry, HousekeepingAuditWriter } from "@/lib/services/audit";
|
||||
import type { HousekeepingCapabilityContext } from "../contracts";
|
||||
import { anyCapability, fail, ok } from "../contracts";
|
||||
import { dispatchHousekeepingCommand } from "./dispatcher";
|
||||
import {
|
||||
type HousekeepingCommand,
|
||||
registerHousekeepingCommand,
|
||||
} from "./registry";
|
||||
|
||||
const actor = { id: 42, username: "operator", rank: 9 };
|
||||
|
||||
function capabilityContext(
|
||||
granted: readonly string[] = ["admin.settings.edit"],
|
||||
): HousekeepingCapabilityContext {
|
||||
const permissions = new Set(granted);
|
||||
return {
|
||||
actor,
|
||||
isSuperAdmin: false,
|
||||
has: (slug) => permissions.has(slug),
|
||||
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)),
|
||||
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)),
|
||||
};
|
||||
}
|
||||
|
||||
function auditRecorder(events: string[] = []): {
|
||||
entries: AuditEntry[];
|
||||
writer: HousekeepingAuditWriter;
|
||||
} {
|
||||
const entries: AuditEntry[] = [];
|
||||
return {
|
||||
entries,
|
||||
writer: {
|
||||
write: async (entry) => {
|
||||
entries.push({ ...entry });
|
||||
events.push(`audit:${entry.outcome}`);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function dependencies(options?: {
|
||||
context?: HousekeepingCapabilityContext;
|
||||
ipAddress?: string;
|
||||
audit?: HousekeepingAuditWriter;
|
||||
rateLimit?: (
|
||||
key: string,
|
||||
attempts: number,
|
||||
windowMs: number,
|
||||
) => Promise<boolean>;
|
||||
}) {
|
||||
return {
|
||||
context: options?.context ?? capabilityContext(),
|
||||
ipAddress: options?.ipAddress ?? "198.51.100.8",
|
||||
audit: options?.audit ?? auditRecorder().writer,
|
||||
rateLimit: options?.rateLimit ?? (async () => true),
|
||||
};
|
||||
}
|
||||
|
||||
function register<I, O>(command: HousekeepingCommand<I, O>): void {
|
||||
registerHousekeepingCommand(command);
|
||||
}
|
||||
|
||||
function baseCommand<I, O>(
|
||||
id: string,
|
||||
overrides: Pick<HousekeepingCommand<I, O>, "input" | "execute"> &
|
||||
Partial<
|
||||
Pick<HousekeepingCommand<I, O>, "risk" | "requiresReason" | "rateLimit">
|
||||
>,
|
||||
): HousekeepingCommand<I, O> {
|
||||
return {
|
||||
id,
|
||||
owner: "system",
|
||||
risk: overrides.risk ?? "safe",
|
||||
capability: anyCapability("admin.settings.edit"),
|
||||
input: overrides.input,
|
||||
requiresReason: overrides.requiresReason ?? false,
|
||||
rateLimit: overrides.rateLimit ?? { attempts: 3, windowMs: 45_000 },
|
||||
execute: overrides.execute,
|
||||
};
|
||||
}
|
||||
|
||||
describe("dispatchHousekeepingCommand", () => {
|
||||
it("returns a typed not-found result for an unknown command", async () => {
|
||||
const result = await dispatchHousekeepingCommand(
|
||||
{ commandId: "system.missing", input: {} },
|
||||
dependencies(),
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "NOT_FOUND",
|
||||
messageKey: "errors.housekeeping.notFound",
|
||||
},
|
||||
});
|
||||
expect(result.correlationId).toMatch(/^[0-9a-f-]{36}$/i);
|
||||
});
|
||||
|
||||
it("rechecks capability without treating actor rank as authorization", async () => {
|
||||
let executed = false;
|
||||
const audit = auditRecorder();
|
||||
register(
|
||||
baseCommand("system.dispatch.denied", {
|
||||
risk: "sensitive",
|
||||
input: z.object({}),
|
||||
execute: async (context) => {
|
||||
executed = true;
|
||||
return ok(null, context.correlationId);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await dispatchHousekeepingCommand(
|
||||
{ commandId: "system.dispatch.denied", input: {} },
|
||||
dependencies({ context: capabilityContext([]), audit: audit.writer }),
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "FORBIDDEN" },
|
||||
});
|
||||
expect(executed).toBe(false);
|
||||
expect(audit.entries.map((entry) => entry.outcome)).toEqual([
|
||||
"intent",
|
||||
"denied",
|
||||
]);
|
||||
expect(audit.entries[0]?.correlationId).toBe(result.correlationId);
|
||||
expect(audit.entries[1]?.correlationId).toBe(result.correlationId);
|
||||
});
|
||||
|
||||
it("rejects invalid input before the command can execute", async () => {
|
||||
let executed = false;
|
||||
register(
|
||||
baseCommand("system.dispatch.invalid-input", {
|
||||
input: z.object({ count: z.number().int().positive() }),
|
||||
execute: async (context) => {
|
||||
executed = true;
|
||||
return ok(null, context.correlationId);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await dispatchHousekeepingCommand(
|
||||
{ commandId: "system.dispatch.invalid-input", input: { count: -1 } },
|
||||
dependencies(),
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "VALIDATION" },
|
||||
});
|
||||
expect(executed).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a missing required reason before the command can execute", async () => {
|
||||
let executed = false;
|
||||
const audit = auditRecorder();
|
||||
register(
|
||||
baseCommand("system.dispatch.reason", {
|
||||
risk: "sensitive",
|
||||
requiresReason: true,
|
||||
input: z.object({}),
|
||||
execute: async (context) => {
|
||||
executed = true;
|
||||
return ok(null, context.correlationId);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await dispatchHousekeepingCommand(
|
||||
{ commandId: "system.dispatch.reason", input: {}, reason: " " },
|
||||
dependencies({ audit: audit.writer }),
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "VALIDATION",
|
||||
fieldErrors: { reason: ["errors.validation.required"] },
|
||||
},
|
||||
});
|
||||
expect(executed).toBe(false);
|
||||
expect(audit.entries.map((entry) => entry.outcome)).toEqual([
|
||||
"intent",
|
||||
"denied",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses actor, IP, command ID, and the approved command limit", async () => {
|
||||
const calls: Array<[string, number, number]> = [];
|
||||
let executed = false;
|
||||
register(
|
||||
baseCommand("system.dispatch.rate-limit", {
|
||||
input: z.object({}),
|
||||
rateLimit: { attempts: 2, windowMs: 90_000 },
|
||||
execute: async (context) => {
|
||||
executed = true;
|
||||
return ok(null, context.correlationId);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await dispatchHousekeepingCommand(
|
||||
{ commandId: "system.dispatch.rate-limit", input: {} },
|
||||
dependencies({
|
||||
rateLimit: async (key, attempts, windowMs) => {
|
||||
calls.push([key, attempts, windowMs]);
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(calls).toEqual([
|
||||
[
|
||||
"housekeeping-command:42:198.51.100.8:system.dispatch.rate-limit",
|
||||
2,
|
||||
90_000,
|
||||
],
|
||||
]);
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "RATE_LIMITED" },
|
||||
});
|
||||
expect(executed).toBe(false);
|
||||
});
|
||||
|
||||
it("executes a safe command with parsed input and writes one success outcome", async () => {
|
||||
const audit = auditRecorder();
|
||||
let receivedCount = 0;
|
||||
register(
|
||||
baseCommand("system.dispatch.safe-success", {
|
||||
input: z.object({ count: z.coerce.number().int().positive() }),
|
||||
execute: async (_context, input) => {
|
||||
receivedCount = input.count;
|
||||
return ok({ doubled: input.count * 2 }, "wrong-command-correlation");
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await dispatchHousekeepingCommand(
|
||||
{ commandId: "system.dispatch.safe-success", input: { count: "4" } },
|
||||
dependencies({ audit: audit.writer }),
|
||||
);
|
||||
|
||||
expect(receivedCount).toBe(4);
|
||||
expect(result).toMatchObject({ ok: true, data: { doubled: 8 } });
|
||||
expect(audit.entries.map((entry) => entry.outcome)).toEqual(["success"]);
|
||||
expect(audit.entries[0]?.correlationId).toBe(result.correlationId);
|
||||
expect(result.correlationId).not.toBe("wrong-command-correlation");
|
||||
});
|
||||
|
||||
it("persists sensitive intent before execution and success afterward", async () => {
|
||||
const events: string[] = [];
|
||||
const audit = auditRecorder(events);
|
||||
let executionCorrelation = "";
|
||||
register(
|
||||
baseCommand("system.dispatch.sensitive-success", {
|
||||
risk: "sensitive",
|
||||
requiresReason: true,
|
||||
input: z.object({ enabled: z.boolean() }),
|
||||
execute: async (context) => {
|
||||
executionCorrelation = context.correlationId;
|
||||
events.push("execute");
|
||||
return ok({ saved: true }, "untrusted-command-correlation");
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await dispatchHousekeepingCommand(
|
||||
{
|
||||
commandId: "system.dispatch.sensitive-success",
|
||||
input: { enabled: true },
|
||||
reason: " Planned change ",
|
||||
},
|
||||
dependencies({ audit: audit.writer }),
|
||||
);
|
||||
|
||||
expect(events).toEqual(["audit:intent", "execute", "audit:success"]);
|
||||
expect(audit.entries).toMatchObject([
|
||||
{
|
||||
userId: 42,
|
||||
action: "system.dispatch.sensitive-success",
|
||||
target: "system",
|
||||
domain: "system",
|
||||
reason: "Planned change",
|
||||
ipAddress: "198.51.100.8",
|
||||
outcome: "intent",
|
||||
},
|
||||
{ outcome: "success" },
|
||||
]);
|
||||
expect(executionCorrelation).toBe(result.correlationId);
|
||||
expect(audit.entries[0]?.correlationId).toBe(result.correlationId);
|
||||
expect(audit.entries[1]?.correlationId).toBe(result.correlationId);
|
||||
});
|
||||
|
||||
it("persists sensitive failure after execution with the same correlation", async () => {
|
||||
const events: string[] = [];
|
||||
const audit = auditRecorder(events);
|
||||
register(
|
||||
baseCommand("system.dispatch.sensitive-failure", {
|
||||
risk: "sensitive",
|
||||
input: z.object({}),
|
||||
execute: async () => {
|
||||
events.push("execute");
|
||||
return fail(
|
||||
"DEPENDENCY_UNAVAILABLE",
|
||||
"errors.housekeeping.dependencyUnavailable",
|
||||
"wrong-command-correlation",
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await dispatchHousekeepingCommand(
|
||||
{ commandId: "system.dispatch.sensitive-failure", input: {} },
|
||||
dependencies({ audit: audit.writer }),
|
||||
);
|
||||
|
||||
expect(events).toEqual(["audit:intent", "execute", "audit:failure"]);
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "DEPENDENCY_UNAVAILABLE" },
|
||||
});
|
||||
expect(audit.entries[0]?.correlationId).toBe(result.correlationId);
|
||||
expect(audit.entries[1]?.correlationId).toBe(result.correlationId);
|
||||
});
|
||||
|
||||
it("blocks sensitive execution when intent persistence fails", async () => {
|
||||
let executed = false;
|
||||
register(
|
||||
baseCommand("system.dispatch.intent-failure", {
|
||||
risk: "sensitive",
|
||||
input: z.object({}),
|
||||
execute: async (context) => {
|
||||
executed = true;
|
||||
return ok(null, context.correlationId);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await dispatchHousekeepingCommand(
|
||||
{ commandId: "system.dispatch.intent-failure", input: {} },
|
||||
dependencies({
|
||||
audit: {
|
||||
write: async () => {
|
||||
throw new Error("audit credentials exposed");
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(executed).toBe(false);
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "INTERNAL",
|
||||
messageKey: "errors.housekeeping.internal",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("sanitizes unknown exceptions and records a failure outcome", async () => {
|
||||
const audit = auditRecorder();
|
||||
register(
|
||||
baseCommand("system.dispatch.exception", {
|
||||
risk: "sensitive",
|
||||
input: z.object({}),
|
||||
execute: async () => {
|
||||
throw new Error("database password=hunter2");
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await dispatchHousekeepingCommand(
|
||||
{ commandId: "system.dispatch.exception", input: {} },
|
||||
dependencies({ audit: audit.writer }),
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "INTERNAL",
|
||||
messageKey: "errors.housekeeping.internal",
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(result)).not.toContain("hunter2");
|
||||
expect(audit.entries.map((entry) => entry.outcome)).toEqual([
|
||||
"intent",
|
||||
"failure",
|
||||
]);
|
||||
expect(audit.entries[1]?.correlationId).toBe(result.correlationId);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user