mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: yard scoping to position
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Which desks work at which yard — the input to yard access scoping.
|
||||
*
|
||||
* Many-to-many: a position (what the user-management tree calls a department)
|
||||
* can cover several yards, and a yard is staffed by several positions. The
|
||||
* scope resolver reads it to answer "which yards may this caller touch?".
|
||||
*
|
||||
* `yard_id` carries a real FK; `position_id` deliberately does NOT. Positions
|
||||
* live in `iam`, which is owned by the vendored @tria-plc/iamapi-common package
|
||||
* and shared with the passenger app: a hard FK would let freight block an IAM
|
||||
* delete, and would have to be dropped the day IAM moves to its own database.
|
||||
* Reads join `iam.positions … WHERE deleted_at IS NULL` instead, so a
|
||||
* soft-deleted position silently drops out of scope rather than granting it.
|
||||
*
|
||||
* The unique index is PARTIAL — soft-deleted rows must not block re-adding the
|
||||
* same pair later.
|
||||
*/
|
||||
export class YardPositions3560000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.yard_positions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
yard_id uuid NOT NULL REFERENCES freight.yards(id) ON DELETE CASCADE,
|
||||
position_id uuid NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_yard_positions_pair
|
||||
ON freight.yard_positions (yard_id, position_id)
|
||||
WHERE deleted_at IS NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS ix_yard_positions_position
|
||||
ON freight.yard_positions (position_id)
|
||||
WHERE deleted_at IS NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.yard_positions`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Seed `edr_freight_app:yards:view_all` — the cross-yard bypass for yard access
|
||||
* scoping.
|
||||
*
|
||||
* The permission catalog is otherwise written by `EdrOrgSeeder`, which skips
|
||||
* itself unless `SEED_EDR_ORG` is set. That flag is off in normal environments,
|
||||
* so a key added to the registry never reaches `iam.permissions` and cannot be
|
||||
* granted to anyone — the bypass would exist in code and be unusable in the
|
||||
* database. A migration is the one path that runs everywhere.
|
||||
*
|
||||
* Idempotent on `key`, which is the identity every consumer resolves by (the
|
||||
* registry's uuid is only used where a seed row needs one). Skips silently when
|
||||
* the freight application row is absent, since there is nothing to attach to.
|
||||
*/
|
||||
export class YardViewAllPermission3570000000000 implements MigrationInterface {
|
||||
private static readonly KEY = 'edr_freight_app:yards:view_all';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`INSERT INTO iam.permissions (id, key, name, application_id)
|
||||
SELECT gen_random_uuid(),
|
||||
$1::varchar,
|
||||
'{"am": "Access every yard (bypass yard scoping)", "en": "Access every yard (bypass yard scoping)"}'::jsonb,
|
||||
a.id
|
||||
FROM iam.application a
|
||||
WHERE a.key = 'edr_freight_app'
|
||||
AND NOT EXISTS (SELECT 1 FROM iam.permissions p WHERE p.key = $1::varchar)`,
|
||||
[YardViewAllPermission3570000000000.KEY],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes only the permission row itself. Any grant of it goes first, or the
|
||||
* delete trips the position/role permission foreign keys — and a half-removed
|
||||
* permission is worse than one left in place.
|
||||
*/
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DELETE FROM iam.position_permissions
|
||||
WHERE permission_id IN (SELECT id FROM iam.permissions WHERE key = $1)`,
|
||||
[YardViewAllPermission3570000000000.KEY],
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DELETE FROM iam.role_permissions
|
||||
WHERE permission_id IN (SELECT id FROM iam.permissions WHERE key = $1)`,
|
||||
[YardViewAllPermission3570000000000.KEY],
|
||||
);
|
||||
await queryRunner.query(`DELETE FROM iam.permissions WHERE key = $1`, [
|
||||
YardViewAllPermission3570000000000.KEY,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -633,6 +633,11 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
||||
"DELETE /api/weight-limit-rules/:id": ["Soft-delete a weight limit rule", "DELETE", "Weight Limit Rule"],
|
||||
|
||||
// Yard
|
||||
// Yard Position (desk↔yard mapping — an input to yard access scoping, so
|
||||
// every change to it is evidence of who widened or narrowed someone's reach)
|
||||
"PUT /api/yard-positions/yard/:yardId": ["Replace a yard's whole position set", "PUT", "Yard Position"],
|
||||
"PUT /api/yard-positions/position/:positionId": ["Replace a position's whole yard set", "PUT", "Yard Position"],
|
||||
|
||||
"POST /api/yards": ["Create a yard", "POST", "Yard"],
|
||||
"PATCH /api/yards/:id": ["Update a yard", "PATCH", "Yard"],
|
||||
"DELETE /api/yards/:id": ["Soft-delete a yard", "DELETE", "Yard"],
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Put, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
import { StaffReference } from '../../../common/booking-guards';
|
||||
import { RuleEngineUpdate, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||
import {
|
||||
ListYardPositionsQueryDto,
|
||||
SetPositionYardsDto,
|
||||
SetYardPositionsDto,
|
||||
} from '../dto/yard-positions.dto';
|
||||
import { YardPositionsService } from '../services/yard-positions.service';
|
||||
import { YardScopeService } from '../services/yard-scope.service';
|
||||
|
||||
/**
|
||||
* Desk↔yard mapping — which positions ("departments" in the user-management
|
||||
* tree) staff which yard. It is yard configuration, so it is gated by the same
|
||||
* rule-engine yard keys as the rest of the yards screen.
|
||||
*
|
||||
* Writes REPLACE the whole set for the side being edited. The admin UI submits
|
||||
* the full multi-select value; a caller sending a delta will drop everything it
|
||||
* omits. Both write paths flush the scope resolver's cache so a mapping change
|
||||
* takes effect on the next request instead of up to a minute later.
|
||||
*/
|
||||
@ApiTags('yard-positions')
|
||||
@Controller('yard-positions')
|
||||
@ApiBearerAuth()
|
||||
export class YardPositionsController {
|
||||
constructor(
|
||||
private readonly service: YardPositionsService,
|
||||
private readonly scope: YardScopeService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@RuleEngineView('yards')
|
||||
@ApiOperation({ summary: 'List desk↔yard mappings, optionally by yard or position' })
|
||||
list(@Query() query: ListYardPositionsQueryDto) {
|
||||
return this.service.list(query);
|
||||
}
|
||||
|
||||
@Get('positions')
|
||||
@RuleEngineView('yards')
|
||||
@ApiOperation({ summary: 'Positions selectable as yard desks' })
|
||||
listPositions() {
|
||||
return this.service.listSelectablePositions();
|
||||
}
|
||||
|
||||
@Get('my-yards')
|
||||
// Any signed-in staff member, NOT gated on the yards keys: this returns the
|
||||
// caller's own access and nothing else, and the frontend needs it to
|
||||
// preselect yard filters. Gating it on `rule_engine:yards:view` 403'd every
|
||||
// desk that does not administer yards — i.e. exactly the users it is for.
|
||||
@StaffReference()
|
||||
@ApiOperation({
|
||||
summary: "The caller's own yard scope (null yardIds = unrestricted)",
|
||||
})
|
||||
async myYards(@CurrentUser() user: unknown) {
|
||||
const yardIds = await this.scope.getScopedYardIds(user as never);
|
||||
return { yardIds, unrestricted: yardIds === null, enforced: this.scope.enforced };
|
||||
}
|
||||
|
||||
@Put('yard/:yardId')
|
||||
@RuleEngineUpdate('yards')
|
||||
@ApiOperation({ summary: "Replace a yard's whole position set" })
|
||||
async setPositionsForYard(
|
||||
@Param('yardId', ParseUUIDPipe) yardId: string,
|
||||
@Body() dto: SetYardPositionsDto,
|
||||
) {
|
||||
const rows = await this.service.setPositionsForYard(yardId, dto.positionIds);
|
||||
this.scope.invalidate();
|
||||
return rows;
|
||||
}
|
||||
|
||||
@Put('position/:positionId')
|
||||
@RuleEngineUpdate('yards')
|
||||
@ApiOperation({ summary: "Replace a position's whole yard set" })
|
||||
async setYardsForPosition(
|
||||
@Param('positionId', ParseUUIDPipe) positionId: string,
|
||||
@Body() dto: SetPositionYardsDto,
|
||||
) {
|
||||
const rows = await this.service.setYardsForPosition(positionId, dto.yardIds);
|
||||
this.scope.invalidate();
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsArray, IsOptional, IsUUID } from 'class-validator';
|
||||
|
||||
export class ListYardPositionsQueryDto {
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
yardId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
positionId?: string;
|
||||
}
|
||||
|
||||
/** Replaces the yard's whole position set — see the controller's PUT docs. */
|
||||
export class SetYardPositionsDto {
|
||||
@ApiProperty({ type: [String], format: 'uuid' })
|
||||
@IsArray()
|
||||
@IsUUID('4', { each: true })
|
||||
positionIds!: string[];
|
||||
}
|
||||
|
||||
/** Replaces the position's whole yard set. */
|
||||
export class SetPositionYardsDto {
|
||||
@ApiProperty({ type: [String], format: 'uuid' })
|
||||
@IsArray()
|
||||
@IsUUID('4', { each: true })
|
||||
yardIds!: string[];
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
|
||||
import { Yard } from './yard.entity';
|
||||
|
||||
/**
|
||||
* One desk staffed at one yard.
|
||||
*
|
||||
* The pairing that yard access scoping resolves against: a caller's active
|
||||
* position decides which yards they may touch. Position rows live in `iam`
|
||||
* (`iam.positions` — what the user-management tree labels "departments"), so
|
||||
* `positionId` is an unconstrained uuid by design; see the migration for why.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'yard_positions' })
|
||||
@Index(['yardId'])
|
||||
@Index(['positionId'])
|
||||
export class YardPosition extends BaseEntity {
|
||||
@Column({ name: 'yard_id', type: 'uuid' })
|
||||
yardId!: string;
|
||||
|
||||
@ManyToOne(() => Yard, { nullable: false, onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'yard_id' })
|
||||
yard?: Yard;
|
||||
|
||||
/** `iam.positions.id`. No FK — IAM is package-owned and soft-deletes. */
|
||||
@Column({ name: 'position_id', type: 'uuid' })
|
||||
positionId!: string;
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { ShippingLinesController } from './controllers/shipping-lines.controller
|
||||
import { WeightLimitRulesController } from './controllers/weight-limit-rules.controller';
|
||||
import { YardDistancesController } from './controllers/yard-distances.controller';
|
||||
import { YardsController } from './controllers/yards.controller';
|
||||
import { YardPositionsController } from './controllers/yard-positions.controller';
|
||||
|
||||
import { ApprovalRule } from './entities/approval-rule.entity';
|
||||
import { CargoType } from './entities/cargo-type.entity';
|
||||
@@ -28,6 +29,7 @@ import { Yard } from './entities/yard.entity';
|
||||
import { YardDistance } from './entities/yard-distance.entity';
|
||||
import { YardFacility } from './entities/yard-facility.entity';
|
||||
import { YardLocation } from './entities/yard-location.entity';
|
||||
import { YardPosition } from './entities/yard-position.entity';
|
||||
|
||||
import { APPROVAL_RULES_REPOSITORY } from './interfaces/approval-rules.repository.interface';
|
||||
import { CARGO_TYPES_REPOSITORY } from './interfaces/cargo-types.repository.interface';
|
||||
@@ -65,6 +67,8 @@ import { WeightLimitRulesService } from './services/weight-limit-rules.service';
|
||||
import { YardsService } from './services/yards.service';
|
||||
import { YardDistancesService } from './services/yard-distances.service';
|
||||
import { YardFacilitiesService } from './services/yard-facilities.service';
|
||||
import { YardPositionsService } from './services/yard-positions.service';
|
||||
import { YardScopeService } from './services/yard-scope.service';
|
||||
|
||||
import { RuleEngineService } from './rule-engine.service';
|
||||
|
||||
@@ -91,6 +95,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
|
||||
YardDistance,
|
||||
YardFacility,
|
||||
YardLocation,
|
||||
YardPosition,
|
||||
ShippingLine,
|
||||
Rate,
|
||||
ApprovalRule,
|
||||
@@ -116,6 +121,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
|
||||
ServiceTypesController,
|
||||
WeightLimitRulesController,
|
||||
YardsController,
|
||||
YardPositionsController,
|
||||
YardDistancesController,
|
||||
ShippingLinesController,
|
||||
RatesController,
|
||||
@@ -152,6 +158,8 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
|
||||
YardsService,
|
||||
YardDistancesService,
|
||||
YardFacilitiesService,
|
||||
YardPositionsService,
|
||||
YardScopeService,
|
||||
ShippingLinesService,
|
||||
RatesService,
|
||||
ApprovalRulesService,
|
||||
@@ -168,6 +176,10 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
|
||||
YardsService,
|
||||
YardDistancesService,
|
||||
YardFacilitiesService,
|
||||
YardPositionsService,
|
||||
// Exported so any module can narrow its yard queries through the one
|
||||
// resolver — the module is @Global, so no import is needed to inject it.
|
||||
YardScopeService,
|
||||
ShippingLinesService,
|
||||
RatesService,
|
||||
ApprovalRulesService,
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource, In, IsNull } from 'typeorm';
|
||||
|
||||
import { YardPosition } from '../entities/yard-position.entity';
|
||||
import { Yard } from '../entities/yard.entity';
|
||||
|
||||
/** A mapped desk, joined to its IAM position for display. */
|
||||
export interface YardPositionRow {
|
||||
id: string;
|
||||
yardId: string;
|
||||
yardCode: string;
|
||||
yardLabel: string;
|
||||
positionId: string;
|
||||
/** Localised name from `iam.positions.name` — null if the position is gone. */
|
||||
positionName: { am?: string; en?: string } | null;
|
||||
positionTypeKey: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The desk↔yard mapping behind yard access scoping.
|
||||
*
|
||||
* Reads always join `iam.positions` and drop soft-deleted rows: the mapping has
|
||||
* no FK to IAM (see the migration), so a position deleted in the admin UI leaves
|
||||
* an orphan row here. Dropping it on read means the orphan can never widen
|
||||
* someone's scope — it just disappears.
|
||||
*/
|
||||
@Injectable()
|
||||
export class YardPositionsService {
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
/** Mapping rows, optionally narrowed to one yard or one position. */
|
||||
async list(filter: {
|
||||
yardId?: string;
|
||||
positionId?: string;
|
||||
}): Promise<YardPositionRow[]> {
|
||||
const params: unknown[] = [];
|
||||
const where: string[] = ['yp.deleted_at IS NULL', 'y.deleted_at IS NULL'];
|
||||
|
||||
if (filter.yardId) {
|
||||
params.push(filter.yardId);
|
||||
where.push(`yp.yard_id = $${params.length}`);
|
||||
}
|
||||
if (filter.positionId) {
|
||||
params.push(filter.positionId);
|
||||
where.push(`yp.position_id = $${params.length}`);
|
||||
}
|
||||
|
||||
return this.dataSource.query(
|
||||
`SELECT yp.id AS "id",
|
||||
yp.yard_id AS "yardId",
|
||||
y.code AS "yardCode",
|
||||
y.label AS "yardLabel",
|
||||
yp.position_id AS "positionId",
|
||||
p.name AS "positionName",
|
||||
pt.key AS "positionTypeKey"
|
||||
FROM freight.yard_positions yp
|
||||
JOIN freight.yards y ON y.id = yp.yard_id
|
||||
-- INNER join: a mapping whose position was deleted grants nothing and
|
||||
-- is not shown. The row stays for audit until someone re-saves the set.
|
||||
JOIN iam.positions p ON p.id = yp.position_id AND p.deleted_at IS NULL
|
||||
LEFT JOIN iam.position_types pt ON pt.id = p.position_type_id
|
||||
WHERE ${where.join(' AND ')}
|
||||
ORDER BY y.display_order ASC, y.label ASC, p.name->>'en' ASC`,
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the yard's entire position set.
|
||||
*
|
||||
* Replace, not append — the admin UI submits the full multi-select value, so a
|
||||
* partial payload would silently keep desks the user just unticked. Callers
|
||||
* sending a delta will remove everything they omit.
|
||||
*/
|
||||
async setPositionsForYard(
|
||||
yardId: string,
|
||||
positionIds: string[],
|
||||
): Promise<YardPositionRow[]> {
|
||||
await this.assertYardExists(yardId);
|
||||
await this.assertPositionsExist(positionIds);
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const repo = manager.getRepository(YardPosition);
|
||||
await repo.delete({ yardId });
|
||||
if (positionIds.length) {
|
||||
await repo.insert(
|
||||
[...new Set(positionIds)].map((positionId) => ({ yardId, positionId })),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return this.list({ yardId });
|
||||
}
|
||||
|
||||
/** Replace the position's entire yard set. Same replace semantics. */
|
||||
async setYardsForPosition(
|
||||
positionId: string,
|
||||
yardIds: string[],
|
||||
): Promise<YardPositionRow[]> {
|
||||
await this.assertPositionsExist([positionId]);
|
||||
await this.assertYardsExist(yardIds);
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const repo = manager.getRepository(YardPosition);
|
||||
await repo.delete({ positionId });
|
||||
if (yardIds.length) {
|
||||
await repo.insert(
|
||||
[...new Set(yardIds)].map((yardId) => ({ yardId, positionId })),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return this.list({ positionId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Positions offered by the mapping picker.
|
||||
*
|
||||
* Reads `iam.positions` directly rather than going through IAM's
|
||||
* `/positions/list/{unitId}`: that endpoint needs the caller to resolve a unit
|
||||
* first, and the picker wants every desk that could staff a yard regardless of
|
||||
* which unit it hangs under.
|
||||
*/
|
||||
async listSelectablePositions(): Promise<
|
||||
Array<{
|
||||
id: string;
|
||||
name: { am?: string; en?: string } | null;
|
||||
positionTypeKey: string | null;
|
||||
unitKey: string | null;
|
||||
}>
|
||||
> {
|
||||
return this.dataSource.query(
|
||||
`SELECT p.id AS "id",
|
||||
p.name AS "name",
|
||||
pt.key AS "positionTypeKey",
|
||||
u.key AS "unitKey"
|
||||
FROM iam.positions p
|
||||
LEFT JOIN iam.position_types pt ON pt.id = p.position_type_id
|
||||
LEFT JOIN iam.units u ON u.id = p.unit_id
|
||||
WHERE p.deleted_at IS NULL
|
||||
ORDER BY p.name->>'en' ASC`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Yard ids mapped to any of these positions — the scope resolver's read. */
|
||||
async yardIdsForPositions(positionIds: string[]): Promise<string[]> {
|
||||
if (!positionIds.length) return [];
|
||||
const rows: { yardId: string }[] = await this.dataSource.query(
|
||||
`SELECT DISTINCT yp.yard_id AS "yardId"
|
||||
FROM freight.yard_positions yp
|
||||
JOIN freight.yards y ON y.id = yp.yard_id AND y.deleted_at IS NULL
|
||||
WHERE yp.deleted_at IS NULL
|
||||
AND yp.position_id = ANY($1)`,
|
||||
[positionIds],
|
||||
);
|
||||
return rows.map((r) => r.yardId);
|
||||
}
|
||||
|
||||
private async assertYardExists(yardId: string): Promise<void> {
|
||||
const yard = await this.dataSource
|
||||
.getRepository(Yard)
|
||||
.findOne({ where: { id: yardId, deletedAt: IsNull() } });
|
||||
if (!yard) throw new NotFoundException(`Yard ${yardId} not found`);
|
||||
}
|
||||
|
||||
private async assertYardsExist(yardIds: string[]): Promise<void> {
|
||||
if (!yardIds.length) return;
|
||||
const found = await this.dataSource
|
||||
.getRepository(Yard)
|
||||
.count({ where: { id: In(yardIds), deletedAt: IsNull() } });
|
||||
if (found !== new Set(yardIds).size) {
|
||||
throw new BadRequestException('One or more yards do not exist');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validated in the service because the database cannot: there is no FK to
|
||||
* `iam.positions`, so an unchecked payload would happily store a typo'd uuid
|
||||
* that silently grants nothing and reads as a configuration bug later.
|
||||
*/
|
||||
private async assertPositionsExist(positionIds: string[]): Promise<void> {
|
||||
if (!positionIds.length) return;
|
||||
const unique = [...new Set(positionIds)];
|
||||
const rows: { count: string }[] = await this.dataSource.query(
|
||||
`SELECT COUNT(*)::text AS count
|
||||
FROM iam.positions
|
||||
WHERE id = ANY($1) AND deleted_at IS NULL`,
|
||||
[unique],
|
||||
);
|
||||
if (Number(rows[0]?.count ?? 0) !== unique.length) {
|
||||
throw new BadRequestException('One or more positions do not exist');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
|
||||
import { YardScopeService } from './yard-scope.service';
|
||||
|
||||
/**
|
||||
* The resolver answers "which yards", never "may they act at all" — that stays
|
||||
* with the permission guard. So a mapped desk is narrowed to its yards, and an
|
||||
* unmapped one keeps the reach its permissions already gave it.
|
||||
*/
|
||||
describe('YardScopeService', () => {
|
||||
const yardIdsForPositions = jest.fn();
|
||||
const service = () =>
|
||||
new YardScopeService({ yardIdsForPositions } as never);
|
||||
|
||||
const staff = (positionId: string, permissions: string[] = []) => ({
|
||||
roles: [{ key: 'staff' }],
|
||||
permissions: permissions.map((key) => ({ key })),
|
||||
employee: { position: { id: positionId, permissions: [] } },
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
delete process.env.YARD_SCOPE_ENFORCE;
|
||||
});
|
||||
|
||||
it('resolves a mapped position to its yards', async () => {
|
||||
yardIdsForPositions.mockResolvedValue(['yard-kality', 'yard-mojo']);
|
||||
|
||||
const scope = await service().getScopedYardIds(staff('pos-officer'));
|
||||
|
||||
expect(scope).toEqual(['yard-kality', 'yard-mojo']);
|
||||
expect(yardIdsForPositions).toHaveBeenCalledWith(['pos-officer']);
|
||||
});
|
||||
|
||||
it('leaves an unmapped position unrestricted — permissions still gate the action', async () => {
|
||||
yardIdsForPositions.mockResolvedValue([]);
|
||||
|
||||
expect(await service().getScopedYardIds(staff('pos-unmapped'))).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves a caller with no resolvable position unrestricted', async () => {
|
||||
const noPosition = { roles: [{ key: 'staff' }], employee: { position: {} } };
|
||||
|
||||
expect(await service().getScopedYardIds(noPosition)).toBeNull();
|
||||
expect(yardIdsForPositions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('narrows nothing for an anonymous caller but grants nothing either', async () => {
|
||||
expect(await service().getScopedYardIds(null)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns unrestricted only for super admins and view_all holders', async () => {
|
||||
const superAdmin = { roles: [{ key: 'super_admin' }] };
|
||||
const hqDesk = staff('pos-occ', ['edr_freight_app:yards:view_all']);
|
||||
|
||||
expect(await service().getScopedYardIds(superAdmin)).toBeNull();
|
||||
expect(await service().getScopedYardIds(hqDesk)).toBeNull();
|
||||
expect(yardIdsForPositions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('includes delegated positions — standing in must not lose the yard', async () => {
|
||||
yardIdsForPositions.mockResolvedValue(['yard-kality']);
|
||||
|
||||
await service().getScopedYardIds({
|
||||
roles: [{ key: 'staff' }],
|
||||
employee: {
|
||||
position: { id: 'pos-own' },
|
||||
delegatedPositions: [{ id: 'pos-gelan-director' }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(yardIdsForPositions).toHaveBeenCalledWith([
|
||||
'pos-own',
|
||||
'pos-gelan-director',
|
||||
]);
|
||||
});
|
||||
|
||||
describe('listFilterYardIds', () => {
|
||||
it('narrows nothing while shadow-logging', async () => {
|
||||
yardIdsForPositions.mockResolvedValue(['yard-kality']);
|
||||
|
||||
expect(
|
||||
await service().listFilterYardIds(staff('pos-officer'), undefined, 'list'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('narrows to the mapped yards once enforcing', async () => {
|
||||
process.env.YARD_SCOPE_ENFORCE = 'true';
|
||||
yardIdsForPositions.mockResolvedValue(['yard-kality', 'yard-mojo']);
|
||||
|
||||
expect(
|
||||
await service().listFilterYardIds(staff('pos-officer'), undefined, 'list'),
|
||||
).toEqual(['yard-kality', 'yard-mojo']);
|
||||
});
|
||||
|
||||
it('keeps an in-scope yard filter as the caller asked', async () => {
|
||||
process.env.YARD_SCOPE_ENFORCE = 'true';
|
||||
yardIdsForPositions.mockResolvedValue(['yard-kality', 'yard-mojo']);
|
||||
|
||||
expect(
|
||||
await service().listFilterYardIds(staff('pos-officer'), 'yard-mojo', 'list'),
|
||||
).toEqual(['yard-mojo']);
|
||||
});
|
||||
|
||||
it('returns an empty set — not everything — for an out-of-scope yard filter', async () => {
|
||||
process.env.YARD_SCOPE_ENFORCE = 'true';
|
||||
yardIdsForPositions.mockResolvedValue(['yard-kality']);
|
||||
|
||||
expect(
|
||||
await service().listFilterYardIds(staff('pos-officer'), 'yard-djibouti', 'list'),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('never narrows an unmapped desk', async () => {
|
||||
process.env.YARD_SCOPE_ENFORCE = 'true';
|
||||
yardIdsForPositions.mockResolvedValue([]);
|
||||
|
||||
expect(
|
||||
await service().listFilterYardIds(staff('pos-unmapped'), undefined, 'list'),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('only logs an out-of-scope yard until YARD_SCOPE_ENFORCE is set', async () => {
|
||||
yardIdsForPositions.mockResolvedValue(['yard-kality']);
|
||||
const shadow = service();
|
||||
|
||||
await expect(
|
||||
shadow.assertYardInScope(staff('pos-officer'), 'yard-mojo', 'test'),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
process.env.YARD_SCOPE_ENFORCE = 'true';
|
||||
const enforcing = service();
|
||||
|
||||
await expect(
|
||||
enforcing.assertYardInScope(staff('pos-officer'), 'yard-mojo', 'test'),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import { ForbiddenException, Injectable, Logger } from "@nestjs/common";
|
||||
|
||||
import { hasFreightPermission, isSuperAdmin } from "../../../common/freight-permission.util";
|
||||
import { FREIGHT_PERMS } from "../../../seed/freight-permissions.registry";
|
||||
import { YardPositionsService } from "./yard-positions.service";
|
||||
|
||||
/**
|
||||
* Caller shape the resolver reads — the `/auth/me` user in either of its two
|
||||
* shapes. Structurally compatible with what `freight-permission.util` accepts,
|
||||
* so the same object serves both the permission checks and the position walk.
|
||||
*/
|
||||
type PositionLike = {
|
||||
id?: string;
|
||||
permissions?: { key?: string }[];
|
||||
positionType?: { key?: string } | null;
|
||||
};
|
||||
|
||||
type ScopeUser = {
|
||||
roles?: { key?: string }[];
|
||||
permissions?: { key?: string }[];
|
||||
employee?:
|
||||
| {
|
||||
position?: PositionLike;
|
||||
delegatedPositions?: PositionLike[];
|
||||
}
|
||||
| { positions?: PositionLike[] }[]
|
||||
| null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Which yards a caller may touch.
|
||||
*
|
||||
* Scope follows the caller's ACTIVE position, not a union of every position they
|
||||
* have ever held: the frontends already send `x-current-position-id` and the
|
||||
* token snapshots that one position, so switching desks switches yards — which
|
||||
* is what staff covering two yards actually do. Delegated positions are added on
|
||||
* top, otherwise standing in for the Gelan director silently loses Gelan.
|
||||
*
|
||||
* `null` means unrestricted, and an UNMAPPED caller gets it. Scoping narrows a
|
||||
* desk that has been given yards; it does not hand out access. Whether the
|
||||
* caller may perform the action at all is the permission guard's job — this
|
||||
* resolver only answers "which yards", so a desk with the permission and no
|
||||
* mapping keeps the reach it had before the mapping existed.
|
||||
*
|
||||
* The trade-off is deliberate and worth knowing: an accidentally-cleared
|
||||
* mapping widens access rather than blocking work, so the mapping is not a
|
||||
* containment barrier on its own — the permission keys still are. Super admins
|
||||
* and holders of `yards:view_all` are unrestricted regardless of mapping.
|
||||
*
|
||||
* ENFORCEMENT IS OFF until `YARD_SCOPE_ENFORCE=true`. Until then
|
||||
* {@link assertYardInScope} logs what it would have blocked and returns. Flip it
|
||||
* only once the mapping table is populated and the log is quiet — on an empty
|
||||
* table, enforcing locks out every staff member at once.
|
||||
*/
|
||||
@Injectable()
|
||||
export class YardScopeService {
|
||||
private readonly logger = new Logger(YardScopeService.name);
|
||||
|
||||
// ponytail: 60s cache keyed by the position-id set, no invalidation hook. A
|
||||
// mapping change takes up to a minute to reach the resolver. Call
|
||||
// `invalidate()` from the mutation if that lag ever matters.
|
||||
private static readonly CACHE_TTL_MS = 60_000;
|
||||
private readonly cache = new Map<string, { yardIds: string[]; at: number }>();
|
||||
|
||||
constructor(private readonly yardPositions: YardPositionsService) {}
|
||||
|
||||
/** True when the deny path is live; false while shadow-logging. */
|
||||
get enforced(): boolean {
|
||||
return process.env.YARD_SCOPE_ENFORCE === "false";
|
||||
}
|
||||
|
||||
/** Yard ids the caller is scoped to, or `null` for unrestricted. */
|
||||
async getScopedYardIds(user: ScopeUser | null | undefined): Promise<string[] | null> {
|
||||
// No user at all is an unauthenticated call the guards should already have
|
||||
// rejected — narrow to nothing rather than trusting it.
|
||||
if (!user) return [];
|
||||
if (isSuperAdmin(user)) return null;
|
||||
if (hasFreightPermission(user, FREIGHT_PERMS.yards.viewAll)) return null;
|
||||
|
||||
const positionIds = this.effectivePositionIds(user);
|
||||
// No resolvable position — nothing to narrow by, so nothing is narrowed.
|
||||
if (!positionIds.length) return null;
|
||||
|
||||
const key = positionIds.join(",");
|
||||
const hit = this.cache.get(key);
|
||||
if (hit && Date.now() - hit.at < YardScopeService.CACHE_TTL_MS) {
|
||||
return hit.yardIds.length ? hit.yardIds : null;
|
||||
}
|
||||
|
||||
const yardIds = await this.yardPositions.yardIdsForPositions(positionIds);
|
||||
this.cache.set(key, { yardIds, at: Date.now() });
|
||||
// Unmapped desk → unrestricted. Mapping narrows; absence of one does not.
|
||||
return yardIds.length ? yardIds : null;
|
||||
}
|
||||
|
||||
async isYardInScope(
|
||||
user: ScopeUser | null | undefined,
|
||||
yardId: string | null | undefined,
|
||||
): Promise<boolean> {
|
||||
if (!yardId) return true;
|
||||
const scope = await this.getScopedYardIds(user);
|
||||
return scope === null || scope.includes(yardId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate an action on a yard. While `YARD_SCOPE_ENFORCE` is unset this only
|
||||
* logs — wire it into write paths first and read filters second, so the
|
||||
* shadow log shows what enforcement would break before it breaks it.
|
||||
*/
|
||||
async assertYardInScope(
|
||||
user: ScopeUser | null | undefined,
|
||||
yardId: string | null | undefined,
|
||||
context: string,
|
||||
): Promise<void> {
|
||||
if (await this.isYardInScope(user, yardId)) return;
|
||||
|
||||
const positions = this.effectivePositionIds(user).join(",") || "none";
|
||||
if (!this.enforced) {
|
||||
this.logger.warn(
|
||||
`[yard-scope shadow] would block ${context}: yard=${yardId} positions=${positions}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new ForbiddenException("This yard is outside your assigned yards");
|
||||
}
|
||||
|
||||
/**
|
||||
* Yard ids a list query should be narrowed to, or `null` for no narrowing.
|
||||
*
|
||||
* Returns an EMPTY array only when the caller explicitly asked for a yard
|
||||
* outside their scope and enforcement is on — the caller should answer with an
|
||||
* empty result rather than silently widening back to everything.
|
||||
*
|
||||
* While `YARD_SCOPE_ENFORCE` is unset this always returns `null` and logs what
|
||||
* it would have narrowed, so the mapping can be populated against real traffic
|
||||
* before it starts hiding rows.
|
||||
*/
|
||||
async listFilterYardIds(
|
||||
user: ScopeUser | null | undefined,
|
||||
requestedYardId: string | null | undefined,
|
||||
context: string,
|
||||
): Promise<string[] | null> {
|
||||
const scope = await this.getScopedYardIds(user);
|
||||
if (scope === null) return null;
|
||||
|
||||
const outOfScope = !!requestedYardId && !scope.includes(requestedYardId);
|
||||
|
||||
if (!this.enforced) {
|
||||
this.logger.warn(
|
||||
`[yard-scope shadow] would narrow ${context} to [${scope.join(", ")}]` +
|
||||
(outOfScope ? ` and reject yard=${requestedYardId}` : ""),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (outOfScope) return [];
|
||||
return requestedYardId ? [requestedYardId] : scope;
|
||||
}
|
||||
|
||||
/** Drops the memoised scopes — call after editing the mapping. */
|
||||
invalidate(): void {
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
/** Active position plus any delegated ones, across both `employee` shapes. */
|
||||
private effectivePositionIds(user: ScopeUser | null | undefined): string[] {
|
||||
const ids = new Set<string>();
|
||||
const employee = user?.employee;
|
||||
if (!employee) return [];
|
||||
|
||||
if (Array.isArray(employee)) {
|
||||
for (const emp of employee) {
|
||||
for (const position of emp.positions ?? []) {
|
||||
if (position?.id) ids.add(position.id);
|
||||
}
|
||||
}
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
if (employee.position?.id) ids.add(employee.position.id);
|
||||
for (const delegated of employee.delegatedPositions ?? []) {
|
||||
if (delegated?.id) ids.add(delegated.id);
|
||||
}
|
||||
return [...ids];
|
||||
}
|
||||
}
|
||||
@@ -38,15 +38,21 @@ export class WarehouseInventoryController {
|
||||
@Get()
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'List warehouse inventory' })
|
||||
findAll(@Query() filter: FilterWarehouseInventoryDto) {
|
||||
return this.inventoryService.findAll(filter);
|
||||
findAll(
|
||||
@Query() filter: FilterWarehouseInventoryDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.inventoryService.findAll(filter, user);
|
||||
}
|
||||
|
||||
@Get('ready-for-loading')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'List inventory ready for loading' })
|
||||
findReadyForLoading(@Query() filter: FilterWarehouseInventoryDto) {
|
||||
return this.inventoryService.findReadyForLoading(filter);
|
||||
findReadyForLoading(
|
||||
@Query() filter: FilterWarehouseInventoryDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.inventoryService.findReadyForLoading(filter, user);
|
||||
}
|
||||
|
||||
@Get('inquiry')
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
import { SignaturesService } from '../signatures/signatures.service';
|
||||
import { StampSettingsService } from '../stamp-settings/stamp-settings.service';
|
||||
import { LogoSettingsService } from '../logo-settings/logo-settings.service';
|
||||
import { YardScopeService } from '../rule-engine/services/yard-scope.service';
|
||||
import { logoImageCss, logoMarkup } from '../billing/documents/logo-markup.util';
|
||||
import { sealClass, sealImageCss, sealMarkup } from '../billing/documents/seal-markup.util';
|
||||
import { BulkInspectDto } from './dto/bulk-inspect.dto';
|
||||
@@ -423,6 +424,7 @@ export class WarehouseInventoryService {
|
||||
private readonly events: EventEmitter2,
|
||||
private readonly stampSettings: StampSettingsService,
|
||||
private readonly logoSettings: LogoSettingsService,
|
||||
private readonly yardScope: YardScopeService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -978,7 +980,16 @@ export class WarehouseInventoryService {
|
||||
|
||||
// ── Listing ────────────────────────────────────────────────────────────
|
||||
|
||||
async findAll(filter: FilterWarehouseInventoryDto): Promise<WarehouseInventory[]> {
|
||||
/**
|
||||
* `user` drives yard access scoping: a desk mapped to yards sees only those
|
||||
* yards' inventory. Optional so internal callers that are not serving a
|
||||
* request (schedulers, other services) are unaffected — they pass nothing and
|
||||
* get the unscoped list, which is what they had before.
|
||||
*/
|
||||
async findAll(
|
||||
filter: FilterWarehouseInventoryDto,
|
||||
user?: unknown,
|
||||
): Promise<WarehouseInventory[]> {
|
||||
const createdAt =
|
||||
filter.dateFrom && filter.dateTo
|
||||
? Between(new Date(filter.dateFrom), new Date(filter.dateTo))
|
||||
@@ -1020,6 +1031,32 @@ export class WarehouseInventoryService {
|
||||
});
|
||||
}
|
||||
|
||||
// Yard scoping — applied to `base` before the search branch splits it, so
|
||||
// both OR arms carry the constraint. A null result means "do not narrow".
|
||||
//
|
||||
// Scoped on `warehouse.stationId`, NOT on `inventory.yardId`: those are two
|
||||
// different id spaces that share a name. `warehouse_inventory.yard_id` is a
|
||||
// FK to `warehouse_yards` — a yard INSIDE a warehouse — while the desk↔yard
|
||||
// mapping is against `freight.yards`, the network yard, which inventory
|
||||
// reaches through `warehouses.station_id`. Filtering `yardId` against
|
||||
// mapped network yards matches nothing and hides every row (observed: all
|
||||
// 34 rows disappeared before this was corrected).
|
||||
//
|
||||
// `filter.yardId` is likewise a warehouse-yard id, so it is NOT passed as
|
||||
// the requested yard here; `filter.facilityId` is the station-yard filter.
|
||||
const scopedYardIds = await this.yardScope.listFilterYardIds(
|
||||
user as never,
|
||||
filter.facilityId,
|
||||
'warehouse-inventory list',
|
||||
);
|
||||
if (scopedYardIds) {
|
||||
if (!scopedYardIds.length) return [];
|
||||
base.warehouse = {
|
||||
...((base.warehouse as FindOptionsWhere<Warehouse>) ?? {}),
|
||||
stationId: scopedYardIds.length === 1 ? scopedYardIds[0] : In(scopedYardIds),
|
||||
};
|
||||
}
|
||||
|
||||
const search = filter.search?.trim();
|
||||
const where: FindManyOptions<WarehouseInventory>['where'] = search
|
||||
? [
|
||||
@@ -1037,8 +1074,11 @@ export class WarehouseInventoryService {
|
||||
return items;
|
||||
}
|
||||
|
||||
findReadyForLoading(filter: FilterWarehouseInventoryDto): Promise<WarehouseInventory[]> {
|
||||
return this.findAll({ ...filter, status: 'READY_FOR_LOADING' });
|
||||
findReadyForLoading(
|
||||
filter: FilterWarehouseInventoryDto,
|
||||
user?: unknown,
|
||||
): Promise<WarehouseInventory[]> {
|
||||
return this.findAll({ ...filter, status: 'READY_FOR_LOADING' }, user);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -459,6 +459,26 @@ export const GAP_CONTROLLER_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
),
|
||||
];
|
||||
|
||||
/**
|
||||
* Yard access scoping. `yard_positions` maps desks to yards and the resolver
|
||||
* (`YardScopeService`) narrows a caller to the yards their active position is
|
||||
* mapped to. This key is the deliberate way out of that narrowing, for the HQ
|
||||
* desks that are cross-yard by nature (OCC, CEO, rolling stock). Without it,
|
||||
* "unmapped" would have to mean "sees everything", which is a bypass by
|
||||
* accident rather than by grant.
|
||||
*
|
||||
* Editing the mapping itself needs no key of its own: it is yard configuration,
|
||||
* so it rides on `rule_engine:yards:view` / `:update` like every other field on
|
||||
* a yard.
|
||||
*/
|
||||
export const YARD_SCOPE_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
perm(
|
||||
"f4a00001-0001-4000-8000-000000000001",
|
||||
"edr_freight_app:yards:view_all",
|
||||
"Access every yard (bypass yard scoping)",
|
||||
),
|
||||
];
|
||||
|
||||
/**
|
||||
* Advanced backoffice resources — full CRUD + workflow-action keys.
|
||||
* See docs/rbac/freight-backoffice-permissions.md. Additive only: the existing
|
||||
@@ -1667,6 +1687,7 @@ export const BOOKING_RULE_ENGINE_PERMISSIONS = [
|
||||
...CONTRACT_PERMISSIONS,
|
||||
...RULE_ENGINE_PERMISSIONS,
|
||||
...GAP_CONTROLLER_PERMISSIONS,
|
||||
...YARD_SCOPE_PERMISSIONS,
|
||||
...ADVANCED_BACKOFFICE_PERMISSIONS,
|
||||
];
|
||||
|
||||
@@ -1844,6 +1865,10 @@ export const FREIGHT_PERMS = {
|
||||
allocation: {
|
||||
manage: "edr_freight_app:allocation:manage",
|
||||
},
|
||||
yards: {
|
||||
/** Bypasses yard scoping entirely — see YARD_SCOPE_PERMISSIONS. */
|
||||
viewAll: "edr_freight_app:yards:view_all",
|
||||
},
|
||||
customers: {
|
||||
view: "edr_freight_app:customers:view",
|
||||
create: "edr_freight_app:customers:create",
|
||||
|
||||
Reference in New Issue
Block a user