mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
fix: ( passenger ) read permissions from position types and all positions
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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]));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user