Files
edr-platform/apps/edr-freight-api/src/modules/rule-engine/services/yard-scope.service.ts
2026-08-18 12:55:11 +00:00

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];
}
}