Files
edr-platform/apps/edr-freight-api/src/common/freight-jwt.guard.ts
Nathnael 01ea05f013 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.
2026-08-25 12:00:43 +00:00

102 lines
3.8 KiB
TypeScript

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;
}
}