Files
Nexus/packages/api/src/router/resource-mutations.ts
T
Hartmut c0c5f762b8 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>
2026-04-17 08:44:11 +02:00

467 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
BlueprintTarget,
CreateResourceSchema,
PermissionKey,
ResourceRoleSchema,
UpdateResourceSchema,
inferStateFromPostalCode,
} from "@capakraken/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { ROLE_BRIEF_SELECT } from "../db/selects.js";
import { adminProcedure, managerProcedure, requirePermission } from "../trpc.js";
import {
assertBlueprintDynamicFields,
getAllowedDynamicFieldKeys,
} from "./blueprint-validation.js";
export const resourceMutationProcedures = {
create: managerProcedure
.input(CreateResourceSchema.extend({ roles: z.array(ResourceRoleSchema).optional() }))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);
const existing = await ctx.db.resource.findFirst({
where: { OR: [{ eid: input.eid }, { email: input.email }] },
});
if (existing) {
throw new TRPCError({
code: "CONFLICT",
message: `Resource with EID "${input.eid}" or email "${input.email}" already exists`,
});
}
await assertBlueprintDynamicFields({
db: ctx.db,
blueprintId: input.blueprintId,
dynamicFields: input.dynamicFields,
target: BlueprintTarget.RESOURCE,
});
const primaryCount = (input.roles ?? []).filter((role) => role.isPrimary).length;
if (primaryCount > 1) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "A resource can have at most one primary role",
});
}
const resource = await ctx.db.$transaction(async (tx) => {
const created = await tx.resource.create({
data: {
eid: input.eid,
displayName: input.displayName,
email: input.email,
chapter: input.chapter,
lcrCents: input.lcrCents,
ucrCents: input.ucrCents,
currency: input.currency,
chargeabilityTarget: input.chargeabilityTarget,
availability: input.availability,
skills: input.skills as unknown as import("@capakraken/db").Prisma.InputJsonValue,
dynamicFields:
input.dynamicFields as unknown as import("@capakraken/db").Prisma.InputJsonValue,
blueprintId: input.blueprintId,
portfolioUrl: input.portfolioUrl || undefined,
roleId: input.roleId || undefined,
...(input.postalCode !== undefined ? { postalCode: input.postalCode } : {}),
...(input.postalCode && !input.federalState
? { federalState: inferStateFromPostalCode(input.postalCode) }
: input.federalState !== undefined
? { federalState: input.federalState }
: {}),
...(input.countryId !== undefined ? { countryId: input.countryId || null } : {}),
...(input.metroCityId !== undefined ? { metroCityId: input.metroCityId || null } : {}),
...(input.orgUnitId !== undefined ? { orgUnitId: input.orgUnitId || null } : {}),
...(input.managementLevelGroupId !== undefined
? { managementLevelGroupId: input.managementLevelGroupId || null }
: {}),
...(input.managementLevelId !== undefined
? { managementLevelId: input.managementLevelId || null }
: {}),
...(input.resourceType !== undefined ? { resourceType: input.resourceType } : {}),
...(input.chgResponsibility !== undefined
? { chgResponsibility: input.chgResponsibility }
: {}),
...(input.rolledOff !== undefined ? { rolledOff: input.rolledOff } : {}),
...(input.departed !== undefined ? { departed: input.departed } : {}),
...(input.enterpriseId !== undefined
? { enterpriseId: input.enterpriseId || null }
: {}),
...(input.clientUnitId !== undefined
? { clientUnitId: input.clientUnitId || null }
: {}),
...(input.fte !== undefined ? { fte: input.fte } : {}),
resourceRoles: input.roles?.length
? {
create: input.roles.map((role) => ({
roleId: role.roleId,
isPrimary: role.isPrimary,
})),
}
: undefined,
} as unknown as Parameters<typeof tx.resource.create>[0]["data"],
include: {
resourceRoles: { include: { role: { select: ROLE_BRIEF_SELECT } } },
},
});
await tx.auditLog.create({
data: {
entityType: "Resource",
entityId: created.id,
action: "CREATE",
userId: ctx.dbUser?.id,
changes: { after: created },
} as unknown as Parameters<typeof tx.auditLog.create>[0]["data"],
});
return created;
});
return resource;
}),
update: managerProcedure
.input(
z.object({
id: z.string(),
data: UpdateResourceSchema.extend({ roles: z.array(ResourceRoleSchema).optional() }),
}),
)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);
const existing = await findUniqueOrThrow(
ctx.db.resource.findUnique({ where: { id: input.id } }),
"Resource",
);
const nextBlueprintId = input.data.blueprintId ?? existing.blueprintId ?? undefined;
const nextDynamicFields = (input.data.dynamicFields ??
existing.dynamicFields ??
{}) as Record<string, unknown>;
await assertBlueprintDynamicFields({
db: ctx.db,
blueprintId: nextBlueprintId,
dynamicFields: nextDynamicFields,
target: BlueprintTarget.RESOURCE,
});
if (input.data.roles !== undefined) {
const primaryCount = input.data.roles.filter((role) => role.isPrimary).length;
if (primaryCount > 1) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "A resource can have at most one primary role",
});
}
}
const updated = await ctx.db.$transaction(async (tx) => {
const result = await tx.resource.update({
where: { id: input.id },
data: {
...(input.data.displayName !== undefined
? { displayName: input.data.displayName }
: {}),
...(input.data.email !== undefined ? { email: input.data.email } : {}),
...(input.data.chapter !== undefined ? { chapter: input.data.chapter } : {}),
...(input.data.lcrCents !== undefined ? { lcrCents: input.data.lcrCents } : {}),
...(input.data.ucrCents !== undefined ? { ucrCents: input.data.ucrCents } : {}),
...(input.data.currency !== undefined ? { currency: input.data.currency } : {}),
...(input.data.chargeabilityTarget !== undefined
? { chargeabilityTarget: input.data.chargeabilityTarget }
: {}),
...(input.data.availability !== undefined
? {
availability: input.data
.availability as unknown as import("@capakraken/db").Prisma.InputJsonValue,
}
: {}),
...(input.data.skills !== undefined
? {
skills: input.data
.skills as unknown as import("@capakraken/db").Prisma.InputJsonValue,
}
: {}),
...(input.data.dynamicFields !== undefined
? {
dynamicFields: input.data
.dynamicFields as unknown as import("@capakraken/db").Prisma.InputJsonValue,
}
: {}),
...(input.data.blueprintId !== undefined
? { blueprintId: input.data.blueprintId }
: {}),
...(input.data.isActive !== undefined ? { isActive: input.data.isActive } : {}),
...(input.data.portfolioUrl !== undefined
? { portfolioUrl: input.data.portfolioUrl || null }
: {}),
...(input.data.roleId !== undefined ? { roleId: input.data.roleId || null } : {}),
...(input.data.postalCode !== undefined ? { postalCode: input.data.postalCode } : {}),
...(input.data.postalCode && !input.data.federalState
? { federalState: inferStateFromPostalCode(input.data.postalCode) }
: input.data.federalState !== undefined
? { federalState: input.data.federalState }
: {}),
...(input.data.countryId !== undefined
? { countryId: input.data.countryId || null }
: {}),
...(input.data.metroCityId !== undefined
? { metroCityId: input.data.metroCityId || null }
: {}),
...(input.data.orgUnitId !== undefined
? { orgUnitId: input.data.orgUnitId || null }
: {}),
...(input.data.managementLevelGroupId !== undefined
? { managementLevelGroupId: input.data.managementLevelGroupId || null }
: {}),
...(input.data.managementLevelId !== undefined
? { managementLevelId: input.data.managementLevelId || null }
: {}),
...(input.data.resourceType !== undefined
? { resourceType: input.data.resourceType }
: {}),
...(input.data.chgResponsibility !== undefined
? { chgResponsibility: input.data.chgResponsibility }
: {}),
...(input.data.rolledOff !== undefined ? { rolledOff: input.data.rolledOff } : {}),
...(input.data.departed !== undefined ? { departed: input.data.departed } : {}),
...(input.data.enterpriseId !== undefined
? { enterpriseId: input.data.enterpriseId || null }
: {}),
...(input.data.clientUnitId !== undefined
? { clientUnitId: input.data.clientUnitId || null }
: {}),
...(input.data.fte !== undefined ? { fte: input.data.fte } : {}),
} as unknown as Parameters<typeof tx.resource.update>[0]["data"],
include: {
resourceRoles: { include: { role: { select: ROLE_BRIEF_SELECT } } },
},
});
if (input.data.roles !== undefined) {
await tx.resourceRole.deleteMany({ where: { resourceId: input.id } });
if (input.data.roles.length > 0) {
await tx.resourceRole.createMany({
data: input.data.roles.map((role) => ({
resourceId: input.id,
roleId: role.roleId,
isPrimary: role.isPrimary,
})),
});
}
}
await tx.auditLog.create({
data: {
entityType: "Resource",
entityId: input.id,
action: "UPDATE",
changes: { before: existing, after: result },
},
});
return result;
});
return updated;
}),
deactivate: managerProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);
const resource = await ctx.db.$transaction(async (tx) => {
const result = await tx.resource.update({
where: { id: input.id },
data: { isActive: false, deletedAt: new Date() },
});
await tx.auditLog.create({
data: {
entityType: "Resource",
entityId: input.id,
action: "UPDATE",
changes: { after: { isActive: false } },
},
});
return result;
});
return resource;
}),
batchDeactivate: managerProcedure
.input(z.object({ ids: z.array(z.string()).min(1).max(100) }))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);
const updated = await ctx.db.$transaction(async (tx) => {
const results = await Promise.all(
input.ids.map((id) =>
tx.resource.update({ where: { id }, data: { isActive: false, deletedAt: new Date() } }),
),
);
await tx.auditLog.create({
data: {
entityType: "Resource",
entityId: input.ids.join(","),
action: "UPDATE",
changes: { after: { isActive: false, ids: input.ids } },
},
});
return results;
});
return { count: updated.length };
}),
batchUpdateCustomFields: managerProcedure
.input(
z.object({
ids: z.array(z.string()).min(1).max(100),
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 }) => {
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 Promise.all(
input.ids.map(
(id) =>
tx.$executeRaw`
UPDATE "Resource"
SET "dynamicFields" = "dynamicFields" || ${JSON.stringify(input.fields)}::jsonb
WHERE id = ${id}
`,
),
);
await tx.auditLog.create({
data: {
entityType: "Resource",
entityId: input.ids.join(","),
action: "UPDATE",
changes: {
after: { dynamicFields: input.fields, ids: input.ids },
} as unknown as import("@capakraken/db").Prisma.InputJsonValue,
},
});
});
return { updated: input.ids.length };
}),
hardDelete: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const resource = await ctx.db.resource.findUnique({
where: { id: input.id },
select: { id: true, displayName: true, eid: true },
});
if (!resource) {
throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
}
await ctx.db.$transaction(async (tx) => {
await tx.assignment.deleteMany({ where: { resourceId: input.id } });
await tx.vacation.deleteMany({ where: { resourceId: input.id } });
await tx.resource.delete({ where: { id: input.id } });
await tx.auditLog.create({
data: {
entityType: "Resource",
entityId: input.id,
action: "DELETE",
userId: ctx.dbUser?.id,
changes: {
before: resource,
} as unknown as import("@capakraken/db").Prisma.InputJsonValue,
},
});
});
return { deleted: true };
}),
batchHardDelete: adminProcedure
.input(z.object({ ids: z.array(z.string()).min(1) }))
.mutation(async ({ ctx, input }) => {
const resources = await ctx.db.resource.findMany({
where: { id: { in: input.ids } },
select: { id: true, displayName: true, eid: true },
});
await ctx.db.$transaction(async (tx) => {
await tx.assignment.deleteMany({ where: { resourceId: { in: input.ids } } });
await tx.vacation.deleteMany({ where: { resourceId: { in: input.ids } } });
await tx.resource.deleteMany({ where: { id: { in: input.ids } } });
await tx.auditLog.createMany({
data: resources.map((r) => ({
entityType: "Resource",
entityId: r.id,
action: "DELETE",
userId: ctx.dbUser?.id,
changes: { before: r } as unknown as import("@capakraken/db").Prisma.InputJsonValue,
})),
});
});
return { deleted: resources.length };
}),
};