c0c5f762b8
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>
467 lines
18 KiB
TypeScript
467 lines
18 KiB
TypeScript
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 };
|
||
}),
|
||
};
|