Files
parking_solution/apps/server/src/routes/users.ts
T
julian 7680d9a0ed feat(recycle-bin): soft delete + restore for master data
Accidental admin deletes of users/roles/subscriptions/plans/tariffs were
hard and unrecoverable. Now they soft-delete into a recycle bin.

Schema (migration 0012): nullable deleted_at + deleted_by on users, roles,
subscriptions, subscription_plans, tariffs. Additive ADD COLUMN; verified
against a copy of the live DB.

Backend: each resource's DELETE route STAMPS instead of removing; every
catalog list filters deleted_at IS NULL. New recycle-bin module + routes
(GET /api/recycle-bin, POST .../restore, DELETE .../:id purge) gated on a
new recyclebin:read/update/delete permission. A 6-hourly + startup sweep
auto-purges items older than RECYCLE_BIN_RETENTION_DAYS (default 30; 0 =
forever).

Invariants: soft-deleted users can't log in (login rejects deleted_at;
no-lockout counts live admins only); a soft-deleted subscription doesn't
open the barrier; plans are versioned so a delete stamps all versions of
the plan_id (bin shows one item); username/role-name UNIQUE spans deleted
rows so reuse returns a clear 409 pointing at the bin; restore doesn't
auto-cascade a dangling role (guard resolves missing role to empty perms).
The signed append-only ledger is OUT of scope (no delete path).

Web: a Recycle bin tab under Setup (RecycleBin.tsx) with Restore/Purge +
purge confirm; api client + i18n (sq + en parity).

Tests: recycle-bin.test.ts (9 unit) + recycle-bin-routes.test.ts (4
integration: delete -> can't-login -> restore -> login, purge, gating,
409 reuse). server 103/103; build+lint+test 19/19.

Wiki: new concepts/soft-delete.md; local-jwt-auth + index + log updated.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 09:33:54 +02:00

258 lines
11 KiB
TypeScript

import { randomUUID } from "node:crypto";
import bcrypt from "bcrypt";
import type { FastifyInstance } from "fastify";
import { and, eq, isNull, roles, users, type Db } from "@parking/db";
import { ADMIN_ROLE_ID } from "@parking/shared";
import { permissionsFor, requirePermission } from "../auth.js";
import { softDelete } from "../recycle-bin.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<string, string | null> {
const out: Record<string, string | null> = {};
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<void> {
const readGuard = requirePermission("user:read");
const createGuard = requirePermission("user:create");
const updateGuard = requirePermission("user:update");
const deleteGuard = requirePermission("user:delete");
/** Count LIVE users currently holding the protected admin role. A soft-deleted admin
* doesn't count — they can't log in — so the no-lockout check uses live admins only. */
function adminCount(): number {
return db.select().from(users).where(and(eq(users.roleId, ADMIN_ROLE_ID), isNull(users.deletedAt))).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 LIVE users (no password hashes) + their role names for display. Soft-deleted
// users live in the recycle bin, not here.
app.get("/api/users", { preHandler: readGuard }, async () => {
const rows = db.select().from(users).where(isNull(users.deletedAt)).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" });
}
const clash = db.select().from(users).where(eq(users.username, username)).get();
if (clash) {
// The username is UNIQUE across live AND soft-deleted rows. If a DELETED user holds
// it, point the admin at the recycle bin (restore or purge) rather than a bare 409.
return reply.code(409).send({
error: clash.deletedAt
? "username belongs to a deleted user — restore or purge it from the recycle bin first"
: "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<string, string | null> = {
...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 — SOFT (recycle bin). Refused if it's the last admin (no-lockout).
// The row is stamped deleted (recoverable), not removed; it vanishes from the list and
// can't log in. Restore/purge from the recycle bin. See recycle-bin.ts.
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(and(eq(users.id, id), isNull(users.deletedAt))).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" });
}
softDelete(db, "user", id, req.user.sub);
return { ok: true };
},
);
}