fix(housekeeping): harden command dispatch boundaries
This commit is contained in:
1 parent
796d009c07
commit
00618fb2a3
8 files changed
+608
-30
No files matched your search
@@ -1,3 +1,4 @@
|
||||
import { isIP } from "node:net";
|
||||
import { z } from "zod";
|
||||
import type { AuditEntry, HousekeepingAuditWriter } from "@/lib/services/audit";
|
||||
import { satisfiesCapability } from "../capability-context";
|
||||
@@ -18,12 +19,19 @@ import {
|
||||
getHousekeepingCommand,
|
||||
type HousekeepingCommand,
|
||||
type HousekeepingCommandContext,
|
||||
isHousekeepingCommandId,
|
||||
} from "./registry";
|
||||
|
||||
const normalizedCommandId = z
|
||||
.string()
|
||||
.transform((value) => value.normalize("NFC").trim())
|
||||
.pipe(z.string().min(1).max(160));
|
||||
.pipe(
|
||||
z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(160)
|
||||
.refine(isHousekeepingCommandId, "invalid command id"),
|
||||
);
|
||||
const normalizedReason = z
|
||||
.string()
|
||||
.transform((value) => value.normalize("NFC").trim())
|
||||
@@ -33,6 +41,34 @@ const commandRequestSchema = z.strictObject({
|
||||
input: z.unknown(),
|
||||
reason: normalizedReason.optional(),
|
||||
});
|
||||
const housekeepingErrorCodeSchema = z.enum([
|
||||
"UNAUTHENTICATED",
|
||||
"FORBIDDEN",
|
||||
"VALIDATION",
|
||||
"NOT_FOUND",
|
||||
"CONFLICT",
|
||||
"RATE_LIMITED",
|
||||
"DEPENDENCY_UNAVAILABLE",
|
||||
"TIMEOUT",
|
||||
"INTERNAL",
|
||||
]);
|
||||
const housekeepingErrorSchema = z.strictObject({
|
||||
code: housekeepingErrorCodeSchema,
|
||||
messageKey: z.string().min(1).max(160),
|
||||
fieldErrors: z.record(z.string(), z.array(z.string())).optional(),
|
||||
});
|
||||
const housekeepingResultSchema = z.discriminatedUnion("ok", [
|
||||
z.strictObject({
|
||||
ok: z.literal(true),
|
||||
data: z.unknown(),
|
||||
correlationId: z.string().min(1).max(160),
|
||||
}),
|
||||
z.strictObject({
|
||||
ok: z.literal(false),
|
||||
error: housekeepingErrorSchema,
|
||||
correlationId: z.string().min(1).max(160),
|
||||
}),
|
||||
]);
|
||||
|
||||
export interface HousekeepingCommandRequest {
|
||||
commandId: string;
|
||||
@@ -52,14 +88,50 @@ export async function dispatchHousekeepingCommand(
|
||||
dependencies: HousekeepingCommandDependencies,
|
||||
): Promise<HousekeepingResult<unknown>> {
|
||||
const correlationId = createCorrelationId();
|
||||
const ipAddress = canonicalizeServerIp(dependencies.ipAddress);
|
||||
if (ipAddress === undefined) {
|
||||
return persistReturnedOutcome(
|
||||
dependencies.audit,
|
||||
createDispatchAuditEntry(
|
||||
"server-context",
|
||||
dependencies.context,
|
||||
correlationId,
|
||||
),
|
||||
"failure",
|
||||
mapUnknownError(new Error("invalid server IP"), correlationId),
|
||||
);
|
||||
}
|
||||
|
||||
if (!isPlainRecord(request)) {
|
||||
return persistMalformedRequestOutcome(
|
||||
dependencies,
|
||||
ipAddress,
|
||||
correlationId,
|
||||
);
|
||||
}
|
||||
|
||||
let parsedRequest: ReturnType<typeof commandRequestSchema.safeParse>;
|
||||
try {
|
||||
parsedRequest = commandRequestSchema.safeParse(request);
|
||||
} catch (error) {
|
||||
return mapUnknownError(error, correlationId);
|
||||
return persistReturnedOutcome(
|
||||
dependencies.audit,
|
||||
createDispatchAuditEntry(
|
||||
"request-envelope",
|
||||
dependencies.context,
|
||||
correlationId,
|
||||
ipAddress,
|
||||
),
|
||||
"failure",
|
||||
mapUnknownError(error, correlationId),
|
||||
);
|
||||
}
|
||||
if (!parsedRequest.success) {
|
||||
return fail("VALIDATION", "errors.housekeeping.validation", correlationId);
|
||||
return persistMalformedRequestOutcome(
|
||||
dependencies,
|
||||
ipAddress,
|
||||
correlationId,
|
||||
);
|
||||
}
|
||||
|
||||
const commandRequest = parsedRequest.data;
|
||||
@@ -74,7 +146,8 @@ export async function dispatchHousekeepingCommand(
|
||||
dependencies.audit,
|
||||
createUnknownCommandAuditEntry(
|
||||
commandRequest.commandId,
|
||||
dependencies,
|
||||
dependencies.context,
|
||||
ipAddress,
|
||||
correlationId,
|
||||
),
|
||||
"denied",
|
||||
@@ -85,7 +158,7 @@ export async function dispatchHousekeepingCommand(
|
||||
const auditEntry = createAuditEntry(
|
||||
command,
|
||||
dependencies.context,
|
||||
dependencies.ipAddress,
|
||||
ipAddress,
|
||||
correlationId,
|
||||
commandRequest.reason || undefined,
|
||||
);
|
||||
@@ -167,11 +240,7 @@ export async function dispatchHousekeepingCommand(
|
||||
let allowed: boolean;
|
||||
try {
|
||||
allowed = await dependencies.rateLimit(
|
||||
createRateLimitKey(
|
||||
command.id,
|
||||
dependencies.context,
|
||||
dependencies.ipAddress,
|
||||
),
|
||||
createRateLimitKey(command.id, dependencies.context, ipAddress),
|
||||
command.rateLimit.attempts,
|
||||
command.rateLimit.windowMs,
|
||||
);
|
||||
@@ -203,11 +272,12 @@ export async function dispatchHousekeepingCommand(
|
||||
const commandContext: HousekeepingCommandContext = {
|
||||
capability: dependencies.context,
|
||||
correlationId,
|
||||
ipAddress: dependencies.ipAddress,
|
||||
ipAddress,
|
||||
};
|
||||
let result: HousekeepingResult<unknown>;
|
||||
let correlatedResult: HousekeepingResult<unknown>;
|
||||
try {
|
||||
result = await command.execute(commandContext, parsedInput.data);
|
||||
const rawResult = await command.execute(commandContext, parsedInput.data);
|
||||
correlatedResult = validateAndCorrelateResult(rawResult, correlationId);
|
||||
} catch (error) {
|
||||
return persistReturnedOutcome(
|
||||
dependencies.audit,
|
||||
@@ -217,7 +287,6 @@ export async function dispatchHousekeepingCommand(
|
||||
);
|
||||
}
|
||||
|
||||
const correlatedResult = withCorrelation(result, correlationId);
|
||||
if (!correlatedResult.ok) {
|
||||
return persistReturnedOutcome(
|
||||
dependencies.audit,
|
||||
@@ -251,17 +320,33 @@ function createAuditEntry(
|
||||
};
|
||||
}
|
||||
|
||||
function createDispatchAuditEntry(
|
||||
target: "request-envelope" | "server-context",
|
||||
context: HousekeepingCapabilityContext,
|
||||
correlationId: string,
|
||||
ipAddress?: string,
|
||||
): AuditEntry {
|
||||
return {
|
||||
userId: context.actor.id,
|
||||
action: "housekeeping.command.dispatch",
|
||||
target,
|
||||
correlationId,
|
||||
...(ipAddress === undefined ? {} : { ipAddress }),
|
||||
};
|
||||
}
|
||||
|
||||
function createUnknownCommandAuditEntry(
|
||||
commandId: string,
|
||||
dependencies: HousekeepingCommandDependencies,
|
||||
context: HousekeepingCapabilityContext,
|
||||
ipAddress: string,
|
||||
correlationId: string,
|
||||
): AuditEntry {
|
||||
return {
|
||||
userId: dependencies.context.actor.id,
|
||||
userId: context.actor.id,
|
||||
action: "housekeeping.command.dispatch",
|
||||
target: commandId,
|
||||
correlationId,
|
||||
ipAddress: dependencies.ipAddress,
|
||||
ipAddress,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -273,11 +358,56 @@ function createRateLimitKey(
|
||||
return `housekeeping-command:${context.actor.id}:${ipAddress}:${commandId}`;
|
||||
}
|
||||
|
||||
function withCorrelation<T>(
|
||||
result: HousekeepingResult<T>,
|
||||
function canonicalizeServerIp(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const candidate = value.trim();
|
||||
const version = isIP(candidate);
|
||||
if (version === 4) {
|
||||
return candidate
|
||||
.split(".")
|
||||
.map((part) => String(Number(part)))
|
||||
.join(".");
|
||||
}
|
||||
if (version === 6) {
|
||||
try {
|
||||
return new URL(`http://[${candidate}]/`).hostname.slice(1, -1);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function validateAndCorrelateResult(
|
||||
value: unknown,
|
||||
correlationId: string,
|
||||
): HousekeepingResult<T> {
|
||||
return { ...result, correlationId };
|
||||
): HousekeepingResult<unknown> {
|
||||
if (!isPlainRecord(value) || !Object.hasOwn(value, "ok")) {
|
||||
throw new Error("invalid housekeeping command result");
|
||||
}
|
||||
if (value.ok === true && !Object.hasOwn(value, "data")) {
|
||||
throw new Error("invalid housekeeping command result");
|
||||
}
|
||||
if (value.ok === false && !Object.hasOwn(value, "error")) {
|
||||
throw new Error("invalid housekeeping command result");
|
||||
}
|
||||
const parsed = housekeepingResultSchema.safeParse(value);
|
||||
if (!parsed.success) {
|
||||
throw new Error("invalid housekeeping command result");
|
||||
}
|
||||
return { ...parsed.data, correlationId };
|
||||
}
|
||||
|
||||
function outcomeForFailure(
|
||||
@@ -290,6 +420,24 @@ function outcomeForFailure(
|
||||
: "failure";
|
||||
}
|
||||
|
||||
function persistMalformedRequestOutcome(
|
||||
dependencies: HousekeepingCommandDependencies,
|
||||
ipAddress: string,
|
||||
correlationId: string,
|
||||
): Promise<HousekeepingResult<never>> {
|
||||
return persistReturnedOutcome(
|
||||
dependencies.audit,
|
||||
createDispatchAuditEntry(
|
||||
"request-envelope",
|
||||
dependencies.context,
|
||||
correlationId,
|
||||
ipAddress,
|
||||
),
|
||||
"denied",
|
||||
fail("VALIDATION", "errors.housekeeping.validation", correlationId),
|
||||
);
|
||||
}
|
||||
|
||||
async function persistReturnedOutcome<T>(
|
||||
writer: HousekeepingAuditWriter,
|
||||
entry: AuditEntry,
|
||||
|
||||
Reference in new issue
Block a user