diff --git a/apps/server/src/routes/roles.test.ts b/apps/server/src/routes/roles.test.ts new file mode 100644 index 0000000..a23545f --- /dev/null +++ b/apps/server/src/routes/roles.test.ts @@ -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 { + 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 }[]).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(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(fixed.permissions); + expect(jobsBehind(fixed.jobs, (p) => has2.has(p))).toEqual([]); + }); +}); diff --git a/apps/server/src/routes/roles.ts b/apps/server/src/routes/roles.ts index 3f7c84f..0bbf9a8 100644 --- a/apps/server/src/routes/roles.ts +++ b/apps/server/src/routes/roles.ts @@ -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.`, +// 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(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 { +/** 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(); + 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 { 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 { 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 { + 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 { } 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 { 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 { 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 { 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 }; }, ); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 4e142b7..9da5c72 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -117,7 +117,6 @@ export async function buildServer(opts: BuildOptions = {}): Promise { @@ -111,8 +116,17 @@ export function RolesManager({ user }: { user: SessionUser | null }) { {t("roles.permCount", { count: r.permissions.length })} · {t("roles.userCount", { count: r.userCount })} + {behindOf(r).map((b) => ( + + {t("roles.behind", { job: t(`jobs.${b.job}`) })} + + ))}
+ {canUpdate && !r.builtin && behindOf(r).length > 0 && ( + + )} {canUpdate && !r.builtin && ( )} @@ -133,15 +147,30 @@ async function deleteRoleSafe(id: string, ok: () => void, onError: (e: unknown) try { await deleteRole(id); ok(); } catch (e) { onError(e); } } +/** The jobs a role follows that have grown past it (this release's bundles). */ +function behindOf(r: ManagedRole): { job: string; missing: Permission[] }[] { + const has = new Set(r.permissions); + return jobsBehind(r.jobs ?? [], (p) => has.has(p)); +} + +/** Re-apply = add what the followed jobs now carry. Nothing is removed; the save is + * signed like any other role edit. */ +async function reapply(r: ManagedRole, ok: () => void, onError: (e: unknown) => void) { + const missing = behindOf(r).flatMap((b) => b.missing); + try { await updateRole(r.id, { permissions: [...new Set([...r.permissions, ...missing])] }); ok(); } catch (e) { onError(e); } +} + /** The jobs the composer offers: every effective module's, in registry order. */ function jobsFor(effective: readonly ModuleId[]): { module: ModuleId; job: JobPreset }[] { return MODULES.filter((m) => effective.includes(m.id)).flatMap((m) => m.jobs.map((job) => ({ module: m.id, job }))); } /** Composer lints — warnings about what the admin just composed. */ -function lintRole(perms: Set, effective: readonly ModuleId[]): { key: string; vars?: Record }[] { +function lintRole(perms: Set, jobs: Set, effective: readonly ModuleId[]): { key: string; vars?: Record }[] { const has = (p: Permission) => perms.has(p); const out: { key: string; vars?: Record }[] = []; + // Behind a job it follows: the bundle grew (a newer release) past what the role holds. + for (const b of jobsBehind([...jobs], has)) out.push({ key: "roles.lintJobBehind", vars: { job: b.job, missing: b.missing.join(", ") } }); // Mixes desks: may OPEN more than one till. const workable: TillId[] = tillsFor(effective, has, "shift"); if (workable.length > 1) out.push({ key: "roles.lintMixedTills", vars: { tills: workable.join(", ") } }); @@ -165,11 +194,14 @@ function RoleEditor({ grouped: Record; effective: readonly ModuleId[]; onCancel: () => void; - onSubmit: (v: { name: string; permissions: Permission[] }) => void; + onSubmit: (v: { name: string; permissions: Permission[]; jobs: string[] }) => void; }) { const { t } = useTranslation(); const [name, setName] = useState(role?.name ?? ""); const [perms, setPerms] = useState>(new Set(role?.permissions ?? [])); + // The jobs this role follows: what was remembered, plus (at save) any bundle that is + // fully present — so a role composed before jobs were remembered picks them up. + const [jobIds, setJobIds] = useState>(new Set(role?.jobs ?? [])); const toggle = (p: Permission) => setPerms((prev) => { const next = new Set(prev); @@ -177,15 +209,24 @@ function RoleEditor({ return next; }); const jobs = useMemo(() => jobsFor(effective), [effective]); - const jobOn = (job: JobPreset) => job.permissions.every((p) => perms.has(p)); - const toggleJob = (job: JobPreset) => + const complete = (job: JobPreset) => job.permissions.every((p) => perms.has(p)); + const jobOn = (job: JobPreset) => jobIds.has(job.id) || complete(job); + const toggleJob = (job: JobPreset) => { + const on = jobOn(job); + setJobIds((prev) => { + const next = new Set(prev); + on ? next.delete(job.id) : next.add(job.id); + return next; + }); setPerms((prev) => { const next = new Set(prev); - if (job.permissions.every((p) => prev.has(p))) for (const p of job.permissions) next.delete(p); + if (on) for (const p of job.permissions) next.delete(p); else for (const p of job.permissions) next.add(p); return next; }); - const lints = useMemo(() => lintRole(perms, effective), [perms, effective]); + }; + const lints = useMemo(() => lintRole(perms, jobIds, effective), [perms, jobIds, effective]); + const followed = () => jobs.filter(({ job }) => jobIds.has(job.id) || complete(job)).map(({ job }) => job.id); const valid = name.trim().length > 0; @@ -244,7 +285,7 @@ function RoleEditor({
- +
); diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index dae0334..11f9055 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -213,6 +213,9 @@ export interface ManagedRole { name: string; builtin: boolean; permissions: Permission[]; + /** The manifest JOBS this role follows (composed from their chips). A later release + * that grows a job shows the role as "behind" it — re-applied with one click. */ + jobs: string[]; userCount: number; } @@ -241,10 +244,10 @@ export function deleteUser(id: string): Promise<{ ok: boolean }> { export function fetchRoles(): Promise<{ catalog: Permission[]; roles: ManagedRole[] }> { return apiFetch("/api/roles"); } -export function createRole(body: { name: string; permissions: Permission[] }): Promise { +export function createRole(body: { name: string; permissions: Permission[]; jobs?: string[] }): Promise { return apiFetch("/api/roles", { method: "POST", body: JSON.stringify(body) }); } -export function updateRole(id: string, body: { name?: string; permissions?: Permission[] }): Promise { +export function updateRole(id: string, body: { name?: string; permissions?: Permission[]; jobs?: string[] }): Promise { return apiFetch(`/api/roles/${id}`, { method: "PUT", body: JSON.stringify(body) }); } export function deleteRole(id: string): Promise<{ ok: boolean }> { diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index 0396577..44fe7e7 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -917,6 +917,9 @@ export const en: Catalog = { jobsHint: "A job adds its permissions in one click; fine-tune below. Tap it again to remove them.", lintMixedTills: "This role can open more than one till ({{tills}}) — one person, two drawers. Intended?", lintPartialJob: "Partial \"{{job}}\": missing {{missing}} — this desk can look but not act.", + lintJobBehind: "Behind \"{{job}}\": this release added {{missing}} to the job. Tap the job chip off and on to take it, or tick it below.", + behind: "behind {{job}}", + reapply: "Update to job", }, jobs: { "booth-operator": "Booth operator", diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts index b76ff91..bde4be1 100644 --- a/apps/web/src/lib/i18n/sq.ts +++ b/apps/web/src/lib/i18n/sq.ts @@ -931,6 +931,9 @@ export const sq = { jobsHint: "Një punë shton lejet e saj me një klik; rregulloji poshtë. Kliko sërish për t'i hequr.", lintMixedTills: "Ky rol mund të hapë më shumë se një arkë ({{tills}}) — një person, dy arka. E qëllimshme?", lintPartialJob: "\"{{job}}\" e pjesshme: mungojnë {{missing}} — kjo tavolinë sheh, por nuk vepron.", + lintJobBehind: "Pas \"{{job}}\": ky version i shtoi punës {{missing}}. Hiqe dhe rivendose punën për t'i marrë, ose shënoji poshtë.", + behind: "pas {{job}}", + reapply: "Përditëso sipas punës", }, jobs: { "booth-operator": "Operator kabine", diff --git a/packages/db/drizzle/0029_role_jobs.sql b/packages/db/drizzle/0029_role_jobs.sql new file mode 100644 index 0000000..40e9d0b --- /dev/null +++ b/packages/db/drizzle/0029_role_jobs.sql @@ -0,0 +1,10 @@ +-- Roles remember the manifest JOBS they were composed from (venue-modules.md §"Permissions +-- matrix", move 2) so a role built from a job chip can be flagged and re-applied when a +-- later release grows that job's bundle. The permission grid stays the enforcement layer. +CREATE TABLE `role_jobs` ( + `role_id` text NOT NULL, + `job_id` text NOT NULL, + FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE UNIQUE INDEX `role_jobs_role_id_job_id_unique` ON `role_jobs` (`role_id`,`job_id`); diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index e28d00f..3e1ff2d 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -204,6 +204,13 @@ "when": 1788605000000, "tag": "0028_carwash_config", "breakpoints": true + }, + { + "idx": 29, + "version": "6", + "when": 1788690000000, + "tag": "0029_role_jobs", + "breakpoints": true } ] } diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 5ebb12d..7879b61 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -53,6 +53,24 @@ export const rolePermissions = sqliteTable( }), ); +/** The JOBS a role follows (venue-modules.md §"Permissions matrix", move 2): the + * manifest job presets the admin composed it from. Remembered so a later release that + * grows a job's bundle can be surfaced ("this role is behind the Wash operator job") + * and re-applied with one click — never expanded silently at runtime: what a role may + * do is always the explicit `role_permissions` grid. */ +export const roleJobs = sqliteTable( + "role_jobs", + { + roleId: text("role_id") + .notNull() + .references(() => roles.id), + jobId: text("job_id").notNull(), + }, + (t) => ({ + uniq: unique().on(t.roleId, t.jobId), + }), +); + export const users = sqliteTable("users", { id: text("id").primaryKey(), username: text("username").notNull().unique(), diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 693a998..5002686 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1937,6 +1937,31 @@ export function tillOfEvent(type: LedgerEventType, payload: { till?: TillId } | return m?.till ?? BOOTH_TILL; } +/** A job preset by id, with the module that declares it (null = no such job — e.g. a + * job remembered by a role whose module was removed from the registry). */ +export function jobById(id: string): { module: ModuleId; job: JobPreset } | null { + for (const m of MODULES) for (const job of m.jobs) if (job.id === id) return { module: m.id, job }; + return null; +} + +/** The jobs a role FOLLOWS whose bundle has grown past what the role holds: the role + * was built from the chip, a later release added a permission to the job, and the + * role fell behind. The admin re-applies with one click (or drops the job); the grid + * is never expanded silently. Jobs no longer in the registry are ignored. */ +export function jobsBehind( + jobs: readonly string[], + has: (p: Permission) => boolean, +): { job: string; missing: Permission[] }[] { + const out: { job: string; missing: Permission[] }[] = []; + for (const id of jobs) { + const found = jobById(id); + if (!found) continue; + const missing = found.job.permissions.filter((p) => !has(p)); + if (missing.length > 0) out.push({ job: id, missing }); + } + return out; +} + export function tillsWorkableBy(effective: readonly ModuleId[], has: (p: Permission) => boolean): TillId[] { return tillsFor(effective, has, "shift"); } diff --git a/wiki/decisions/venue-modules.md b/wiki/decisions/venue-modules.md index 303d30a..cf738bc 100644 --- a/wiki/decisions/venue-modules.md +++ b/wiki/decisions/venue-modules.md @@ -439,9 +439,23 @@ resources — the till already IS that copy. Role *templates stored in the DB* (they change with the module), roles are data; keep that line. **Status.** Moves 1, 2 and 3 built 2026-09-05 (see the Tills as-built below and [[shift]] -§Tills). Open: whether `booth-supervisor` should carry `subscription:*` by default; whether a -job should be *re-applicable* after a module update (today a chip only adds/removes the bundle -as it is now); an audit `config_change` on role edits. +§Tills). The three loose ends closed 2026-09-06: +- `booth-supervisor` DOES carry `subscription:read/create/update` (plus `tariff:read`, + `validation:read`) — it already did; the note was stale. Decided: a supervisor sells and + edits subscriptions by default. +- **Jobs are remembered and re-appliable.** A role stores the jobs it follows (`role_jobs`: + the chips on at save, plus any bundle fully present). `jobsBehind(jobs, has)` in + `@parking/shared` lists a followed job whose bundle has GROWN past the role (a newer + release added a permission); the roles list shows a "behind " badge with a one-click + "Update to job" (the union; nothing removed), the editor lints it. Deliberately NOT a + runtime union: what a role may do is always the explicit grid, and a software update never + changes it without an admin's click — see the threat model. The first failure of this kind + was the wash operator's empty price list (the settings read needed `site:read`; now + `carwash:read` OR `site:read`, `requireAnyPermission`). +- **Role edits are signed.** Create/update/delete each append one `config_change` + (`setting: role.`, `value`/`prev` = name + sorted permissions + jobs, `operator`); a + no-op resave signs nothing. A role edit is a privilege change and was the one setting an + admin could alter without a trace. ## Tills: shifts per money-taking module — BUILT (raised + built 2026-09-05) diff --git a/wiki/entities/local-jwt-auth.md b/wiki/entities/local-jwt-auth.md index a758d76..f2c4969 100644 --- a/wiki/entities/local-jwt-auth.md +++ b/wiki/entities/local-jwt-auth.md @@ -37,6 +37,11 @@ Authentication and authorization, kept **fully local** — a direct consequence `bumpPermsCache()`, which user update/delete now call), so REASSIGNING a user's role — or deleting the user (→ 401 on their next request) — applies immediately too. Found when a user moved to a new wash role kept the old role's rights until logout. + **Role edits are signed (2026-09-06):** every create/update/delete of a role appends a + `config_change` (`role.`, before/after shape, operator) to the ledger, and a role remembers + the manifest JOBS it was composed from (`role_jobs`) so a job that grows in a later release can + be re-applied with one click rather than expanding silently — [[venue-modules]] §Permissions + matrix. - **Protected built-in `admin` role** (`id='admin'`, `builtin=1`): non-editable, non-deletable, and always resolves to the FULL permission set in code. The app refuses to delete or downgrade the **last user holding admin** — administration can never be locked out of the appliance. diff --git a/wiki/log.md b/wiki/log.md index b869d5b..9b74bc5 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -3070,3 +3070,13 @@ category/service pickers stayed empty. Cause: `GET /api/carwash/settings` was gu `site:read` only — the price list is Setup's data AND the desk's working data. Fixed with a new `requireAnyPermission(...)` guard (auth.ts): the read opens to `carwash:read` OR `site:read`; the write stays `site:update`. Regression test in carwash.test.ts. + +## [2026-09-06] ingest | Permissions matrix loose ends: jobs remembered + re-appliable, role edits signed +Roles now store the jobs they follow (`role_jobs`, migration 0029); `jobsBehind()` in +`@parking/shared` surfaces a followed job whose bundle grew past the role; the roles list shows a +"behind " badge + "Update to job" (union, nothing removed) and the editor lints it. Not a +runtime union by decision (the grid stays explicit; an update never widens a role without a +click). Every role create/update/delete appends a `config_change` (`role.`, prev/value = +name + permissions + jobs, operator); a no-op resave signs nothing. The stale "should +booth-supervisor carry subscription:*" note is closed — it already does. Tests: routes/roles.test.ts. +Updated [[venue-modules]] §Permissions matrix status, [[local-jwt-auth]].