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,6 +1,21 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { registerHousekeepingCommand } from "@/features/housekeeping/foundation/commands/registry";
|
import { registerHousekeepingCommand } from "@/features/housekeeping/foundation/commands/registry";
|
||||||
|
|
||||||
|
vi.mock(
|
||||||
|
"@/features/housekeeping/foundation/commands/registry",
|
||||||
|
async (importOriginal) => ({
|
||||||
|
...(await importOriginal<
|
||||||
|
typeof import("@/features/housekeeping/foundation/commands/registry")
|
||||||
|
>()),
|
||||||
|
sealHousekeepingCommandRegistry: sealRegistryMock,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.mock("@/features/housekeeping/foundation/commands/bootstrap", () => ({
|
||||||
|
housekeepingCommandRegistryReady: true,
|
||||||
|
}));
|
||||||
|
|
||||||
import {
|
import {
|
||||||
anyCapability,
|
anyCapability,
|
||||||
ok,
|
ok,
|
||||||
@@ -15,6 +30,7 @@ const {
|
|||||||
getContextMock,
|
getContextMock,
|
||||||
getIpMock,
|
getIpMock,
|
||||||
rateLimitCalls,
|
rateLimitCalls,
|
||||||
|
sealRegistryMock,
|
||||||
} = vi.hoisted(() => ({
|
} = vi.hoisted(() => ({
|
||||||
auditEntries: [] as AuditEntry[],
|
auditEntries: [] as AuditEntry[],
|
||||||
auditWriteMock: vi.fn(),
|
auditWriteMock: vi.fn(),
|
||||||
@@ -30,6 +46,7 @@ const {
|
|||||||
getContextMock: vi.fn(),
|
getContextMock: vi.fn(),
|
||||||
getIpMock: vi.fn(),
|
getIpMock: vi.fn(),
|
||||||
rateLimitCalls: [] as Array<[string, number, number]>,
|
rateLimitCalls: [] as Array<[string, number, number]>,
|
||||||
|
sealRegistryMock: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({
|
vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({
|
||||||
@@ -77,6 +94,7 @@ beforeEach(() => {
|
|||||||
rateLimitCalls.length = 0;
|
rateLimitCalls.length = 0;
|
||||||
getContextMock.mockReset().mockResolvedValue(context);
|
getContextMock.mockReset().mockResolvedValue(context);
|
||||||
getIpMock.mockReset().mockResolvedValue("203.0.113.7");
|
getIpMock.mockReset().mockResolvedValue("203.0.113.7");
|
||||||
|
sealRegistryMock.mockReset();
|
||||||
auditWriteMock.mockReset().mockImplementation(async (entry: AuditEntry) => {
|
auditWriteMock.mockReset().mockImplementation(async (entry: AuditEntry) => {
|
||||||
auditEntries.push({ ...entry });
|
auditEntries.push({ ...entry });
|
||||||
});
|
});
|
||||||
@@ -115,6 +133,7 @@ describe("executeHousekeepingCommand", () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
expect(auditEntries[0]?.correlationId).toBe(result.correlationId);
|
expect(auditEntries[0]?.correlationId).toBe(result.correlationId);
|
||||||
|
expect(sealRegistryMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("strictly rejects spoofed server-owned metadata before execution", async () => {
|
it("strictly rejects spoofed server-owned metadata before execution", async () => {
|
||||||
@@ -136,7 +155,16 @@ describe("executeHousekeepingCommand", () => {
|
|||||||
});
|
});
|
||||||
expect(commandExecutions).toEqual([]);
|
expect(commandExecutions).toEqual([]);
|
||||||
expect(rateLimitCalls).toEqual([]);
|
expect(rateLimitCalls).toEqual([]);
|
||||||
expect(auditEntries).toEqual([]);
|
expect(auditEntries).toMatchObject([
|
||||||
|
{
|
||||||
|
userId: 71,
|
||||||
|
action: "housekeeping.command.dispatch",
|
||||||
|
target: "request-envelope",
|
||||||
|
ipAddress: "203.0.113.7",
|
||||||
|
outcome: "denied",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(JSON.stringify(auditEntries)).not.toContain("forged");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sanitizes server context acquisition failures into typed results", async () => {
|
it("sanitizes server context acquisition failures into typed results", async () => {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import "@/features/housekeeping/foundation/commands/bootstrap";
|
||||||
import { AuditOutcomePersistenceError } from "@/features/housekeeping/foundation/commands/audit-envelope";
|
import { AuditOutcomePersistenceError } from "@/features/housekeeping/foundation/commands/audit-envelope";
|
||||||
import { dispatchHousekeepingCommand } from "@/features/housekeeping/foundation/commands/dispatcher";
|
import { dispatchHousekeepingCommand } from "@/features/housekeeping/foundation/commands/dispatcher";
|
||||||
import { sealHousekeepingCommandRegistry } from "@/features/housekeeping/foundation/commands/registry";
|
|
||||||
import {
|
import {
|
||||||
fail,
|
fail,
|
||||||
type HousekeepingResult,
|
type HousekeepingResult,
|
||||||
@@ -15,7 +15,6 @@ import { housekeepingAuditWriter } from "@/lib/services/audit";
|
|||||||
export async function executeHousekeepingCommand(
|
export async function executeHousekeepingCommand(
|
||||||
request: unknown,
|
request: unknown,
|
||||||
): Promise<HousekeepingResult<unknown>> {
|
): Promise<HousekeepingResult<unknown>> {
|
||||||
sealHousekeepingCommandRegistry();
|
|
||||||
try {
|
try {
|
||||||
const [context, ipAddress] = await Promise.all([
|
const [context, ipAddress] = await Promise.all([
|
||||||
getHousekeepingCapabilityContext(),
|
getHousekeepingCapabilityContext(),
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { anyCapability, ok } from "../contracts";
|
||||||
|
import { housekeepingCommandRegistryReady } from "./bootstrap";
|
||||||
|
import { registerHousekeepingCommand } from "./registry";
|
||||||
|
|
||||||
|
describe("housekeeping command bootstrap", () => {
|
||||||
|
it("registers the complete current list and seals during module initialization", () => {
|
||||||
|
expect(housekeepingCommandRegistryReady).toBe(true);
|
||||||
|
expect(() =>
|
||||||
|
registerHousekeepingCommand({
|
||||||
|
id: "system.bootstrap.too-late",
|
||||||
|
owner: "system",
|
||||||
|
risk: "safe",
|
||||||
|
capability: anyCapability("admin.settings.view"),
|
||||||
|
input: z.object({}),
|
||||||
|
requiresReason: false,
|
||||||
|
rateLimit: { attempts: 1, windowMs: 1_000 },
|
||||||
|
execute: async (context) => ok(null, context.correlationId),
|
||||||
|
}),
|
||||||
|
).toThrow("command registry is sealed");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import type { HousekeepingCommand } from "./registry";
|
||||||
|
import {
|
||||||
|
registerHousekeepingCommand,
|
||||||
|
sealHousekeepingCommandRegistry,
|
||||||
|
} from "./registry";
|
||||||
|
|
||||||
|
const currentHousekeepingCommands =
|
||||||
|
[] as const satisfies readonly HousekeepingCommand<unknown, unknown>[];
|
||||||
|
|
||||||
|
for (const command of currentHousekeepingCommands) {
|
||||||
|
registerHousekeepingCommand(command);
|
||||||
|
}
|
||||||
|
sealHousekeepingCommandRegistry();
|
||||||
|
|
||||||
|
export const housekeepingCommandRegistryReady = true;
|
||||||
@@ -504,7 +504,7 @@ describe("dispatchHousekeepingCommand", () => {
|
|||||||
it.each([
|
it.each([
|
||||||
null,
|
null,
|
||||||
[],
|
[],
|
||||||
Object.assign(Object.create({ inherited: true }), {
|
Object.assign(Object.create(Object.freeze({})), {
|
||||||
commandId: "system.dispatch.unknown",
|
commandId: "system.dispatch.unknown",
|
||||||
input: {},
|
input: {},
|
||||||
}),
|
}),
|
||||||
@@ -691,4 +691,196 @@ describe("dispatchHousekeepingCommand", () => {
|
|||||||
expect(JSON.stringify(result)).not.toContain("secret exposed");
|
expect(JSON.stringify(result)).not.toContain("secret exposed");
|
||||||
expect(attempts).toEqual(["failure"]);
|
expect(attempts).toEqual(["failure"]);
|
||||||
});
|
});
|
||||||
|
it("audits malformed envelopes without copying unvalidated metadata", async () => {
|
||||||
|
const audit = auditRecorder();
|
||||||
|
const result = await dispatchHousekeepingCommand(
|
||||||
|
{
|
||||||
|
commandId: "people.client-controlled-target",
|
||||||
|
input: { password: "secret" },
|
||||||
|
reason: "client reason",
|
||||||
|
domain: "people",
|
||||||
|
},
|
||||||
|
dependencies({ audit: audit.writer }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: { code: "VALIDATION" },
|
||||||
|
});
|
||||||
|
expect(audit.entries).toEqual([
|
||||||
|
{
|
||||||
|
userId: 42,
|
||||||
|
action: "housekeeping.command.dispatch",
|
||||||
|
target: "request-envelope",
|
||||||
|
correlationId: result.correlationId,
|
||||||
|
outcome: "denied",
|
||||||
|
ipAddress: "198.51.100.8",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(JSON.stringify(audit.entries)).not.toContain("client-controlled");
|
||||||
|
expect(JSON.stringify(audit.entries)).not.toContain("secret");
|
||||||
|
expect(JSON.stringify(audit.entries)).not.toContain("client reason");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses canonical command-ID grammar before unknown-command evidence", async () => {
|
||||||
|
const audit = auditRecorder();
|
||||||
|
const result = await dispatchHousekeepingCommand(
|
||||||
|
{ commandId: "system.bad\nidentifier", input: {} },
|
||||||
|
dependencies({ audit: audit.writer }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: { code: "VALIDATION" },
|
||||||
|
});
|
||||||
|
expect(audit.entries).toMatchObject([
|
||||||
|
{
|
||||||
|
action: "housekeeping.command.dispatch",
|
||||||
|
target: "request-envelope",
|
||||||
|
outcome: "denied",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(JSON.stringify(audit.entries)).not.toContain("bad\\nidentifier");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["null", null],
|
||||||
|
["missing success data", { ok: true, correlationId: "forged" }],
|
||||||
|
[
|
||||||
|
"invalid success flag",
|
||||||
|
{ ok: "yes", data: null, correlationId: "forged" },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"invalid failure error",
|
||||||
|
{ ok: false, error: null, correlationId: "forged" },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"throwing getter",
|
||||||
|
Object.defineProperty({}, "ok", {
|
||||||
|
enumerable: true,
|
||||||
|
get: () => {
|
||||||
|
throw new Error("result getter secret exposed");
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
] as const)(
|
||||||
|
"sanitizes malformed command result: %s",
|
||||||
|
async (label, commandResult) => {
|
||||||
|
const commandId = `system.dispatch.malformed-result-${label.replaceAll(" ", "-")}`;
|
||||||
|
const audit = auditRecorder();
|
||||||
|
register(
|
||||||
|
baseCommand(commandId, {
|
||||||
|
input: z.object({}),
|
||||||
|
execute: async () => commandResult as never,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await dispatchHousekeepingCommand(
|
||||||
|
{ commandId, input: {} },
|
||||||
|
dependencies({ audit: audit.writer }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: { code: "INTERNAL", messageKey: "errors.housekeeping.internal" },
|
||||||
|
});
|
||||||
|
expect(result.correlationId).toMatch(/^[0-9a-f-]{36}$/i);
|
||||||
|
expect(result.correlationId).not.toBe("forged");
|
||||||
|
expect(JSON.stringify(result)).not.toContain("secret exposed");
|
||||||
|
expect(audit.entries.map((entry) => entry.outcome)).toEqual(["failure"]);
|
||||||
|
expect(audit.entries[0]?.correlationId).toBe(result.correlationId);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["empty", ""],
|
||||||
|
["text", "not-an-ip"],
|
||||||
|
["range", "999.1.1.1"],
|
||||||
|
[
|
||||||
|
"pathological",
|
||||||
|
{
|
||||||
|
toString: () => {
|
||||||
|
throw new Error("IP getter secret exposed");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
])(
|
||||||
|
"rejects invalid server IP without using it in keys or evidence %s",
|
||||||
|
async (label, ipAddress) => {
|
||||||
|
const audit = auditRecorder();
|
||||||
|
const rateKeys: string[] = [];
|
||||||
|
let executed = false;
|
||||||
|
const commandId = `system.dispatch.invalid-ip-${label}`;
|
||||||
|
register(
|
||||||
|
baseCommand(commandId, {
|
||||||
|
input: z.object({}),
|
||||||
|
execute: async (context) => {
|
||||||
|
executed = true;
|
||||||
|
return ok(null, context.correlationId);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await dispatchHousekeepingCommand(
|
||||||
|
{ commandId, input: {} },
|
||||||
|
dependencies({
|
||||||
|
ipAddress: ipAddress as never,
|
||||||
|
audit: audit.writer,
|
||||||
|
rateLimit: async (key) => {
|
||||||
|
rateKeys.push(key);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: { code: "INTERNAL", messageKey: "errors.housekeeping.internal" },
|
||||||
|
});
|
||||||
|
expect(executed).toBe(false);
|
||||||
|
expect(rateKeys).toEqual([]);
|
||||||
|
expect(audit.entries).toMatchObject([
|
||||||
|
{
|
||||||
|
action: "housekeeping.command.dispatch",
|
||||||
|
target: "server-context",
|
||||||
|
outcome: "failure",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(audit.entries[0]).not.toHaveProperty("ipAddress");
|
||||||
|
expect(JSON.stringify(audit.entries)).not.toContain("not-an-ip");
|
||||||
|
expect(JSON.stringify(audit.entries)).not.toContain("secret exposed");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
it("canonicalizes IPv6 for command context, rate identity, and audit evidence", async () => {
|
||||||
|
const audit = auditRecorder();
|
||||||
|
const rateKeys: string[] = [];
|
||||||
|
let executionIp = "";
|
||||||
|
register(
|
||||||
|
baseCommand("system.dispatch.canonical-ip", {
|
||||||
|
input: z.object({}),
|
||||||
|
execute: async (context) => {
|
||||||
|
executionIp = context.ipAddress;
|
||||||
|
return ok(null, context.correlationId);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await dispatchHousekeepingCommand(
|
||||||
|
{ commandId: "system.dispatch.canonical-ip", input: {} },
|
||||||
|
dependencies({
|
||||||
|
ipAddress: "2001:0DB8:0:0:0:0:0:1",
|
||||||
|
audit: audit.writer,
|
||||||
|
rateLimit: async (key) => {
|
||||||
|
rateKeys.push(key);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(executionIp).toBe("2001:db8::1");
|
||||||
|
expect(rateKeys).toEqual([
|
||||||
|
"housekeeping-command:42:2001:db8::1:system.dispatch.canonical-ip",
|
||||||
|
]);
|
||||||
|
expect(audit.entries[0]?.ipAddress).toBe("2001:db8::1");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { isIP } from "node:net";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { AuditEntry, HousekeepingAuditWriter } from "@/lib/services/audit";
|
import type { AuditEntry, HousekeepingAuditWriter } from "@/lib/services/audit";
|
||||||
import { satisfiesCapability } from "../capability-context";
|
import { satisfiesCapability } from "../capability-context";
|
||||||
@@ -18,12 +19,19 @@ import {
|
|||||||
getHousekeepingCommand,
|
getHousekeepingCommand,
|
||||||
type HousekeepingCommand,
|
type HousekeepingCommand,
|
||||||
type HousekeepingCommandContext,
|
type HousekeepingCommandContext,
|
||||||
|
isHousekeepingCommandId,
|
||||||
} from "./registry";
|
} from "./registry";
|
||||||
|
|
||||||
const normalizedCommandId = z
|
const normalizedCommandId = z
|
||||||
.string()
|
.string()
|
||||||
.transform((value) => value.normalize("NFC").trim())
|
.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
|
const normalizedReason = z
|
||||||
.string()
|
.string()
|
||||||
.transform((value) => value.normalize("NFC").trim())
|
.transform((value) => value.normalize("NFC").trim())
|
||||||
@@ -33,6 +41,34 @@ const commandRequestSchema = z.strictObject({
|
|||||||
input: z.unknown(),
|
input: z.unknown(),
|
||||||
reason: normalizedReason.optional(),
|
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 {
|
export interface HousekeepingCommandRequest {
|
||||||
commandId: string;
|
commandId: string;
|
||||||
@@ -52,14 +88,50 @@ export async function dispatchHousekeepingCommand(
|
|||||||
dependencies: HousekeepingCommandDependencies,
|
dependencies: HousekeepingCommandDependencies,
|
||||||
): Promise<HousekeepingResult<unknown>> {
|
): Promise<HousekeepingResult<unknown>> {
|
||||||
const correlationId = createCorrelationId();
|
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>;
|
let parsedRequest: ReturnType<typeof commandRequestSchema.safeParse>;
|
||||||
try {
|
try {
|
||||||
parsedRequest = commandRequestSchema.safeParse(request);
|
parsedRequest = commandRequestSchema.safeParse(request);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return mapUnknownError(error, correlationId);
|
return persistReturnedOutcome(
|
||||||
|
dependencies.audit,
|
||||||
|
createDispatchAuditEntry(
|
||||||
|
"request-envelope",
|
||||||
|
dependencies.context,
|
||||||
|
correlationId,
|
||||||
|
ipAddress,
|
||||||
|
),
|
||||||
|
"failure",
|
||||||
|
mapUnknownError(error, correlationId),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (!parsedRequest.success) {
|
if (!parsedRequest.success) {
|
||||||
return fail("VALIDATION", "errors.housekeeping.validation", correlationId);
|
return persistMalformedRequestOutcome(
|
||||||
|
dependencies,
|
||||||
|
ipAddress,
|
||||||
|
correlationId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const commandRequest = parsedRequest.data;
|
const commandRequest = parsedRequest.data;
|
||||||
@@ -74,7 +146,8 @@ export async function dispatchHousekeepingCommand(
|
|||||||
dependencies.audit,
|
dependencies.audit,
|
||||||
createUnknownCommandAuditEntry(
|
createUnknownCommandAuditEntry(
|
||||||
commandRequest.commandId,
|
commandRequest.commandId,
|
||||||
dependencies,
|
dependencies.context,
|
||||||
|
ipAddress,
|
||||||
correlationId,
|
correlationId,
|
||||||
),
|
),
|
||||||
"denied",
|
"denied",
|
||||||
@@ -85,7 +158,7 @@ export async function dispatchHousekeepingCommand(
|
|||||||
const auditEntry = createAuditEntry(
|
const auditEntry = createAuditEntry(
|
||||||
command,
|
command,
|
||||||
dependencies.context,
|
dependencies.context,
|
||||||
dependencies.ipAddress,
|
ipAddress,
|
||||||
correlationId,
|
correlationId,
|
||||||
commandRequest.reason || undefined,
|
commandRequest.reason || undefined,
|
||||||
);
|
);
|
||||||
@@ -167,11 +240,7 @@ export async function dispatchHousekeepingCommand(
|
|||||||
let allowed: boolean;
|
let allowed: boolean;
|
||||||
try {
|
try {
|
||||||
allowed = await dependencies.rateLimit(
|
allowed = await dependencies.rateLimit(
|
||||||
createRateLimitKey(
|
createRateLimitKey(command.id, dependencies.context, ipAddress),
|
||||||
command.id,
|
|
||||||
dependencies.context,
|
|
||||||
dependencies.ipAddress,
|
|
||||||
),
|
|
||||||
command.rateLimit.attempts,
|
command.rateLimit.attempts,
|
||||||
command.rateLimit.windowMs,
|
command.rateLimit.windowMs,
|
||||||
);
|
);
|
||||||
@@ -203,11 +272,12 @@ export async function dispatchHousekeepingCommand(
|
|||||||
const commandContext: HousekeepingCommandContext = {
|
const commandContext: HousekeepingCommandContext = {
|
||||||
capability: dependencies.context,
|
capability: dependencies.context,
|
||||||
correlationId,
|
correlationId,
|
||||||
ipAddress: dependencies.ipAddress,
|
ipAddress,
|
||||||
};
|
};
|
||||||
let result: HousekeepingResult<unknown>;
|
let correlatedResult: HousekeepingResult<unknown>;
|
||||||
try {
|
try {
|
||||||
result = await command.execute(commandContext, parsedInput.data);
|
const rawResult = await command.execute(commandContext, parsedInput.data);
|
||||||
|
correlatedResult = validateAndCorrelateResult(rawResult, correlationId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return persistReturnedOutcome(
|
return persistReturnedOutcome(
|
||||||
dependencies.audit,
|
dependencies.audit,
|
||||||
@@ -217,7 +287,6 @@ export async function dispatchHousekeepingCommand(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const correlatedResult = withCorrelation(result, correlationId);
|
|
||||||
if (!correlatedResult.ok) {
|
if (!correlatedResult.ok) {
|
||||||
return persistReturnedOutcome(
|
return persistReturnedOutcome(
|
||||||
dependencies.audit,
|
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(
|
function createUnknownCommandAuditEntry(
|
||||||
commandId: string,
|
commandId: string,
|
||||||
dependencies: HousekeepingCommandDependencies,
|
context: HousekeepingCapabilityContext,
|
||||||
|
ipAddress: string,
|
||||||
correlationId: string,
|
correlationId: string,
|
||||||
): AuditEntry {
|
): AuditEntry {
|
||||||
return {
|
return {
|
||||||
userId: dependencies.context.actor.id,
|
userId: context.actor.id,
|
||||||
action: "housekeeping.command.dispatch",
|
action: "housekeeping.command.dispatch",
|
||||||
target: commandId,
|
target: commandId,
|
||||||
correlationId,
|
correlationId,
|
||||||
ipAddress: dependencies.ipAddress,
|
ipAddress,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,11 +358,56 @@ function createRateLimitKey(
|
|||||||
return `housekeeping-command:${context.actor.id}:${ipAddress}:${commandId}`;
|
return `housekeeping-command:${context.actor.id}:${ipAddress}:${commandId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function withCorrelation<T>(
|
function canonicalizeServerIp(value: unknown): string | undefined {
|
||||||
result: HousekeepingResult<T>,
|
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,
|
correlationId: string,
|
||||||
): HousekeepingResult<T> {
|
): HousekeepingResult<unknown> {
|
||||||
return { ...result, correlationId };
|
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(
|
function outcomeForFailure(
|
||||||
@@ -290,6 +420,24 @@ function outcomeForFailure(
|
|||||||
: "failure";
|
: "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>(
|
async function persistReturnedOutcome<T>(
|
||||||
writer: HousekeepingAuditWriter,
|
writer: HousekeepingAuditWriter,
|
||||||
entry: AuditEntry,
|
entry: AuditEntry,
|
||||||
|
|||||||
@@ -123,6 +123,37 @@ describe("housekeeping command registry", () => {
|
|||||||
expect(() => registerHousekeepingCommand(invalid)).toThrow();
|
expect(() => registerHousekeepingCommand(invalid)).toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps registered validation unchanged after original shape and child mutation", () => {
|
||||||
|
const child = z.string().min(3);
|
||||||
|
const input = z.object({ value: child, guard: child });
|
||||||
|
registerHousekeepingCommand({
|
||||||
|
...command("people.registry.deep-validation"),
|
||||||
|
input,
|
||||||
|
execute: async (context, value) =>
|
||||||
|
ok({ id: value.value.length }, context.correlationId),
|
||||||
|
} as HousekeepingCommand<{ value: string; guard: string }, { id: number }>);
|
||||||
|
const registered = getHousekeepingCommand(
|
||||||
|
"people.registry.deep-validation",
|
||||||
|
);
|
||||||
|
if (!registered) throw new Error("registered command missing");
|
||||||
|
|
||||||
|
(input.def as { shape: { value: z.ZodType } }).shape.value = z.number();
|
||||||
|
const minCheck = (child.def as { checks: unknown[] }).checks[0] as {
|
||||||
|
_zod: { def: { minimum: number } };
|
||||||
|
};
|
||||||
|
minCheck._zod.def.minimum = 0;
|
||||||
|
|
||||||
|
expect(input.safeParse({ value: 4, guard: "a" }).success).toBe(true);
|
||||||
|
expect(
|
||||||
|
registered.input.safeParse({ value: "abcd", guard: "ab" }).success,
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
registered.input.safeParse({ value: "abcd", guard: "abcd" }).success,
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
registered.input.safeParse({ value: 4, guard: "abcd" }).success,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
it("seals the global registry after deterministic bootstrap", () => {
|
it("seals the global registry after deterministic bootstrap", () => {
|
||||||
sealHousekeepingCommandRegistry();
|
sealHousekeepingCommandRegistry();
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,13 @@ export interface HousekeepingCommand<I, O> {
|
|||||||
|
|
||||||
type RegisteredHousekeepingCommand = HousekeepingCommand<unknown, unknown>;
|
type RegisteredHousekeepingCommand = HousekeepingCommand<unknown, unknown>;
|
||||||
|
|
||||||
|
type ZodInternalNode = {
|
||||||
|
readonly _zod: {
|
||||||
|
readonly constr: new (definition: never) => unknown;
|
||||||
|
readonly def: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const approvedOwners = new Set<string>(HOUSEKEEPING_DOMAIN_IDS);
|
const approvedOwners = new Set<string>(HOUSEKEEPING_DOMAIN_IDS);
|
||||||
const knownCapabilitySlugs = new Set<string>(Object.values(PERMS));
|
const knownCapabilitySlugs = new Set<string>(Object.values(PERMS));
|
||||||
const commandIdPattern = /^[a-z][a-z0-9-]*(?:\.[a-z0-9][a-z0-9-]*)+$/;
|
const commandIdPattern = /^[a-z][a-z0-9-]*(?:\.[a-z0-9][a-z0-9-]*)+$/;
|
||||||
@@ -56,7 +63,7 @@ export function registerHousekeepingCommand<I, O>(
|
|||||||
owner: command.owner,
|
owner: command.owner,
|
||||||
risk: command.risk,
|
risk: command.risk,
|
||||||
capability,
|
capability,
|
||||||
input: Object.freeze(command.input.clone()),
|
input: isolateValidationGraph(command.input),
|
||||||
requiresReason: command.requiresReason,
|
requiresReason: command.requiresReason,
|
||||||
rateLimit: Object.freeze({ ...command.rateLimit }),
|
rateLimit: Object.freeze({ ...command.rateLimit }),
|
||||||
execute: command.execute,
|
execute: command.execute,
|
||||||
@@ -74,11 +81,12 @@ export function getHousekeepingCommand(
|
|||||||
return commands.get(id);
|
return commands.get(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isHousekeepingCommandId(value: unknown): value is string {
|
||||||
|
return isNormalizedIdentifier(value) && commandIdPattern.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
function validateCommand<I, O>(command: HousekeepingCommand<I, O>): void {
|
function validateCommand<I, O>(command: HousekeepingCommand<I, O>): void {
|
||||||
if (
|
if (!isHousekeepingCommandId(command.id)) {
|
||||||
!isNormalizedIdentifier(command.id) ||
|
|
||||||
!commandIdPattern.test(command.id)
|
|
||||||
) {
|
|
||||||
throw new Error(`invalid command id: ${String(command.id)}`);
|
throw new Error(`invalid command id: ${String(command.id)}`);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@@ -135,6 +143,138 @@ function validateCapability(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isolateValidationGraph<T extends z.ZodType>(schema: T): T {
|
||||||
|
const seen = new WeakMap<object, unknown>();
|
||||||
|
|
||||||
|
function cloneGraph(value: unknown): unknown {
|
||||||
|
if (typeof value !== "object" || value === null) return value;
|
||||||
|
const cached = seen.get(value);
|
||||||
|
if (cached !== undefined) return cached;
|
||||||
|
|
||||||
|
if (value instanceof z.ZodType) {
|
||||||
|
const clonedDefinition = cloneGraph(value.def);
|
||||||
|
const cloned = value.clone(clonedDefinition as typeof value.def);
|
||||||
|
seen.set(value, cloned);
|
||||||
|
return cloned;
|
||||||
|
}
|
||||||
|
if (isZodInternalNode(value)) {
|
||||||
|
const clonedDefinition = cloneGraph(value._zod.def);
|
||||||
|
const cloned = new value._zod.constr(clonedDefinition as never) as {
|
||||||
|
_zod: { check?: unknown };
|
||||||
|
};
|
||||||
|
const runtimeCheck = (value._zod as { check?: unknown }).check;
|
||||||
|
if (runtimeCheck !== undefined && cloned._zod.check === undefined) {
|
||||||
|
cloned._zod.check = runtimeCheck;
|
||||||
|
}
|
||||||
|
seen.set(value, cloned);
|
||||||
|
return cloned;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const cloned: unknown[] = [];
|
||||||
|
seen.set(value, cloned);
|
||||||
|
for (const item of value) cloned.push(cloneGraph(item));
|
||||||
|
return cloned;
|
||||||
|
}
|
||||||
|
if (value instanceof RegExp) {
|
||||||
|
const cloned = new RegExp(value.source, value.flags);
|
||||||
|
seen.set(value, cloned);
|
||||||
|
return cloned;
|
||||||
|
}
|
||||||
|
if (value instanceof Date) {
|
||||||
|
const cloned = new Date(value.getTime());
|
||||||
|
seen.set(value, cloned);
|
||||||
|
return cloned;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cloned = Object.create(Object.getPrototypeOf(value)) as Record<
|
||||||
|
PropertyKey,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
seen.set(value, cloned);
|
||||||
|
for (const key of Reflect.ownKeys(value)) {
|
||||||
|
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
||||||
|
if (!descriptor) continue;
|
||||||
|
const clonedDescriptor =
|
||||||
|
"value" in descriptor
|
||||||
|
? { ...descriptor, value: cloneGraph(descriptor.value) }
|
||||||
|
: descriptor.get
|
||||||
|
? {
|
||||||
|
configurable: descriptor.configurable,
|
||||||
|
enumerable: descriptor.enumerable,
|
||||||
|
writable: false,
|
||||||
|
value: cloneGraph(Reflect.get(value, key)),
|
||||||
|
}
|
||||||
|
: descriptor;
|
||||||
|
Object.defineProperty(cloned, key, clonedDescriptor);
|
||||||
|
}
|
||||||
|
return cloned;
|
||||||
|
}
|
||||||
|
|
||||||
|
return createReadonlyValidationFacade(cloneGraph(schema) as T);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isZodInternalNode(value: object): value is ZodInternalNode {
|
||||||
|
const internal = (value as { _zod?: unknown })._zod;
|
||||||
|
return (
|
||||||
|
typeof internal === "object" &&
|
||||||
|
internal !== null &&
|
||||||
|
typeof (internal as { constr?: unknown }).constr === "function" &&
|
||||||
|
"def" in internal
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createReadonlyValidationFacade<T extends z.ZodType>(schema: T): T {
|
||||||
|
const views = new WeakMap<object, unknown>();
|
||||||
|
|
||||||
|
function readonlyView(value: unknown): unknown {
|
||||||
|
if (typeof value !== "object" || value === null) return value;
|
||||||
|
const cached = views.get(value);
|
||||||
|
if (cached !== undefined) return cached;
|
||||||
|
if (value instanceof z.ZodType) return schemaFacade(value);
|
||||||
|
|
||||||
|
const view = new Proxy(value, {
|
||||||
|
defineProperty: () => false,
|
||||||
|
deleteProperty: () => false,
|
||||||
|
get: (target, property) => readonlyView(Reflect.get(target, property)),
|
||||||
|
set: () => false,
|
||||||
|
setPrototypeOf: () => false,
|
||||||
|
});
|
||||||
|
views.set(value, view);
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
|
function schemaFacade<S extends z.ZodType>(target: S): S {
|
||||||
|
const cached = views.get(target);
|
||||||
|
if (cached !== undefined) return cached as S;
|
||||||
|
|
||||||
|
const facade = Object.create(Object.getPrototypeOf(target)) as Record<
|
||||||
|
PropertyKey,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
views.set(target, facade);
|
||||||
|
for (const key of Reflect.ownKeys(target)) {
|
||||||
|
const raw = Reflect.get(target, key);
|
||||||
|
const exposed =
|
||||||
|
typeof raw === "function"
|
||||||
|
? (...args: unknown[]) => {
|
||||||
|
const result = Reflect.apply(raw, target, args);
|
||||||
|
return result instanceof z.ZodType
|
||||||
|
? isolateValidationGraph(result)
|
||||||
|
: result;
|
||||||
|
}
|
||||||
|
: readonlyView(raw);
|
||||||
|
Object.defineProperty(facade, key, {
|
||||||
|
configurable: false,
|
||||||
|
enumerable: Object.prototype.propertyIsEnumerable.call(target, key),
|
||||||
|
value: exposed,
|
||||||
|
writable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Object.freeze(facade) as S;
|
||||||
|
}
|
||||||
|
|
||||||
|
return schemaFacade(schema);
|
||||||
|
}
|
||||||
function isNormalizedIdentifier(value: unknown): value is string {
|
function isNormalizedIdentifier(value: unknown): value is string {
|
||||||
return (
|
return (
|
||||||
typeof value === "string" &&
|
typeof value === "string" &&
|
||||||
|
|||||||
Reference in new issue
Block a user