mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 23:28:11 +00:00
feat: yard scoping to position
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
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];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user