mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
187 lines
7.0 KiB
TypeScript
187 lines
7.0 KiB
TypeScript
import { ForbiddenException, Injectable, Logger } from "@nestjs/common";
|
|
|
|
import { hasFreightPermission, isSuperAdmin } from "../../../common/freight-permission.util";
|
|
import { FREIGHT_PERMS } from "../../../seed/freight-permissions.registry";
|
|
import { YardPositionsService } from "./yard-positions.service";
|
|
|
|
/**
|
|
* Caller shape the resolver reads — the `/auth/me` user in either of its two
|
|
* shapes. Structurally compatible with what `freight-permission.util` accepts,
|
|
* so the same object serves both the permission checks and the position walk.
|
|
*/
|
|
type PositionLike = {
|
|
id?: string;
|
|
permissions?: { key?: string }[];
|
|
positionType?: { key?: string } | null;
|
|
};
|
|
|
|
type ScopeUser = {
|
|
roles?: { key?: string }[];
|
|
permissions?: { key?: string }[];
|
|
employee?:
|
|
| {
|
|
position?: PositionLike;
|
|
delegatedPositions?: PositionLike[];
|
|
}
|
|
| { positions?: PositionLike[] }[]
|
|
| null;
|
|
};
|
|
|
|
/**
|
|
* Which yards a caller may touch.
|
|
*
|
|
* Scope follows the caller's ACTIVE position, not a union of every position they
|
|
* have ever held: the frontends already send `x-current-position-id` and the
|
|
* token snapshots that one position, so switching desks switches yards — which
|
|
* is what staff covering two yards actually do. Delegated positions are added on
|
|
* top, otherwise standing in for the Gelan director silently loses Gelan.
|
|
*
|
|
* `null` means unrestricted, and an UNMAPPED caller gets it. Scoping narrows a
|
|
* desk that has been given yards; it does not hand out access. Whether the
|
|
* caller may perform the action at all is the permission guard's job — this
|
|
* resolver only answers "which yards", so a desk with the permission and no
|
|
* mapping keeps the reach it had before the mapping existed.
|
|
*
|
|
* The trade-off is deliberate and worth knowing: an accidentally-cleared
|
|
* mapping widens access rather than blocking work, so the mapping is not a
|
|
* containment barrier on its own — the permission keys still are. Super admins
|
|
* and holders of `yards:view_all` are unrestricted regardless of mapping.
|
|
*
|
|
* ENFORCEMENT IS OFF until `YARD_SCOPE_ENFORCE=true`. Until then
|
|
* {@link assertYardInScope} logs what it would have blocked and returns. Flip it
|
|
* only once the mapping table is populated and the log is quiet — on an empty
|
|
* table, enforcing locks out every staff member at once.
|
|
*/
|
|
@Injectable()
|
|
export class YardScopeService {
|
|
private readonly logger = new Logger(YardScopeService.name);
|
|
|
|
// ponytail: 60s cache keyed by the position-id set, no invalidation hook. A
|
|
// mapping change takes up to a minute to reach the resolver. Call
|
|
// `invalidate()` from the mutation if that lag ever matters.
|
|
private static readonly CACHE_TTL_MS = 60_000;
|
|
private readonly cache = new Map<string, { yardIds: string[]; at: number }>();
|
|
|
|
constructor(private readonly yardPositions: YardPositionsService) {}
|
|
|
|
/** True when the deny path is live; false while shadow-logging. */
|
|
get enforced(): boolean {
|
|
return process.env.YARD_SCOPE_ENFORCE === "false";
|
|
}
|
|
|
|
/** Yard ids the caller is scoped to, or `null` for unrestricted. */
|
|
async getScopedYardIds(user: ScopeUser | null | undefined): Promise<string[] | null> {
|
|
// No user at all is an unauthenticated call the guards should already have
|
|
// rejected — narrow to nothing rather than trusting it.
|
|
if (!user) return [];
|
|
if (isSuperAdmin(user)) return null;
|
|
if (hasFreightPermission(user, FREIGHT_PERMS.yards.viewAll)) return null;
|
|
|
|
const positionIds = this.effectivePositionIds(user);
|
|
// No resolvable position — nothing to narrow by, so nothing is narrowed.
|
|
if (!positionIds.length) return null;
|
|
|
|
const key = positionIds.join(",");
|
|
const hit = this.cache.get(key);
|
|
if (hit && Date.now() - hit.at < YardScopeService.CACHE_TTL_MS) {
|
|
return hit.yardIds.length ? hit.yardIds : null;
|
|
}
|
|
|
|
const yardIds = await this.yardPositions.yardIdsForPositions(positionIds);
|
|
this.cache.set(key, { yardIds, at: Date.now() });
|
|
// Unmapped desk → unrestricted. Mapping narrows; absence of one does not.
|
|
return yardIds.length ? yardIds : null;
|
|
}
|
|
|
|
async isYardInScope(
|
|
user: ScopeUser | null | undefined,
|
|
yardId: string | null | undefined,
|
|
): Promise<boolean> {
|
|
if (!yardId) return true;
|
|
const scope = await this.getScopedYardIds(user);
|
|
return scope === null || scope.includes(yardId);
|
|
}
|
|
|
|
/**
|
|
* Gate an action on a yard. While `YARD_SCOPE_ENFORCE` is unset this only
|
|
* logs — wire it into write paths first and read filters second, so the
|
|
* shadow log shows what enforcement would break before it breaks it.
|
|
*/
|
|
async assertYardInScope(
|
|
user: ScopeUser | null | undefined,
|
|
yardId: string | null | undefined,
|
|
context: string,
|
|
): Promise<void> {
|
|
if (await this.isYardInScope(user, yardId)) return;
|
|
|
|
const positions = this.effectivePositionIds(user).join(",") || "none";
|
|
if (!this.enforced) {
|
|
this.logger.warn(
|
|
`[yard-scope shadow] would block ${context}: yard=${yardId} positions=${positions}`,
|
|
);
|
|
return;
|
|
}
|
|
throw new ForbiddenException("This yard is outside your assigned yards");
|
|
}
|
|
|
|
/**
|
|
* Yard ids a list query should be narrowed to, or `null` for no narrowing.
|
|
*
|
|
* Returns an EMPTY array only when the caller explicitly asked for a yard
|
|
* outside their scope and enforcement is on — the caller should answer with an
|
|
* empty result rather than silently widening back to everything.
|
|
*
|
|
* While `YARD_SCOPE_ENFORCE` is unset this always returns `null` and logs what
|
|
* it would have narrowed, so the mapping can be populated against real traffic
|
|
* before it starts hiding rows.
|
|
*/
|
|
async listFilterYardIds(
|
|
user: ScopeUser | null | undefined,
|
|
requestedYardId: string | null | undefined,
|
|
context: string,
|
|
): Promise<string[] | null> {
|
|
const scope = await this.getScopedYardIds(user);
|
|
if (scope === null) return null;
|
|
|
|
const outOfScope = !!requestedYardId && !scope.includes(requestedYardId);
|
|
|
|
if (!this.enforced) {
|
|
this.logger.warn(
|
|
`[yard-scope shadow] would narrow ${context} to [${scope.join(", ")}]` +
|
|
(outOfScope ? ` and reject yard=${requestedYardId}` : ""),
|
|
);
|
|
return null;
|
|
}
|
|
|
|
if (outOfScope) return [];
|
|
return requestedYardId ? [requestedYardId] : scope;
|
|
}
|
|
|
|
/** Drops the memoised scopes — call after editing the mapping. */
|
|
invalidate(): void {
|
|
this.cache.clear();
|
|
}
|
|
|
|
/** Active position plus any delegated ones, across both `employee` shapes. */
|
|
private effectivePositionIds(user: ScopeUser | null | undefined): string[] {
|
|
const ids = new Set<string>();
|
|
const employee = user?.employee;
|
|
if (!employee) return [];
|
|
|
|
if (Array.isArray(employee)) {
|
|
for (const emp of employee) {
|
|
for (const position of emp.positions ?? []) {
|
|
if (position?.id) ids.add(position.id);
|
|
}
|
|
}
|
|
return [...ids];
|
|
}
|
|
|
|
if (employee.position?.id) ids.add(employee.position.id);
|
|
for (const delegated of employee.delegatedPositions ?? []) {
|
|
if (delegated?.id) ids.add(delegated.id);
|
|
}
|
|
return [...ids];
|
|
}
|
|
}
|