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