feat(roles): roles remember the jobs they follow (re-appliable), every role edit is signed
Closes the permissions-matrix loose ends (venue-modules.md §Permissions matrix): - `role_jobs` (migration 0029): a role stores the manifest jobs it was composed from (chips on at save + any bundle fully present). `jobById` / `jobsBehind` in @parking/shared surface a followed job whose bundle grew past the role in a later release; the roles list shows a "behind <job>" badge with a one-click "Update to job" (the union, nothing removed) and the editor lints it. Never a runtime union: the grid stays the explicit enforcement layer and an update never widens a role without a click. - Every role create/update/delete appends a `config_change` (`role.<id>`, prev/value = name + sorted permissions + jobs, operator); a no-op resave signs nothing. roleRoutes now takes the ledger. - booth-supervisor already carries subscription:*; the stale open note is closed. Tests: routes/roles.test.ts. Wiki: venue-modules status, local-jwt-auth, log. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import { type Db } from "@parking/db";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { jobsBehind } from "@parking/shared";
|
||||
import { buildServer } from "../server.js";
|
||||
import { login, seedUser } from "../test-helpers.js";
|
||||
|
||||
// Roles are data composed from the permission grid (venue-modules.md §Permissions
|
||||
// matrix): every edit is SIGNED as a config_change, and a role remembers the manifest
|
||||
// JOBS it was built from so a grown job can be surfaced and re-applied.
|
||||
|
||||
let db: Db;
|
||||
let close: () => void;
|
||||
let app: FastifyInstance;
|
||||
beforeEach(async () => {
|
||||
delete process.env.MODULES_ENTITLED;
|
||||
const t = createTestDb();
|
||||
db = t.db;
|
||||
close = t.close;
|
||||
app = await buildServer({ db });
|
||||
await app.ready();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
close();
|
||||
});
|
||||
type Auth = { cookie: string; csrf: string };
|
||||
const hdrs = (a: Auth) => ({ cookie: a.cookie, "x-csrf-token": a.csrf });
|
||||
async function admin(): Promise<Auth> {
|
||||
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||
return login(app, username, password);
|
||||
}
|
||||
async function roleChanges(a: Auth) {
|
||||
const r = await app.inject({ method: "GET", url: "/api/events?limit=100", headers: { cookie: a.cookie } });
|
||||
return (r.json().events as { type: string; payload: Record<string, unknown> }[]).filter(
|
||||
(e) => e.type === "config_change" && String(e.payload.setting).startsWith("role."),
|
||||
);
|
||||
}
|
||||
|
||||
describe("role edits are signed and jobs are remembered", () => {
|
||||
it("create / update / delete each sign one config_change with prev + value + operator; a no-op resave signs nothing", async () => {
|
||||
const a = await admin();
|
||||
const created = await app.inject({
|
||||
method: "POST", url: "/api/roles", headers: hdrs(a),
|
||||
payload: { name: "Lavazh", permissions: ["carwash:read", "carwash:create", "carwash:update", "carwash:cash"], jobs: ["wash-operator"] },
|
||||
});
|
||||
expect(created.statusCode).toBe(201);
|
||||
const role = created.json();
|
||||
expect(role.jobs).toEqual(["wash-operator"]);
|
||||
let evs = await roleChanges(a);
|
||||
expect(evs).toHaveLength(1);
|
||||
expect(evs[0]!.payload).toMatchObject({
|
||||
setting: `role.${role.id}`, prev: null, operator: "boss",
|
||||
value: { name: "Lavazh", jobs: ["wash-operator"] },
|
||||
});
|
||||
expect((evs[0]!.payload.value as { permissions: string[] }).permissions).toEqual(["carwash:cash", "carwash:create", "carwash:read", "carwash:update"]);
|
||||
|
||||
// Same content again → nothing new on the chain.
|
||||
const same = await app.inject({
|
||||
method: "PUT", url: `/api/roles/${role.id}`, headers: hdrs(a),
|
||||
payload: { permissions: ["carwash:read", "carwash:create", "carwash:update", "carwash:cash"], jobs: ["wash-operator"] },
|
||||
});
|
||||
expect(same.statusCode).toBe(200);
|
||||
expect(await roleChanges(a)).toHaveLength(1);
|
||||
|
||||
// A real change: prev is the old shape, value the new.
|
||||
const renamed = await app.inject({ method: "PUT", url: `/api/roles/${role.id}`, headers: hdrs(a), payload: { name: "Lavazh NEW" } });
|
||||
expect(renamed.statusCode).toBe(200);
|
||||
evs = await roleChanges(a);
|
||||
expect(evs).toHaveLength(2);
|
||||
expect(evs[0]!.payload).toMatchObject({ prev: { name: "Lavazh" }, value: { name: "Lavazh NEW" } });
|
||||
|
||||
const gone = await app.inject({ method: "DELETE", url: `/api/roles/${role.id}`, headers: hdrs(a) });
|
||||
expect(gone.statusCode).toBe(200);
|
||||
evs = await roleChanges(a);
|
||||
expect(evs).toHaveLength(3);
|
||||
expect(evs[0]!.payload).toMatchObject({ prev: { name: "Lavazh NEW" }, value: null });
|
||||
});
|
||||
|
||||
it("unknown jobs are refused; a role built from a job that later grew reports what it is missing", async () => {
|
||||
const a = await admin();
|
||||
const bad = await app.inject({ method: "POST", url: "/api/roles", headers: hdrs(a), payload: { name: "X", permissions: [], jobs: ["bar-tender"] } });
|
||||
expect(bad.statusCode).toBe(400);
|
||||
// Compose "behind": the role follows wash-operator but holds only part of today's bundle
|
||||
// — exactly what an older release's chip would have left once the job grew.
|
||||
const r = (await app.inject({
|
||||
method: "POST", url: "/api/roles", headers: hdrs(a),
|
||||
payload: { name: "Old wash", permissions: ["carwash:read", "carwash:create"], jobs: ["wash-operator"] },
|
||||
})).json();
|
||||
const view = (await app.inject({ method: "GET", url: "/api/roles", headers: { cookie: a.cookie } })).json().roles.find((x: { id: string }) => x.id === r.id);
|
||||
const has = new Set<string>(view.permissions);
|
||||
expect(jobsBehind(view.jobs, (p) => has.has(p))).toEqual([{ job: "wash-operator", missing: ["carwash:update", "carwash:cash"] }]);
|
||||
// Re-apply = the union; then nothing is behind.
|
||||
const fixed = (await app.inject({
|
||||
method: "PUT", url: `/api/roles/${r.id}`, headers: hdrs(a),
|
||||
payload: { permissions: [...has, "carwash:update", "carwash:cash"] },
|
||||
})).json();
|
||||
const has2 = new Set<string>(fixed.permissions);
|
||||
expect(jobsBehind(fixed.jobs, (p) => has2.has(p))).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,9 @@
|
||||
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 { 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
|
||||
@@ -18,14 +19,30 @@ import { softDelete } from "../recycle-bin.js";
|
||||
// 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.<id>`,
|
||||
// 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<string>(PERMISSIONS);
|
||||
@@ -41,7 +58,19 @@ function cleanPermissions(input: unknown): { ok: true; perms: Permission[] } | {
|
||||
return { ok: true, perms: [...out] };
|
||||
}
|
||||
|
||||
export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
/** 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<string>();
|
||||
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<void> {
|
||||
const readGuard = requirePermission("role:read");
|
||||
const createGuard = requirePermission("role:create");
|
||||
const updateGuard = requirePermission("role:update");
|
||||
@@ -64,10 +93,39 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
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<void> {
|
||||
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();
|
||||
@@ -104,13 +162,17 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
}
|
||||
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));
|
||||
});
|
||||
|
||||
@@ -125,6 +187,7 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
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();
|
||||
@@ -140,7 +203,13 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
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);
|
||||
},
|
||||
);
|
||||
@@ -163,8 +232,10 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
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 };
|
||||
},
|
||||
);
|
||||
|
||||
@@ -117,7 +117,6 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
// RBAC administration: compose roles (role:*) + manage users (user:*). The
|
||||
// built-in admin role is protected; the last admin can't be removed. See auth.ts.
|
||||
await userRoutes(app, db);
|
||||
await roleRoutes(app, db);
|
||||
|
||||
// Vision (ANPR) client — built early so the device monitor can include the vision
|
||||
// service's health in the footer, AND so the setup wizard's "Test ANPR" can run a
|
||||
@@ -144,6 +143,7 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
// no lane — a parking lot is one pool with a flexible set of entry/exit points.
|
||||
// See wiki/concepts/first-run-setup.md, entry-exit-points.md.
|
||||
await setupRoutes(app, db, visionClient, eventLog);
|
||||
await roleRoutes(app, db, eventLog);
|
||||
|
||||
// Inbound device pushes (e.g. Dingtian Input Link URL → button events),
|
||||
// guarded by source-IP allowlist + a shared-secret path token, both read from
|
||||
|
||||
Reference in New Issue
Block a user