mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-05 18:53:38 +00:00
Staff given a post in Smart Office and another in freight only ever loaded one of them. Two causes, both in how the IAM guard collapses the login snapshot: - `x-current-position-id` is read two ways inside one function: the employee row is matched on `position.id`, the position on `employeePositionId`. Freight sends the latter, Smart Office the former, so whichever value arrives one lookup matches nothing and falls back to `positions[0]`. FreightJwtGuard now matches both fields. - IAM keeps one employee row per organization, and EDR and EDR Freight are separate organizations, so a user holding a post in each owns two rows. Only the active row reached `collectPermissionKeys`, so the freight post's permissions disappeared whenever the other row won the active slot. `employee.positions` now unions every row, which is what the util already does for the array shape. `delegatedPositions` stays scoped to the active row on purpose: yard scope widens on it, and someone standing in on another organization's row is not this desk's stand-in. /auth/me now returns every employee row, active row first, so the position picker can offer a desk that is not on the active row. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
186 lines
6.9 KiB
TypeScript
186 lines
6.9 KiB
TypeScript
import { Injectable } from '@nestjs/common';
|
|
import { InjectDataSource } from '@nestjs/typeorm';
|
|
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
|
import { DataSource } from 'typeorm';
|
|
|
|
import type { SnapshotEmployee } from '../../common/freight-jwt.guard';
|
|
import {
|
|
collectPermissionKeys,
|
|
isSuperAdmin,
|
|
} from '../../common/freight-permission.util';
|
|
import { PERMISSIONS_CATALOG } from '../../seed/freight-permissions.registry';
|
|
|
|
/** One position as the session snapshot carries it. */
|
|
type TokenPosition = NonNullable<TCurrentUser['employee']>['position'];
|
|
|
|
@Injectable()
|
|
export class FreightMeService {
|
|
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
|
|
|
|
/**
|
|
* The JWT session snapshot has no position TYPE, but the backoffice needs it
|
|
* (GL sub-positions are identified by type key). Resolved live from IAM.
|
|
*/
|
|
private async lookupPositionType(
|
|
positionId: string | undefined,
|
|
): Promise<{ key: string; name: unknown } | null> {
|
|
if (!positionId) return null;
|
|
try {
|
|
const rows: { key: string; name: unknown }[] = await this.dataSource.query(
|
|
`SELECT pt.key, pt.name
|
|
FROM iam.positions p
|
|
JOIN iam.position_types pt ON pt.id = p.position_type_id
|
|
WHERE p.id = $1`,
|
|
[positionId],
|
|
);
|
|
return rows[0] ?? null;
|
|
} catch {
|
|
return null; // iam schema unreachable — degrade to the old payload shape
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Permissions granted to the position's TYPE (`iam.position_type_permissions`).
|
|
* A position type is the platform's notion of a role, and admin-created
|
|
* positions carry their grants there rather than on the position itself — but
|
|
* the JWT only ever snapshots direct position permissions. Without this, staff
|
|
* on such a position resolve to zero permissions and every permission-gated
|
|
* route rejects them (this is what locked GL officers out of their clearance
|
|
* detail pages). Resolved live from IAM, same as the position type above.
|
|
*/
|
|
private async lookupPositionTypePermissions(
|
|
positionId: string | undefined,
|
|
): Promise<string[]> {
|
|
if (!positionId) return [];
|
|
try {
|
|
const rows: { key: string }[] = await this.dataSource.query(
|
|
`SELECT DISTINCT perm.key
|
|
FROM iam.positions p
|
|
JOIN iam.position_type_permissions ptp
|
|
ON ptp.position_type_id = p.position_type_id
|
|
JOIN iam.permissions perm ON perm.id = ptp.permission_id
|
|
WHERE p.id = $1`,
|
|
[positionId],
|
|
);
|
|
return rows.map((r) => r.key).filter(Boolean);
|
|
} catch {
|
|
return []; // iam schema unreachable — degrade to position-only permissions
|
|
}
|
|
}
|
|
|
|
async getEnrichedProfile(user: TCurrentUser) {
|
|
const employeeRecord = user.employee as
|
|
| (typeof user.employee & { positions?: TokenPosition[] })
|
|
| undefined;
|
|
|
|
// `FreightJwtGuard` restores every position the login snapshot holds; the
|
|
// stock IAM guard only ever leaves the single `position`. Fall back to it
|
|
// so a request that somehow skipped our guard still resolves one post
|
|
// rather than none.
|
|
const rawPositions: TokenPosition[] = employeeRecord?.positions?.length
|
|
? employeeRecord.positions
|
|
: employeeRecord?.position
|
|
? [employeeRecord.position]
|
|
: [];
|
|
|
|
// IAM keeps one employee row per organization, so a user holding a freight
|
|
// post and a Smart Office post owns two rows. The backoffice reads
|
|
// `employee` as an array and the position picker lists what it finds there
|
|
// — returning only the active row hides the other desk and makes it
|
|
// unselectable. `FreightJwtGuard` leaves the full set here.
|
|
const employeeRows = (user as { employeeRows?: SnapshotEmployee[] })
|
|
.employeeRows;
|
|
|
|
// Active row first: the backoffice reads `employee[0]` for
|
|
// unitId/organizationId, so the desk the caller is acting as must lead.
|
|
const rows: SnapshotEmployee[] = employeeRows?.length
|
|
? [
|
|
...employeeRows.filter((row) => row.id === employeeRecord?.id),
|
|
...employeeRows.filter((row) => row.id !== employeeRecord?.id),
|
|
]
|
|
: employeeRecord
|
|
? [{ ...employeeRecord, positions: rawPositions } as SnapshotEmployee]
|
|
: [];
|
|
|
|
const enrichPosition = async (position: TokenPosition) => {
|
|
const [positionType, positionTypePermissionKeys] = await Promise.all([
|
|
this.lookupPositionType(position.id),
|
|
this.lookupPositionTypePermissions(position.id),
|
|
]);
|
|
|
|
// Merge the type-level grants into this position's own permission list
|
|
// so BOTH consumers see them: `collectPermissionKeys` below, and the
|
|
// backoffice's `getPermissionKeys`, which walks this nested array.
|
|
const permissions = [...(position.permissions ?? [])];
|
|
const seen = new Set(permissions.map((p) => p?.key).filter(Boolean));
|
|
for (const key of positionTypePermissionKeys) {
|
|
if (!seen.has(key)) {
|
|
seen.add(key);
|
|
permissions.push({ key } as (typeof permissions)[number]);
|
|
}
|
|
}
|
|
|
|
return {
|
|
positionTypePermissionKeys,
|
|
position: {
|
|
id: position.id,
|
|
key: position.key,
|
|
employeePositionId: position.employeePositionId,
|
|
name: position.name,
|
|
isDelegate: position.isDelegate,
|
|
parentPositionId: position.parentPositionId,
|
|
permissions,
|
|
positionType,
|
|
},
|
|
};
|
|
};
|
|
|
|
const enrichedRows = await Promise.all(
|
|
rows.map(async (row) => ({
|
|
row,
|
|
positions: await Promise.all(
|
|
((row.positions ?? []) as TokenPosition[]).map(enrichPosition),
|
|
),
|
|
})),
|
|
);
|
|
|
|
const employee = enrichedRows.map(({ row, positions }) => ({
|
|
id: row.id as string,
|
|
organizationId: row.organizationId as string,
|
|
unitId: row.unitId as string,
|
|
name: row.name,
|
|
positions: positions.map((p) => p.position),
|
|
}));
|
|
|
|
// `collectPermissionKeys` reads the raw token (position-level only), so
|
|
// union the type-level grants in — the backoffice prefers this flat list
|
|
// over the nested array and would otherwise still see none of them.
|
|
const permissionKeys = [
|
|
...new Set([
|
|
...collectPermissionKeys(user),
|
|
...enrichedRows.flatMap(({ positions }) =>
|
|
positions.flatMap((p) => p.positionTypePermissionKeys),
|
|
),
|
|
]),
|
|
];
|
|
|
|
return {
|
|
id: user.id,
|
|
email: user.email,
|
|
name: user.name,
|
|
username: user.username,
|
|
phoneNumber: user.phoneNumber,
|
|
userType: user.userType,
|
|
status: user.status,
|
|
hasFinishedRegistration: user.hasFinishedRegistration,
|
|
hasFinishedDMSOnboarding: user.hasFinishedDMSOnboarding,
|
|
roles: user.roles,
|
|
permissions: user.permissions,
|
|
employee,
|
|
permissionKeys,
|
|
isSuperAdmin: isSuperAdmin(user),
|
|
permissionsCatalog: PERMISSIONS_CATALOG,
|
|
};
|
|
}
|
|
}
|