mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
fix(auth): resolve position-type permissions so GL staff can open clearance pages
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
import {
|
||||
assertCanApproveContractStep,
|
||||
canEditContractStep,
|
||||
collectPermissionKeys,
|
||||
hasFreightPermission,
|
||||
setPositionTypePermissionResolver,
|
||||
} from './freight-permission.util';
|
||||
import { FREIGHT_PERMS } from '../seed/freight-permissions.registry';
|
||||
|
||||
@@ -49,3 +52,72 @@ describe('canEditContractStep (strict per-step edit gate)', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The GL lockout regression: positions created through the admin UI keep their
|
||||
* grants on the position TYPE, and the JWT only ever snapshots DIRECT position
|
||||
* permissions. Without the type resolver those staff resolved to zero
|
||||
* permissions, so every gated route rejected them — which is what kept GL
|
||||
* officers out of their own clearance detail pages.
|
||||
*/
|
||||
describe('collectPermissionKeys — position-type grants', () => {
|
||||
const CLEARANCE = FREIGHT_PERMS.contracts.clearanceReview;
|
||||
|
||||
afterEach(() => {
|
||||
setPositionTypePermissionResolver(() => []);
|
||||
});
|
||||
|
||||
const glOfficer = {
|
||||
roles: [],
|
||||
permissions: [],
|
||||
employee: {
|
||||
position: {
|
||||
permissions: [], // admin-created position carries NO direct grants
|
||||
positionType: { key: 'commercial-global-logistics-(et)-officer' },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it('resolves permissions carried by the position type', () => {
|
||||
setPositionTypePermissionResolver((key) =>
|
||||
key === 'commercial-global-logistics-(et)-officer' ? [CLEARANCE] : [],
|
||||
);
|
||||
|
||||
expect(collectPermissionKeys(glOfficer)).toContain(CLEARANCE);
|
||||
expect(hasFreightPermission(glOfficer, CLEARANCE)).toBe(true);
|
||||
});
|
||||
|
||||
it('handles the array-shaped employee payload too', () => {
|
||||
setPositionTypePermissionResolver(() => [CLEARANCE]);
|
||||
|
||||
const arrayShaped = {
|
||||
roles: [],
|
||||
permissions: [],
|
||||
employee: [
|
||||
{
|
||||
positions: [
|
||||
{ permissions: [], positionType: { key: 'djibouti-gl-officer' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(hasFreightPermission(arrayShaped, CLEARANCE)).toBe(true);
|
||||
});
|
||||
|
||||
it('still rejects when neither the position nor its type grants it', () => {
|
||||
setPositionTypePermissionResolver(() => []);
|
||||
|
||||
expect(hasFreightPermission(glOfficer, CLEARANCE)).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps direct position permissions working with no resolver installed', () => {
|
||||
const direct = {
|
||||
roles: [],
|
||||
permissions: [],
|
||||
employee: { position: { permissions: [{ key: CLEARANCE }] } },
|
||||
};
|
||||
|
||||
expect(hasFreightPermission(direct, CLEARANCE)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,12 +42,41 @@ export function isFreightApprovalAdmin(user: MeLikeUser | null | undefined): boo
|
||||
return isSuperAdmin(user) || isOrganizationAdmin(user);
|
||||
}
|
||||
|
||||
/** Flat permission keys from JWT / session user (roles + position permissions). */
|
||||
/**
|
||||
* Permissions carried by a position TYPE rather than the position itself.
|
||||
*
|
||||
* The JWT snapshots only DIRECT position permissions, so type-level grants —
|
||||
* which is where admin-created positions keep theirs — are absent from the
|
||||
* token entirely. This resolver is installed at startup
|
||||
* (see `PositionTypePermissionsCache`) so the synchronous permission checks
|
||||
* below can still see them. Left as a no-op resolver until then, which
|
||||
* degrades to the old position-only behaviour rather than throwing.
|
||||
*/
|
||||
let positionTypePermissionResolver: (positionTypeKey: string) => string[] = () =>
|
||||
[];
|
||||
|
||||
export function setPositionTypePermissionResolver(
|
||||
resolver: (positionTypeKey: string) => string[],
|
||||
): void {
|
||||
positionTypePermissionResolver = resolver;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flat permission keys from JWT / session user: roles, position permissions,
|
||||
* and the grants held by each position's TYPE.
|
||||
*/
|
||||
export function collectPermissionKeys(user: MeLikeUser | null | undefined): string[] {
|
||||
if (!user) return [];
|
||||
|
||||
const keys = new Set<string>();
|
||||
|
||||
const addTypePermissions = (positionType: PositionTypeLike | null | undefined) => {
|
||||
if (!positionType?.key) return;
|
||||
for (const key of positionTypePermissionResolver(positionType.key)) {
|
||||
keys.add(key);
|
||||
}
|
||||
};
|
||||
|
||||
for (const p of user.permissions ?? []) {
|
||||
if (p.key) keys.add(p.key);
|
||||
}
|
||||
@@ -63,6 +92,7 @@ export function collectPermissionKeys(user: MeLikeUser | null | undefined): stri
|
||||
for (const p of pos.permissions ?? []) {
|
||||
if (p.key) keys.add(p.key);
|
||||
}
|
||||
addTypePermissions(pos.positionType);
|
||||
}
|
||||
}
|
||||
return [...keys];
|
||||
@@ -71,6 +101,7 @@ export function collectPermissionKeys(user: MeLikeUser | null | undefined): stri
|
||||
for (const p of employee.position?.permissions ?? []) {
|
||||
if (p.key) keys.add(p.key);
|
||||
}
|
||||
addTypePermissions(employee.position?.positionType);
|
||||
for (const delegated of employee.delegatedPositions ?? []) {
|
||||
for (const p of delegated.permissions ?? []) {
|
||||
if (p.key) keys.add(p.key);
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { setPositionTypePermissionResolver } from './freight-permission.util';
|
||||
|
||||
/**
|
||||
* Permissions granted to a position TYPE (`iam.position_type_permissions`).
|
||||
*
|
||||
* A position type is the platform's notion of a role, and positions created
|
||||
* through the admin UI carry their grants there rather than on the position
|
||||
* itself. The JWT only ever snapshots DIRECT position permissions, so those
|
||||
* grants are invisible to `collectPermissionKeys` — staff on such a position
|
||||
* resolve to zero permissions and every permission-gated route rejects them.
|
||||
*
|
||||
* The permission checks (`hasFreightPermission`, `FreightPermissionGuard`) are
|
||||
* synchronous and sit on the request path, so the mapping is held in memory and
|
||||
* refreshed periodically rather than queried per request. The dataset is tiny
|
||||
* (tens of types, a few hundred rows), so a full reload is cheaper than any
|
||||
* incremental scheme.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PositionTypePermissionsCache implements OnModuleInit {
|
||||
private readonly logger = new Logger(PositionTypePermissionsCache.name);
|
||||
|
||||
/** position_type key → permission keys. Empty until the first load lands. */
|
||||
private byPositionTypeKey = new Map<string, string[]>();
|
||||
|
||||
// ponytail: fixed 5-min refresh, no invalidation hook. A permission granted
|
||||
// in the admin UI takes up to one interval to reach the guards. Wire the
|
||||
// grant mutation to call `refresh()` if that lag ever matters.
|
||||
private static readonly REFRESH_INTERVAL_MS = 5 * 60 * 1000;
|
||||
|
||||
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
await this.refresh();
|
||||
// Hand the lookup to the permission utils, whose checks are synchronous and
|
||||
// therefore cannot query IAM themselves.
|
||||
setPositionTypePermissionResolver((positionTypeKey) =>
|
||||
this.get(positionTypeKey),
|
||||
);
|
||||
const timer = setInterval(() => {
|
||||
void this.refresh();
|
||||
}, PositionTypePermissionsCache.REFRESH_INTERVAL_MS);
|
||||
// Never hold the process open for a cache refresh.
|
||||
timer.unref?.();
|
||||
}
|
||||
|
||||
/** Permission keys for a position-type key ([] when unknown/not loaded). */
|
||||
get(positionTypeKey: string | undefined | null): string[] {
|
||||
if (!positionTypeKey) return [];
|
||||
return this.byPositionTypeKey.get(positionTypeKey) ?? [];
|
||||
}
|
||||
|
||||
/** Reload the whole mapping. Failures keep the previous snapshot in place. */
|
||||
async refresh(): Promise<void> {
|
||||
try {
|
||||
const rows: { position_type_key: string; permission_key: string }[] =
|
||||
await this.dataSource.query(
|
||||
`SELECT pt.key AS position_type_key, perm.key AS permission_key
|
||||
FROM iam.position_type_permissions ptp
|
||||
JOIN iam.position_types pt ON pt.id = ptp.position_type_id
|
||||
JOIN iam.permissions perm ON perm.id = ptp.permission_id`,
|
||||
);
|
||||
|
||||
const next = new Map<string, string[]>();
|
||||
for (const row of rows) {
|
||||
if (!row.position_type_key || !row.permission_key) continue;
|
||||
const keys = next.get(row.position_type_key);
|
||||
if (keys) keys.push(row.permission_key);
|
||||
else next.set(row.position_type_key, [row.permission_key]);
|
||||
}
|
||||
this.byPositionTypeKey = next;
|
||||
} catch (err) {
|
||||
// iam schema unreachable — keep serving the previous snapshot rather than
|
||||
// dropping every type-derived permission and locking staff out.
|
||||
this.logger.warn(
|
||||
`Position-type permission refresh failed: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user