import { randomUUID } from "node:crypto"; import type { FastifyInstance } from "fastify"; import { and, eq, isNull, roleJobs, rolePermissions, roles, users, type Db } from "@parking/db"; import { ADMIN_ROLE_ID, PERMISSIONS, jobById, type Permission } from "@parking/shared"; import { bumpPermsCache, permissionsFor, requirePermission } from "../auth.js"; import type { EventLog } from "../event-log.js"; import { softDelete } from "../recycle-bin.js"; // Role management (admin). Roles are DATA: an admin composes a role from the // code-defined PERMISSIONS grid (resource:action), and users are assigned one // role. The built-in `admin` role (id ADMIN_ROLE_ID) is PROTECTED — it can't be // edited or deleted and always resolves to every permission in code. Every write // here bumps the in-memory permission cache so changes take effect on the next // request. See @parking/shared PERMISSIONS and ../auth.ts. // // PRIVILEGE-ESCALATION GUARD: `role:update`/`role:create` must NOT let a caller // grant a permission they don't themselves hold — otherwise a non-admin with // `role:*` could edit their own role to add (say) `tariff:update`, or mint a role // that grants admin-equivalent powers, and escalate. So a non-admin caller may // only put permissions they ALREADY hold onto a role. An admin (full set) is // unrestricted, which is the intended behaviour. // // EVERY role edit is SIGNED on the ledger as a `config_change` (setting `role.`, // value/prev = the role's name + permissions + jobs, operator = who) — a role edit is a // privilege change, and under this threat model the only setting an admin could alter // without a trace. A role also REMEMBERS the manifest JOBS it was composed from // (role_jobs) so a later release that grows a job's bundle can be surfaced and // re-applied — the grid is never expanded silently (venue-modules.md §Permissions matrix). interface RoleBody { name: string; permissions: string[]; jobs?: string[]; } interface UpdateBody { name?: string; permissions?: string[]; jobs?: string[]; } /** What a signed role change records (before/after). */ interface RoleShape { name: string; permissions: Permission[]; jobs: string[]; } const VALID = new Set(PERMISSIONS); /** Validate + dedupe a requested permission list against the code-defined grid. */ function cleanPermissions(input: unknown): { ok: true; perms: Permission[] } | { ok: false; bad: string } { if (!Array.isArray(input)) return { ok: false, bad: "permissions must be an array" }; const out = new Set(); for (const p of input) { if (typeof p !== "string" || !VALID.has(p)) return { ok: false, bad: `unknown permission: ${String(p)}` }; out.add(p as Permission); } return { ok: true, perms: [...out] }; } /** Validate + dedupe a requested job list against the registry's job presets. */ function cleanJobs(input: unknown): { ok: true; jobs: string[] } | { ok: false; bad: string } { if (input == null) return { ok: true, jobs: [] }; if (!Array.isArray(input)) return { ok: false, bad: "jobs must be an array" }; const out = new Set(); for (const j of input) { if (typeof j !== "string" || !jobById(j)) return { ok: false, bad: `unknown job: ${String(j)}` }; out.add(j); } return { ok: true, jobs: [...out] }; } export async function roleRoutes(app: FastifyInstance, db: Db, eventLog?: EventLog): Promise { const readGuard = requirePermission("role:read"); const createGuard = requirePermission("role:create"); const updateGuard = requirePermission("role:update"); const deleteGuard = requirePermission("role:delete"); /** A role + its permission list + how many users hold it. */ function roleView(roleId: string) { const role = db.select().from(roles).where(eq(roles.id, roleId)).get(); if (!role) return null; const perms = db .select({ permission: rolePermissions.permission }) .from(rolePermissions) .where(eq(rolePermissions.roleId, roleId)) .all() .map((r) => r.permission); const userCount = db.select().from(users).where(and(eq(users.roleId, roleId), isNull(users.deletedAt))).all().length; // The admin role always reports the full grid (it's enforced in code). return { id: role.id, name: role.name, builtin: role.builtin === 1, permissions: role.id === ADMIN_ROLE_ID ? [...PERMISSIONS] : perms, jobs: jobsOf(roleId), userCount, }; } function jobsOf(roleId: string): string[] { return db.select({ jobId: roleJobs.jobId }).from(roleJobs).where(eq(roleJobs.roleId, roleId)).all().map((r) => r.jobId).sort(); } /** The role as the ledger records it (sorted so two identical shapes compare equal). */ function shapeOf(roleId: string): RoleShape | null { const v = roleView(roleId); if (!v) return null; return { name: v.name, permissions: [...v.permissions].sort() as Permission[], jobs: v.jobs }; } /** Replace a role's remembered jobs. */ function setJobs(roleId: string, jobs: string[]): void { db.delete(roleJobs).where(eq(roleJobs.roleId, roleId)).run(); for (const jobId of jobs) db.insert(roleJobs).values({ roleId, jobId }).run(); } /** Sign a role change. `prev` null = created; `value` null = deleted. Skipped when * nothing changed (a no-op resave leaves no trace, like the site-config flips). */ async function signRoleChange(req: { user?: { username?: string } }, roleId: string, prev: RoleShape | null, value: RoleShape | null): Promise { if (JSON.stringify(prev) === JSON.stringify(value)) return; await eventLog?.append({ type: "config_change", source: "manual", payload: { setting: `role.${roleId}`, value, prev, operator: req.user?.username ?? "unknown" }, }); } /** Replace a role's permission rows with `perms` (in a single pass). */ function setPermissions(roleId: string, perms: Permission[]): void { db.delete(rolePermissions).where(eq(rolePermissions.roleId, roleId)).run(); for (const p of perms) { db.insert(rolePermissions).values({ roleId, permission: p }).run(); } } // The full permission grid (for the role-composer checkbox UI) + every LIVE role. // Soft-deleted roles live in the recycle bin, not here. app.get("/api/roles", { preHandler: readGuard }, async () => { const all = db.select().from(roles).where(isNull(roles.deletedAt)).all(); return { catalog: PERMISSIONS, roles: all.map((r) => roleView(r.id)).filter((r) => r != null), }; }); /** Reject any permission in `perms` the caller does not themselves hold — so a * non-admin can't grant privileges beyond their own. Returns the offending * permission, or null if all are within the caller's set. (Admin holds the full * set, so it never trips.) */ function escalates(callerRoleId: string, perms: Permission[]): Permission | null { const held = permissionsFor(callerRoleId); return perms.find((p) => !held.has(p)) ?? null; } // Create a composable role from a name + a permission set. app.post<{ Body: RoleBody }>("/api/roles", { preHandler: createGuard }, async (req, reply) => { const name = (req.body?.name ?? "").trim(); if (!name) return reply.code(400).send({ error: "name required" }); if (db.select().from(roles).where(eq(roles.name, name)).get()) { return reply.code(409).send({ error: "a role with that name already exists" }); } const cleaned = cleanPermissions(req.body?.permissions ?? []); if (!cleaned.ok) return reply.code(400).send({ error: cleaned.bad }); const jobs = cleanJobs(req.body?.jobs); if (!jobs.ok) return reply.code(400).send({ error: jobs.bad }); const over = escalates(req.user.roleId, cleaned.perms); if (over) return reply.code(403).send({ error: `cannot grant a permission you do not hold: ${over}` }); const id = randomUUID(); db.insert(roles).values({ id, name, builtin: 0 }).run(); setPermissions(id, cleaned.perms); setJobs(id, jobs.jobs); bumpPermsCache(); await signRoleChange(req, id, null, shapeOf(id)); return reply.code(201).send(roleView(id)); }); // Edit a role's name and/or permission set. The built-in admin role is locked. app.put<{ Params: { id: string }; Body: UpdateBody }>( "/api/roles/:id", { preHandler: updateGuard }, async (req, reply) => { const id = req.params.id; const role = db.select().from(roles).where(eq(roles.id, id)).get(); if (!role) return reply.code(404).send({ error: "role not found" }); if (role.builtin === 1) { return reply.code(409).send({ error: "the built-in admin role cannot be edited" }); } const prev = shapeOf(id); if (req.body?.name != null) { const name = req.body.name.trim(); if (!name) return reply.code(400).send({ error: "name cannot be empty" }); const clash = db.select().from(roles).where(eq(roles.name, name)).get(); if (clash && clash.id !== id) return reply.code(409).send({ error: "a role with that name already exists" }); db.update(roles).set({ name }).where(eq(roles.id, id)).run(); } if (req.body?.permissions != null) { const cleaned = cleanPermissions(req.body.permissions); if (!cleaned.ok) return reply.code(400).send({ error: cleaned.bad }); const over = escalates(req.user.roleId, cleaned.perms); if (over) return reply.code(403).send({ error: `cannot grant a permission you do not hold: ${over}` }); setPermissions(id, cleaned.perms); } if (req.body?.jobs != null) { const jobs = cleanJobs(req.body.jobs); if (!jobs.ok) return reply.code(400).send({ error: jobs.bad }); setJobs(id, jobs.jobs); } bumpPermsCache(); await signRoleChange(req, id, prev, shapeOf(id)); return roleView(id); }, ); // Delete a role — SOFT (recycle bin). Refused if built-in or any LIVE user still holds // it. The row is stamped deleted (recoverable), not removed; its permission rows are // KEPT so a restore brings the role back intact. Restore/purge from the recycle bin. app.delete<{ Params: { id: string } }>( "/api/roles/:id", { preHandler: deleteGuard }, async (req, reply) => { const id = req.params.id; const role = db.select().from(roles).where(and(eq(roles.id, id), isNull(roles.deletedAt))).get(); if (!role) return reply.code(404).send({ error: "role not found" }); if (role.builtin === 1) { return reply.code(409).send({ error: "the built-in admin role cannot be deleted" }); } // Only LIVE holders block deletion (a soft-deleted user's role assignment is moot). const holders = db.select().from(users).where(and(eq(users.roleId, id), isNull(users.deletedAt))).all().length; if (holders > 0) { return reply.code(409).send({ error: `cannot delete a role still assigned to ${holders} user(s)` }); } const prev = shapeOf(id); softDelete(db, "role", id, req.user.sub); bumpPermsCache(); await signRoleChange(req, id, prev, null); return { ok: true }; }, ); }