import { randomUUID } from "node:crypto"; import bcrypt from "bcrypt"; import type { FastifyInstance } from "fastify"; import { eq, roles, users, type Db } from "@parking/db"; import { ADMIN_ROLE_ID } from "@parking/shared"; import { permissionsFor, requirePermission } from "../auth.js"; // User management (admin). Users are created/edited at runtime here — the // install-time seed-admin.mjs only bootstraps the FIRST admin. Each user has one // role (RBAC); the role resolves to a permission set at request time. Passwords // are bcrypt-hashed (cost 12) and never returned. See @parking/shared PERMISSIONS. // // NO-LOCKOUT INVARIANT: the app refuses to delete, or move off the `admin` role, // the LAST user still holding `admin`. Administration can therefore never be // locked out of the appliance. See wiki/entities/local-jwt-auth.md. // // PRIVILEGE-ESCALATION GUARD: a non-admin caller with `user:*` must NOT be able to // (a) ASSIGN a role whose permissions exceed their own (e.g. hand themselves or a // peer the admin role, or any role broader than theirs), nor (b) MODIFY a user who // already holds a role broader than the caller's (resetting an admin's password is // account takeover; deleting an admin is sabotage). Both are blocked below by // comparing permission SETS. An admin holds the full set, so it is unrestricted. // Optional profile metadata accepted on create/update. All nullable; "" is treated // as "clear" (→ null). Trimmed before persisting. interface ProfileBody { fullName?: string | null; phone?: string | null; email?: string | null; address?: string | null; } interface CreateBody extends ProfileBody { username: string; password: string; roleId: string; } interface UpdateBody extends ProfileBody { username?: string; roleId?: string; } interface PasswordBody { password: string; } const MIN_PASSWORD = 8; const PROFILE_FIELDS = ["fullName", "phone", "email", "address"] as const; /** Pull the optional profile fields out of a body → a patch of trimmed values * ("" → null). Absent keys are omitted (so an update only touches what's sent). */ function profilePatch(body: ProfileBody): Record { const out: Record = {}; for (const k of PROFILE_FIELDS) { const v = body[k]; if (v === undefined) continue; const trimmed = typeof v === "string" ? v.trim() : ""; out[k] = trimmed === "" ? null : trimmed; } return out; } export async function userRoutes(app: FastifyInstance, db: Db): Promise { const readGuard = requirePermission("user:read"); const createGuard = requirePermission("user:create"); const updateGuard = requirePermission("user:update"); const deleteGuard = requirePermission("user:delete"); /** Count users currently holding the protected admin role. */ function adminCount(): number { return db.select().from(users).where(eq(users.roleId, ADMIN_ROLE_ID)).all().length; } /** True if removing/relocating `userId` from admin would leave zero admins. */ function isLastAdmin(userId: string): boolean { const u = db.select().from(users).where(eq(users.id, userId)).get(); return u?.roleId === ADMIN_ROLE_ID && adminCount() <= 1; } /** A user row safe to return — never the password hash. */ function publicUser(u: { id: string; username: string; roleId: string; language: string; createdAt: string; fullName?: string | null; phone?: string | null; email?: string | null; address?: string | null; }) { return { id: u.id, username: u.username, roleId: u.roleId, language: u.language, createdAt: u.createdAt, fullName: u.fullName ?? null, phone: u.phone ?? null, email: u.email ?? null, address: u.address ?? null, }; } /** True if `targetRoleId` grants any permission the caller's role does NOT hold, * i.e. assigning or touching it would let the caller act beyond their own * privileges. (Admin holds the full set, so it never trips.) */ function exceedsCaller(callerRoleId: string, targetRoleId: string): boolean { if (callerRoleId === targetRoleId) return false; const held = permissionsFor(callerRoleId); for (const p of permissionsFor(targetRoleId)) { if (!held.has(p)) return true; } return false; } // List all users (no password hashes) + their role names for display. app.get("/api/users", { preHandler: readGuard }, async () => { const rows = db.select().from(users).all(); const roleRows = db.select().from(roles).all(); const roleName = new Map(roleRows.map((r) => [r.id, r.name])); return { users: rows.map((u) => ({ ...publicUser(u), roleName: roleName.get(u.roleId) ?? u.roleId })), }; }); // Create a user. Username unique; password >= 8 chars; roleId must exist. app.post<{ Body: CreateBody }>("/api/users", { preHandler: createGuard }, async (req, reply) => { const username = (req.body?.username ?? "").trim(); const password = req.body?.password ?? ""; const roleId = (req.body?.roleId ?? "").trim(); if (!username || !roleId) { return reply.code(400).send({ error: "username and roleId required" }); } if (password.length < MIN_PASSWORD) { return reply.code(400).send({ error: `password must be at least ${MIN_PASSWORD} characters` }); } if (!db.select().from(roles).where(eq(roles.id, roleId)).get()) { return reply.code(400).send({ error: "unknown roleId" }); } // No-escalation: can't create a user with a role broader than your own. if (exceedsCaller(req.user.roleId, roleId)) { return reply.code(403).send({ error: "cannot assign a role with permissions beyond your own" }); } if (db.select().from(users).where(eq(users.username, username)).get()) { return reply.code(409).send({ error: "username already exists" }); } const id = randomUUID(); const passwordHash = await bcrypt.hash(password, 12); db.insert(users).values({ id, username, passwordHash, roleId, ...profilePatch(req.body) }).run(); const created = db.select().from(users).where(eq(users.id, id)).get()!; return reply.code(201).send(publicUser(created)); }); // Update a user's username and/or role. Guarded against orphaning admin. app.put<{ Params: { id: string }; Body: UpdateBody }>( "/api/users/:id", { preHandler: updateGuard }, async (req, reply) => { const id = req.params.id; const existing = db.select().from(users).where(eq(users.id, id)).get(); if (!existing) return reply.code(404).send({ error: "user not found" }); // No-escalation: can't modify a user who already outranks you. if (exceedsCaller(req.user.roleId, existing.roleId)) { return reply.code(403).send({ error: "cannot modify a user whose role exceeds your own" }); } const next: { username?: string; roleId?: string } & Record = { ...profilePatch(req.body ?? {}), }; if (req.body?.username != null) { const username = req.body.username.trim(); if (!username) return reply.code(400).send({ error: "username cannot be empty" }); const clash = db.select().from(users).where(eq(users.username, username)).get(); if (clash && clash.id !== id) return reply.code(409).send({ error: "username already exists" }); next.username = username; } if (req.body?.roleId != null) { const roleId = req.body.roleId.trim(); if (!db.select().from(roles).where(eq(roles.id, roleId)).get()) { return reply.code(400).send({ error: "unknown roleId" }); } // No-escalation: can't promote a user into a role broader than your own. if (exceedsCaller(req.user.roleId, roleId)) { return reply.code(403).send({ error: "cannot assign a role with permissions beyond your own" }); } // No-lockout: don't move the last admin off the admin role. if (roleId !== ADMIN_ROLE_ID && isLastAdmin(id)) { return reply.code(409).send({ error: "cannot change the role of the last admin" }); } next.roleId = roleId; } if (Object.keys(next).length === 0) { return reply.code(400).send({ error: "nothing to update" }); } db.update(users).set(next).where(eq(users.id, id)).run(); return publicUser(db.select().from(users).where(eq(users.id, id)).get()!); }, ); // Reset a user's password (admin sets a new one; >= 8 chars). app.put<{ Params: { id: string }; Body: PasswordBody }>( "/api/users/:id/password", { preHandler: updateGuard }, async (req, reply) => { const id = req.params.id; const target = db.select().from(users).where(eq(users.id, id)).get(); if (!target) { return reply.code(404).send({ error: "user not found" }); } // No-escalation: can't reset the password of a user who outranks you // (that would be account takeover of a more-privileged account). if (exceedsCaller(req.user.roleId, target.roleId)) { return reply.code(403).send({ error: "cannot reset the password of a user whose role exceeds your own" }); } const password = req.body?.password ?? ""; if (password.length < MIN_PASSWORD) { return reply.code(400).send({ error: `password must be at least ${MIN_PASSWORD} characters` }); } const passwordHash = await bcrypt.hash(password, 12); db.update(users).set({ passwordHash }).where(eq(users.id, id)).run(); return { ok: true }; }, ); // Delete a user. Refused if it's the last admin (no-lockout). app.delete<{ Params: { id: string } }>( "/api/users/:id", { preHandler: deleteGuard }, async (req, reply) => { const id = req.params.id; const target = db.select().from(users).where(eq(users.id, id)).get(); if (!target) { return reply.code(404).send({ error: "user not found" }); } // No-escalation: can't delete a user who outranks you. if (exceedsCaller(req.user.roleId, target.roleId)) { return reply.code(403).send({ error: "cannot delete a user whose role exceeds your own" }); } if (isLastAdmin(id)) { return reply.code(409).send({ error: "cannot delete the last admin" }); } db.delete(users).where(eq(users.id, id)).run(); return { ok: true }; }, ); }