mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
243 lines
8.1 KiB
TypeScript
243 lines
8.1 KiB
TypeScript
import { ForbiddenException } from '@nestjs/common';
|
|
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
|
|
|
import { FREIGHT_PERMS } from '../seed/freight-permissions.registry';
|
|
|
|
const SUPER_ADMIN_ROLE = 'super_admin';
|
|
const ORGANIZATION_ADMIN_ROLE = 'organization_admin';
|
|
|
|
type PermissionLike = { key?: string };
|
|
type PositionTypeLike = { key?: string };
|
|
type MeLikeUser = {
|
|
roles?: { key?: string }[];
|
|
permissions?: PermissionLike[];
|
|
employee?:
|
|
| {
|
|
position?: {
|
|
permissions?: PermissionLike[];
|
|
positionType?: PositionTypeLike | null;
|
|
};
|
|
delegatedPositions?: { permissions?: PermissionLike[] }[];
|
|
}
|
|
| {
|
|
positions?: {
|
|
permissions?: PermissionLike[];
|
|
positionType?: PositionTypeLike | null;
|
|
}[];
|
|
}[]
|
|
| null;
|
|
};
|
|
|
|
export function isSuperAdmin(user: MeLikeUser | null | undefined): boolean {
|
|
if (!user?.roles?.length) return false;
|
|
return user.roles.some((r) => r.key === SUPER_ADMIN_ROLE);
|
|
}
|
|
|
|
export function isOrganizationAdmin(user: MeLikeUser | null | undefined): boolean {
|
|
if (!user?.roles?.length) return false;
|
|
return user.roles.some((r) => r.key === ORGANIZATION_ADMIN_ROLE);
|
|
}
|
|
|
|
export function isFreightApprovalAdmin(user: MeLikeUser | null | undefined): boolean {
|
|
return isSuperAdmin(user) || isOrganizationAdmin(user);
|
|
}
|
|
|
|
/** Flat permission keys from JWT / session user (roles + position permissions). */
|
|
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];
|
|
}
|
|
|
|
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];
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
return [...keys];
|
|
}
|
|
|
|
export function hasFreightPermission(
|
|
user: MeLikeUser | null | undefined,
|
|
permissionKey: string,
|
|
): boolean {
|
|
if (!user) return false;
|
|
if (isSuperAdmin(user)) return true;
|
|
return collectPermissionKeys(user).includes(permissionKey);
|
|
}
|
|
|
|
export function assertFreightPermission(
|
|
user: TCurrentUser | MeLikeUser | null | undefined,
|
|
permissionKey: string,
|
|
): void {
|
|
if (hasFreightPermission(user, permissionKey)) return;
|
|
throw new ForbiddenException(`Missing permission: ${permissionKey}`);
|
|
}
|
|
|
|
/**
|
|
* The caller's IAM position-type keys (`iam.position_types.key`). A position
|
|
* type is the platform's notion of a role — it is what carries permissions via
|
|
* `iam.position_type_permissions` — and it is the vocabulary contract approval
|
|
* chains are configured in.
|
|
*
|
|
* Mirrors `collectPermissionKeys`' handling of both JWT shapes: `employee` is
|
|
* an object on some tokens and an array on others.
|
|
*
|
|
* Note delegated positions carry no `positionType` in the token, so a delegate
|
|
* is not reachable here — they authorize through the permission arm of
|
|
* `assertCanApproveContractStep` instead.
|
|
*/
|
|
export function collectPositionTypeKeys(
|
|
user: MeLikeUser | null | undefined,
|
|
): string[] {
|
|
const employee = user?.employee;
|
|
if (!employee) return [];
|
|
|
|
const keys = new Set<string>();
|
|
|
|
if (Array.isArray(employee)) {
|
|
for (const emp of employee) {
|
|
for (const pos of emp.positions ?? []) {
|
|
if (pos.positionType?.key) keys.add(pos.positionType.key);
|
|
}
|
|
}
|
|
return [...keys];
|
|
}
|
|
|
|
if (employee.position?.positionType?.key) {
|
|
keys.add(employee.position.positionType.key);
|
|
}
|
|
return [...keys];
|
|
}
|
|
|
|
/**
|
|
* Legacy chain roles predate position types. Historical `approval_rules` and
|
|
* in-flight `contract_approval_steps` rows still carry them, so map each to the
|
|
* position types that stand in for it. Without this, an approver holding a
|
|
* modern position type could not action an older step.
|
|
*/
|
|
const LEGACY_ROLE_POSITION_TYPES: Record<string, string[]> = {
|
|
LINE_STAFF: ['employee', 'teamLeader', 'officeHead', 'recordOfficer'],
|
|
DIRECTOR: ['director', 'operation-director'],
|
|
CEO: ['chief', 'deputy'],
|
|
};
|
|
|
|
const APPROVE_ROLE_PERMISSION: Record<string, string> = {
|
|
LINE_STAFF: FREIGHT_PERMS.bookings.approveLineStaff,
|
|
DIRECTOR: FREIGHT_PERMS.bookings.approveDirector,
|
|
CEO: FREIGHT_PERMS.bookings.approveCeo,
|
|
};
|
|
|
|
const CONTRACT_APPROVE_ROLE_PERMISSION: Record<string, string> = {
|
|
LINE_STAFF: FREIGHT_PERMS.contracts.approveLineStaff,
|
|
DIRECTOR: FREIGHT_PERMS.contracts.approveDirector,
|
|
CEO: FREIGHT_PERMS.contracts.approveCeo,
|
|
};
|
|
|
|
const ANY_CONTRACT_APPROVE_PERMISSION = [
|
|
FREIGHT_PERMS.contracts.approveLineStaff,
|
|
FREIGHT_PERMS.contracts.approveDirector,
|
|
FREIGHT_PERMS.contracts.approveCeo,
|
|
];
|
|
|
|
/**
|
|
* May this caller action a contract approval step requiring `requiredRole`?
|
|
*
|
|
* `requiredRole` is an `iam.position_types.key` for chains configured by an
|
|
* admin, or one of the legacy LINE_STAFF/DIRECTOR/CEO strings for older rows.
|
|
* A caller passes when any of these hold:
|
|
*
|
|
* - they are a super/organization admin (blanket bypass);
|
|
* - their position type matches the step, directly or via a legacy alias;
|
|
* - they hold the approve permission the legacy role maps to;
|
|
* - they hold any contract approve permission — this covers delegates (whose
|
|
* position type is absent from the token) and staff whose IAM position has
|
|
* no position type assigned yet.
|
|
*/
|
|
export function assertCanApproveContractStep(
|
|
user: TCurrentUser | MeLikeUser | null | undefined,
|
|
requiredRole: string,
|
|
): void {
|
|
if (isFreightApprovalAdmin(user)) return;
|
|
|
|
const positionTypes = collectPositionTypeKeys(user);
|
|
if (positionTypes.includes(requiredRole)) return;
|
|
|
|
const aliases = LEGACY_ROLE_POSITION_TYPES[requiredRole] ?? [];
|
|
if (aliases.some((alias) => positionTypes.includes(alias))) return;
|
|
|
|
const legacyPermission = CONTRACT_APPROVE_ROLE_PERMISSION[requiredRole];
|
|
if (legacyPermission && hasFreightPermission(user, legacyPermission)) return;
|
|
|
|
if (ANY_CONTRACT_APPROVE_PERMISSION.some((p) => hasFreightPermission(user, p))) {
|
|
return;
|
|
}
|
|
|
|
throw new ForbiddenException(
|
|
`You are not the required approver (${requiredRole}) for this step.`,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Strict "is it exactly this caller's turn?" test — mirrors the backoffice
|
|
* `canApproveContractStep`. Same passes as {@link assertCanApproveContractStep}
|
|
* EXCEPT the blanket "holds any contract-approve permission" fallback is
|
|
* dropped: a line-staff holding `approveLineStaff` must NOT read as the director
|
|
* for a director step. Used to gate contract-document editing so approval hands
|
|
* edit rights to the NEXT approver only — a previous approver who already acted
|
|
* (but still holds an approve permission) loses the edit button, as required.
|
|
*
|
|
* (Kept separate from the approve/reject gate, which keeps the blanket fallback
|
|
* so delegates whose token omits a position type can still action their step.)
|
|
*/
|
|
export function canEditContractStep(
|
|
user: TCurrentUser | MeLikeUser | null | undefined,
|
|
requiredRole: string,
|
|
): boolean {
|
|
if (isFreightApprovalAdmin(user)) return true;
|
|
|
|
const positionTypes = collectPositionTypeKeys(user);
|
|
if (positionTypes.includes(requiredRole)) return true;
|
|
|
|
const aliases = LEGACY_ROLE_POSITION_TYPES[requiredRole] ?? [];
|
|
if (aliases.some((alias) => positionTypes.includes(alias))) return true;
|
|
|
|
const legacyPermission = CONTRACT_APPROVE_ROLE_PERMISSION[requiredRole];
|
|
return Boolean(legacyPermission && hasFreightPermission(user, legacyPermission));
|
|
}
|
|
|
|
export function assertCanApproveBookingStep(
|
|
user: TCurrentUser | MeLikeUser | null | undefined,
|
|
requiredRole: string,
|
|
): void {
|
|
if (isFreightApprovalAdmin(user)) return;
|
|
const perm = APPROVE_ROLE_PERMISSION[requiredRole];
|
|
if (!perm) {
|
|
throw new ForbiddenException(`Unknown approval role: ${requiredRole}`);
|
|
}
|
|
assertFreightPermission(user, perm);
|
|
}
|