mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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.
102 lines
3.8 KiB
TypeScript
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;
|
|
}
|
|
}
|