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(); 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 { // 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 { 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 { 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 { 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(); 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]; } }