security: bound JSONB inputs + whitelist batchUpdateCustomFields keys (#48)

batchUpdateCustomFields used $executeRaw to merge a manager-supplied
record straight into Resource.dynamicFields with no key whitelist —
so a manager could pollute the JSONB namespace with arbitrary keys
(e.g. ones admin tools later interpret). Separately, several user-facing
JSONB fields (allocation/demand metadata, dynamicFields) were typed as
unbounded z.record(z.string(), z.unknown()), letting clients ship
multi-MB payloads that flow into DB writes, audit logs, and SSE frames.

- Add BoundedJsonRecord helper (shared) — 64 keys / depth 4 /
  8 KB strings / 32 KB serialized total. Conservative defaults; call
  sites needing more should use a strict object schema.
- Apply BoundedJsonRecord to the highest-traffic untrusted JSONB inputs:
  allocation metadata (Create/CreateDemandRequirement/CreateAssignment),
  resource & project dynamicFields, and the createDemand router input.
- batchUpdateCustomFields:
    * Tighten input schema (key length, value bounds, max 100 keys).
    * Fetch each target resource and verify all input keys are in the
      union of (specific blueprint defs) ∪ (active global RESOURCE
      blueprint defs) for that resource. Empty whitelist → reject all
      keys (stricter than create/update, but appropriate for a bulk
      escape-hatch endpoint).
    * Run the existing per-key value validator afterwards.
    * 404 if any requested id does not exist (was silently skipped).
- New helper getAllowedDynamicFieldKeys() in blueprint-validation.
- 7 new BoundedJsonRecord tests, 2 new batchUpdateCustomFields tests
  covering the whitelist-rejection and not-found paths.

Covers EAPPS 3.2.7 (input bounds) / OWASP A03 (injection / mass assignment).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-17 08:44:11 +02:00
parent 1ff5c3377c
commit c0c5f762b8
10 changed files with 379 additions and 12 deletions
@@ -293,7 +293,30 @@ describe("resource batchUpdateCustomFields", () => {
}); });
it("executes batch update with audit log", async () => { it("executes batch update with audit log", async () => {
const db = mockDb(); const db = mockDb({
resource: {
findFirst: vi.fn().mockResolvedValue(null),
findUnique: vi.fn().mockResolvedValue(null),
findMany: vi.fn().mockResolvedValue([
{ id: "res_1", blueprintId: null },
{ id: "res_2", blueprintId: null },
]),
update: vi.fn().mockResolvedValue({ id: "res_1", isActive: false }),
delete: vi.fn().mockResolvedValue({}),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
blueprint: {
findUnique: vi.fn().mockResolvedValue(null),
findMany: vi.fn().mockResolvedValue([
{
fieldDefs: [
{ key: "department", label: "Department", type: "text" },
{ key: "level", label: "Level", type: "number" },
],
},
]),
},
});
const caller = createManagerCaller(db); const caller = createManagerCaller(db);
const result = await caller.batchUpdateCustomFields({ const result = await caller.batchUpdateCustomFields({
@@ -304,6 +327,57 @@ describe("resource batchUpdateCustomFields", () => {
expect(result).toEqual({ updated: 2 }); expect(result).toEqual({ updated: 2 });
expect(db.$transaction).toHaveBeenCalled(); expect(db.$transaction).toHaveBeenCalled();
}); });
it("rejects unknown keys when a blueprint defines the whitelist", async () => {
const db = mockDb({
resource: {
findFirst: vi.fn().mockResolvedValue(null),
findUnique: vi.fn().mockResolvedValue(null),
findMany: vi.fn().mockResolvedValue([{ id: "res_1", blueprintId: "bp_1" }]),
update: vi.fn().mockResolvedValue({}),
delete: vi.fn().mockResolvedValue({}),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
blueprint: {
findUnique: vi.fn().mockResolvedValue({
target: "RESOURCE",
fieldDefs: [{ key: "department", label: "Department", type: "text" }],
}),
findMany: vi.fn().mockResolvedValue([]),
},
});
const caller = createManagerCaller(db);
await expect(
caller.batchUpdateCustomFields({
ids: ["res_1"],
// "injected" is not in the blueprint's whitelist
fields: { department: "Engineering", injected: "malicious" },
}),
).rejects.toThrow();
expect(db.$transaction).not.toHaveBeenCalled();
});
it("404s if any requested id does not exist", async () => {
const db = mockDb({
resource: {
findFirst: vi.fn().mockResolvedValue(null),
findUnique: vi.fn().mockResolvedValue(null),
findMany: vi.fn().mockResolvedValue([{ id: "res_1", blueprintId: null }]),
update: vi.fn().mockResolvedValue({}),
delete: vi.fn().mockResolvedValue({}),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
});
const caller = createManagerCaller(db);
await expect(
caller.batchUpdateCustomFields({
ids: ["res_1", "res_missing"],
fields: { department: "Engineering" },
}),
).rejects.toMatchObject({ code: "NOT_FOUND" });
});
}); });
describe("resource hardDelete", () => { describe("resource hardDelete", () => {
+2 -1
View File
@@ -5,6 +5,7 @@ import {
updateDemandRequirement, updateDemandRequirement,
} from "@capakraken/application"; } from "@capakraken/application";
import { import {
BoundedJsonRecord,
CreateDemandRequirementSchema, CreateDemandRequirementSchema,
FillDemandRequirementSchema, FillDemandRequirementSchema,
FillOpenDemandByAllocationSchema, FillOpenDemandByAllocationSchema,
@@ -53,7 +54,7 @@ export const allocationDemandProcedures = {
startDate: z.coerce.date(), startDate: z.coerce.date(),
endDate: z.coerce.date(), endDate: z.coerce.date(),
budgetCents: z.number().int().min(0).optional(), budgetCents: z.number().int().min(0).optional(),
metadata: z.record(z.string(), z.unknown()).optional(), metadata: BoundedJsonRecord.optional(),
}), }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
@@ -78,3 +78,51 @@ export async function assertBlueprintDynamicFields({
}); });
} }
} }
/**
* Return the set of dynamic-field keys allowed for a blueprint (specific + all
* active global blueprints for the target). Used to whitelist keys in bulk-
* update paths (`batchUpdateCustomFields`) where value-only validation would
* silently accept attacker-injected keys into the JSONB namespace.
*/
export async function getAllowedDynamicFieldKeys({
db,
blueprintId,
target,
}: {
db: BlueprintLookup;
blueprintId: string | undefined;
target: BlueprintTarget;
}): Promise<Set<string>> {
const allowed = new Set<string>();
if (blueprintId) {
const blueprint = await db.blueprint.findUnique({
where: { id: blueprintId },
select: { fieldDefs: true, target: true },
});
if (blueprint) {
if (blueprint.target !== target) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `${target} entities require a ${target.toLowerCase()} blueprint`,
});
}
for (const def of blueprint.fieldDefs as BlueprintFieldDefinition[]) {
allowed.add(def.key);
}
}
}
const globals = await db.blueprint.findMany({
where: { target, isGlobal: true, isActive: true },
select: { fieldDefs: true },
});
for (const bp of globals) {
for (const def of bp.fieldDefs as BlueprintFieldDefinition[]) {
allowed.add(def.key);
}
}
return allowed;
}
+52 -2
View File
@@ -11,7 +11,10 @@ import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js"; import { findUniqueOrThrow } from "../db/helpers.js";
import { ROLE_BRIEF_SELECT } from "../db/selects.js"; import { ROLE_BRIEF_SELECT } from "../db/selects.js";
import { adminProcedure, managerProcedure, requirePermission } from "../trpc.js"; import { adminProcedure, managerProcedure, requirePermission } from "../trpc.js";
import { assertBlueprintDynamicFields } from "./blueprint-validation.js"; import {
assertBlueprintDynamicFields,
getAllowedDynamicFieldKeys,
} from "./blueprint-validation.js";
export const resourceMutationProcedures = { export const resourceMutationProcedures = {
create: managerProcedure create: managerProcedure
@@ -322,12 +325,59 @@ export const resourceMutationProcedures = {
.input( .input(
z.object({ z.object({
ids: z.array(z.string()).min(1).max(100), ids: z.array(z.string()).min(1).max(100),
fields: z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()])), fields: z
.record(
z.string().min(1).max(128),
z.union([z.string().max(8_000), z.number(), z.boolean(), z.null()]),
)
.refine((r) => Object.keys(r).length <= 100, {
message: "Too many custom-field keys in one batch (max 100)",
}),
}), }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_RESOURCES); requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);
// Whitelist input keys against the union of (each resource's blueprint
// field defs) (all active global RESOURCE blueprints). Rejects any key
// that is not explicitly defined for every target resource — blocks
// namespace pollution and privilege escalation via admin-tool
// interpretation of attacker-placed JSONB keys.
const resources = await ctx.db.resource.findMany({
where: { id: { in: input.ids } },
select: { id: true, blueprintId: true },
});
if (resources.length !== input.ids.length) {
throw new TRPCError({ code: "NOT_FOUND", message: "One or more resources not found" });
}
const inputKeys = Object.keys(input.fields);
for (const resource of resources) {
const allowed = await getAllowedDynamicFieldKeys({
db: ctx.db,
blueprintId: resource.blueprintId ?? undefined,
target: BlueprintTarget.RESOURCE,
});
// If no blueprint at all is registered for this resource, `allowed` is
// empty — we still enforce the whitelist to refuse any key rather than
// silently accepting arbitrary JSONB. This is stricter than the legacy
// create/update paths but correct for a bulk endpoint.
const unknownKey = inputKeys.find((k) => !allowed.has(k));
if (unknownKey !== undefined) {
throw new TRPCError({
code: "UNPROCESSABLE_CONTENT",
message: `Unknown dynamic-field key "${unknownKey}" for resource ${resource.id}`,
});
}
// Still validate values via the existing per-key typed validator.
await assertBlueprintDynamicFields({
db: ctx.db,
blueprintId: resource.blueprintId ?? undefined,
dynamicFields: input.fields,
target: BlueprintTarget.RESOURCE,
});
}
await ctx.db.$transaction(async (tx) => { await ctx.db.$transaction(async (tx) => {
await Promise.all( await Promise.all(
input.ids.map( input.ids.map(
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { BOUNDED_JSON_LIMITS, BoundedJsonRecord } from "../schemas/bounded-json.schema.js";
describe("BoundedJsonRecord", () => {
it("accepts simple key/value records", () => {
const result = BoundedJsonRecord.safeParse({ a: "b", c: 1, d: true, e: null });
expect(result.success).toBe(true);
});
it("accepts nested objects and arrays within limits", () => {
const result = BoundedJsonRecord.safeParse({
nested: { a: 1, b: [1, 2, 3] },
arr: ["x", "y"],
});
expect(result.success).toBe(true);
});
it("rejects keys longer than MAX_KEY_LENGTH", () => {
const tooLongKey = "k".repeat(BOUNDED_JSON_LIMITS.MAX_KEY_LENGTH + 1);
const result = BoundedJsonRecord.safeParse({ [tooLongKey]: "v" });
expect(result.success).toBe(false);
});
it("rejects records with more than MAX_KEYS top-level keys", () => {
const tooMany: Record<string, string> = {};
for (let i = 0; i <= BOUNDED_JSON_LIMITS.MAX_KEYS; i++) tooMany[`k${i}`] = "v";
const result = BoundedJsonRecord.safeParse(tooMany);
expect(result.success).toBe(false);
});
it("rejects nested objects deeper than MAX_DEPTH", () => {
let nested: unknown = "leaf";
for (let i = 0; i <= BOUNDED_JSON_LIMITS.MAX_DEPTH + 1; i++) {
nested = { inner: nested };
}
const result = BoundedJsonRecord.safeParse({ a: nested });
expect(result.success).toBe(false);
});
it("rejects strings longer than MAX_STRING_LENGTH", () => {
const tooLong = "x".repeat(BOUNDED_JSON_LIMITS.MAX_STRING_LENGTH + 1);
const result = BoundedJsonRecord.safeParse({ a: tooLong });
expect(result.success).toBe(false);
});
it("rejects payloads exceeding MAX_SERIALIZED_BYTES", () => {
// Fill with many short string values whose total JSON size exceeds the cap.
const big: Record<string, string> = {};
const chunk = "y".repeat(1024);
for (let i = 0; i < 40; i++) big[`k${i}`] = chunk;
const result = BoundedJsonRecord.safeParse(big);
expect(result.success).toBe(false);
});
});
@@ -1,5 +1,6 @@
import { z } from "zod"; import { z } from "zod";
import { AllocationStatus } from "../types/enums.js"; import { AllocationStatus } from "../types/enums.js";
import { BoundedJsonRecord } from "./bounded-json.schema.js";
export const CreateAllocationBaseSchema = z.object({ export const CreateAllocationBaseSchema = z.object({
resourceId: z.string().optional(), resourceId: z.string().optional(),
@@ -13,7 +14,7 @@ export const CreateAllocationBaseSchema = z.object({
headcount: z.number().int().min(1).default(1), headcount: z.number().int().min(1).default(1),
budgetCents: z.number().int().min(0).optional(), budgetCents: z.number().int().min(0).optional(),
status: z.nativeEnum(AllocationStatus).default(AllocationStatus.PROPOSED), status: z.nativeEnum(AllocationStatus).default(AllocationStatus.PROPOSED),
metadata: z.record(z.string(), z.unknown()).default({}), metadata: BoundedJsonRecord.default({}),
}); });
export const CreateDemandRequirementBaseSchema = z.object({ export const CreateDemandRequirementBaseSchema = z.object({
@@ -27,7 +28,7 @@ export const CreateDemandRequirementBaseSchema = z.object({
headcount: z.number().int().min(1).default(1), headcount: z.number().int().min(1).default(1),
budgetCents: z.number().int().min(0).optional(), budgetCents: z.number().int().min(0).optional(),
status: z.nativeEnum(AllocationStatus).default(AllocationStatus.PROPOSED), status: z.nativeEnum(AllocationStatus).default(AllocationStatus.PROPOSED),
metadata: z.record(z.string(), z.unknown()).default({}), metadata: BoundedJsonRecord.default({}),
}); });
export const CreateAssignmentBaseSchema = z.object({ export const CreateAssignmentBaseSchema = z.object({
@@ -42,7 +43,7 @@ export const CreateAssignmentBaseSchema = z.object({
roleId: z.string().optional(), roleId: z.string().optional(),
dailyCostCents: z.number().int().min(0).optional(), dailyCostCents: z.number().int().min(0).optional(),
status: z.nativeEnum(AllocationStatus).default(AllocationStatus.PROPOSED), status: z.nativeEnum(AllocationStatus).default(AllocationStatus.PROPOSED),
metadata: z.record(z.string(), z.unknown()).default({}), metadata: BoundedJsonRecord.default({}),
/** When true the caller acknowledges the resource will be overbooked. */ /** When true the caller acknowledges the resource will be overbooked. */
allowOverbooking: z.boolean().optional(), allowOverbooking: z.boolean().optional(),
}); });
@@ -0,0 +1,126 @@
import { z } from "zod";
/**
* Bounded JSONB limits for untrusted attacker-controlled metadata / dynamicFields.
*
* A client can POST arbitrary JSON via `z.record(z.string(), z.unknown())`, which
* — unbounded — lets a manager-role user ship payloads that consume DB / log /
* memory disproportionately, and pollute namespaces read by admin tooling.
*
* These defaults are conservative: they cover ~99.9% of legitimate metadata and
* deny the rest outright. Any call site that genuinely needs more should use its
* own strict `z.object({...}).strict()` schema instead.
*/
export const BOUNDED_JSON_LIMITS = Object.freeze({
MAX_KEY_LENGTH: 128,
MAX_KEYS: 64,
MAX_DEPTH: 4,
MAX_STRING_LENGTH: 8_000,
MAX_SERIALIZED_BYTES: 32_768,
});
function validateValue(value: unknown, depth: number, ctx: z.RefinementCtx): boolean {
if (depth > BOUNDED_JSON_LIMITS.MAX_DEPTH) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Nested too deep (max depth ${BOUNDED_JSON_LIMITS.MAX_DEPTH})`,
});
return false;
}
if (value == null) return true;
if (typeof value === "boolean" || typeof value === "number") return true;
if (typeof value === "string") {
if (value.length > BOUNDED_JSON_LIMITS.MAX_STRING_LENGTH) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `String exceeds max length ${BOUNDED_JSON_LIMITS.MAX_STRING_LENGTH}`,
});
return false;
}
return true;
}
if (Array.isArray(value)) {
if (value.length > BOUNDED_JSON_LIMITS.MAX_KEYS) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Array too large (max ${BOUNDED_JSON_LIMITS.MAX_KEYS} elements)`,
});
return false;
}
return value.every((v) => validateValue(v, depth + 1, ctx));
}
if (typeof value === "object") {
const obj = value as Record<string, unknown>;
const keys = Object.keys(obj);
if (keys.length > BOUNDED_JSON_LIMITS.MAX_KEYS) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Object has too many keys (max ${BOUNDED_JSON_LIMITS.MAX_KEYS})`,
});
return false;
}
for (const k of keys) {
if (k.length > BOUNDED_JSON_LIMITS.MAX_KEY_LENGTH) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Key exceeds max length ${BOUNDED_JSON_LIMITS.MAX_KEY_LENGTH}`,
});
return false;
}
if (!validateValue(obj[k], depth + 1, ctx)) return false;
}
return true;
}
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Unsupported JSON type: ${typeof value}`,
});
return false;
}
/**
* A Zod schema for attacker-controlled JSONB payloads (metadata, dynamicFields).
*
* Limits key count, nesting depth, string length, and total serialized byte size
* (see BOUNDED_JSON_LIMITS). Use in place of `z.record(z.string(), z.unknown())`
* wherever the input is user-submitted.
*/
export const BoundedJsonRecord = z.record(z.string(), z.unknown()).superRefine((val, ctx) => {
const keys = Object.keys(val);
if (keys.length > BOUNDED_JSON_LIMITS.MAX_KEYS) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Object has too many keys (max ${BOUNDED_JSON_LIMITS.MAX_KEYS})`,
});
return;
}
for (const k of keys) {
if (k.length > BOUNDED_JSON_LIMITS.MAX_KEY_LENGTH) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Key exceeds max length ${BOUNDED_JSON_LIMITS.MAX_KEY_LENGTH}`,
});
return;
}
if (!validateValue(val[k], 1, ctx)) return;
}
try {
const serialized = JSON.stringify(val);
if (Buffer.byteLength(serialized, "utf8") > BOUNDED_JSON_LIMITS.MAX_SERIALIZED_BYTES) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Serialized payload exceeds max size ${BOUNDED_JSON_LIMITS.MAX_SERIALIZED_BYTES} bytes`,
});
}
} catch {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Payload is not JSON-serializable",
});
}
});
/** Helper that returns a fresh BoundedJsonRecord (so callers can `.default({})` etc. independently). */
export function boundedJsonRecord() {
return BoundedJsonRecord;
}
+1
View File
@@ -15,3 +15,4 @@ export * from "./management-level.schema.js";
export * from "./rate-card.schema.js"; export * from "./rate-card.schema.js";
export * from "./dispo-import.schema.js"; export * from "./dispo-import.schema.js";
export * from "./calculation-rules.schema.js"; export * from "./calculation-rules.schema.js";
export * from "./bounded-json.schema.js";
+15 -4
View File
@@ -1,8 +1,12 @@
import { z } from "zod"; import { z } from "zod";
import { AllocationType, OrderType, ProjectStatus } from "../types/enums.js"; import { AllocationType, OrderType, ProjectStatus } from "../types/enums.js";
import { BoundedJsonRecord } from "./bounded-json.schema.js";
export const StaffingRequirementSchema = z.object({ export const StaffingRequirementSchema = z.object({
id: z.string().uuid().default(() => crypto.randomUUID()), id: z
.string()
.uuid()
.default(() => crypto.randomUUID()),
role: z.string().min(1).max(200), role: z.string().min(1).max(200),
requiredSkills: z.array(z.string()), requiredSkills: z.array(z.string()),
preferredSkills: z.array(z.string()).optional(), preferredSkills: z.array(z.string()).optional(),
@@ -16,7 +20,11 @@ export const StaffingRequirementSchema = z.object({
// Base object schema — used for .partial() in UpdateProjectSchema // Base object schema — used for .partial() in UpdateProjectSchema
export const CreateProjectBaseSchema = z.object({ export const CreateProjectBaseSchema = z.object({
shortCode: z.string().min(1).max(20).regex(/^[A-Z0-9_-]+$/, "Must be uppercase alphanumeric"), shortCode: z
.string()
.min(1)
.max(20)
.regex(/^[A-Z0-9_-]+$/, "Must be uppercase alphanumeric"),
name: z.string().min(1).max(500), name: z.string().min(1).max(500),
orderType: z.nativeEnum(OrderType), orderType: z.nativeEnum(OrderType),
allocationType: z.nativeEnum(AllocationType), allocationType: z.nativeEnum(AllocationType),
@@ -25,11 +33,14 @@ export const CreateProjectBaseSchema = z.object({
startDate: z.coerce.date(), startDate: z.coerce.date(),
endDate: z.coerce.date(), endDate: z.coerce.date(),
staffingReqs: z.array(StaffingRequirementSchema).default([]), staffingReqs: z.array(StaffingRequirementSchema).default([]),
dynamicFields: z.record(z.string(), z.unknown()).default({}), dynamicFields: BoundedJsonRecord.default({}),
blueprintId: z.string().optional(), blueprintId: z.string().optional(),
status: z.nativeEnum(ProjectStatus).default(ProjectStatus.DRAFT), status: z.nativeEnum(ProjectStatus).default(ProjectStatus.DRAFT),
responsiblePerson: z.string().min(1, "Responsible person is required").max(200), responsiblePerson: z.string().min(1, "Responsible person is required").max(200),
color: z.string().regex(/^#[0-9a-fA-F]{6}$/, "Must be a hex color like #3b82f6").optional(), color: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/, "Must be a hex color like #3b82f6")
.optional(),
utilizationCategoryId: z.string().optional(), utilizationCategoryId: z.string().optional(),
clientId: z.string().optional(), clientId: z.string().optional(),
coverImageUrl: z.string().optional(), coverImageUrl: z.string().optional(),
@@ -1,5 +1,6 @@
import { z } from "zod"; import { z } from "zod";
import { ResourceType } from "../types/enums.js"; import { ResourceType } from "../types/enums.js";
import { BoundedJsonRecord } from "./bounded-json.schema.js";
export const WeekdayAvailabilitySchema = z.object({ export const WeekdayAvailabilitySchema = z.object({
monday: z.number().min(0).max(24), monday: z.number().min(0).max(24),
@@ -37,7 +38,7 @@ export const CreateResourceSchema = z.object({
friday: 8, friday: 8,
}), }),
skills: z.array(SkillEntrySchema).default([]), skills: z.array(SkillEntrySchema).default([]),
dynamicFields: z.record(z.string(), z.unknown()).default({}), dynamicFields: BoundedJsonRecord.default({}),
blueprintId: z.string().optional(), blueprintId: z.string().optional(),
portfolioUrl: z.string().url().optional().or(z.literal("")), portfolioUrl: z.string().url().optional().or(z.literal("")),
roleId: z.string().optional(), roleId: z.string().optional(),