feat(platform): harden access scoping and delivery baseline
This commit is contained in:
@@ -163,6 +163,7 @@ const ENTITY_MAP = {
|
||||
} as const;
|
||||
|
||||
type EntityKey = keyof typeof ENTITY_MAP;
|
||||
const PERIOD_MONTH_PATTERN = /^\d{4}-(0[1-9]|1[0-2])$/;
|
||||
|
||||
/** Allowlist of top-level scalar fields per entity that can be filtered/sorted on. */
|
||||
const ALLOWED_SCALAR_FIELDS: Record<EntityKey, Set<string>> = {
|
||||
@@ -190,6 +191,158 @@ function getValidScalarField(entity: EntityKey, field: string): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function getColumnDef(entity: EntityKey, columnKey: string): ColumnDef | undefined {
|
||||
return COLUMN_MAP[entity].find((column) => column.key === columnKey);
|
||||
}
|
||||
|
||||
function assertKnownColumns(entity: EntityKey, columns: string[]): void {
|
||||
const invalidColumns = columns.filter((column) => !getColumnDef(entity, column));
|
||||
if (invalidColumns.length > 0) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Unknown columns for ${entity}: ${invalidColumns.join(", ")}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function assertValidFilterField(entity: EntityKey, field: string): string {
|
||||
if (entity === "resource_month") {
|
||||
if (!getColumnDef(entity, field)) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Unknown filter field for ${entity}: ${field}`,
|
||||
});
|
||||
}
|
||||
return field;
|
||||
}
|
||||
|
||||
const validField = getValidScalarField(entity, field);
|
||||
if (!validField) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Unsupported filter field for ${entity}: ${field}`,
|
||||
});
|
||||
}
|
||||
return validField;
|
||||
}
|
||||
|
||||
function assertValidSortField(entity: EntityKey, field: string): void {
|
||||
if (entity === "resource_month") {
|
||||
if (!getColumnDef(entity, field)) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Unknown sort field for ${entity}: ${field}`,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!getValidScalarField(entity, field)) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Unsupported sort field for ${entity}: ${field}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function assertValidGroupField(entity: EntityKey, field: string): void {
|
||||
const knownField =
|
||||
entity === "resource_month"
|
||||
? getColumnDef(entity, field)?.key
|
||||
: getValidScalarField(entity, field);
|
||||
if (!knownField) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Unsupported group field for ${entity}: ${field}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function parseFilterValueOrThrow(def: ColumnDef, value: string): unknown {
|
||||
if (def.dataType === "number") {
|
||||
const parsed = Number(value);
|
||||
if (Number.isNaN(parsed)) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Invalid numeric filter value for ${def.key}: ${value}`,
|
||||
});
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
if (def.dataType === "boolean") {
|
||||
if (value !== "true" && value !== "false") {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Invalid boolean filter value for ${def.key}: ${value}`,
|
||||
});
|
||||
}
|
||||
return value === "true";
|
||||
}
|
||||
|
||||
if (def.dataType === "date") {
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Invalid date filter value for ${def.key}: ${value}`,
|
||||
});
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateReportInput(input: ReportInput | z.infer<typeof ReportTemplateConfigSchema>): void {
|
||||
assertKnownColumns(input.entity, input.columns);
|
||||
|
||||
if (input.periodMonth && !PERIOD_MONTH_PATTERN.test(input.periodMonth)) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Invalid periodMonth: ${input.periodMonth}. Expected YYYY-MM with a month between 01 and 12.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (input.entity !== "resource_month" && input.periodMonth) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "periodMonth is only supported for resource_month reports",
|
||||
});
|
||||
}
|
||||
|
||||
if (input.sortBy) {
|
||||
assertValidSortField(input.entity, input.sortBy);
|
||||
}
|
||||
|
||||
if (input.groupBy) {
|
||||
assertValidGroupField(input.entity, input.groupBy);
|
||||
}
|
||||
|
||||
for (const filter of input.filters) {
|
||||
const field = assertValidFilterField(input.entity, filter.field);
|
||||
const def = getColumnDef(input.entity, field);
|
||||
if (!def) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Unknown filter field for ${input.entity}: ${filter.field}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (filter.op === "contains" || filter.op === "in") {
|
||||
if (def.dataType !== "string") {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Filter operator ${filter.op} is only supported for string fields like ${def.key}`,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
void parseFilterValueOrThrow(def, filter.value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Prisma `select` object from the requested columns.
|
||||
* Always includes `id`. For relation columns like "country.name",
|
||||
@@ -254,24 +407,15 @@ function buildWhere(
|
||||
const where: Record<string, unknown> = {};
|
||||
|
||||
for (const filter of filters) {
|
||||
const field = getValidScalarField(entity, filter.field);
|
||||
if (!field) continue;
|
||||
|
||||
const entityColumns = COLUMN_MAP[entity];
|
||||
const colDef = entityColumns.find((c) => c.key === field);
|
||||
const dataType = colDef?.dataType ?? "string";
|
||||
|
||||
// Parse value based on data type
|
||||
let parsedValue: unknown = filter.value;
|
||||
if (dataType === "number") {
|
||||
parsedValue = Number(filter.value);
|
||||
if (Number.isNaN(parsedValue as number)) continue;
|
||||
} else if (dataType === "boolean") {
|
||||
parsedValue = filter.value === "true";
|
||||
} else if (dataType === "date") {
|
||||
parsedValue = new Date(filter.value);
|
||||
if (Number.isNaN((parsedValue as Date).getTime())) continue;
|
||||
const field = assertValidFilterField(entity, filter.field);
|
||||
const colDef = getColumnDef(entity, field);
|
||||
if (!colDef) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Unknown filter field for ${entity}: ${filter.field}`,
|
||||
});
|
||||
}
|
||||
const parsedValue = parseFilterValueOrThrow(colDef, filter.value);
|
||||
|
||||
switch (filter.op) {
|
||||
case "eq":
|
||||
@@ -293,14 +437,28 @@ function buildWhere(
|
||||
where[field] = { lte: parsedValue };
|
||||
break;
|
||||
case "contains":
|
||||
if (dataType === "string") {
|
||||
where[field] = { contains: filter.value, mode: "insensitive" };
|
||||
if (colDef.dataType !== "string") {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Filter operator contains is only supported for string fields like ${field}`,
|
||||
});
|
||||
}
|
||||
where[field] = { contains: filter.value, mode: "insensitive" };
|
||||
break;
|
||||
case "in":
|
||||
if (dataType === "string") {
|
||||
where[field] = { in: filter.value.split(",").map((v) => v.trim()) };
|
||||
if (colDef.dataType !== "string") {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Filter operator in is only supported for string fields like ${field}`,
|
||||
});
|
||||
}
|
||||
where[field] = { in: filter.value.split(",").map((v) => v.trim()) };
|
||||
break;
|
||||
default:
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Unsupported filter operator: ${filter.op}`,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -355,7 +513,7 @@ const ReportInputSchema = z.object({
|
||||
groupBy: z.string().optional(),
|
||||
sortBy: z.string().optional(),
|
||||
sortDir: z.enum(["asc", "desc"]).default("asc"),
|
||||
periodMonth: z.string().regex(/^\d{4}-\d{2}$/).optional(),
|
||||
periodMonth: z.string().regex(PERIOD_MONTH_PATTERN).optional(),
|
||||
limit: z.number().int().min(1).max(5000).default(50),
|
||||
offset: z.number().int().min(0).default(0),
|
||||
});
|
||||
@@ -440,6 +598,7 @@ export const reportRouter = createTRPCRouter({
|
||||
config: ReportTemplateConfigSchema,
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
validateReportInput(input.config);
|
||||
const reportTemplate = getReportTemplateDelegate(ctx.db);
|
||||
const payload = input.config as unknown as Prisma.InputJsonValue;
|
||||
const entity = toTemplateEntity(input.config.entity);
|
||||
@@ -568,6 +727,8 @@ async function executeReportQuery(
|
||||
db: any,
|
||||
input: ReportInput,
|
||||
): Promise<{ rows: Record<string, unknown>[]; columns: string[]; totalCount: number }> {
|
||||
validateReportInput(input);
|
||||
|
||||
if (input.entity === "resource_month") {
|
||||
return executeResourceMonthReport(db, input);
|
||||
}
|
||||
@@ -579,9 +740,13 @@ async function executeReportQuery(
|
||||
let orderBy: Record<string, string> | undefined;
|
||||
if (sortBy) {
|
||||
const validField = getValidScalarField(entity, sortBy);
|
||||
if (validField) {
|
||||
orderBy = { [validField]: sortDir };
|
||||
if (!validField) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Unsupported sort field for ${entity}: ${sortBy}`,
|
||||
});
|
||||
}
|
||||
orderBy = { [validField]: sortDir };
|
||||
}
|
||||
|
||||
const modelDelegate = getModelDelegate(db, entity);
|
||||
|
||||
Reference in New Issue
Block a user