7680d9a0ed
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
172 lines
7.8 KiB
TypeScript
172 lines
7.8 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import type { FastifyInstance } from "fastify";
|
|
import { and, eq, isNull, rolePermissions, roles, users, type Db } from "@parking/db";
|
|
import { ADMIN_ROLE_ID, PERMISSIONS, type Permission } from "@parking/shared";
|
|
import { bumpPermsCache, permissionsFor, requirePermission } from "../auth.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.
|
|
|
|
interface RoleBody {
|
|
name: string;
|
|
permissions: string[];
|
|
}
|
|
interface UpdateBody {
|
|
name?: string;
|
|
permissions?: string[];
|
|
}
|
|
|
|
const VALID = new Set<string>(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<Permission>();
|
|
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] };
|
|
}
|
|
|
|
export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|
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,
|
|
userCount,
|
|
};
|
|
}
|
|
|
|
/** 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 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);
|
|
bumpPermsCache();
|
|
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" });
|
|
}
|
|
|
|
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);
|
|
}
|
|
bumpPermsCache();
|
|
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)` });
|
|
}
|
|
softDelete(db, "role", id, req.user.sub);
|
|
bumpPermsCache();
|
|
return { ok: true };
|
|
},
|
|
);
|
|
}
|