fix(api): harden raw SQL jsonb field validation in batchUpdateCustomFields
Replace z.unknown() with z.union([z.string(), z.number(), z.boolean(), z.null()]) to constrain what values can be written into the dynamicFields jsonb column via the $executeRaw path. Prevents arbitrary nested structures from being serialized. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,11 @@
|
|||||||
import { BlueprintTarget, CreateResourceSchema, PermissionKey, ResourceRoleSchema, UpdateResourceSchema, inferStateFromPostalCode } from "@capakraken/shared";
|
import {
|
||||||
|
BlueprintTarget,
|
||||||
|
CreateResourceSchema,
|
||||||
|
PermissionKey,
|
||||||
|
ResourceRoleSchema,
|
||||||
|
UpdateResourceSchema,
|
||||||
|
inferStateFromPostalCode,
|
||||||
|
} from "@capakraken/shared";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { findUniqueOrThrow } from "../db/helpers.js";
|
import { findUniqueOrThrow } from "../db/helpers.js";
|
||||||
@@ -31,7 +38,10 @@ export const resourceMutationProcedures = {
|
|||||||
|
|
||||||
const primaryCount = (input.roles ?? []).filter((role) => role.isPrimary).length;
|
const primaryCount = (input.roles ?? []).filter((role) => role.isPrimary).length;
|
||||||
if (primaryCount > 1) {
|
if (primaryCount > 1) {
|
||||||
throw new TRPCError({ code: "BAD_REQUEST", message: "A resource can have at most one primary role" });
|
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 resource = await ctx.db.$transaction(async (tx) => {
|
||||||
@@ -47,7 +57,8 @@ export const resourceMutationProcedures = {
|
|||||||
chargeabilityTarget: input.chargeabilityTarget,
|
chargeabilityTarget: input.chargeabilityTarget,
|
||||||
availability: input.availability,
|
availability: input.availability,
|
||||||
skills: input.skills as unknown as import("@capakraken/db").Prisma.InputJsonValue,
|
skills: input.skills as unknown as import("@capakraken/db").Prisma.InputJsonValue,
|
||||||
dynamicFields: input.dynamicFields as unknown as import("@capakraken/db").Prisma.InputJsonValue,
|
dynamicFields:
|
||||||
|
input.dynamicFields as unknown as import("@capakraken/db").Prisma.InputJsonValue,
|
||||||
blueprintId: input.blueprintId,
|
blueprintId: input.blueprintId,
|
||||||
portfolioUrl: input.portfolioUrl || undefined,
|
portfolioUrl: input.portfolioUrl || undefined,
|
||||||
roleId: input.roleId || undefined,
|
roleId: input.roleId || undefined,
|
||||||
@@ -60,14 +71,24 @@ export const resourceMutationProcedures = {
|
|||||||
...(input.countryId !== undefined ? { countryId: input.countryId || null } : {}),
|
...(input.countryId !== undefined ? { countryId: input.countryId || null } : {}),
|
||||||
...(input.metroCityId !== undefined ? { metroCityId: input.metroCityId || null } : {}),
|
...(input.metroCityId !== undefined ? { metroCityId: input.metroCityId || null } : {}),
|
||||||
...(input.orgUnitId !== undefined ? { orgUnitId: input.orgUnitId || null } : {}),
|
...(input.orgUnitId !== undefined ? { orgUnitId: input.orgUnitId || null } : {}),
|
||||||
...(input.managementLevelGroupId !== undefined ? { managementLevelGroupId: input.managementLevelGroupId || null } : {}),
|
...(input.managementLevelGroupId !== undefined
|
||||||
...(input.managementLevelId !== undefined ? { managementLevelId: input.managementLevelId || null } : {}),
|
? { managementLevelGroupId: input.managementLevelGroupId || null }
|
||||||
|
: {}),
|
||||||
|
...(input.managementLevelId !== undefined
|
||||||
|
? { managementLevelId: input.managementLevelId || null }
|
||||||
|
: {}),
|
||||||
...(input.resourceType !== undefined ? { resourceType: input.resourceType } : {}),
|
...(input.resourceType !== undefined ? { resourceType: input.resourceType } : {}),
|
||||||
...(input.chgResponsibility !== undefined ? { chgResponsibility: input.chgResponsibility } : {}),
|
...(input.chgResponsibility !== undefined
|
||||||
|
? { chgResponsibility: input.chgResponsibility }
|
||||||
|
: {}),
|
||||||
...(input.rolledOff !== undefined ? { rolledOff: input.rolledOff } : {}),
|
...(input.rolledOff !== undefined ? { rolledOff: input.rolledOff } : {}),
|
||||||
...(input.departed !== undefined ? { departed: input.departed } : {}),
|
...(input.departed !== undefined ? { departed: input.departed } : {}),
|
||||||
...(input.enterpriseId !== undefined ? { enterpriseId: input.enterpriseId || null } : {}),
|
...(input.enterpriseId !== undefined
|
||||||
...(input.clientUnitId !== undefined ? { clientUnitId: input.clientUnitId || null } : {}),
|
? { enterpriseId: input.enterpriseId || null }
|
||||||
|
: {}),
|
||||||
|
...(input.clientUnitId !== undefined
|
||||||
|
? { clientUnitId: input.clientUnitId || null }
|
||||||
|
: {}),
|
||||||
...(input.fte !== undefined ? { fte: input.fte } : {}),
|
...(input.fte !== undefined ? { fte: input.fte } : {}),
|
||||||
resourceRoles: input.roles?.length
|
resourceRoles: input.roles?.length
|
||||||
? {
|
? {
|
||||||
@@ -100,7 +121,12 @@ export const resourceMutationProcedures = {
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
update: managerProcedure
|
update: managerProcedure
|
||||||
.input(z.object({ id: z.string(), data: UpdateResourceSchema.extend({ roles: z.array(ResourceRoleSchema).optional() }) }))
|
.input(
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
data: UpdateResourceSchema.extend({ roles: z.array(ResourceRoleSchema).optional() }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);
|
requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);
|
||||||
const existing = await findUniqueOrThrow(
|
const existing = await findUniqueOrThrow(
|
||||||
@@ -109,7 +135,9 @@ export const resourceMutationProcedures = {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const nextBlueprintId = input.data.blueprintId ?? existing.blueprintId ?? undefined;
|
const nextBlueprintId = input.data.blueprintId ?? existing.blueprintId ?? undefined;
|
||||||
const nextDynamicFields = (input.data.dynamicFields ?? existing.dynamicFields ?? {}) as Record<string, unknown>;
|
const nextDynamicFields = (input.data.dynamicFields ??
|
||||||
|
existing.dynamicFields ??
|
||||||
|
{}) as Record<string, unknown>;
|
||||||
|
|
||||||
await assertBlueprintDynamicFields({
|
await assertBlueprintDynamicFields({
|
||||||
db: ctx.db,
|
db: ctx.db,
|
||||||
@@ -121,7 +149,10 @@ export const resourceMutationProcedures = {
|
|||||||
if (input.data.roles !== undefined) {
|
if (input.data.roles !== undefined) {
|
||||||
const primaryCount = input.data.roles.filter((role) => role.isPrimary).length;
|
const primaryCount = input.data.roles.filter((role) => role.isPrimary).length;
|
||||||
if (primaryCount > 1) {
|
if (primaryCount > 1) {
|
||||||
throw new TRPCError({ code: "BAD_REQUEST", message: "A resource can have at most one primary role" });
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "A resource can have at most one primary role",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,19 +160,42 @@ export const resourceMutationProcedures = {
|
|||||||
const result = await tx.resource.update({
|
const result = await tx.resource.update({
|
||||||
where: { id: input.id },
|
where: { id: input.id },
|
||||||
data: {
|
data: {
|
||||||
...(input.data.displayName !== undefined ? { displayName: input.data.displayName } : {}),
|
...(input.data.displayName !== undefined
|
||||||
|
? { displayName: input.data.displayName }
|
||||||
|
: {}),
|
||||||
...(input.data.email !== undefined ? { email: input.data.email } : {}),
|
...(input.data.email !== undefined ? { email: input.data.email } : {}),
|
||||||
...(input.data.chapter !== undefined ? { chapter: input.data.chapter } : {}),
|
...(input.data.chapter !== undefined ? { chapter: input.data.chapter } : {}),
|
||||||
...(input.data.lcrCents !== undefined ? { lcrCents: input.data.lcrCents } : {}),
|
...(input.data.lcrCents !== undefined ? { lcrCents: input.data.lcrCents } : {}),
|
||||||
...(input.data.ucrCents !== undefined ? { ucrCents: input.data.ucrCents } : {}),
|
...(input.data.ucrCents !== undefined ? { ucrCents: input.data.ucrCents } : {}),
|
||||||
...(input.data.currency !== undefined ? { currency: input.data.currency } : {}),
|
...(input.data.currency !== undefined ? { currency: input.data.currency } : {}),
|
||||||
...(input.data.chargeabilityTarget !== undefined ? { chargeabilityTarget: input.data.chargeabilityTarget } : {}),
|
...(input.data.chargeabilityTarget !== undefined
|
||||||
...(input.data.availability !== undefined ? { availability: input.data.availability as unknown as import("@capakraken/db").Prisma.InputJsonValue } : {}),
|
? { chargeabilityTarget: input.data.chargeabilityTarget }
|
||||||
...(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.availability !== undefined
|
||||||
...(input.data.blueprintId !== undefined ? { blueprintId: input.data.blueprintId } : {}),
|
? {
|
||||||
|
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.isActive !== undefined ? { isActive: input.data.isActive } : {}),
|
||||||
...(input.data.portfolioUrl !== undefined ? { portfolioUrl: input.data.portfolioUrl || null } : {}),
|
...(input.data.portfolioUrl !== undefined
|
||||||
|
? { portfolioUrl: input.data.portfolioUrl || null }
|
||||||
|
: {}),
|
||||||
...(input.data.roleId !== undefined ? { roleId: input.data.roleId || null } : {}),
|
...(input.data.roleId !== undefined ? { roleId: input.data.roleId || null } : {}),
|
||||||
...(input.data.postalCode !== undefined ? { postalCode: input.data.postalCode } : {}),
|
...(input.data.postalCode !== undefined ? { postalCode: input.data.postalCode } : {}),
|
||||||
...(input.data.postalCode && !input.data.federalState
|
...(input.data.postalCode && !input.data.federalState
|
||||||
@@ -149,17 +203,35 @@ export const resourceMutationProcedures = {
|
|||||||
: input.data.federalState !== undefined
|
: input.data.federalState !== undefined
|
||||||
? { federalState: input.data.federalState }
|
? { federalState: input.data.federalState }
|
||||||
: {}),
|
: {}),
|
||||||
...(input.data.countryId !== undefined ? { countryId: input.data.countryId || null } : {}),
|
...(input.data.countryId !== undefined
|
||||||
...(input.data.metroCityId !== undefined ? { metroCityId: input.data.metroCityId || null } : {}),
|
? { countryId: input.data.countryId || null }
|
||||||
...(input.data.orgUnitId !== undefined ? { orgUnitId: input.data.orgUnitId || null } : {}),
|
: {}),
|
||||||
...(input.data.managementLevelGroupId !== undefined ? { managementLevelGroupId: input.data.managementLevelGroupId || null } : {}),
|
...(input.data.metroCityId !== undefined
|
||||||
...(input.data.managementLevelId !== undefined ? { managementLevelId: input.data.managementLevelId || null } : {}),
|
? { metroCityId: input.data.metroCityId || null }
|
||||||
...(input.data.resourceType !== undefined ? { resourceType: input.data.resourceType } : {}),
|
: {}),
|
||||||
...(input.data.chgResponsibility !== undefined ? { chgResponsibility: input.data.chgResponsibility } : {}),
|
...(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.rolledOff !== undefined ? { rolledOff: input.data.rolledOff } : {}),
|
||||||
...(input.data.departed !== undefined ? { departed: input.data.departed } : {}),
|
...(input.data.departed !== undefined ? { departed: input.data.departed } : {}),
|
||||||
...(input.data.enterpriseId !== undefined ? { enterpriseId: input.data.enterpriseId || null } : {}),
|
...(input.data.enterpriseId !== undefined
|
||||||
...(input.data.clientUnitId !== undefined ? { clientUnitId: input.data.clientUnitId || null } : {}),
|
? { enterpriseId: input.data.enterpriseId || null }
|
||||||
|
: {}),
|
||||||
|
...(input.data.clientUnitId !== undefined
|
||||||
|
? { clientUnitId: input.data.clientUnitId || null }
|
||||||
|
: {}),
|
||||||
...(input.data.fte !== undefined ? { fte: input.data.fte } : {}),
|
...(input.data.fte !== undefined ? { fte: input.data.fte } : {}),
|
||||||
} as unknown as Parameters<typeof tx.resource.update>[0]["data"],
|
} as unknown as Parameters<typeof tx.resource.update>[0]["data"],
|
||||||
include: {
|
include: {
|
||||||
@@ -247,17 +319,20 @@ export const resourceMutationProcedures = {
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
batchUpdateCustomFields: managerProcedure
|
batchUpdateCustomFields: managerProcedure
|
||||||
.input(z.object({
|
.input(
|
||||||
ids: z.array(z.string()).min(1).max(100),
|
z.object({
|
||||||
fields: z.record(z.string(), z.unknown()),
|
ids: z.array(z.string()).min(1).max(100),
|
||||||
}))
|
fields: z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()])),
|
||||||
|
}),
|
||||||
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);
|
requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);
|
||||||
|
|
||||||
await ctx.db.$transaction(async (tx) => {
|
await ctx.db.$transaction(async (tx) => {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
input.ids.map((id) =>
|
input.ids.map(
|
||||||
tx.$executeRaw`
|
(id) =>
|
||||||
|
tx.$executeRaw`
|
||||||
UPDATE "Resource"
|
UPDATE "Resource"
|
||||||
SET "dynamicFields" = "dynamicFields" || ${JSON.stringify(input.fields)}::jsonb
|
SET "dynamicFields" = "dynamicFields" || ${JSON.stringify(input.fields)}::jsonb
|
||||||
WHERE id = ${id}
|
WHERE id = ${id}
|
||||||
@@ -270,7 +345,9 @@ export const resourceMutationProcedures = {
|
|||||||
entityType: "Resource",
|
entityType: "Resource",
|
||||||
entityId: input.ids.join(","),
|
entityId: input.ids.join(","),
|
||||||
action: "UPDATE",
|
action: "UPDATE",
|
||||||
changes: { after: { dynamicFields: input.fields, ids: input.ids } } as unknown as import("@capakraken/db").Prisma.InputJsonValue,
|
changes: {
|
||||||
|
after: { dynamicFields: input.fields, ids: input.ids },
|
||||||
|
} as unknown as import("@capakraken/db").Prisma.InputJsonValue,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -300,7 +377,9 @@ export const resourceMutationProcedures = {
|
|||||||
entityId: input.id,
|
entityId: input.id,
|
||||||
action: "DELETE",
|
action: "DELETE",
|
||||||
userId: ctx.dbUser?.id,
|
userId: ctx.dbUser?.id,
|
||||||
changes: { before: resource } as unknown as import("@capakraken/db").Prisma.InputJsonValue,
|
changes: {
|
||||||
|
before: resource,
|
||||||
|
} as unknown as import("@capakraken/db").Prisma.InputJsonValue,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user