mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 15:58:18 +00:00
176 lines
5.9 KiB
TypeScript
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(', ')}`,
|
|
);
|
|
}
|