fix(auth): keep secondary positions in permission checks

IAM lets an employee hold several positions, but the vendored JwtGuard
collapses employee.positions[] down to a single employee.position and
drops the rest. Non-delegate secondary positions vanished entirely, so
staff on two posts resolved to one post's permissions and every check
on the other rejected them.

FreightJwtGuard re-attaches the full list from the same session
snapshot the parent guard already read, so nothing extra is fetched
per request beyond a cached session lookup. employee.position is left
untouched, keeping audit logging and delegation unaffected.
collectPermissionKeys and collectPositionTypeKeys now union across
every position, and /me returns them all.

Verified against a real two-position user (djibouti-gl-director +
djibouti-gl-chief) on the local dev database:

  /me positions                     1   -> 2
  /me permissionKeys                17  -> 28
  GET /api/interchange-documents    403 -> 200
  GET /api/trains                   403 -> 200

11 permissions recovered, none lost. Six single-position users return
byte-identical payloads before and after.
This commit is contained in:
Nathnael
2026-08-25 12:00:43 +00:00
parent 0a85dbe60d
commit 01ea05f013
12 changed files with 265 additions and 67 deletions

View File

@@ -1,5 +1,5 @@
import { applyDecorators, UseGuards } from '@nestjs/common';
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
import { FreightJwtGuard } from './freight-jwt.guard';
import {
FreightPermissionGuard,
@@ -11,7 +11,7 @@ import { FREIGHT_PERMS } from '../seed/freight-permissions.registry';
export const BookingStaff = (permission: string | string[]) =>
applyDecorators(
UseGuards(
JwtGuard,
FreightJwtGuard,
FreightPermissionGuard(
Array.isArray(permission) ? permission : [permission],
),
@@ -26,11 +26,11 @@ export const BookingStaff = (permission: string | string[]) =>
* BookingStaff(<view key>) or MixedAudience(); kept for routes not yet swept.
*/
export const StaffReference = () =>
applyDecorators(UseGuards(JwtGuard, FreightPermissionGuard([])));
applyDecorators(UseGuards(FreightJwtGuard, FreightPermissionGuard([])));
/** Portal routes: customer accounts only; ownership scoping stays in services. */
export const PortalCustomer = () =>
applyDecorators(UseGuards(JwtGuard, PortalCustomerGuard));
applyDecorators(UseGuards(FreightJwtGuard, PortalCustomerGuard));
/**
* Routes both audiences call (sign, shared document reads, handover): staff
@@ -40,7 +40,7 @@ export const PortalCustomer = () =>
export const MixedAudience = (permission: string | string[]) =>
applyDecorators(
UseGuards(
JwtGuard,
FreightJwtGuard,
MixedAudienceGuard(
Array.isArray(permission) ? permission : [permission],
),

View File

@@ -0,0 +1,101 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { InjectDataSource } from '@nestjs/typeorm';
import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { DataSource } from 'typeorm';
/** One position as the login snapshot stores it (`iam.sessions.userInfo`). */
type SnapshotPosition = { id?: string; [key: string]: unknown };
type SessionUserInfo = {
employee?: { id?: string; positions?: SnapshotPosition[] }[];
};
/**
* Like the IAM JwtGuard, but keeps the caller's SECONDARY positions.
*
* IAM models an employee as holding many positions, and the login snapshot in
* `iam.sessions.userInfo` carries all of them. `JwtGuard.parseToken` then
* collapses that to a single `employee.position` — whichever the request
* headers select, else `positions[0]` — and drops the rest. Non-delegate
* secondary positions vanish entirely, so staff holding two posts resolve to
* only one post's permissions and every check on the other one rejects them.
*
* This re-attaches the full list as `employee.positions`. `employee.position`
* is left exactly as the parent set it, so everything reading the single
* position today (audit log, delegation deadline) is unaffected; only the
* permission utils, which prefer the array, see the difference.
*/
@Injectable()
export class FreightJwtGuard extends IamJwtGuard implements CanActivate {
// ponytail: unbounded-until-TTL map, cleared wholesale when it gets big.
// Sessions are few and the value is small; swap for an LRU if that changes.
private static readonly CACHE_TTL_MS = 30_000;
private static readonly CACHE_MAX_ENTRIES = 5_000;
private readonly cache = new Map<
string,
{ positions: SnapshotPosition[]; expiresAt: number }
>();
constructor(
reflector: Reflector,
@InjectDataSource() private readonly ds: DataSource,
) {
super(reflector, ds);
}
async canActivate(context: ExecutionContext): Promise<boolean> {
if (!(await super.canActivate(context))) return false;
const user = context.switchToHttp().getRequest().user as
| TCurrentUser
| undefined;
const employee = user?.employee;
if (!employee || !user?.sessionId) return true;
const positions = await this.positionsForSession(
user.sessionId,
employee.id,
);
// Never blank out what the parent resolved: an unreadable session or a
// snapshot without positions must degrade to the single-position
// behaviour, not to no positions at all.
if (positions.length) {
(employee as { positions?: SnapshotPosition[] }).positions = positions;
}
return true;
}
/** Every position the login snapshot holds for this employee. */
private async positionsForSession(
sessionId: string,
employeeId: string | undefined,
): Promise<SnapshotPosition[]> {
const now = Date.now();
const hit = this.cache.get(sessionId);
if (hit && hit.expiresAt > now) return hit.positions;
let positions: SnapshotPosition[] = [];
try {
const rows: { userInfo: SessionUserInfo | null }[] = await this.ds.query(
`SELECT "userInfo" FROM iam.sessions WHERE id = $1`,
[sessionId],
);
const employees = rows[0]?.userInfo?.employee ?? [];
const match =
employees.find((e) => e?.id && e.id === employeeId) ?? employees[0];
positions = match?.positions ?? [];
} catch {
return []; // iam unreachable — caller keeps the parent's single position
}
if (this.cache.size >= FreightJwtGuard.CACHE_MAX_ENTRIES)
this.cache.clear();
this.cache.set(sessionId, {
positions,
expiresAt: now + FreightJwtGuard.CACHE_TTL_MS,
});
return positions;
}
}

View File

@@ -2,6 +2,7 @@ import {
assertCanApproveContractStep,
canEditContractStep,
collectPermissionKeys,
collectPositionTypeKeys,
hasFreightPermission,
setPositionTypePermissionResolver,
} from './freight-permission.util';
@@ -121,3 +122,66 @@ describe('collectPermissionKeys — position-type grants', () => {
expect(hasFreightPermission(direct, CLEARANCE)).toBe(true);
});
});
/**
* IAM lets an employee hold several positions, but the vendored `JwtGuard`
* collapses `employee.positions[]` down to a single `employee.position` and
* drops the rest — so staff on two posts resolved to one post's permissions
* and every check on the other rejected them. `FreightJwtGuard` restores the
* full list as `employee.positions`; these cover the union that depends on it.
*/
describe('multiple positions', () => {
// Shaped like the real two-post employee: GL chief AND GL director.
const twoPost = {
employee: {
// What the vendored guard leaves behind — one of the two, arbitrarily.
position: {
positionType: { key: 'djibouti-gl-chief' },
permissions: [{ key: FREIGHT_PERMS.contracts.view }],
},
// What FreightJwtGuard puts back.
positions: [
{
positionType: { key: 'djibouti-gl-chief' },
permissions: [{ key: FREIGHT_PERMS.contracts.view }],
},
{
positionType: { key: 'djibouti-gl-director' },
permissions: [{ key: FREIGHT_PERMS.bookings.view }],
},
],
},
};
it('unions permissions across every position', () => {
const keys = collectPermissionKeys(twoPost);
expect(keys).toContain(FREIGHT_PERMS.contracts.view);
expect(keys).toContain(FREIGHT_PERMS.bookings.view);
});
it('grants the secondary positions permission, not just the first', () => {
expect(hasFreightPermission(twoPost, FREIGHT_PERMS.bookings.view)).toBe(true);
});
it('answers to both position types', () => {
expect(collectPositionTypeKeys(twoPost)).toEqual(
expect.arrayContaining(['djibouti-gl-chief', 'djibouti-gl-director']),
);
});
it('does not double-count the position the guard also left singular', () => {
const keys = collectPermissionKeys(twoPost);
expect(keys.filter((k) => k === FREIGHT_PERMS.contracts.view)).toHaveLength(1);
});
it('still resolves the single position when the array is absent', () => {
// A request that skipped FreightJwtGuard must degrade to the old behaviour,
// not to no permissions at all.
const onePost = {
employee: {
position: { permissions: [{ key: FREIGHT_PERMS.contracts.view }] },
},
};
expect(hasFreightPermission(onePost, FREIGHT_PERMS.contracts.view)).toBe(true);
});
});

View File

@@ -17,6 +17,15 @@ type MeLikeUser = {
permissions?: PermissionLike[];
positionType?: PositionTypeLike | null;
};
/**
* Every position the employee holds, restored by `FreightJwtGuard`
* from the login snapshot. The IAM guard only ever sets the singular
* `position` above; without this, a second post's grants are invisible.
*/
positions?: {
permissions?: PermissionLike[];
positionType?: PositionTypeLike | null;
}[];
delegatedPositions?: { permissions?: PermissionLike[] }[];
}
| {
@@ -98,10 +107,15 @@ export function collectPermissionKeys(user: MeLikeUser | null | undefined): stri
return [...keys];
}
for (const p of employee.position?.permissions ?? []) {
// `position` is whichever single post the IAM guard selected; `positions` is
// the full set FreightJwtGuard restores. Walk both — the array is absent on
// a session the guard could not re-read, and the two overlap harmlessly.
for (const pos of [employee.position, ...(employee.positions ?? [])]) {
for (const p of pos?.permissions ?? []) {
if (p.key) keys.add(p.key);
}
addTypePermissions(employee.position?.positionType);
addTypePermissions(pos?.positionType);
}
for (const delegated of employee.delegatedPositions ?? []) {
for (const p of delegated.permissions ?? []) {
if (p.key) keys.add(p.key);
@@ -158,8 +172,10 @@ export function collectPositionTypeKeys(
return [...keys];
}
if (employee.position?.positionType?.key) {
keys.add(employee.position.positionType.key);
// Both shapes, same reason as collectPermissionKeys: an employee holding two
// posts answers to both their position types.
for (const pos of [employee.position, ...(employee.positions ?? [])]) {
if (pos?.positionType?.key) keys.add(pos.positionType.key);
}
return [...keys];
}

View File

@@ -1,5 +1,5 @@
import { applyDecorators, UseGuards } from '@nestjs/common';
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
import { FreightJwtGuard } from './freight-jwt.guard';
import { FreightPermissionGuard } from './freight-permission.guard';
import {
@@ -10,7 +10,7 @@ import {
export const RuleEngineView = (slug: RuleEngineResourceSlug) =>
applyDecorators(
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.view(slug)])),
UseGuards(FreightJwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.view(slug)])),
);
// Granular CRUD replaces the retired coarse RuleEngineManage. Each write
@@ -18,17 +18,17 @@ export const RuleEngineView = (slug: RuleEngineResourceSlug) =>
// update on PATCH / reorder / move-order, delete on DELETE.
export const RuleEngineCreate = (slug: RuleEngineResourceSlug) =>
applyDecorators(
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.create(slug)])),
UseGuards(FreightJwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.create(slug)])),
);
export const RuleEngineUpdate = (slug: RuleEngineResourceSlug) =>
applyDecorators(
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.update(slug)])),
UseGuards(FreightJwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.update(slug)])),
);
export const RuleEngineDelete = (slug: RuleEngineResourceSlug) =>
applyDecorators(
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.delete(slug)])),
UseGuards(FreightJwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.delete(slug)])),
);
/**
@@ -38,5 +38,5 @@ export const RuleEngineDelete = (slug: RuleEngineResourceSlug) =>
*/
export const RuleEngineApprove = (slug: RuleEngineApprovableSlug) =>
applyDecorators(
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.approve(slug)])),
UseGuards(FreightJwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.approve(slug)])),
);

View File

@@ -1,7 +1,7 @@
import { Body, Controller, Patch, Post, UseGuards } from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { CurrentUser } from "@tria-plc/api-common/modules/auth/decorators/current-user.decorator";
import { JwtGuard } from "@tria-plc/api-common/modules/auth/services/jwt.guard";
import { FreightJwtGuard } from "../../common/freight-jwt.guard";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
import { AccountService } from "./account.service";
@@ -19,7 +19,7 @@ import {
@ApiTags("auth")
@Controller("me")
@ApiBearerAuth()
@UseGuards(JwtGuard)
@UseGuards(FreightJwtGuard)
export class AccountController {
constructor(private readonly accountService: AccountService) {}

View File

@@ -1,7 +1,7 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '@tria-plc/api-common/modules/auth/decorators/current-user.decorator';
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
import { FreightJwtGuard } from '../../common/freight-jwt.guard';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { FreightMeService } from './freight-me.service';
@@ -13,7 +13,7 @@ export class FreightMeController {
constructor(private readonly freightMeService: FreightMeService) {}
@Get()
@UseGuards(JwtGuard)
@UseGuards(FreightJwtGuard)
@ApiOperation({
summary: 'Current user with flat permissionKeys for backoffice gating',
})

View File

@@ -9,6 +9,9 @@ import {
} from '../../common/freight-permission.util';
import { PERMISSIONS_CATALOG } from '../../seed/freight-permissions.registry';
/** One position as the session snapshot carries it. */
type TokenPosition = NonNullable<TCurrentUser['employee']>['position'];
@Injectable()
export class FreightMeService {
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
@@ -65,49 +68,63 @@ export class FreightMeService {
}
async getEnrichedProfile(user: TCurrentUser) {
const positionId = user.employee?.position?.id;
const employeeRecord = user.employee as
| (typeof user.employee & { positions?: TokenPosition[] })
| undefined;
// `FreightJwtGuard` restores every position the login snapshot holds; the
// stock IAM guard only ever leaves the single `position`. Fall back to it
// so a request that somehow skipped our guard still resolves one post
// rather than none.
const rawPositions: TokenPosition[] = employeeRecord?.positions?.length
? employeeRecord.positions
: employeeRecord?.position
? [employeeRecord.position]
: [];
const enrichedPositions = await Promise.all(
rawPositions.map(async (position) => {
const [positionType, positionTypePermissionKeys] = await Promise.all([
this.lookupPositionType(positionId),
this.lookupPositionTypePermissions(positionId),
this.lookupPositionType(position.id),
this.lookupPositionTypePermissions(position.id),
]);
// Merge the type-level grants into the position's own permission list so
// BOTH consumers see them: `collectPermissionKeys` below, and the
// backoffice's `getPermissionKeys`, which walks this same nested array.
const positionPermissions = [
...(user.employee?.position?.permissions ?? []),
];
const seenPermissionKeys = new Set(
positionPermissions.map((p) => p?.key).filter(Boolean),
);
// Merge the type-level grants into this position's own permission list
// so BOTH consumers see them: `collectPermissionKeys` below, and the
// backoffice's `getPermissionKeys`, which walks this nested array.
const permissions = [...(position.permissions ?? [])];
const seen = new Set(permissions.map((p) => p?.key).filter(Boolean));
for (const key of positionTypePermissionKeys) {
if (!seenPermissionKeys.has(key)) {
seenPermissionKeys.add(key);
positionPermissions.push({ key } as (typeof positionPermissions)[number]);
if (!seen.has(key)) {
seen.add(key);
permissions.push({ key } as (typeof permissions)[number]);
}
}
const employee = user.employee
? [
{
id: user.employee.id,
organizationId: user.employee.organizationId,
unitId: user.employee.unitId,
name: user.employee.name,
positions: user.employee.position
? [
{
id: user.employee.position.id,
key: user.employee.position.key,
employeePositionId: user.employee.position.employeePositionId,
name: user.employee.position.name,
isDelegate: user.employee.position.isDelegate,
parentPositionId: user.employee.position.parentPositionId,
permissions: positionPermissions,
return {
positionTypePermissionKeys,
position: {
id: position.id,
key: position.key,
employeePositionId: position.employeePositionId,
name: position.name,
isDelegate: position.isDelegate,
parentPositionId: position.parentPositionId,
permissions,
positionType,
},
]
: [],
};
}),
);
const employee = employeeRecord
? [
{
id: employeeRecord.id,
organizationId: employeeRecord.organizationId,
unitId: employeeRecord.unitId,
name: employeeRecord.name,
positions: enrichedPositions.map((p) => p.position),
},
]
: [];
@@ -118,7 +135,7 @@ export class FreightMeService {
const permissionKeys = [
...new Set([
...collectPermissionKeys(user),
...positionTypePermissionKeys,
...enrichedPositions.flatMap((p) => p.positionTypePermissionKeys),
]),
];

View File

@@ -1,7 +1,7 @@
import { Controller, Get, Post, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '@tria-plc/api-common/modules/auth/decorators/current-user.decorator';
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
import { FreightJwtGuard } from '../../common/freight-jwt.guard';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { ChatSync } from '../../common/booking-guards';
@@ -18,7 +18,7 @@ export class ChatController {
) {}
@Get('sso')
@UseGuards(JwtGuard)
@UseGuards(FreightJwtGuard)
@ApiOperation({ summary: 'One-click sign-in link into EDR internal chat' })
getSso(@CurrentUser() user: TCurrentUser) {
return this.sso.getSsoUrl(user);

View File

@@ -3,7 +3,7 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { CurrentUser } from '@edr/api-common';
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
import { FreightJwtGuard } from '../../common/freight-jwt.guard';
import type { Response } from 'express';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
@@ -57,7 +57,7 @@ const toCatalogEntry = (dataset: ExportDataset): ExportCatalogEntry => ({
@ApiTags('Exports')
@ApiBearerAuth()
@Controller('exports')
@UseGuards(JwtGuard)
@UseGuards(FreightJwtGuard)
export class ExportsController {
constructor(
private readonly runner: ExportRunnerService,

View File

@@ -10,7 +10,7 @@ import {
UseGuards,
} from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { JwtGuard } from "@tria-plc/api-common/modules/auth/services/jwt.guard";
import { FreightJwtGuard } from "../../common/freight-jwt.guard";
import {
AuthUserPayload,
@@ -21,7 +21,7 @@ import { NotificationInboxService } from "./notification-inbox.service";
@ApiTags("notifications")
@ApiBearerAuth()
@UseGuards(JwtGuard)
@UseGuards(FreightJwtGuard)
@Controller("notifications")
export class NotificationInboxController {
constructor(private readonly service: NotificationInboxService) {}

View File

@@ -17,7 +17,7 @@ import {
} from '@nestjs/swagger';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
import { FreightJwtGuard } from '../../common/freight-jwt.guard';
import { OptionalJwtGuard } from './optional-jwt.guard';
import {
CompleteVerificationResultDto,
@@ -91,7 +91,7 @@ export class VerifaydaController {
}
@Get('status')
@UseGuards(JwtGuard)
@UseGuards(FreightJwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: "Get the current user's Fayda verification status",