import { ForbiddenException } from '@nestjs/common'; 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[]; }; export type MeLikeUser = { roles?: { key?: string }[]; permissions?: PermissionLike[]; employee?: EmployeeLike | EmployeeLike[] | null; }; export function isSuperAdmin(user: MeLikeUser | null | undefined): boolean { return user?.roles?.some((r) => r.key === SUPER_ADMIN_ROLE) ?? false; } export function isOrganizationAdmin(user: MeLikeUser | null | undefined): boolean { 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, ): 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(value: T[] | null | undefined): T[] { return Array.isArray(value) ? value : []; } export function collectPermissionKeys(user: MeLikeUser | null | undefined): string[] { if (!user) return []; const keys = new Set(); for (const p of user.permissions ?? []) { if (p.key) keys.add(p.key); } const employee = user.employee; if (!employee) 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 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); } } return [...keys]; } export function hasPassengerPermission( user: MeLikeUser | null | undefined, permissionKey: string, ): boolean { if (!user) return false; if (isSuperAdmin(user) || isOrganizationAdmin(user)) return true; return collectPermissionKeys(user).includes(permissionKey); } /** * Same check as {@link hasPassengerPermission} but WITHOUT the super-admin / * org-admin bypass — the permission key must be explicitly granted, whether via * a role or an employee position. Use for actions that must stay auditable to a * deliberate grant (e.g. ticket generation, which can waive a fare). */ export function hasPassengerPermissionStrict( user: MeLikeUser | null | undefined, permissionKey: string, ): boolean { if (!user) return false; return collectPermissionKeys(user).includes(permissionKey); } export function assertPassengerPermission( user: MeLikeUser | null | undefined, permissionKey: string, ): void { if (hasPassengerPermission(user, permissionKey)) return; throw new ForbiddenException(`Missing permission: ${permissionKey}`); } /** Holds at least one of the keys. Same OR semantics as `PassengerPermissionGuard`. */ export function hasAnyPassengerPermission( user: MeLikeUser | null | undefined, permissionKeys: string[], ): boolean { return permissionKeys.some((key) => hasPassengerPermission(user, key)); } /** * The in-handler equivalent of `@PassengerStaff([...])`, for actions a decorator * cannot see — where the destructive variant is chosen by a body field rather * than by the route. Cancelling a schedule is the case this exists for: * `PATCH /schedules/:id/status` carries `{ status: 'CANCELLED' }` on the same * route as every routine transition. * * Pass the umbrella and admin keys alongside the narrow one, exactly as a guard * array would, so existing grants keep working. */ export function assertAnyPassengerPermission( user: MeLikeUser | null | undefined, permissionKeys: string[], ): void { if (hasAnyPassengerPermission(user, permissionKeys)) return; throw new ForbiddenException( `Missing permission. Required one of: ${permissionKeys.join(', ')}`, ); }