feat(permissions): per-desk till guards, jobs in the role composer, permission-scoped live feed; role reassignment applies without re-login
Permissions matrix rethink (wiki/decisions/venue-modules.md §"Permissions matrix", open-questions #16) — the grid stays the enforcement layer: - Move 1: each desk's money is guarded by that desk's own permissions. Manifest tillGuards {read, shift, cash}: booth = shift:read / shift:create / drawer:create (unchanged), carwash = carwash:read / carwash:cash (new). Shift + drawer routes resolve the guard FROM THE TILL (requireTill); a wash role holds no shift:* and cannot touch the booth by construction. Replaces the session:read borrowing (tillPermission). /api/shift/tills lists the role's readable tills with canWork; history/movements without a till filter return the union of readable tills. - Move 2: jobs — manifest permission bundles (booth-operator, booth-supervisor, merchant, wash-operator) as one-click chips in Setup → Roles, with "mixes desks" and "partial job" lints (warnings, never blocks). - Move 3: the live WebSocket admits any watch permission (event/session/device read or a module's feedPermission) and filters every push per role; report:read is the reports screen only. Auth: the token's roleId is only a hint — refreshRole() after every jwtVerify resolves the user's CURRENT role (cached, bumped on role/user writes), so reassigning a user's role applies on the next request and a deleted user's session ends with 401. Tests: till guards + look-only role, feed rules, every job's permissions exist, role reassignment without re-login. 353/353. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
This commit is contained in:
+36
-3
@@ -1,6 +1,6 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import type { FastifyReply, FastifyRequest } from "fastify";
|
||||
import { eq, rolePermissions, type Db } from "@parking/db";
|
||||
import { eq, rolePermissions, users, type Db } from "@parking/db";
|
||||
import { ADMIN_ROLE_ID, PERMISSIONS, type Permission } from "@parking/shared";
|
||||
|
||||
// Local JWT auth helpers — fully local, no external identity provider
|
||||
@@ -143,10 +143,41 @@ export function initAuth(db: Db): void {
|
||||
permsCache.clear();
|
||||
}
|
||||
|
||||
/** Clear the permission cache. Call after ANY write to roles / role_permissions
|
||||
* (or a user's roleId) so the change takes effect on the next request. */
|
||||
/** Clear the permission + role caches. Call after ANY write to roles / role_permissions
|
||||
* or to a user's roleId / deletion, so the change takes effect on the next request. */
|
||||
export function bumpPermsCache(): void {
|
||||
permsCache.clear();
|
||||
roleCache.clear();
|
||||
}
|
||||
|
||||
/** userId → CURRENT roleId, cached until bumpPermsCache(). */
|
||||
const roleCache = new Map<string, string | null>();
|
||||
|
||||
/** The user's CURRENT role. The token pins the roleId that was current at LOGIN; an
|
||||
* admin reassigning a user's role (or deleting the user) must take effect on the next
|
||||
* request exactly like editing a role does — otherwise the reassigned user keeps the
|
||||
* old role's rights until they log out (found 2026-09-05: a user moved to a new
|
||||
* wash role kept 403ing on the new role's permissions). null = the user is gone. */
|
||||
export function currentRoleId(sub: string): string | null {
|
||||
if (!authDb) throw new Error("auth not initialised (call initAuth)");
|
||||
const hit = roleCache.get(sub);
|
||||
if (hit !== undefined) return hit;
|
||||
const row = authDb
|
||||
.select({ roleId: users.roleId, deletedAt: users.deletedAt })
|
||||
.from(users)
|
||||
.where(eq(users.id, sub))
|
||||
.get();
|
||||
const roleId = row && row.deletedAt == null ? row.roleId : null;
|
||||
roleCache.set(sub, roleId);
|
||||
return roleId;
|
||||
}
|
||||
|
||||
/** After jwtVerify: replace the token's pinned roleId with the user's current one, or
|
||||
* end the session if the user no longer exists. */
|
||||
function refreshRole(req: FastifyRequest): void {
|
||||
const roleId = currentRoleId(req.user.sub);
|
||||
if (roleId === null) throw Object.assign(new Error("session no longer valid"), { statusCode: 401 });
|
||||
if (roleId !== req.user.roleId) req.user.roleId = roleId;
|
||||
}
|
||||
|
||||
/** The permission set for a role id, cached. `admin` is always the full set. */
|
||||
@@ -184,6 +215,7 @@ export function requirePermission(...required: Permission[]) {
|
||||
return async (req: FastifyRequest, _reply: FastifyReply) => {
|
||||
await req.jwtVerify(); // reads the token cookie (configured in server.ts)
|
||||
assertCsrf(req);
|
||||
refreshRole(req);
|
||||
if (!req.user || !roleHasPermissions(req.user.roleId, required)) {
|
||||
throw Object.assign(new Error("forbidden"), { statusCode: 403 });
|
||||
}
|
||||
@@ -201,4 +233,5 @@ export async function requireAuth(
|
||||
): Promise<void> {
|
||||
await req.jwtVerify();
|
||||
assertCsrf(req);
|
||||
refreshRole(req);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user