feat(auth): dynamic RBAC — composable roles + resource×CRUD permissions
Replace the hardcoded role enum (admin/operator/cashier/readonly, checked
literally as requireRole("admin",...) across ~15 routes) with dynamic RBAC:
roles are DATA, route guards check a PERMISSION.
@parking/shared defines a code-defined grid: RESOURCES (user/role/tariff/
subscription/site/device/shift/payment/session/event/report) × Action
(create/read/update/delete + domain verbs void/cash) -> PERMISSIONS
(resource:action, e.g. tariff:update, payment:create, event:void).
DB: new roles + role_permissions tables; users.role enum -> role_id FK;
migration 0007_rbac (create tables, seed the builtin admin role + all 26
perms, seed operator/cashier/readonly composable roles matching old
behaviour, rebuild users to swap the column copying all rows).
auth.ts: JWT payload role -> roleId; permissionsFor(roleId) with an
in-memory cache + bumpPermsCache(); requirePermission(...perms) preHandler;
requireAuth for /me & /language; initAuth(db) wires the resolver once. Every
route guard mapped to a permission; device ingress (devices/qr-reader) stays
auth-free by design. New routes/users.ts (user:* CRUD, bcrypt 12, last-admin
guard) + routes/roles.ts (role:* CRUD, builtin-protected, perms validated
against the grid, cache bump on write). auth/me + /login return
{roleId, roleName, permissions, language}. seed-admin -> roleId:'admin'.
Frontend: SessionUser carries permissions + can() helper; router nav/route
guards gate by permission (requirePerm replaces adminOnly); SiteSettings
edit gated by site:update; new UsersManager + RolesManager (permission
checkbox grid; admin role locked); i18n nav.users/roles + blocks (sq+en).
Decisions: one role per user; protected built-in admin (no-lockout: the last
admin can't be deleted/downgraded); JWT carries roleId, perms resolved
per-request so role edits apply immediately (no re-login).
Verified: full build green; 20-assertion inject test passes (cashier 403s on
tariff publish + user list, admin passes, granting a perm applies on the next
request, last-admin + builtin-role protections return 409); migration 0007
applied to a copy of the live DB (incl WAL/shm) — existing admin maps to
role_id='admin', all rows preserved. Append-only event chain untouched
(event:void gates appending a void, not a delete).
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { eq, rolePermissions, roles, users, type Db } from "@parking/db";
|
||||
import { ADMIN_ROLE_ID, PERMISSIONS, type Permission } from "@parking/shared";
|
||||
import { bumpPermsCache, requirePermission } from "../auth.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.
|
||||
|
||||
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(eq(users.roleId, roleId)).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 role.
|
||||
app.get("/api/roles", { preHandler: readGuard }, async () => {
|
||||
const all = db.select().from(roles).all();
|
||||
return {
|
||||
catalog: PERMISSIONS,
|
||||
roles: all.map((r) => roleView(r.id)).filter((r) => r != 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 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 });
|
||||
setPermissions(id, cleaned.perms);
|
||||
}
|
||||
bumpPermsCache();
|
||||
return roleView(id);
|
||||
},
|
||||
);
|
||||
|
||||
// Delete a role. Refused if it's built-in or any user still holds it.
|
||||
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(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 deleted" });
|
||||
}
|
||||
const holders = db.select().from(users).where(eq(users.roleId, id)).all().length;
|
||||
if (holders > 0) {
|
||||
return reply.code(409).send({ error: `cannot delete a role still assigned to ${holders} user(s)` });
|
||||
}
|
||||
db.delete(rolePermissions).where(eq(rolePermissions.roleId, id)).run();
|
||||
db.delete(roles).where(eq(roles.id, id)).run();
|
||||
bumpPermsCache();
|
||||
return { ok: true };
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user