fix(housekeeping): harden audited command dispatch
This commit is contained in:
1 parent
6aed71a8b2
commit
796d009c07
7 files changed
+775
-124
No files matched your search
@@ -1,3 +1,4 @@
|
||||
import { z } from "zod";
|
||||
import type { AuditEntry, HousekeepingAuditWriter } from "@/lib/services/audit";
|
||||
import { satisfiesCapability } from "../capability-context";
|
||||
import {
|
||||
@@ -7,7 +8,11 @@ import {
|
||||
mapUnknownError,
|
||||
} from "../contracts";
|
||||
import { createCorrelationId } from "../correlation";
|
||||
import { writeIntent, writeOutcome } from "./audit-envelope";
|
||||
import {
|
||||
AuditOutcomePersistenceError,
|
||||
writeIntent,
|
||||
writeOutcome,
|
||||
} from "./audit-envelope";
|
||||
import { confirmHousekeepingCommand } from "./confirmation";
|
||||
import {
|
||||
getHousekeepingCommand,
|
||||
@@ -15,6 +20,20 @@ import {
|
||||
type HousekeepingCommandContext,
|
||||
} from "./registry";
|
||||
|
||||
const normalizedCommandId = z
|
||||
.string()
|
||||
.transform((value) => value.normalize("NFC").trim())
|
||||
.pipe(z.string().min(1).max(160));
|
||||
const normalizedReason = z
|
||||
.string()
|
||||
.transform((value) => value.normalize("NFC").trim())
|
||||
.pipe(z.string().max(1000));
|
||||
const commandRequestSchema = z.strictObject({
|
||||
commandId: normalizedCommandId,
|
||||
input: z.unknown(),
|
||||
reason: normalizedReason.optional(),
|
||||
});
|
||||
|
||||
export interface HousekeepingCommandRequest {
|
||||
commandId: string;
|
||||
input: unknown;
|
||||
@@ -29,54 +48,86 @@ export interface HousekeepingCommandDependencies {
|
||||
}
|
||||
|
||||
export async function dispatchHousekeepingCommand(
|
||||
request: HousekeepingCommandRequest,
|
||||
request: unknown,
|
||||
dependencies: HousekeepingCommandDependencies,
|
||||
): Promise<HousekeepingResult<unknown>> {
|
||||
const correlationId = createCorrelationId();
|
||||
const command = getHousekeepingCommand(request.commandId);
|
||||
let parsedRequest: ReturnType<typeof commandRequestSchema.safeParse>;
|
||||
try {
|
||||
parsedRequest = commandRequestSchema.safeParse(request);
|
||||
} catch (error) {
|
||||
return mapUnknownError(error, correlationId);
|
||||
}
|
||||
if (!parsedRequest.success) {
|
||||
return fail("VALIDATION", "errors.housekeeping.validation", correlationId);
|
||||
}
|
||||
|
||||
const commandRequest = parsedRequest.data;
|
||||
const command = getHousekeepingCommand(commandRequest.commandId);
|
||||
if (!command) {
|
||||
return fail("NOT_FOUND", "errors.housekeeping.notFound", correlationId);
|
||||
const result = fail(
|
||||
"NOT_FOUND",
|
||||
"errors.housekeeping.notFound",
|
||||
correlationId,
|
||||
);
|
||||
return persistReturnedOutcome(
|
||||
dependencies.audit,
|
||||
createUnknownCommandAuditEntry(
|
||||
commandRequest.commandId,
|
||||
dependencies,
|
||||
correlationId,
|
||||
),
|
||||
"denied",
|
||||
result,
|
||||
);
|
||||
}
|
||||
|
||||
const reason =
|
||||
typeof request.reason === "string"
|
||||
? request.reason.normalize("NFC").trim() || undefined
|
||||
: undefined;
|
||||
const auditEntry = createAuditEntry(
|
||||
command,
|
||||
dependencies.context,
|
||||
dependencies.ipAddress,
|
||||
correlationId,
|
||||
reason,
|
||||
commandRequest.reason || undefined,
|
||||
);
|
||||
|
||||
if (command.risk === "sensitive") {
|
||||
try {
|
||||
await writeIntent(dependencies.audit, auditEntry);
|
||||
} catch (error) {
|
||||
return mapUnknownError(error, correlationId);
|
||||
}
|
||||
let authorized: boolean;
|
||||
try {
|
||||
authorized = satisfiesCapability(dependencies.context, command.capability);
|
||||
} catch (error) {
|
||||
return persistReturnedOutcome(
|
||||
dependencies.audit,
|
||||
auditEntry,
|
||||
"failure",
|
||||
mapUnknownError(error, correlationId),
|
||||
);
|
||||
}
|
||||
|
||||
if (!satisfiesCapability(dependencies.context, command.capability)) {
|
||||
return finishWithAudit(
|
||||
if (!authorized) {
|
||||
return persistReturnedOutcome(
|
||||
dependencies.audit,
|
||||
auditEntry,
|
||||
"denied",
|
||||
fail("FORBIDDEN", "errors.housekeeping.forbidden", correlationId),
|
||||
correlationId,
|
||||
);
|
||||
}
|
||||
|
||||
const parsedInput = command.input.safeParse(request.input);
|
||||
let parsedInput: ReturnType<typeof command.input.safeParse>;
|
||||
try {
|
||||
parsedInput = command.input.safeParse(commandRequest.input);
|
||||
} catch (error) {
|
||||
return persistReturnedOutcome(
|
||||
dependencies.audit,
|
||||
auditEntry,
|
||||
"failure",
|
||||
mapUnknownError(error, correlationId),
|
||||
);
|
||||
}
|
||||
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(
|
||||
return persistReturnedOutcome(
|
||||
dependencies.audit,
|
||||
auditEntry,
|
||||
"denied",
|
||||
@@ -86,22 +137,30 @@ export async function dispatchHousekeepingCommand(
|
||||
correlationId,
|
||||
fieldErrors,
|
||||
),
|
||||
correlationId,
|
||||
);
|
||||
}
|
||||
|
||||
const confirmation = confirmHousekeepingCommand(
|
||||
command,
|
||||
reason,
|
||||
correlationId,
|
||||
);
|
||||
let confirmation: HousekeepingResult<unknown>;
|
||||
try {
|
||||
confirmation = confirmHousekeepingCommand(
|
||||
command,
|
||||
commandRequest.reason || undefined,
|
||||
correlationId,
|
||||
);
|
||||
} catch (error) {
|
||||
return persistReturnedOutcome(
|
||||
dependencies.audit,
|
||||
auditEntry,
|
||||
"failure",
|
||||
mapUnknownError(error, correlationId),
|
||||
);
|
||||
}
|
||||
if (!confirmation.ok) {
|
||||
return finishWithAudit(
|
||||
return persistReturnedOutcome(
|
||||
dependencies.audit,
|
||||
auditEntry,
|
||||
"denied",
|
||||
confirmation,
|
||||
correlationId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -117,25 +176,30 @@ export async function dispatchHousekeepingCommand(
|
||||
command.rateLimit.windowMs,
|
||||
);
|
||||
} catch (error) {
|
||||
return finishWithAudit(
|
||||
return persistReturnedOutcome(
|
||||
dependencies.audit,
|
||||
auditEntry,
|
||||
"failure",
|
||||
mapUnknownError(error, correlationId),
|
||||
correlationId,
|
||||
);
|
||||
}
|
||||
|
||||
if (!allowed) {
|
||||
return finishWithAudit(
|
||||
return persistReturnedOutcome(
|
||||
dependencies.audit,
|
||||
auditEntry,
|
||||
"denied",
|
||||
fail("RATE_LIMITED", "errors.housekeeping.rateLimited", correlationId),
|
||||
correlationId,
|
||||
);
|
||||
}
|
||||
|
||||
if (command.risk === "sensitive") {
|
||||
try {
|
||||
await writeIntent(dependencies.audit, auditEntry);
|
||||
} catch (error) {
|
||||
return mapUnknownError(error, correlationId);
|
||||
}
|
||||
}
|
||||
|
||||
const commandContext: HousekeepingCommandContext = {
|
||||
capability: dependencies.context,
|
||||
correlationId,
|
||||
@@ -145,22 +209,27 @@ export async function dispatchHousekeepingCommand(
|
||||
try {
|
||||
result = await command.execute(commandContext, parsedInput.data);
|
||||
} catch (error) {
|
||||
return finishWithAudit(
|
||||
return persistReturnedOutcome(
|
||||
dependencies.audit,
|
||||
auditEntry,
|
||||
"failure",
|
||||
mapUnknownError(error, correlationId),
|
||||
correlationId,
|
||||
);
|
||||
}
|
||||
|
||||
const correlatedResult = withCorrelation(result, correlationId);
|
||||
return finishWithAudit(
|
||||
if (!correlatedResult.ok) {
|
||||
return persistReturnedOutcome(
|
||||
dependencies.audit,
|
||||
auditEntry,
|
||||
outcomeForFailure(correlatedResult),
|
||||
correlatedResult,
|
||||
);
|
||||
}
|
||||
return persistSuccessfulOutcome(
|
||||
dependencies.audit,
|
||||
auditEntry,
|
||||
outcomeFor(correlatedResult),
|
||||
correlatedResult,
|
||||
correlationId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -182,10 +251,24 @@ function createAuditEntry(
|
||||
};
|
||||
}
|
||||
|
||||
function createUnknownCommandAuditEntry(
|
||||
commandId: string,
|
||||
dependencies: HousekeepingCommandDependencies,
|
||||
correlationId: string,
|
||||
): AuditEntry {
|
||||
return {
|
||||
userId: dependencies.context.actor.id,
|
||||
action: "housekeeping.command.dispatch",
|
||||
target: commandId,
|
||||
correlationId,
|
||||
ipAddress: dependencies.ipAddress,
|
||||
};
|
||||
}
|
||||
|
||||
function createRateLimitKey(
|
||||
commandId: string,
|
||||
context: HousekeepingCapabilityContext,
|
||||
ipAddress = "0.0.0.0",
|
||||
ipAddress: string,
|
||||
): string {
|
||||
return `housekeeping-command:${context.actor.id}:${ipAddress}:${commandId}`;
|
||||
}
|
||||
@@ -197,10 +280,9 @@ function withCorrelation<T>(
|
||||
return { ...result, correlationId };
|
||||
}
|
||||
|
||||
function outcomeFor(
|
||||
result: HousekeepingResult<unknown>,
|
||||
): "success" | "failure" | "denied" {
|
||||
if (result.ok) return "success";
|
||||
function outcomeForFailure(
|
||||
result: Extract<HousekeepingResult<unknown>, { ok: false }>,
|
||||
): "failure" | "denied" {
|
||||
return result.error.code === "FORBIDDEN" ||
|
||||
result.error.code === "VALIDATION" ||
|
||||
result.error.code === "RATE_LIMITED"
|
||||
@@ -208,17 +290,34 @@ function outcomeFor(
|
||||
: "failure";
|
||||
}
|
||||
|
||||
async function finishWithAudit<T>(
|
||||
async function persistReturnedOutcome<T>(
|
||||
writer: HousekeepingAuditWriter,
|
||||
entry: AuditEntry,
|
||||
outcome: "success" | "failure" | "denied",
|
||||
outcome: "failure" | "denied",
|
||||
result: HousekeepingResult<T>,
|
||||
correlationId: string,
|
||||
): Promise<HousekeepingResult<T>> {
|
||||
try {
|
||||
await writeOutcome(writer, entry, outcome);
|
||||
} catch {
|
||||
// The command/preflight failure remains authoritative if evidence is down.
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function persistSuccessfulOutcome<T>(
|
||||
writer: HousekeepingAuditWriter,
|
||||
entry: AuditEntry,
|
||||
result: HousekeepingResult<T>,
|
||||
): Promise<HousekeepingResult<T>> {
|
||||
try {
|
||||
await writeOutcome(writer, entry, "success");
|
||||
return result;
|
||||
} catch (error) {
|
||||
return mapUnknownError(error, correlationId);
|
||||
} catch (auditOutcomeError) {
|
||||
try {
|
||||
await writeOutcome(writer, entry, "partial");
|
||||
} catch {
|
||||
// The completed-operation error below remains the primary evidence.
|
||||
}
|
||||
throw new AuditOutcomePersistenceError(result, auditOutcomeError);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user