mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 16:28:12 +00:00
@@ -95,9 +95,14 @@ export const TrainSchedulingRulesManage = () =>
|
||||
* wagons:delete, …). The legacy coarse fleet:view / fleet:manage keys remain
|
||||
* valid as a one-of fallback so existing role grants keep working.
|
||||
*/
|
||||
export const FleetView = (granular?: string) =>
|
||||
export const FleetView = (granular?: string | string[]) =>
|
||||
BookingStaff(
|
||||
granular ? [granular, FREIGHT_PERMS.fleet.view] : FREIGHT_PERMS.fleet.view,
|
||||
granular
|
||||
? [
|
||||
...(Array.isArray(granular) ? granular : [granular]),
|
||||
FREIGHT_PERMS.fleet.view,
|
||||
]
|
||||
: FREIGHT_PERMS.fleet.view,
|
||||
);
|
||||
|
||||
export const FleetManage = (granular?: string) =>
|
||||
|
||||
@@ -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 `<module>:view` is ALSO
|
||||
* satisfied by the weaker `<module>: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<CanActivate> {
|
||||
@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<CanActivate> {
|
||||
@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<CanActivate> {
|
||||
}
|
||||
if (
|
||||
!permissions?.length ||
|
||||
permissions.some((p) => hasFreightPermission(user, p))
|
||||
permissions.some((p) => satisfiedBy(user, p, request.method))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,12 @@ import { FilterInvoiceDto } from "./dto/filter-invoice.dto";
|
||||
|
||||
@ApiTags("billing")
|
||||
@Controller("billing")
|
||||
@BookingStaff(FREIGHT_PERMS.invoices.view)
|
||||
// Class gate lists every key its routes use: Nest runs class AND method
|
||||
// guards, so a key missing here would deny before the route's own key runs.
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.invoices.view,
|
||||
FREIGHT_PERMS.invoices.export,
|
||||
])
|
||||
@ApiBearerAuth()
|
||||
export class BillingController {
|
||||
constructor(
|
||||
|
||||
@@ -20,7 +20,14 @@ import { CargoesService } from './cargoes.service';
|
||||
|
||||
@ApiTags('cargoes')
|
||||
@Controller('cargoes')
|
||||
@FleetView(FREIGHT_PERMS.cargoes.view)
|
||||
// Class gate lists every key its routes use: Nest runs class AND method
|
||||
// guards, so a key missing here would deny before the route's own key runs.
|
||||
@FleetView([
|
||||
FREIGHT_PERMS.cargoes.view,
|
||||
FREIGHT_PERMS.cargoes.create,
|
||||
FREIGHT_PERMS.cargoes.update,
|
||||
FREIGHT_PERMS.cargoes.delete,
|
||||
])
|
||||
export class CargoesController {
|
||||
constructor(private readonly cargoesService: CargoesService) {}
|
||||
|
||||
|
||||
@@ -11,7 +11,12 @@ import { ComplianceType } from './entities/compliance-record.entity';
|
||||
|
||||
@ApiTags('Vehicle Compliance')
|
||||
@Controller('compliance')
|
||||
@BookingStaff(FREIGHT_PERMS.compliance.view)
|
||||
// Class gate lists every key its routes use: Nest runs class AND method
|
||||
// guards, so a key missing here would deny before the route's own key runs.
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.compliance.view,
|
||||
FREIGHT_PERMS.compliance.manage,
|
||||
])
|
||||
export class ComplianceController {
|
||||
constructor(private readonly complianceService: ComplianceService) {}
|
||||
|
||||
|
||||
@@ -17,7 +17,12 @@ import { FilterConsignmentDto } from "./dto/filter-consignment.dto";
|
||||
|
||||
@ApiTags("consignments")
|
||||
@Controller("consignments")
|
||||
@FleetView(FREIGHT_PERMS.consignments.view)
|
||||
// Class gate lists every key its routes use: Nest runs class AND method
|
||||
// guards, so a key missing here would deny before the route's own key runs.
|
||||
@FleetView([
|
||||
FREIGHT_PERMS.consignments.view,
|
||||
FREIGHT_PERMS.consignments.create,
|
||||
])
|
||||
export class ConsignmentsController {
|
||||
constructor(private readonly consignmentsService: ConsignmentsService) {}
|
||||
|
||||
|
||||
@@ -19,7 +19,14 @@ import { ContainersService } from './containers.service';
|
||||
|
||||
@ApiTags('containers')
|
||||
@Controller('containers')
|
||||
@FleetView(FREIGHT_PERMS.containers.view)
|
||||
// Class gate lists every key its routes use: Nest runs class AND method
|
||||
// guards, so a key missing here would deny before the route's own key runs.
|
||||
@FleetView([
|
||||
FREIGHT_PERMS.containers.view,
|
||||
FREIGHT_PERMS.containers.create,
|
||||
FREIGHT_PERMS.containers.update,
|
||||
FREIGHT_PERMS.containers.delete,
|
||||
])
|
||||
export class ContainersController {
|
||||
constructor(private readonly containersService: ContainersService) {}
|
||||
|
||||
|
||||
@@ -24,7 +24,14 @@ import { FleetHistoryService } from '../fleet-history/fleet-history.service';
|
||||
@ApiTags('drivers')
|
||||
@ApiBearerAuth()
|
||||
@Controller('drivers')
|
||||
@BookingStaff(FREIGHT_PERMS.drivers.view)
|
||||
// Class gate lists every key its routes use: Nest runs class AND method
|
||||
// guards, so a key missing here would deny before the route's own key runs.
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.drivers.view,
|
||||
FREIGHT_PERMS.drivers.create,
|
||||
FREIGHT_PERMS.drivers.update,
|
||||
FREIGHT_PERMS.drivers.delete,
|
||||
])
|
||||
export class DriversController {
|
||||
constructor(
|
||||
private readonly driversService: DriversService,
|
||||
|
||||
@@ -10,7 +10,12 @@ import { FacilitiesService } from './facilities.service';
|
||||
|
||||
@ApiTags('Facilities')
|
||||
@Controller('facilities')
|
||||
@BookingStaff(FREIGHT_PERMS.facilities.view)
|
||||
// Class gate lists every key its routes use: Nest runs class AND method
|
||||
// guards, so a key missing here would deny before the route's own key runs.
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.facilities.view,
|
||||
FREIGHT_PERMS.facilities.manage,
|
||||
])
|
||||
export class FacilitiesController {
|
||||
constructor(private readonly facilitiesService: FacilitiesService) {}
|
||||
|
||||
|
||||
@@ -19,7 +19,12 @@ import { RegisterDeviceDto, UpdateDeviceDto } from './dto/gps-device.dto';
|
||||
@ApiTags('gps-tracking')
|
||||
@ApiBearerAuth()
|
||||
@Controller('gps')
|
||||
@BookingStaff(FREIGHT_PERMS.tracking.view)
|
||||
// Class gate lists every key its routes use: Nest runs class AND method
|
||||
// guards, so a key missing here would deny before the route's own key runs.
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.tracking.view,
|
||||
FREIGHT_PERMS.tracking.manage,
|
||||
])
|
||||
export class GpsTrackingController {
|
||||
constructor(private readonly gps: GpsTrackingService) {}
|
||||
|
||||
|
||||
@@ -22,7 +22,14 @@ import { IncidentStatus, IncidentType } from './entities/incident.entity';
|
||||
// No incidents-specific permission exists in the registry, so this reuses the
|
||||
// (real) drivers.* fleet-road keys — incident records are driver-safety data
|
||||
// (driver stats / incident history). TODO: add a dedicated incidents:* key.
|
||||
@BookingStaff(FREIGHT_PERMS.drivers.view)
|
||||
// Class gate lists every key its routes use: Nest runs class AND method
|
||||
// guards, so a key missing here would deny before the route's own key runs.
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.drivers.view,
|
||||
FREIGHT_PERMS.drivers.create,
|
||||
FREIGHT_PERMS.drivers.update,
|
||||
FREIGHT_PERMS.drivers.delete,
|
||||
])
|
||||
export class IncidentsController {
|
||||
constructor(private readonly incidentsService: IncidentsService) {}
|
||||
|
||||
|
||||
@@ -15,7 +15,14 @@ import { InterchangeDocumentsService } from './interchange-documents.service';
|
||||
@ApiBearerAuth()
|
||||
@Controller('interchange-documents')
|
||||
// Class-level view guard; each write route adds its own manage permission below.
|
||||
@BookingStaff(FREIGHT_PERMS.interchangeDocuments.view)
|
||||
// Class gate lists every key its routes use: Nest runs class AND method
|
||||
// guards, so a key missing here would deny before the route's own key runs.
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.interchangeDocuments.view,
|
||||
FREIGHT_PERMS.interchangeDocuments.generate,
|
||||
FREIGHT_PERMS.interchangeDocuments.acknowledge,
|
||||
FREIGHT_PERMS.interchangeDocuments.dispute,
|
||||
])
|
||||
export class InterchangeDocumentsController {
|
||||
constructor(private readonly service: InterchangeDocumentsService) {}
|
||||
|
||||
|
||||
@@ -13,7 +13,14 @@ import {
|
||||
|
||||
@ApiTags('Procurement & Asset Lifecycle')
|
||||
@Controller('procurement')
|
||||
@BookingStaff(FREIGHT_PERMS.procurement.view)
|
||||
// Class gate lists every key its routes use: Nest runs class AND method
|
||||
// guards, so a key missing here would deny before the route's own key runs.
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.procurement.view,
|
||||
FREIGHT_PERMS.procurement.vendorManage,
|
||||
FREIGHT_PERMS.procurement.acquisitionManage,
|
||||
FREIGHT_PERMS.procurement.disposalManage,
|
||||
])
|
||||
export class ProcurementController {
|
||||
constructor(private readonly procurementService: ProcurementService) {}
|
||||
|
||||
|
||||
@@ -27,7 +27,15 @@ import { RoutesService } from './routes.service';
|
||||
@ApiTags('routes')
|
||||
@ApiBearerAuth()
|
||||
@Controller('routes')
|
||||
@FleetView(FREIGHT_PERMS.routes.view)
|
||||
// Class gate lists every key its routes use: Nest runs class AND method
|
||||
// guards, so a key missing here would deny before the route's own key runs.
|
||||
@FleetView([
|
||||
FREIGHT_PERMS.routes.view,
|
||||
FREIGHT_PERMS.routes.create,
|
||||
FREIGHT_PERMS.routes.update,
|
||||
FREIGHT_PERMS.routes.hardDelete,
|
||||
FREIGHT_PERMS.routes.delete,
|
||||
])
|
||||
export class RoutesController {
|
||||
constructor(private readonly routesService: RoutesService) {}
|
||||
|
||||
|
||||
@@ -31,7 +31,15 @@ import { TrainBuilderService } from './train-builder.service';
|
||||
@ApiTags('train-builder')
|
||||
@ApiBearerAuth()
|
||||
@Controller('train-builder')
|
||||
@FleetView(FREIGHT_PERMS.trains.view)
|
||||
// Class gate lists every key its routes use: Nest runs class AND method
|
||||
// guards, so a key missing here would deny before the route's own key runs.
|
||||
@FleetView([
|
||||
FREIGHT_PERMS.trains.view,
|
||||
FREIGHT_PERMS.trains.create,
|
||||
FREIGHT_PERMS.trains.update,
|
||||
FREIGHT_PERMS.trains.assignWagons,
|
||||
FREIGHT_PERMS.trains.delete,
|
||||
])
|
||||
export class TrainBuilderController {
|
||||
constructor(private readonly trainBuilderService: TrainBuilderService) {}
|
||||
|
||||
|
||||
@@ -19,7 +19,14 @@ import { TrainsService } from "./trains.service";
|
||||
|
||||
@ApiTags("trains")
|
||||
@Controller("trains")
|
||||
@FleetView(FREIGHT_PERMS.trains.view)
|
||||
// Class gate lists every key its routes use: Nest runs class AND method
|
||||
// guards, so a key missing here would deny before the route's own key runs.
|
||||
@FleetView([
|
||||
FREIGHT_PERMS.trains.view,
|
||||
FREIGHT_PERMS.trains.create,
|
||||
FREIGHT_PERMS.trains.update,
|
||||
FREIGHT_PERMS.trains.delete,
|
||||
])
|
||||
export class TrainsController {
|
||||
constructor(private readonly trainsService: TrainsService) {}
|
||||
|
||||
|
||||
@@ -20,7 +20,14 @@ import { FleetHistoryService } from '../fleet-history/fleet-history.service';
|
||||
@ApiTags('vehicles')
|
||||
@ApiBearerAuth()
|
||||
@Controller('vehicles')
|
||||
@BookingStaff(FREIGHT_PERMS.vehicles.view)
|
||||
// Class gate lists every key its routes use: Nest runs class AND method
|
||||
// guards, so a key missing here would deny before the route's own key runs.
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.vehicles.view,
|
||||
FREIGHT_PERMS.vehicles.create,
|
||||
FREIGHT_PERMS.vehicles.update,
|
||||
FREIGHT_PERMS.vehicles.delete,
|
||||
])
|
||||
export class VehiclesController {
|
||||
constructor(
|
||||
private readonly vehiclesService: VehiclesService,
|
||||
|
||||
@@ -23,9 +23,13 @@ import { WarehouseInspectionService } from './warehouse-inspection.service';
|
||||
// Baseline read: inspection reports are opened from inventory screens too —
|
||||
// either view permission grants reads; writes stack their own per route.
|
||||
@Controller()
|
||||
// Class gate lists every key its routes use: Nest runs class AND method
|
||||
// guards, so a key missing here would deny before the route's own key runs.
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.warehouseInspectionReports.view,
|
||||
FREIGHT_PERMS.warehouseInventory.view,
|
||||
FREIGHT_PERMS.warehouseInspectionReports.create,
|
||||
FREIGHT_PERMS.warehouseInspectionReports.update,
|
||||
])
|
||||
export class WarehouseInspectionController {
|
||||
constructor(private readonly inspectionService: WarehouseInspectionService) {}
|
||||
|
||||
@@ -12,7 +12,13 @@ import { WarehouseZonesService } from './warehouse-zones.service';
|
||||
// receive/move pickers) — either view permission grants reads; writes stack
|
||||
// their specific permission per route.
|
||||
@Controller('warehouse-zones')
|
||||
@BookingStaff([FREIGHT_PERMS.warehouseZones.view, FREIGHT_PERMS.warehouseInventory.view])
|
||||
// Class gate lists every key its routes use: Nest runs class AND method
|
||||
// guards, so a key missing here would deny before the route's own key runs.
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.warehouseZones.view,
|
||||
FREIGHT_PERMS.warehouseInventory.view,
|
||||
FREIGHT_PERMS.warehouseZones.update,
|
||||
])
|
||||
export class WarehouseZonesController {
|
||||
constructor(private readonly zonesService: WarehouseZonesService) {}
|
||||
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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}$/,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
@@ -1359,6 +1368,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. `<module>: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",
|
||||
@@ -2079,7 +2141,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),
|
||||
|
||||
Reference in New Issue
Block a user