fix: 2026-06-09 full-codebase audit hardening
Validation: shared NaN-guarded Zod coercion helpers in schemas/common.ts replace the raw number|string transform idiom across every schema (the root-cause NaN bug class); emailOrEmpty + lenient isoDateString/timeString. Security: roles privilege-escalation closed; refresh-token family revocation on reuse; TOTP uses config params; read endpoints permission-guarded; received-invoices gross VAT on all paths; orders-pdf custom-items authz. Concurrency: $queryRaw SELECT...FOR UPDATE locks in ascending-id order (warehouse confirm/cancel, attendance lockUserRow); uniqueness checks moved into create transactions (TOCTOU -> 409); deterministic id tiebreak on second-precision timestamp ordering (plan resolveCell/resolveGrid, warehouse FIFO). Frontend: Rules-of-Hooks fixed across ~14 pages + PlanCellModal; UTC-date persisted fields; dashboard invalidation gaps; stale-closure confirm bugs. Tooling/tests: ESLint flat config (react-hooks/rules-of-hooks = error) + Prettier; tsconfig.test.json so tsc -b type-checks the tests; removed 3 dead deps; npm audit fix (8 -> 3). Suite 195 -> 247 (happy-path auth, FIFO oldest-first, flakiness fixes), isolated on app_test via .env.test with a hard-throw setup guard. Gates: tsc 0 | build 0 | vitest 247/247 | eslint 0 errors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -60,31 +60,54 @@ export default async function rolesRoutes(
|
||||
"/",
|
||||
{ preHandler: requirePermission("settings.roles") },
|
||||
async (request, reply) => {
|
||||
// Privilege-escalation guard: only an admin may create roles (and thus
|
||||
// attach arbitrary permission_ids). A non-admin `settings.roles` holder
|
||||
// must not be able to mint a new high-privilege role.
|
||||
if (request.authData!.roleName !== "admin") {
|
||||
return error(reply, "Pouze administrátor může vytvářet role", 403);
|
||||
}
|
||||
|
||||
const parsed = parseBody(CreateRoleSchema, request.body);
|
||||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||||
const body = parsed.data;
|
||||
|
||||
const role = await prisma.roles.create({
|
||||
data: {
|
||||
name: String(body.name),
|
||||
display_name: String(body.display_name),
|
||||
description: body.description ? String(body.description) : null,
|
||||
},
|
||||
});
|
||||
const name = String(body.name);
|
||||
|
||||
if (Array.isArray(body.permission_ids)) {
|
||||
await prisma.$transaction(
|
||||
(body.permission_ids as number[]).map((pid) =>
|
||||
prisma.role_permissions.create({
|
||||
data: {
|
||||
role_id: role.id,
|
||||
permission_id: pid,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
// Never allow minting another "admin" role (would shadow / escalate).
|
||||
if (name.toLowerCase() === "admin") {
|
||||
return error(reply, "Nelze vytvořit roli s názvem admin", 400);
|
||||
}
|
||||
|
||||
// Enforce role-name uniqueness explicitly (clear 409 instead of a raw
|
||||
// Prisma constraint 500).
|
||||
const duplicate = await prisma.roles.findFirst({ where: { name } });
|
||||
if (duplicate) {
|
||||
return error(reply, "Role s tímto názvem již existuje", 409);
|
||||
}
|
||||
|
||||
// Role insert + permission assignment in a SINGLE transaction so a
|
||||
// partial failure can never leave an orphan role with no permissions.
|
||||
const role = await prisma.$transaction(async (tx) => {
|
||||
const created = await tx.roles.create({
|
||||
data: {
|
||||
name,
|
||||
display_name: String(body.display_name),
|
||||
description: body.description ? String(body.description) : null,
|
||||
},
|
||||
});
|
||||
|
||||
if (Array.isArray(body.permission_ids) && body.permission_ids.length) {
|
||||
await tx.role_permissions.createMany({
|
||||
data: (body.permission_ids as number[]).map((pid) => ({
|
||||
role_id: created.id,
|
||||
permission_id: pid,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return created;
|
||||
});
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
@@ -103,6 +126,12 @@ export default async function rolesRoutes(
|
||||
"/:id",
|
||||
{ preHandler: requirePermission("settings.roles") },
|
||||
async (request, reply) => {
|
||||
// Privilege-escalation guard: only an admin may modify roles or their
|
||||
// permission sets.
|
||||
if (request.authData!.roleName !== "admin") {
|
||||
return error(reply, "Pouze administrátor může upravovat role", 403);
|
||||
}
|
||||
|
||||
const id = parseId(request.params.id, reply);
|
||||
if (id === null) return;
|
||||
const parsed = parseBody(UpdateRoleSchema, request.body);
|
||||
@@ -112,6 +141,13 @@ export default async function rolesRoutes(
|
||||
const existing = await prisma.roles.findUnique({ where: { id } });
|
||||
if (!existing) return error(reply, "Role nenalezena", 404);
|
||||
|
||||
// Mirror the DELETE guard: the built-in admin role's permission set is
|
||||
// immutable (it is resolved as "all permissions" anyway). Block any
|
||||
// attempt to rewrite it via permission_ids → no lockout / escalation.
|
||||
if (existing.name === "admin" && Array.isArray(body.permission_ids)) {
|
||||
return error(reply, "Oprávnění role admin nelze měnit", 400);
|
||||
}
|
||||
|
||||
await prisma.roles.update({
|
||||
where: { id },
|
||||
data: {
|
||||
|
||||
Reference in New Issue
Block a user