import type { FunctionalRole, Principal, Privilege } from './authorization.types'; /** * Pure matchers used by the BFF route guards (and, in the future, * by SPA-side UI predicates) to evaluate a `Principal` against an * authorization requirement per [ADR-0025 §"Guard surface"](../../../../../docs/decisions/0025-authorization-model-privileges-roles-scopes.md). * * The matchers live in the shared lib so the projection contract * between BFF and SPA stays single-sourced — the SPA can pre-check * a privilege/role to decide whether to render a UI element with * the exact same logic the BFF guards apply server-side. The SPA * check is a UX optimisation (avoid rendering a button that 403s); * the BFF guard is the authoritative gate. * * **Composition.** Each matcher takes a list of required values; * a `Principal` matches when it holds **any** of them (OR). Decorator * stacking is AND-combined at the Nest level (separate guards run * sequentially), so the BFF can express * `@RequireRole('rh') @RequirePrivilege('Portal.Admin')` to mean * "RH **and** Admin"; within a single decorator the values are OR. */ export function principalHasAnyPrivilege( principal: Principal, required: ReadonlyArray, ): boolean { if (required.length === 0) { return true; } for (const want of required) { if (principal.privileges.includes(want)) { return true; } } return false; } export function principalHasAnyRole( principal: Principal, required: ReadonlyArray, ): boolean { if (required.length === 0) { return true; } for (const want of required) { if (principal.roles.includes(want)) { return true; } } return false; } /** * Shape an `@RequireScope` handler hands to the matcher. Mirrors * the parentage chain of a `Person`-scoped resource without * pulling Prisma types into the shared lib — guards extract the * subset their resource exposes (a route operating on an * `Etablissement` populates `etablissementFiness`, * `delegationCode`, `regionCode`; one operating on a `Dossier` * populates the etablissement chain via the dossier's * `etablissement` parent; one operating on a `Person` populates * `personId` for the `self` rule). * * Every field is optional — the matcher iterates over the * principal's scopes and returns true on the first scope that * matches a present field. Missing parentage simply makes * coarser-grained scopes fail to match, which is the safe * direction (deny on doubt). */ export interface ScopableResource { /** * The resource's owner-`Person` id, matched against `self` scope. * Present only on resources owned by a single Person (a `Dossier` * the Person is the beneficiary of, a `NoteDeFrais` they * submitted). Absent on collective resources (an `Etablissement`, * a `Delegation`). */ readonly personId?: string; /** FINESS code, matched against `etablissement:` scope. */ readonly etablissementFiness?: string; /** French department code, matched against `delegation:` scope. */ readonly delegationCode?: string; /** INSEE region code, matched against `region:` scope. */ readonly regionCode?: string; /** * True for resources that belong to the national head office. * Matched against `siege` scope. Distinct from "lives in Paris" — * a Paris-based délégation is `delegation:75`, not `siege`. */ readonly isSiege?: boolean; } /** * Helper for `@RequireScope`. Returns `true` if at least one of * the principal's scopes covers the resource per ADR-0025 §"Scope * expansion at check time". Containment rules: * * - `unrestricted` covers every resource. * - `self` covers a resource whose `personId` matches the * principal's user — caller supplies `personId` when this * comparison is meaningful for the route. * - `etablissement:` covers a resource whose * `etablissementFiness` equals the scope value. * - `delegation:` covers a resource whose * `delegationCode` equals the scope value. * - `region:` covers a resource whose `regionCode` * equals the scope value. * - `siege` covers resources flagged `isSiege: true`. * * Empty scope list ⇒ no coverage (returns false). The BFF's * `PrincipalBuilder` always populates *something* (in v1, every * principal carries `[{ kind: 'unrestricted' }]` via the stub * resolver), but the matcher does not assume that — a future * Prisma-backed resolver returning an empty list for a user must * deny safely. */ export function principalCoversResource(principal: Principal, resource: ScopableResource): boolean { for (const scope of principal.scopes) { switch (scope.kind) { case 'unrestricted': return true; case 'self': if (resource.personId !== undefined && resource.personId === principal.user.personId) { return true; } break; case 'etablissement': if ( resource.etablissementFiness !== undefined && scope.value === resource.etablissementFiness ) { return true; } break; case 'delegation': if (resource.delegationCode !== undefined && scope.value === resource.delegationCode) { return true; } break; case 'region': if (resource.regionCode !== undefined && scope.value === resource.regionCode) { return true; } break; case 'siege': if (resource.isSiege === true) { return true; } break; } } return false; }