diff --git a/apps/edr-freight-api/src/common/freight-permission.guard.ts b/apps/edr-freight-api/src/common/freight-permission.guard.ts index db6275c07..9cb002891 100644 --- a/apps/edr-freight-api/src/common/freight-permission.guard.ts +++ b/apps/edr-freight-api/src/common/freight-permission.guard.ts @@ -9,6 +9,7 @@ import { import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import { hasFreightPermission, isSuperAdmin } from './freight-permission.util'; +import { readTwinOf } from '../seed/freight-permissions.registry'; // String literals on purpose (same reasoning as login-audience.middleware.ts): // the values are wire-format constants from iam.users.user_type, and importing @@ -22,13 +23,43 @@ const userTypeOf = (user: TCurrentUser): string | undefined => const isEmployee = (user: TCurrentUser): boolean => userTypeOf(user) === 'employee' || isSuperAdmin(user); +const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); + +/** + * Does the caller satisfy a required permission? + * + * Holding the key outright always passes. A required `:view` is ALSO + * satisfied by the weaker `:read` — the key that buys API reads + * without putting the module in the backoffice sidebar — but only on a safe + * HTTP method. + * + * The method restriction is load-bearing, not caution. Nest runs class AND + * method guards, so controllers list every route key on the class gate, + * `:view` among the write keys. Without this check a `:read` holder would + * clear that class gate and then reach any write route that has no method + * gate of its own. Keying on the HTTP verb closes that by construction rather + * than by an audit that goes stale the next time a route is added. + */ +const satisfiedBy = ( + user: TCurrentUser, + required: string, + method: string, +): boolean => { + if (hasFreightPermission(user, required)) return true; + if (!SAFE_METHODS.has(method)) return false; + const readTwin = readTwinOf(required); + return Boolean(readTwin && hasFreightPermission(user, readTwin)); +}; + export function FreightPermissionGuard( permissions: string[], ): Type { @Injectable() class FreightPermissionsGuard implements CanActivate { canActivate(context: ExecutionContext): boolean { - const request = context.switchToHttp().getRequest<{ user?: TCurrentUser }>(); + const request = context + .switchToHttp() + .getRequest<{ user?: TCurrentUser; method: string }>(); const user = request.user; if (!user) { @@ -39,7 +70,7 @@ export function FreightPermissionGuard( } if (!permissions?.length) return true; - if (permissions.some((p) => hasFreightPermission(user, p))) { + if (permissions.some((p) => satisfiedBy(user, p, request.method))) { return true; } @@ -79,7 +110,9 @@ export function MixedAudienceGuard(permissions: string[]): Type { @Injectable() class MixedAudiencesGuard implements CanActivate { canActivate(context: ExecutionContext): boolean { - const request = context.switchToHttp().getRequest<{ user?: TCurrentUser }>(); + const request = context + .switchToHttp() + .getRequest<{ user?: TCurrentUser; method: string }>(); const user = request.user; if (!user) { @@ -93,7 +126,7 @@ export function MixedAudienceGuard(permissions: string[]): Type { } if ( !permissions?.length || - permissions.some((p) => hasFreightPermission(user, p)) + permissions.some((p) => satisfiedBy(user, p, request.method)) ) { return true; } diff --git a/apps/edr-freight-api/src/seed/edr-freight.seed.ts b/apps/edr-freight-api/src/seed/edr-freight.seed.ts index 22f7fe5ef..d90fe7b80 100644 --- a/apps/edr-freight-api/src/seed/edr-freight.seed.ts +++ b/apps/edr-freight-api/src/seed/edr-freight.seed.ts @@ -1,6 +1,7 @@ import { BOOKING_RULE_ENGINE_PERMISSIONS, BOOKING_RULE_ENGINE_PERMISSION_KEYS, + deriveReadPermissions, POSITION_PERMISSION_PRESETS, ROLE_PERMISSION_PRESETS, } from './freight-permissions.registry'; @@ -190,7 +191,7 @@ const POSITION_TYPE_PERMISSIONS = [ }, ] as const; -export const EDR_FREIGHT_PERMISSIONS = [ +const EDR_FREIGHT_VIEWABLE_PERMISSIONS = [ ...EMPLOYEE_REGISTRATION_PERMISSIONS, ...ROLE_ASSIGNMENT_PERMISSIONS, ...HIERARCHY_UNIT_PERMISSIONS, @@ -200,6 +201,15 @@ export const EDR_FREIGHT_PERMISSIONS = [ ...BOOKING_RULE_ENGINE_PERMISSIONS, ]; +export const EDR_FREIGHT_PERMISSIONS = [ + ...EDR_FREIGHT_VIEWABLE_PERMISSIONS, + // API-read twin of every `:view` key above — grants the module's GET routes + // without putting it in the backoffice sidebar. Seeded so they can be + // assigned; no role or position preset below grants one, that stays + // hand-curated in iam.position_type_permissions. + ...deriveReadPermissions(EDR_FREIGHT_VIEWABLE_PERMISSIONS), +]; + export { BOOKING_RULE_ENGINE_PERMISSION_KEYS } from './freight-permissions.registry'; export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [ diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.spec.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.spec.ts index caab85c3e..b69dcff71 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.spec.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.spec.ts @@ -1,4 +1,5 @@ import { EDR_FREIGHT_PERMISSIONS } from './edr-freight.seed'; +import { readTwinOf } from './freight-permissions.registry'; describe('EDR_FREIGHT_PERMISSIONS', () => { // The seeder inserts the whole catalog in one ON CONFLICT (key) DO UPDATE @@ -10,4 +11,40 @@ describe('EDR_FREIGHT_PERMISSIONS', () => { expect(duplicates).toEqual([]); }); + + // The `:read` ids are derived rather than hand-written, so a collision + // would surface here instead of as a primary-key violation on a fresh + // database. + it('has no duplicate ids', () => { + const ids = EDR_FREIGHT_PERMISSIONS.map((permission) => permission.id); + const duplicates = [...new Set(ids.filter((id, i) => ids.indexOf(id) !== i))]; + + expect(duplicates).toEqual([]); + }); + + // `:read` grants a module's GET routes without putting it in the backoffice + // sidebar. Every `:view` needs its twin or that module has no way to be + // granted API-only access. + it('gives every :view key a :read twin', () => { + const keys = new Set(EDR_FREIGHT_PERMISSIONS.map((p) => p.key)); + const missing = [...keys] + .filter((key) => key.endsWith(':view')) + .map((key) => readTwinOf(key)) + .filter((twin): twin is string => twin !== null && !keys.has(twin)); + + expect(missing).toEqual([]); + }); + + it('mints every :read id in the v5 block', () => { + const reads = EDR_FREIGHT_PERMISSIONS.filter((p) => p.key.endsWith(':read')); + + expect(reads.length).toBeGreaterThan(0); + // Version nibble 5 — the source `:view` ids are all v4, so the two sets + // cannot overlap however many keys are added. + for (const read of reads) { + expect(read.id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$/, + ); + } + }); }); diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 11f0ee40c..cbe2fcf11 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -31,6 +31,15 @@ export type RuleEngineResourceSlug = const slugToResourceKey = (slug: RuleEngineResourceSlug): string => slug.replace(/-/g, "_"); +const VIEW_KEY_SUFFIX = ":view"; +const READ_KEY_SUFFIX = ":read"; + +/** The `:read` twin of a `:view` key, or null if `key` is not a view key. */ +export const readTwinOf = (key: string): string | null => + key.endsWith(VIEW_KEY_SUFFIX) + ? `${key.slice(0, -VIEW_KEY_SUFFIX.length)}${READ_KEY_SUFFIX}` + : null; + const perm = (id: string, key: string, en: string): FreightPermissionSeed => ({ id, key, @@ -1352,6 +1361,59 @@ export const BOOKING_RULE_ENGINE_PERMISSIONS = [ export const BOOKING_RULE_ENGINE_PERMISSION_KEYS = BOOKING_RULE_ENGINE_PERMISSIONS.map((p) => p.key); +/** + * Id for a derived `:read` key: the source `:view` id with its version nibble + * moved 4 → 5. + * + * Unique because the source ids are, and disjoint from every hand-written id + * because those are all v4-shaped. Nothing index-derived, so a new `:view` + * landing mid-list cannot shift ids already seeded — the trap the + * RULE_ENGINE_RESOURCE_SLUGS comment warns about. + * + * (`EdrOrgSeeder.ensurePermissions` deliberately never sends an id — the + * column default wins and `key` is the identity every consumer resolves by. + * These exist to satisfy the seed type and keep the array self-consistent.) + */ +const readPermissionId = (viewId: string): string => + `${viewId.slice(0, 14)}5${viewId.slice(15)}`; + +/** + * API-read twin of every `:view` key. + * + * `:view` does three jobs at once — backoffice sidebar entry (App.tsx), route + * admission (RequirePermission), and API read. That bundling means a user who + * only needs another module's list endpoint for a form dropdown has to be + * granted the whole module, page and all. `:read` unbundles it: the + * guard accepts it wherever the matching `:view` is required on a GET, and the + * frontend never looks at it, so the module stays out of the menu. + * + * Derived rather than hand-written so a new `:view` key gets its twin for + * free. Grants stay hand-curated in `iam.position_type_permissions` — nothing + * here hands a `:read` to anyone. + */ +export const deriveReadPermissions = ( + seeds: readonly FreightPermissionSeed[], +): FreightPermissionSeed[] => + seeds + .filter((p) => p.key.endsWith(VIEW_KEY_SUFFIX)) + .map((p) => + perm( + readPermissionId(p.id), + readTwinOf(p.key) as string, + `Read ${p.name.en.replace(/^View /, "")} (API only)`, + ), + ); + +/** + * Read twins of the freight-domain catalog, for `PERMISSIONS_CATALOG`. The + * IAM/hierarchy keys seeded alongside it live in `edr-freight.seed.ts` and get + * theirs there — each twin is derived from its own source row, so the two call + * sites agree on any key they share without coordination. + */ +export const FREIGHT_READ_PERMISSIONS = deriveReadPermissions( + BOOKING_RULE_ENGINE_PERMISSIONS, +); + export const FREIGHT_PERMS = { bookings: { view: "edr_freight_app:bookings:view", @@ -2069,7 +2131,10 @@ export const POSITION_PERMISSION_PRESETS = { /** Derive the module bucket from the resource segment of a permission key. */ const moduleOf = (key: string): string => key.split(":")[1] ?? "other"; -export const PERMISSIONS_CATALOG = BOOKING_RULE_ENGINE_PERMISSIONS.map((p) => ({ +export const PERMISSIONS_CATALOG = [ + ...BOOKING_RULE_ENGINE_PERMISSIONS, + ...FREIGHT_READ_PERMISSIONS, +].map((p) => ({ key: p.key, label: p.name.en, module: moduleOf(p.key),