fix(housekeeping): harden audited command dispatch
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import type { z } from "zod";
|
||||
import { z } from "zod";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import {
|
||||
HOUSEKEEPING_DOMAIN_IDS,
|
||||
type HousekeepingDomainId,
|
||||
@@ -10,39 +11,61 @@ import type {
|
||||
} from "../contracts";
|
||||
|
||||
export interface HousekeepingCommandContext {
|
||||
capability: HousekeepingCapabilityContext;
|
||||
correlationId: string;
|
||||
ipAddress: string;
|
||||
readonly capability: HousekeepingCapabilityContext;
|
||||
readonly correlationId: string;
|
||||
readonly 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(
|
||||
readonly id: string;
|
||||
readonly owner: HousekeepingDomainId;
|
||||
readonly risk: "safe" | "sensitive";
|
||||
readonly capability: CapabilityRequirement;
|
||||
readonly input: z.ZodType<I>;
|
||||
readonly requiresReason: boolean;
|
||||
readonly rateLimit: Readonly<{ attempts: number; windowMs: number }>;
|
||||
readonly execute: (
|
||||
context: HousekeepingCommandContext,
|
||||
input: I,
|
||||
): Promise<HousekeepingResult<O>>;
|
||||
) => Promise<HousekeepingResult<O>>;
|
||||
}
|
||||
|
||||
type RegisteredHousekeepingCommand = HousekeepingCommand<unknown, unknown>;
|
||||
|
||||
const approvedOwners = new Set<string>(HOUSEKEEPING_DOMAIN_IDS);
|
||||
const knownCapabilitySlugs = new Set<string>(Object.values(PERMS));
|
||||
const commandIdPattern = /^[a-z][a-z0-9-]*(?:\.[a-z0-9][a-z0-9-]*)+$/;
|
||||
const commands = new Map<string, RegisteredHousekeepingCommand>();
|
||||
let sealed = false;
|
||||
|
||||
export function registerHousekeepingCommand<I, O>(
|
||||
command: HousekeepingCommand<I, O>,
|
||||
): void {
|
||||
if (sealed) throw new Error("command registry is sealed");
|
||||
validateCommand(command);
|
||||
if (commands.has(command.id)) {
|
||||
throw new Error(`duplicate command id: ${command.id}`);
|
||||
}
|
||||
|
||||
commands.set(command.id, command as RegisteredHousekeepingCommand);
|
||||
const capability = Object.freeze({
|
||||
mode: command.capability.mode,
|
||||
slugs: Object.freeze([...command.capability.slugs]),
|
||||
}) satisfies CapabilityRequirement;
|
||||
const snapshot = Object.freeze({
|
||||
id: command.id,
|
||||
owner: command.owner,
|
||||
risk: command.risk,
|
||||
capability,
|
||||
input: Object.freeze(command.input.clone()),
|
||||
requiresReason: command.requiresReason,
|
||||
rateLimit: Object.freeze({ ...command.rateLimit }),
|
||||
execute: command.execute,
|
||||
}) as RegisteredHousekeepingCommand;
|
||||
commands.set(snapshot.id, snapshot);
|
||||
}
|
||||
|
||||
export function sealHousekeepingCommandRegistry(): void {
|
||||
sealed = true;
|
||||
}
|
||||
|
||||
export function getHousekeepingCommand(
|
||||
@@ -52,22 +75,37 @@ export function getHousekeepingCommand(
|
||||
}
|
||||
|
||||
function validateCommand<I, O>(command: HousekeepingCommand<I, O>): void {
|
||||
if (typeof command.id !== "string" || !command.id.trim()) {
|
||||
throw new Error("empty command id");
|
||||
if (
|
||||
!isNormalizedIdentifier(command.id) ||
|
||||
!commandIdPattern.test(command.id)
|
||||
) {
|
||||
throw new Error(`invalid command id: ${String(command.id)}`);
|
||||
}
|
||||
if (!approvedOwners.has(command.owner)) {
|
||||
throw new Error(`unknown command owner: ${command.owner}`);
|
||||
if (
|
||||
!isNormalizedIdentifier(command.owner) ||
|
||||
!approvedOwners.has(command.owner)
|
||||
) {
|
||||
throw new Error(`unknown command owner: ${String(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"
|
||||
) {
|
||||
if (command.risk !== "safe" && command.risk !== "sensitive") {
|
||||
throw new Error(`invalid command risk: ${command.id}`);
|
||||
}
|
||||
if (typeof command.requiresReason !== "boolean") {
|
||||
throw new Error(`invalid command reason policy: ${command.id}`);
|
||||
}
|
||||
validateCapability(command.capability, command.id);
|
||||
if (!(command.input instanceof z.ZodType)) {
|
||||
throw new Error(`command input schema missing: ${command.id}`);
|
||||
}
|
||||
if (typeof command.execute !== "function") {
|
||||
throw new Error(`command execute missing: ${command.id}`);
|
||||
}
|
||||
if (
|
||||
typeof command.rateLimit !== "object" ||
|
||||
command.rateLimit === null ||
|
||||
!Number.isInteger(command.rateLimit.attempts) ||
|
||||
command.rateLimit.attempts <= 0 ||
|
||||
!Number.isInteger(command.rateLimit.windowMs) ||
|
||||
@@ -76,3 +114,32 @@ function validateCommand<I, O>(command: HousekeepingCommand<I, O>): void {
|
||||
throw new Error(`invalid command rate limit: ${command.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateCapability(
|
||||
requirement: CapabilityRequirement,
|
||||
commandId: string,
|
||||
): void {
|
||||
if (
|
||||
typeof requirement !== "object" ||
|
||||
requirement === null ||
|
||||
(requirement.mode !== "any" && requirement.mode !== "all") ||
|
||||
!Array.isArray(requirement.slugs) ||
|
||||
requirement.slugs.length === 0
|
||||
) {
|
||||
throw new Error(`invalid command capability: ${commandId}`);
|
||||
}
|
||||
for (const slug of requirement.slugs) {
|
||||
if (!isNormalizedIdentifier(slug) || !knownCapabilitySlugs.has(slug)) {
|
||||
throw new Error(`invalid command capability: ${commandId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isNormalizedIdentifier(value: unknown): value is string {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
value.length > 0 &&
|
||||
value.length <= 160 &&
|
||||
value === value.normalize("NFC").trim()
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user