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,112 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { registerHousekeepingCommand } from "@/features/housekeeping/foundation/commands/registry";
|
||||||
|
import {
|
||||||
|
anyCapability,
|
||||||
|
ok,
|
||||||
|
} from "@/features/housekeeping/foundation/contracts";
|
||||||
|
import type { AuditEntry } from "@/lib/services/audit";
|
||||||
|
|
||||||
|
const { auditEntries, context, rateLimitCalls } = vi.hoisted(() => ({
|
||||||
|
auditEntries: [] as AuditEntry[],
|
||||||
|
context: {
|
||||||
|
actor: { id: 71, username: "server-operator", rank: 4 },
|
||||||
|
isSuperAdmin: false,
|
||||||
|
has: (slug: string) => slug === "admin.settings.edit",
|
||||||
|
hasAny: (...slugs: string[]) => slugs.includes("admin.settings.edit"),
|
||||||
|
hasAll: (...slugs: string[]) =>
|
||||||
|
slugs.every((slug) => slug === "admin.settings.edit"),
|
||||||
|
},
|
||||||
|
rateLimitCalls: [] as Array<[string, number, number]>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({
|
||||||
|
getHousekeepingCapabilityContext: async () => context,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/services/audit", () => ({
|
||||||
|
housekeepingAuditWriter: {
|
||||||
|
write: async (entry: AuditEntry) => {
|
||||||
|
auditEntries.push({ ...entry });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/rate-limit", () => ({
|
||||||
|
clientIp: async () => "203.0.113.7",
|
||||||
|
rateLimit: async (key: string, attempts: number, windowMs: number) => {
|
||||||
|
rateLimitCalls.push([key, attempts, windowMs]);
|
||||||
|
return { ok: true, retryAfter: 0 };
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { executeHousekeepingCommand } from "./housekeeping-command";
|
||||||
|
|
||||||
|
registerHousekeepingCommand({
|
||||||
|
id: "system.server-action.serializable",
|
||||||
|
owner: "system",
|
||||||
|
risk: "safe",
|
||||||
|
capability: anyCapability("admin.settings.edit"),
|
||||||
|
input: z.object({ value: z.string() }),
|
||||||
|
requiresReason: false,
|
||||||
|
rateLimit: { attempts: 5, windowMs: 120_000 },
|
||||||
|
execute: async (commandContext, input) =>
|
||||||
|
ok(
|
||||||
|
{
|
||||||
|
value: input.value,
|
||||||
|
actorId: commandContext.capability.actor.id,
|
||||||
|
ipAddress: commandContext.ipAddress,
|
||||||
|
},
|
||||||
|
commandContext.correlationId,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
auditEntries.length = 0;
|
||||||
|
rateLimitCalls.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("executeHousekeepingCommand", () => {
|
||||||
|
it("accepts a plain request and ignores spoofed server-owned metadata", async () => {
|
||||||
|
const request = {
|
||||||
|
commandId: "system.server-action.serializable",
|
||||||
|
input: { value: "saved" },
|
||||||
|
risk: "sensitive",
|
||||||
|
owner: "people",
|
||||||
|
capability: { mode: "any", slugs: ["forged.permission"] },
|
||||||
|
actor: { id: 999 },
|
||||||
|
ipAddress: "198.51.100.9",
|
||||||
|
rateLimit: { attempts: 999, windowMs: 1 },
|
||||||
|
audit: { action: "forged.action", target: "forged-target" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await executeHousekeepingCommand(request);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
value: "saved",
|
||||||
|
actorId: 71,
|
||||||
|
ipAddress: "203.0.113.7",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(rateLimitCalls).toEqual([
|
||||||
|
[
|
||||||
|
"housekeeping-command:71:203.0.113.7:system.server-action.serializable",
|
||||||
|
5,
|
||||||
|
120_000,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
expect(auditEntries).toMatchObject([
|
||||||
|
{
|
||||||
|
userId: 71,
|
||||||
|
action: "system.server-action.serializable",
|
||||||
|
target: "system",
|
||||||
|
domain: "system",
|
||||||
|
ipAddress: "203.0.113.7",
|
||||||
|
outcome: "success",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(auditEntries[0]?.correlationId).toBe(result.correlationId);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import {
|
||||||
|
dispatchHousekeepingCommand,
|
||||||
|
type HousekeepingCommandRequest,
|
||||||
|
} from "@/features/housekeeping/foundation/commands/dispatcher";
|
||||||
|
import type { HousekeepingResult } from "@/features/housekeeping/foundation/contracts";
|
||||||
|
import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context";
|
||||||
|
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||||
|
import { housekeepingAuditWriter } from "@/lib/services/audit";
|
||||||
|
|
||||||
|
export async function executeHousekeepingCommand(
|
||||||
|
request: HousekeepingCommandRequest,
|
||||||
|
): Promise<HousekeepingResult<unknown>> {
|
||||||
|
const [context, ipAddress] = await Promise.all([
|
||||||
|
getHousekeepingCapabilityContext(),
|
||||||
|
clientIp(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return dispatchHousekeepingCommand(request, {
|
||||||
|
context,
|
||||||
|
ipAddress,
|
||||||
|
audit: housekeepingAuditWriter,
|
||||||
|
rateLimit: async (key, attempts, windowMs) =>
|
||||||
|
(await rateLimit(key, attempts, windowMs)).ok,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { anyCapability, ok } from "../contracts";
|
||||||
|
import { confirmHousekeepingCommand } from "./confirmation";
|
||||||
|
import type { HousekeepingCommand } from "./registry";
|
||||||
|
|
||||||
|
function command(
|
||||||
|
requiresReason: boolean,
|
||||||
|
risk: "safe" | "sensitive",
|
||||||
|
): HousekeepingCommand<Record<string, never>, null> {
|
||||||
|
return {
|
||||||
|
id: "system.confirmation.test",
|
||||||
|
owner: "system",
|
||||||
|
risk,
|
||||||
|
capability: anyCapability("admin.settings.edit"),
|
||||||
|
input: z.object({}),
|
||||||
|
requiresReason,
|
||||||
|
rateLimit: { attempts: 2, windowMs: 60_000 },
|
||||||
|
execute: async (context) => ok(null, context.correlationId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("housekeeping command confirmation", () => {
|
||||||
|
it("rejects blank reasons when the registered command requires one", () => {
|
||||||
|
expect(
|
||||||
|
confirmHousekeepingCommand(
|
||||||
|
command(true, "sensitive"),
|
||||||
|
" \t ",
|
||||||
|
"corr-reason-missing",
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: "VALIDATION",
|
||||||
|
messageKey: "errors.housekeeping.validation",
|
||||||
|
fieldErrors: { reason: ["errors.validation.required"] },
|
||||||
|
},
|
||||||
|
correlationId: "corr-reason-missing",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives confirmation risk from the registered command and normalizes its reason", () => {
|
||||||
|
expect(
|
||||||
|
confirmHousekeepingCommand(
|
||||||
|
command(true, "sensitive"),
|
||||||
|
" Scheduled maintenance ",
|
||||||
|
"corr-confirm",
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
commandId: "system.confirmation.test",
|
||||||
|
risk: "sensitive",
|
||||||
|
requiresReason: true,
|
||||||
|
reason: "Scheduled maintenance",
|
||||||
|
},
|
||||||
|
correlationId: "corr-confirm",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits an empty optional reason without inventing confirmation metadata", () => {
|
||||||
|
const result = confirmHousekeepingCommand(
|
||||||
|
command(false, "safe"),
|
||||||
|
undefined,
|
||||||
|
"corr-safe",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
commandId: "system.confirmation.test",
|
||||||
|
risk: "safe",
|
||||||
|
requiresReason: false,
|
||||||
|
},
|
||||||
|
correlationId: "corr-safe",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { fail, type HousekeepingResult, ok } from "../contracts";
|
||||||
|
import type { HousekeepingCommand } from "./registry";
|
||||||
|
|
||||||
|
export interface HousekeepingCommandConfirmation {
|
||||||
|
commandId: string;
|
||||||
|
risk: "safe" | "sensitive";
|
||||||
|
requiresReason: boolean;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function confirmHousekeepingCommand(
|
||||||
|
command: HousekeepingCommand<unknown, unknown>,
|
||||||
|
reason: string | undefined,
|
||||||
|
correlationId: string,
|
||||||
|
): HousekeepingResult<HousekeepingCommandConfirmation> {
|
||||||
|
const normalizedReason = reason?.normalize("NFC").trim() || undefined;
|
||||||
|
if (command.requiresReason && !normalizedReason) {
|
||||||
|
return fail("VALIDATION", "errors.housekeeping.validation", correlationId, {
|
||||||
|
reason: ["errors.validation.required"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok(
|
||||||
|
{
|
||||||
|
commandId: command.id,
|
||||||
|
risk: command.risk,
|
||||||
|
requiresReason: command.requiresReason,
|
||||||
|
...(normalizedReason === undefined ? {} : { reason: normalizedReason }),
|
||||||
|
},
|
||||||
|
correlationId,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import type { AuditEntry, HousekeepingAuditWriter } from "@/lib/services/audit";
|
||||||
|
import { satisfiesCapability } from "../capability-context";
|
||||||
|
import {
|
||||||
|
fail,
|
||||||
|
type HousekeepingCapabilityContext,
|
||||||
|
type HousekeepingResult,
|
||||||
|
mapUnknownError,
|
||||||
|
} from "../contracts";
|
||||||
|
import { createCorrelationId } from "../correlation";
|
||||||
|
import { writeIntent, writeOutcome } from "./audit-envelope";
|
||||||
|
import { confirmHousekeepingCommand } from "./confirmation";
|
||||||
|
import {
|
||||||
|
getHousekeepingCommand,
|
||||||
|
type HousekeepingCommand,
|
||||||
|
type HousekeepingCommandContext,
|
||||||
|
} from "./registry";
|
||||||
|
|
||||||
|
export interface HousekeepingCommandRequest {
|
||||||
|
commandId: string;
|
||||||
|
input: unknown;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HousekeepingCommandDependencies {
|
||||||
|
context: HousekeepingCapabilityContext;
|
||||||
|
ipAddress: string;
|
||||||
|
audit: HousekeepingAuditWriter;
|
||||||
|
rateLimit(key: string, attempts: number, windowMs: number): Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dispatchHousekeepingCommand(
|
||||||
|
request: HousekeepingCommandRequest,
|
||||||
|
dependencies: HousekeepingCommandDependencies,
|
||||||
|
): Promise<HousekeepingResult<unknown>> {
|
||||||
|
const correlationId = createCorrelationId();
|
||||||
|
const command = getHousekeepingCommand(request.commandId);
|
||||||
|
if (!command) {
|
||||||
|
return fail("NOT_FOUND", "errors.housekeeping.notFound", correlationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reason =
|
||||||
|
typeof request.reason === "string"
|
||||||
|
? request.reason.normalize("NFC").trim() || undefined
|
||||||
|
: undefined;
|
||||||
|
const auditEntry = createAuditEntry(
|
||||||
|
command,
|
||||||
|
dependencies.context,
|
||||||
|
dependencies.ipAddress,
|
||||||
|
correlationId,
|
||||||
|
reason,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (command.risk === "sensitive") {
|
||||||
|
try {
|
||||||
|
await writeIntent(dependencies.audit, auditEntry);
|
||||||
|
} catch (error) {
|
||||||
|
return mapUnknownError(error, correlationId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!satisfiesCapability(dependencies.context, command.capability)) {
|
||||||
|
return finishWithAudit(
|
||||||
|
dependencies.audit,
|
||||||
|
auditEntry,
|
||||||
|
"denied",
|
||||||
|
fail("FORBIDDEN", "errors.housekeeping.forbidden", correlationId),
|
||||||
|
correlationId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedInput = command.input.safeParse(request.input);
|
||||||
|
if (!parsedInput.success) {
|
||||||
|
const fieldErrors: Record<string, readonly string[]> = {};
|
||||||
|
for (const issue of parsedInput.error.issues) {
|
||||||
|
const field = String(issue.path[0] ?? "input");
|
||||||
|
fieldErrors[field] = ["errors.validation.invalid"];
|
||||||
|
}
|
||||||
|
|
||||||
|
return finishWithAudit(
|
||||||
|
dependencies.audit,
|
||||||
|
auditEntry,
|
||||||
|
"denied",
|
||||||
|
fail(
|
||||||
|
"VALIDATION",
|
||||||
|
"errors.housekeeping.validation",
|
||||||
|
correlationId,
|
||||||
|
fieldErrors,
|
||||||
|
),
|
||||||
|
correlationId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmation = confirmHousekeepingCommand(
|
||||||
|
command,
|
||||||
|
reason,
|
||||||
|
correlationId,
|
||||||
|
);
|
||||||
|
if (!confirmation.ok) {
|
||||||
|
return finishWithAudit(
|
||||||
|
dependencies.audit,
|
||||||
|
auditEntry,
|
||||||
|
"denied",
|
||||||
|
confirmation,
|
||||||
|
correlationId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let allowed: boolean;
|
||||||
|
try {
|
||||||
|
allowed = await dependencies.rateLimit(
|
||||||
|
createRateLimitKey(
|
||||||
|
command.id,
|
||||||
|
dependencies.context,
|
||||||
|
dependencies.ipAddress,
|
||||||
|
),
|
||||||
|
command.rateLimit.attempts,
|
||||||
|
command.rateLimit.windowMs,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return finishWithAudit(
|
||||||
|
dependencies.audit,
|
||||||
|
auditEntry,
|
||||||
|
"failure",
|
||||||
|
mapUnknownError(error, correlationId),
|
||||||
|
correlationId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allowed) {
|
||||||
|
return finishWithAudit(
|
||||||
|
dependencies.audit,
|
||||||
|
auditEntry,
|
||||||
|
"denied",
|
||||||
|
fail("RATE_LIMITED", "errors.housekeeping.rateLimited", correlationId),
|
||||||
|
correlationId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const commandContext: HousekeepingCommandContext = {
|
||||||
|
capability: dependencies.context,
|
||||||
|
correlationId,
|
||||||
|
ipAddress: dependencies.ipAddress,
|
||||||
|
};
|
||||||
|
let result: HousekeepingResult<unknown>;
|
||||||
|
try {
|
||||||
|
result = await command.execute(commandContext, parsedInput.data);
|
||||||
|
} catch (error) {
|
||||||
|
return finishWithAudit(
|
||||||
|
dependencies.audit,
|
||||||
|
auditEntry,
|
||||||
|
"failure",
|
||||||
|
mapUnknownError(error, correlationId),
|
||||||
|
correlationId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const correlatedResult = withCorrelation(result, correlationId);
|
||||||
|
return finishWithAudit(
|
||||||
|
dependencies.audit,
|
||||||
|
auditEntry,
|
||||||
|
outcomeFor(correlatedResult),
|
||||||
|
correlatedResult,
|
||||||
|
correlationId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAuditEntry(
|
||||||
|
command: HousekeepingCommand<unknown, unknown>,
|
||||||
|
context: HousekeepingCapabilityContext,
|
||||||
|
ipAddress: string,
|
||||||
|
correlationId: string,
|
||||||
|
reason: string | undefined,
|
||||||
|
): AuditEntry {
|
||||||
|
return {
|
||||||
|
userId: context.actor.id,
|
||||||
|
action: command.id,
|
||||||
|
target: command.owner,
|
||||||
|
correlationId,
|
||||||
|
domain: command.owner,
|
||||||
|
...(reason === undefined ? {} : { reason }),
|
||||||
|
ipAddress,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRateLimitKey(
|
||||||
|
commandId: string,
|
||||||
|
context: HousekeepingCapabilityContext,
|
||||||
|
ipAddress = "0.0.0.0",
|
||||||
|
): string {
|
||||||
|
return `housekeeping-command:${context.actor.id}:${ipAddress}:${commandId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function withCorrelation<T>(
|
||||||
|
result: HousekeepingResult<T>,
|
||||||
|
correlationId: string,
|
||||||
|
): HousekeepingResult<T> {
|
||||||
|
return { ...result, correlationId };
|
||||||
|
}
|
||||||
|
|
||||||
|
function outcomeFor(
|
||||||
|
result: HousekeepingResult<unknown>,
|
||||||
|
): "success" | "failure" | "denied" {
|
||||||
|
if (result.ok) return "success";
|
||||||
|
return result.error.code === "FORBIDDEN" ||
|
||||||
|
result.error.code === "VALIDATION" ||
|
||||||
|
result.error.code === "RATE_LIMITED"
|
||||||
|
? "denied"
|
||||||
|
: "failure";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finishWithAudit<T>(
|
||||||
|
writer: HousekeepingAuditWriter,
|
||||||
|
entry: AuditEntry,
|
||||||
|
outcome: "success" | "failure" | "denied",
|
||||||
|
result: HousekeepingResult<T>,
|
||||||
|
correlationId: string,
|
||||||
|
): Promise<HousekeepingResult<T>> {
|
||||||
|
try {
|
||||||
|
await writeOutcome(writer, entry, outcome);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
return mapUnknownError(error, correlationId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { anyCapability, ok } from "../contracts";
|
||||||
|
import {
|
||||||
|
getHousekeepingCommand,
|
||||||
|
type HousekeepingCommand,
|
||||||
|
registerHousekeepingCommand,
|
||||||
|
} from "./registry";
|
||||||
|
|
||||||
|
function command(
|
||||||
|
id: string,
|
||||||
|
owner: "people" | "system" = "people",
|
||||||
|
): HousekeepingCommand<{ id: number }, { id: number }> {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
owner,
|
||||||
|
risk: "safe",
|
||||||
|
capability: anyCapability("admin.users.view"),
|
||||||
|
input: z.object({ id: z.number().int().positive() }),
|
||||||
|
requiresReason: false,
|
||||||
|
rateLimit: { attempts: 4, windowMs: 30_000 },
|
||||||
|
execute: async (context, input) => ok(input, context.correlationId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("housekeeping command registry", () => {
|
||||||
|
it("returns the exact command registered under its global ID", () => {
|
||||||
|
const registered = command("people.registry.lookup");
|
||||||
|
|
||||||
|
registerHousekeepingCommand(registered);
|
||||||
|
|
||||||
|
expect(getHousekeepingCommand(registered.id)).toBe(registered);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a duplicate global command ID before it can shadow its owner", () => {
|
||||||
|
registerHousekeepingCommand(command("people.registry.duplicate"));
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
registerHousekeepingCommand(command("people.registry.duplicate")),
|
||||||
|
).toThrow("duplicate command id: people.registry.duplicate");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a command whose namespace disagrees with its declared owner", () => {
|
||||||
|
expect(() =>
|
||||||
|
registerHousekeepingCommand(
|
||||||
|
command("people.registry.owner-mismatch", "system"),
|
||||||
|
),
|
||||||
|
).toThrow("command owner mismatch: people.registry.owner-mismatch");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import type { z } from "zod";
|
||||||
|
import {
|
||||||
|
HOUSEKEEPING_DOMAIN_IDS,
|
||||||
|
type HousekeepingDomainId,
|
||||||
|
} from "../../migration/types";
|
||||||
|
import type {
|
||||||
|
CapabilityRequirement,
|
||||||
|
HousekeepingCapabilityContext,
|
||||||
|
HousekeepingResult,
|
||||||
|
} from "../contracts";
|
||||||
|
|
||||||
|
export interface HousekeepingCommandContext {
|
||||||
|
capability: HousekeepingCapabilityContext;
|
||||||
|
correlationId: string;
|
||||||
|
ipAddress: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HousekeepingCommand<I, O> {
|
||||||
|
id: string;
|
||||||
|
owner: HousekeepingDomainId;
|
||||||
|
risk: "safe" | "sensitive";
|
||||||
|
capability: CapabilityRequirement;
|
||||||
|
input: z.ZodType<I>;
|
||||||
|
requiresReason: boolean;
|
||||||
|
rateLimit: { attempts: number; windowMs: number };
|
||||||
|
execute(
|
||||||
|
context: HousekeepingCommandContext,
|
||||||
|
input: I,
|
||||||
|
): Promise<HousekeepingResult<O>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RegisteredHousekeepingCommand = HousekeepingCommand<unknown, unknown>;
|
||||||
|
|
||||||
|
const approvedOwners = new Set<string>(HOUSEKEEPING_DOMAIN_IDS);
|
||||||
|
const commands = new Map<string, RegisteredHousekeepingCommand>();
|
||||||
|
|
||||||
|
export function registerHousekeepingCommand<I, O>(
|
||||||
|
command: HousekeepingCommand<I, O>,
|
||||||
|
): void {
|
||||||
|
validateCommand(command);
|
||||||
|
if (commands.has(command.id)) {
|
||||||
|
throw new Error(`duplicate command id: ${command.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
commands.set(command.id, command as RegisteredHousekeepingCommand);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHousekeepingCommand(
|
||||||
|
id: string,
|
||||||
|
): RegisteredHousekeepingCommand | undefined {
|
||||||
|
return commands.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateCommand<I, O>(command: HousekeepingCommand<I, O>): void {
|
||||||
|
if (typeof command.id !== "string" || !command.id.trim()) {
|
||||||
|
throw new Error("empty command id");
|
||||||
|
}
|
||||||
|
if (!approvedOwners.has(command.owner)) {
|
||||||
|
throw new Error(`unknown command owner: ${command.owner}`);
|
||||||
|
}
|
||||||
|
if (!command.id.startsWith(`${command.owner}.`)) {
|
||||||
|
throw new Error(`command owner mismatch: ${command.id}`);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!command.input ||
|
||||||
|
typeof (command.input as { safeParse?: unknown }).safeParse !== "function"
|
||||||
|
) {
|
||||||
|
throw new Error(`command input schema missing: ${command.id}`);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!Number.isInteger(command.rateLimit.attempts) ||
|
||||||
|
command.rateLimit.attempts <= 0 ||
|
||||||
|
!Number.isInteger(command.rateLimit.windowMs) ||
|
||||||
|
command.rateLimit.windowMs <= 0
|
||||||
|
) {
|
||||||
|
throw new Error(`invalid command rate limit: ${command.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user