Files
edr-platform/apps/edr-passenger-api/src/common/passenger-permission.util.ts

176 lines
5.9 KiB
TypeScript

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<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 [];
const keys = new Set<string>();
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(', ')}`,
);
}