fix(housekeeping): require reasons for destructive changes
This commit is contained in:
1 parent
50598472ad
commit
93367a239f
16 files changed
+323
-15
No files matched your search
@@ -7,13 +7,13 @@ import type { HousekeepingCommand } from "./registry";
|
||||
function command(
|
||||
requiresReason: boolean,
|
||||
risk: "safe" | "sensitive",
|
||||
): HousekeepingCommand<Record<string, never>, null> {
|
||||
): HousekeepingCommand<Record<string, unknown>, null> {
|
||||
return {
|
||||
id: "system.confirmation.test",
|
||||
owner: "system",
|
||||
risk,
|
||||
capability: anyCapability("admin.settings.edit"),
|
||||
input: z.object({}),
|
||||
input: z.object({}).catchall(z.unknown()),
|
||||
requiresReason,
|
||||
rateLimit: { attempts: 2, windowMs: 60_000 },
|
||||
execute: async (context) => ok(null, context.correlationId),
|
||||
@@ -75,4 +75,23 @@ describe("housekeeping command confirmation", () => {
|
||||
correlationId: "corr-safe",
|
||||
});
|
||||
});
|
||||
|
||||
it("requires a reason dynamically when parsed input binds a delete action", () => {
|
||||
expect(
|
||||
confirmHousekeepingCommand(
|
||||
command(false, "sensitive"),
|
||||
" \t ",
|
||||
"corr-delete-reason",
|
||||
{ action: "delete", id: "7" },
|
||||
),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "VALIDATION",
|
||||
messageKey: "errors.housekeeping.validation",
|
||||
fieldErrors: { reason: ["errors.validation.required"] },
|
||||
},
|
||||
correlationId: "corr-delete-reason",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,8 @@
|
||||
import { fail, type HousekeepingResult, ok } from "../contracts";
|
||||
import {
|
||||
normalizeHousekeepingReason,
|
||||
requiresHousekeepingReason,
|
||||
} from "./reason-policy";
|
||||
import type { HousekeepingCommand } from "./registry";
|
||||
|
||||
export interface HousekeepingCommandConfirmation {
|
||||
@@ -12,9 +16,14 @@ export function confirmHousekeepingCommand<I, O>(
|
||||
command: HousekeepingCommand<I, O>,
|
||||
reason: string | undefined,
|
||||
correlationId: string,
|
||||
input?: I,
|
||||
): HousekeepingResult<HousekeepingCommandConfirmation> {
|
||||
const normalizedReason = reason?.normalize("NFC").trim() || undefined;
|
||||
if (command.requiresReason && !normalizedReason) {
|
||||
const normalizedReason = normalizeHousekeepingReason(reason);
|
||||
const reasonRequired = requiresHousekeepingReason(
|
||||
command.requiresReason,
|
||||
input,
|
||||
);
|
||||
if (reasonRequired && !normalizedReason) {
|
||||
return fail("VALIDATION", "errors.housekeeping.validation", correlationId, {
|
||||
reason: ["errors.validation.required"],
|
||||
});
|
||||
@@ -24,7 +33,7 @@ export function confirmHousekeepingCommand<I, O>(
|
||||
{
|
||||
commandId: command.id,
|
||||
risk: command.risk,
|
||||
requiresReason: command.requiresReason,
|
||||
requiresReason: reasonRequired,
|
||||
...(normalizedReason === undefined ? {} : { reason: normalizedReason }),
|
||||
},
|
||||
correlationId,
|
||||
|
||||
@@ -374,6 +374,53 @@ describe("dispatchHousekeepingCommand", () => {
|
||||
expect(audit.entries.map((entry) => entry.outcome)).toEqual(["denied"]);
|
||||
});
|
||||
|
||||
it("requires and forwards a normalized reason for a dynamically destructive input", async () => {
|
||||
let executionReason: string | undefined;
|
||||
let executions = 0;
|
||||
register(
|
||||
baseCommand("system.dispatch.dynamic-delete-reason", {
|
||||
risk: "sensitive",
|
||||
input: z.object({
|
||||
action: z.enum(["update", "delete"]),
|
||||
id: z.string(),
|
||||
}),
|
||||
execute: async (context) => {
|
||||
executions += 1;
|
||||
executionReason = context.reason;
|
||||
return ok(null, context.correlationId);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const denied = await dispatchHousekeepingCommand(
|
||||
{
|
||||
commandId: "system.dispatch.dynamic-delete-reason",
|
||||
input: { action: "delete", id: "7" },
|
||||
},
|
||||
dependencies(),
|
||||
);
|
||||
expect(denied).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "VALIDATION",
|
||||
fieldErrors: { reason: ["errors.validation.required"] },
|
||||
},
|
||||
});
|
||||
expect(executions).toBe(0);
|
||||
|
||||
const allowed = await dispatchHousekeepingCommand(
|
||||
{
|
||||
commandId: "system.dispatch.dynamic-delete-reason",
|
||||
input: { action: "delete", id: "7" },
|
||||
reason: " Remove duplicate ",
|
||||
},
|
||||
dependencies(),
|
||||
);
|
||||
expect(allowed).toMatchObject({ ok: true });
|
||||
expect(executions).toBe(1);
|
||||
expect(executionReason).toBe("Remove duplicate");
|
||||
});
|
||||
|
||||
it("uses actor, IP, command ID, and the approved command limit", async () => {
|
||||
const calls: Array<[string, number, number]> = [];
|
||||
let executed = false;
|
||||
|
||||
@@ -223,6 +223,7 @@ export async function dispatchHousekeepingCommand(
|
||||
command,
|
||||
commandRequest.reason || undefined,
|
||||
correlationId,
|
||||
parsedInput.data,
|
||||
);
|
||||
} catch (error) {
|
||||
return persistReturnedOutcome(
|
||||
@@ -277,6 +278,7 @@ export async function dispatchHousekeepingCommand(
|
||||
capability: dependencies.context,
|
||||
correlationId,
|
||||
ipAddress,
|
||||
...(commandRequest.reason ? { reason: commandRequest.reason } : {}),
|
||||
};
|
||||
let correlatedResult: HousekeepingResult<unknown>;
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
export function normalizeHousekeepingReason(
|
||||
reason: string | undefined,
|
||||
): string | undefined {
|
||||
return reason?.normalize("NFC").trim() || undefined;
|
||||
}
|
||||
|
||||
export function isHousekeepingDeleteInput(input: unknown): boolean {
|
||||
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return Reflect.get(input, "action") === "delete";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function requiresHousekeepingReason(
|
||||
commandRequiresReason: boolean,
|
||||
input: unknown,
|
||||
): boolean {
|
||||
return commandRequiresReason || isHousekeepingDeleteInput(input);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export interface HousekeepingCommandContext {
|
||||
readonly capability: HousekeepingCapabilityContext;
|
||||
readonly correlationId: string;
|
||||
readonly ipAddress: string;
|
||||
readonly reason?: string;
|
||||
}
|
||||
|
||||
export interface HousekeepingCommand<I, O> {
|
||||
|
||||
Reference in new issue
Block a user