fix: ( passenger ) read permissions from position types and all positions

This commit is contained in:
Abubeker Yasin
2026-08-20 12:33:39 +03:00
parent a00606823e
commit e174efb8e0
6 changed files with 651 additions and 84 deletions

View File

@@ -46,7 +46,7 @@
"@prisma/client": "^6.19.3",
"@sendgrid/mail": "^8.1.0",
"@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.6.0.tgz",
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-1.0.0.tgz",
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-1.1.0.tgz",
"@types/bcrypt": "^6.0.0",
"amqp-connection-manager": "^5.0.0",
"amqplib": "^2.0.1",

View File

@@ -0,0 +1,280 @@
import { ForbiddenException } from '@nestjs/common';
import {
assertPassengerPermission,
collectPermissionKeys,
hasPassengerPermission,
hasPassengerPermissionStrict,
isOrganizationAdmin,
isSuperAdmin,
} from './passenger-permission.util';
const TICKETS_MANAGE = 'edr_passenger_app:tickets:manage';
const TICKETS_VIEW = 'edr_passenger_app:tickets:view';
const BOOKINGS_VIEW = 'edr_passenger_app:bookings:view';
const FLEET_MANAGE = 'edr_passenger_app:fleet:manage';
/** IAM shape: position types nest the key one level deeper, under `.permission`. */
const positionType = (keys: string[]) => ({
positionTypePermissions: keys.map((key) => ({ permission: { key } })),
});
/** Mirrors the ticket-officer position IAM returns: own grant + two position types. */
const ticketOfficerPosition = () => ({
id: 'pos-ticket-officer',
permissions: [{ key: BOOKINGS_VIEW }],
positionType: positionType([TICKETS_VIEW, TICKETS_MANAGE]),
positionTypes: [
positionType(['can:view:expectation']),
positionType([TICKETS_VIEW, TICKETS_MANAGE]),
],
});
const hrPosition = () => ({
id: 'pos-hr',
permissions: [{ key: 'hr_app:employees:view' }],
positionType: positionType(['hr_app:leave:approve']),
positionTypes: [positionType(['hr_app:leave:approve'])],
});
/** `/v1/auth/me` — `employee` is an array of employees, each with `positions[]`. */
const meShape = (positions: any[]) => ({
roles: [],
permissions: [],
employee: [{ id: 'emp-1', positions }],
});
/**
* `request.user` — `JwtGuard.parseToken` spreads the employee, then overwrites
* `position` with the one it selected and adds `delegatedPositions[]`. The full
* `positions[]` survives the spread.
*/
const requestShape = (positions: any[], selectedIndex = 0) => ({
roles: [],
permissions: [],
employee: {
id: 'emp-1',
positions,
position: positions[selectedIndex],
delegatedPositions: positions.filter((p: any) => p.isDelegate),
},
});
describe('collectPermissionKeys', () => {
it('returns nothing for a missing user', () => {
expect(collectPermissionKeys(null)).toEqual([]);
expect(collectPermissionKeys(undefined)).toEqual([]);
expect(collectPermissionKeys({})).toEqual([]);
});
it('collects role permissions when there is no employee record', () => {
expect(collectPermissionKeys({ permissions: [{ key: TICKETS_VIEW }] })).toEqual([TICKETS_VIEW]);
expect(collectPermissionKeys({ permissions: [{ key: TICKETS_VIEW }], employee: null })).toEqual([
TICKETS_VIEW,
]);
});
describe('backward compatibility — positions with no position types', () => {
it('still reads a position own permissions[] in the /v1/auth/me shape', () => {
const user = meShape([{ id: 'p1', permissions: [{ key: BOOKINGS_VIEW }] }]);
expect(collectPermissionKeys(user)).toEqual([BOOKINGS_VIEW]);
});
it('still reads a position own permissions[] in the request.user shape', () => {
const user = requestShape([{ id: 'p1', permissions: [{ key: BOOKINGS_VIEW }] }]);
expect(collectPermissionKeys(user)).toEqual([BOOKINGS_VIEW]);
});
it('merges role permissions with position permissions', () => {
const user = {
permissions: [{ key: 'role:key' }],
employee: [{ positions: [{ permissions: [{ key: BOOKINGS_VIEW }] }] }],
};
expect(collectPermissionKeys(user).sort()).toEqual([BOOKINGS_VIEW, 'role:key'].sort());
});
});
describe('position types', () => {
it('collects from the legacy singular positionType', () => {
const user = meShape([{ permissions: [], positionType: positionType([TICKETS_MANAGE]) }]);
expect(collectPermissionKeys(user)).toEqual([TICKETS_MANAGE]);
});
it('collects from the new positionTypes[] array', () => {
const user = meShape([{ permissions: [], positionTypes: [positionType([TICKETS_MANAGE])] }]);
expect(collectPermissionKeys(user)).toEqual([TICKETS_MANAGE]);
});
it('collects from every entry of positionTypes[], not just the first', () => {
const user = meShape([
{
permissions: [],
positionTypes: [positionType(['a']), positionType(['b']), positionType(['c'])],
},
]);
expect(collectPermissionKeys(user).sort()).toEqual(['a', 'b', 'c']);
});
it('unions permissions[], positionType and positionTypes[] without duplicates', () => {
// ticket-officer appears as BOTH the singular positionType and inside positionTypes[]
const keys = collectPermissionKeys(meShape([ticketOfficerPosition()]));
expect(keys.sort()).toEqual(
[BOOKINGS_VIEW, TICKETS_VIEW, TICKETS_MANAGE, 'can:view:expectation'].sort(),
);
expect(keys).toHaveLength(new Set(keys).size);
});
it('works identically in the request.user shape', () => {
expect(collectPermissionKeys(requestShape([ticketOfficerPosition()])).sort()).toEqual(
collectPermissionKeys(meShape([ticketOfficerPosition()])).sort(),
);
});
});
describe('all positions count, not only the selected one', () => {
it('grants a permission held by a position that is not positions[0]', () => {
// parseToken selected positions[0] (HR) because no x-current-position-id was sent
const user = requestShape([hrPosition(), ticketOfficerPosition()], 0);
expect(collectPermissionKeys(user)).toContain(TICKETS_MANAGE);
});
it('matches what the backoffice computes from the same payload', () => {
const positions = [hrPosition(), ticketOfficerPosition()];
expect(collectPermissionKeys(requestShape(positions, 0)).sort()).toEqual(
collectPermissionKeys(meShape(positions)).sort(),
);
});
it('spans multiple employee records', () => {
const user = {
employee: [{ positions: [hrPosition()] }, { positions: [ticketOfficerPosition()] }],
};
expect(collectPermissionKeys(user)).toContain(TICKETS_MANAGE);
expect(collectPermissionKeys(user)).toContain('hr_app:leave:approve');
});
it('includes delegated positions', () => {
const delegated = { ...ticketOfficerPosition(), isDelegate: true };
const user = {
employee: { positions: undefined, position: hrPosition(), delegatedPositions: [delegated] },
};
expect(collectPermissionKeys(user)).toContain(TICKETS_MANAGE);
});
it('does not invent permissions nobody was granted', () => {
const user = requestShape([hrPosition(), ticketOfficerPosition()]);
expect(collectPermissionKeys(user)).not.toContain(FLEET_MANAGE);
});
});
describe('malformed payloads', () => {
it('survives nulls, missing keys and non-array fields', () => {
const user: any = {
permissions: [{}, { key: 'kept' }],
employee: {
// parseToken passes `positions` through untouched when it is not an array
positions: { id: 'not-an-array' },
position: {
permissions: null,
positionType: null,
positionTypes: [null, { positionTypePermissions: null }, positionType([TICKETS_VIEW])],
},
delegatedPositions: null,
},
};
expect(collectPermissionKeys(user).sort()).toEqual(['kept', TICKETS_VIEW].sort());
});
it('ignores positionTypePermissions entries with no permission object', () => {
const user: any = {
employee: [
{
positions: [
{
positionTypes: [
{
positionTypePermissions: [
{},
{ permission: null },
{ permission: { key: 'ok' } },
],
},
],
},
],
},
],
};
expect(collectPermissionKeys(user)).toEqual(['ok']);
});
});
});
describe('role helpers', () => {
it('detects super admin and org admin', () => {
expect(isSuperAdmin({ roles: [{ key: 'super_admin' }] })).toBe(true);
expect(isOrganizationAdmin({ roles: [{ key: 'organization_admin' }] })).toBe(true);
expect(isSuperAdmin({ roles: [{ key: 'ticket_officer' }] })).toBe(false);
expect(isOrganizationAdmin({})).toBe(false);
});
});
describe('hasPassengerPermission', () => {
it('is true for a permission granted only through a position type', () => {
expect(hasPassengerPermission(requestShape([ticketOfficerPosition()]), TICKETS_MANAGE)).toBe(
true,
);
});
it('is true when the granting position is not the selected one', () => {
const user = requestShape([hrPosition(), ticketOfficerPosition()], 0);
expect(hasPassengerPermission(user, TICKETS_MANAGE)).toBe(true);
});
it('is false for a permission nobody granted', () => {
expect(hasPassengerPermission(requestShape([ticketOfficerPosition()]), FLEET_MANAGE)).toBe(
false,
);
});
it('is false without a user', () => {
expect(hasPassengerPermission(null, TICKETS_MANAGE)).toBe(false);
});
it('lets super admins and org admins bypass', () => {
expect(hasPassengerPermission({ roles: [{ key: 'super_admin' }] }, FLEET_MANAGE)).toBe(true);
expect(hasPassengerPermission({ roles: [{ key: 'organization_admin' }] }, FLEET_MANAGE)).toBe(
true,
);
});
});
describe('hasPassengerPermissionStrict', () => {
it('does not let super admins bypass', () => {
expect(hasPassengerPermissionStrict({ roles: [{ key: 'super_admin' }] }, FLEET_MANAGE)).toBe(
false,
);
});
it('honours a position-type grant', () => {
expect(hasPassengerPermissionStrict(requestShape([ticketOfficerPosition()]), TICKETS_MANAGE)).toBe(
true,
);
});
});
describe('assertPassengerPermission', () => {
it('passes silently when granted through a position type', () => {
expect(() =>
assertPassengerPermission(requestShape([ticketOfficerPosition()]), TICKETS_MANAGE),
).not.toThrow();
});
it('throws ForbiddenException naming the missing key', () => {
expect(() => assertPassengerPermission(requestShape([hrPosition()]), TICKETS_MANAGE)).toThrow(
ForbiddenException,
);
expect(() => assertPassengerPermission(requestShape([hrPosition()]), TICKETS_MANAGE)).toThrow(
TICKETS_MANAGE,
);
});
});

View File

@@ -4,13 +4,34 @@ const SUPER_ADMIN_ROLE = 'super_admin';
const ORGANIZATION_ADMIN_ROLE = 'organization_admin';
type PermissionLike = { key?: string };
/**
* A position type carries its own grants. IAM ships them as
* `positionTypePermissions[].permission.key` — note the extra `permission`
* wrapper, unlike the flat `permissions[]` on a position.
*/
type PositionTypeLike = {
positionTypePermissions?: ({ permission?: PermissionLike | null } | null)[] | null;
};
type PositionLike = {
permissions?: PermissionLike[];
/** Legacy single position type. */
positionType?: PositionTypeLike | null;
/** Newer array — a position can now carry several position types. */
positionTypes?: (PositionTypeLike | null)[] | null;
};
type EmployeeLike = {
position?: PositionLike;
positions?: PositionLike[];
delegatedPositions?: PositionLike[];
};
type MeLikeUser = {
roles?: { key?: string }[];
permissions?: PermissionLike[];
employee?:
| { position?: { permissions?: PermissionLike[] }; delegatedPositions?: { permissions?: PermissionLike[] }[] }
| { positions?: { permissions?: PermissionLike[] }[] }[]
| null;
employee?: EmployeeLike | EmployeeLike[] | null;
};
export function isSuperAdmin(user: MeLikeUser | null | undefined): boolean {
@@ -21,6 +42,42 @@ export function isOrganizationAdmin(user: MeLikeUser | null | undefined): boolea
return user?.roles?.some((r) => r.key === ORGANIZATION_ADMIN_ROLE) ?? false;
}
/**
* Add every permission key a single position grants.
*
* A position's own `permissions[]` used to be the whole story. IAM now also
* hangs grants off *position types* — the legacy singular `positionType` plus
* the newer `positionTypes[]` array — so we union all three rather than trust
* IAM to have merged them back into `permissions[]`.
*/
function addPositionPermissionKeys(
position: PositionLike | null | undefined,
keys: Set<string>,
): void {
if (!position) return;
for (const p of position.permissions ?? []) {
if (p?.key) keys.add(p.key);
}
const positionTypes: (PositionTypeLike | null | undefined)[] = [
position.positionType,
...(position.positionTypes ?? []),
];
for (const positionType of positionTypes) {
for (const ptp of positionType?.positionTypePermissions ?? []) {
const key = ptp?.permission?.key;
if (key) keys.add(key);
}
}
}
/** `parseToken` passes `positions` through untouched, so it is not always an array. */
function asArray<T>(value: T[] | null | undefined): T[] {
return Array.isArray(value) ? value : [];
}
export function collectPermissionKeys(user: MeLikeUser | null | undefined): string[] {
if (!user) return [];
@@ -33,23 +90,25 @@ export function collectPermissionKeys(user: MeLikeUser | null | undefined): stri
const employee = user.employee;
if (!employee) return [...keys];
if (Array.isArray(employee)) {
for (const emp of employee) {
for (const pos of emp.positions ?? []) {
for (const p of pos.permissions ?? []) {
if (p.key) keys.add(p.key);
}
}
}
return [...keys];
}
// `/v1/auth/me` hands back `employee` as an array of employees, each with
// `positions[]`. `JwtGuard.parseToken` collapses it to a single employee with
// the active `position` plus `delegatedPositions[]` — but it spreads the
// employee, so the full `positions[]` survives on `request.user` too. Both
// shapes reach here.
const employees = Array.isArray(employee) ? employee : [employee];
for (const p of employee.position?.permissions ?? []) {
if (p.key) keys.add(p.key);
}
for (const delegated of employee.delegatedPositions ?? []) {
for (const p of delegated.permissions ?? []) {
if (p.key) keys.add(p.key);
for (const emp of employees) {
// Every position the person holds counts, not just the one
// `parseToken` selected. Without a `x-current-position-id` header it picks
// `positions[0]`, so a second-listed passenger position would 403 here while
// the backoffice — which unions all positions at login — renders the action
// as available. Union them here so the two agree.
addPositionPermissionKeys(emp.position, keys);
for (const pos of asArray(emp.positions)) {
addPositionPermissionKeys(pos, keys);
}
for (const delegated of asArray(emp.delegatedPositions)) {
addPositionPermissionKeys(delegated, keys);
}
}

View File

@@ -6,6 +6,33 @@ import axios from 'axios';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
/**
* Every permission key a single IAM position grants.
*
* A position's own `permissions[]` used to be the whole story. IAM now also
* hangs grants off *position types* — the legacy singular `positionType` plus
* the newer `positionTypes[]` array, whose entries expose
* `positionTypePermissions[].permission.key` (note the extra `permission`
* wrapper). Union all three rather than trust IAM to have merged them back into
* `permissions[]`. Mirrors collectPermissionKeys() on the API.
*/
export function positionPermissionKeys(pos: any): string[] {
const keys: string[] = (pos?.permissions ?? [])
.map((p: any) => p?.key)
.filter(Boolean)
.map(String);
const positionTypes: any[] = [pos?.positionType, ...(pos?.positionTypes ?? [])];
for (const pt of positionTypes) {
for (const ptp of pt?.positionTypePermissions ?? []) {
const key = ptp?.permission?.key;
if (key) keys.push(String(key));
}
}
return keys;
}
function mapIamRole(roles: { key?: string }[]): 'ADMIN' | 'AGENT' | 'SUPERVISOR' {
const keys = roles.map((r) => r.key ?? '');
if (keys.some((k) => k.includes('admin') || k === 'super_admin' || k === 'organization_admin')) return 'ADMIN';
@@ -70,12 +97,11 @@ export const useAuthStore = create<AuthState>((set, get) => ({
// Role permissions — flat array in data.permissions
const rolePerms = (iamUser.permissions ?? []).map((p: any) => String(p.key));
// Position permissions — employee[] is an array here; positions[].permissions[] merged by IAM
// Position permissions — employee[] is an array here; each position contributes
// its own permissions[] plus everything its position type(s) grant.
const employeeArr: any[] = Array.isArray(iamUser.employee) ? iamUser.employee : [];
const positionPerms = employeeArr.flatMap((emp: any) =>
(emp.positions ?? []).flatMap((pos: any) =>
(pos.permissions ?? []).map((p: any) => String(p.key))
)
(emp.positions ?? []).flatMap((pos: any) => positionPermissionKeys(pos))
);
const permissions = Array.from(new Set([...rolePerms, ...positionPerms]));