mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
fix(auth): list every route key on the class-level guard
Nest runs class and method guards together, so a class gate naming only the view key silently required view AND action. Staff granted just an action were denied before their key was checked. Each class gate now names every key its routes use, and FleetView accepts an array so the fleet controllers keep their coarse fallback. Drops the one-off grant mapping SQL with it: already applied to dev, and this fix removes the companion-view rule that was its recurring part.
This commit is contained in:
@@ -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) =>
|
||||
|
||||
@@ -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,119 +0,0 @@
|
||||
-- Position-type grant mapping for the granular permission system rollout.
|
||||
-- Grants the NEW granular keys to every hand-curated position type that holds
|
||||
-- the old broad key whose routes the new keys took over. Idempotent (unique
|
||||
-- constraint on (position_type_id, permission_id) + ON CONFLICT DO NOTHING).
|
||||
--
|
||||
-- PREREQUISITE: run the seeded API once first (SEED_EDR_ORG=true) so
|
||||
-- EdrOrgSeeder has created the new permission rows this script references.
|
||||
-- Running it too early is not destructive but silently under-applies: keys that
|
||||
-- do not exist yet simply match nothing (measured: 11 of 42 rows land pre-seed,
|
||||
-- because invoices:view/export and payments:view already exist on dev). Re-run
|
||||
-- after seeding — it is safe to run any number of times.
|
||||
--
|
||||
-- Verified 2026-08-07 on a virgin restore of the live dev DB: seeded boot, then
|
||||
-- this script → 42 rows inserted, second run → 0 rows, final per-key grant
|
||||
-- counts identical to the reference environment. Per-user API probes across 20
|
||||
-- departmental test accounts confirm the keys resolve through /me and gate
|
||||
-- routes correctly.
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Helper shape used throughout:
|
||||
-- holders of <old key> => also grant <new keys>
|
||||
|
||||
-- 1. bookings:view holders => dashboard/read keys that replaced blanket access
|
||||
INSERT INTO iam.position_type_permissions (position_type_id, permission_id)
|
||||
SELECT DISTINCT ptp.position_type_id, pnew.id
|
||||
FROM iam.position_type_permissions ptp
|
||||
JOIN iam.permissions pold ON pold.id = ptp.permission_id
|
||||
AND pold.key = 'edr_freight_app:bookings:view'
|
||||
JOIN iam.permissions pnew ON pnew.key IN (
|
||||
'edr_freight_app:overview:view',
|
||||
'edr_freight_app:reports:view',
|
||||
'edr_freight_app:invoices:view',
|
||||
'edr_freight_app:invoices:export',
|
||||
'edr_freight_app:payments:view'
|
||||
)
|
||||
ON CONFLICT (position_type_id, permission_id) DO NOTHING;
|
||||
|
||||
-- 2. train_scheduling:update holders => the write actions split out of it
|
||||
INSERT INTO iam.position_type_permissions (position_type_id, permission_id)
|
||||
SELECT DISTINCT ptp.position_type_id, pnew.id
|
||||
FROM iam.position_type_permissions ptp
|
||||
JOIN iam.permissions pold ON pold.id = ptp.permission_id
|
||||
AND pold.key = 'edr_freight_app:train_scheduling:update'
|
||||
JOIN iam.permissions pnew ON pnew.key IN (
|
||||
'edr_freight_app:train_scheduling:dispatch',
|
||||
'edr_freight_app:train_scheduling:mark_paid',
|
||||
'edr_freight_app:train_scheduling:expire_booking'
|
||||
)
|
||||
ON CONFLICT (position_type_id, permission_id) DO NOTHING;
|
||||
|
||||
-- 3. GL Djibouti clearance holders => final-invoice raise + confirm
|
||||
INSERT INTO iam.position_type_permissions (position_type_id, permission_id)
|
||||
SELECT DISTINCT ptp.position_type_id, pnew.id
|
||||
FROM iam.position_type_permissions ptp
|
||||
JOIN iam.permissions pold ON pold.id = ptp.permission_id
|
||||
AND pold.key = 'edr_freight_app:contracts:clearance_dj_actions'
|
||||
JOIN iam.permissions pnew ON pnew.key IN (
|
||||
'edr_freight_app:contracts:final_invoice_raise',
|
||||
'edr_freight_app:contracts:final_invoice_confirm'
|
||||
)
|
||||
ON CONFLICT (position_type_id, permission_id) DO NOTHING;
|
||||
|
||||
-- 4. GL Ethiopia clearance holders => final-invoice confirm
|
||||
INSERT INTO iam.position_type_permissions (position_type_id, permission_id)
|
||||
SELECT DISTINCT ptp.position_type_id, pnew.id
|
||||
FROM iam.position_type_permissions ptp
|
||||
JOIN iam.permissions pold ON pold.id = ptp.permission_id
|
||||
AND pold.key = 'edr_freight_app:contracts:clearance_et_actions'
|
||||
JOIN iam.permissions pnew ON pnew.key = 'edr_freight_app:contracts:final_invoice_confirm'
|
||||
ON CONFLICT (position_type_id, permission_id) DO NOTHING;
|
||||
|
||||
-- 5. Contract-intake holders (any staff_accept flavour) => edit_document
|
||||
INSERT INTO iam.position_type_permissions (position_type_id, permission_id)
|
||||
SELECT DISTINCT ptp.position_type_id, pnew.id
|
||||
FROM iam.position_type_permissions ptp
|
||||
JOIN iam.permissions pold ON pold.id = ptp.permission_id
|
||||
AND pold.key IN (
|
||||
'edr_freight_app:bookings:staff_accept',
|
||||
'edr_freight_app:contracts:staff_accept:bulk',
|
||||
'edr_freight_app:contracts:staff_accept:container'
|
||||
)
|
||||
JOIN iam.permissions pnew ON pnew.key = 'edr_freight_app:contracts:edit_document'
|
||||
ON CONFLICT (position_type_id, permission_id) DO NOTHING;
|
||||
|
||||
-- 6. Support inbox ownership (decision 2026-08-07): marketing department types
|
||||
INSERT INTO iam.position_type_permissions (position_type_id, permission_id)
|
||||
SELECT pt.id, p.id
|
||||
FROM iam.position_types pt
|
||||
CROSS JOIN iam.permissions p
|
||||
WHERE (pt.name::text ILIKE '%marketing%' OR pt.key ILIKE '%marketing%')
|
||||
AND p.key IN (
|
||||
'edr_freight_app:support:agent_view',
|
||||
'edr_freight_app:support:agent_send'
|
||||
)
|
||||
ON CONFLICT (position_type_id, permission_id) DO NOTHING;
|
||||
|
||||
-- 7. Companion view keys.
|
||||
-- Most freight controllers carry a class-level `<module>:view` guard, and Nest
|
||||
-- runs class AND method guards — so a type holding only `<module>:<action>` is
|
||||
-- denied before the action key is ever checked. Grant the module's view key
|
||||
-- alongside every action key the type already holds. View-only, so it widens
|
||||
-- reads within a module the type already operates in, never across modules.
|
||||
INSERT INTO iam.position_type_permissions (position_type_id, permission_id)
|
||||
SELECT DISTINCT ptp.position_type_id, pview.id
|
||||
FROM iam.position_type_permissions ptp
|
||||
JOIN iam.permissions pact ON pact.id = ptp.permission_id
|
||||
AND pact.key LIKE 'edr_freight_app:%'
|
||||
JOIN iam.permissions pview ON pview.key = regexp_replace(pact.key, ':[^:]+$', ':view')
|
||||
ON CONFLICT (position_type_id, permission_id) DO NOTHING;
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- Verification: expected non-zero counts per new key after running.
|
||||
-- SELECT p.key, count(*) FROM iam.position_type_permissions ptp
|
||||
-- JOIN iam.permissions p ON p.id = ptp.permission_id
|
||||
-- WHERE p.key IN ('edr_freight_app:overview:view','edr_freight_app:support:agent_view',
|
||||
-- 'edr_freight_app:train_scheduling:dispatch','edr_freight_app:contracts:final_invoice_raise')
|
||||
-- GROUP BY p.key;
|
||||
Reference in New Issue
Block a user