feat(auth): @RequirePrivilege/@RequireRole/@RequireScope guards (ADR-0025) #208
Reference in New Issue
Block a user
Delete Branch "feat/auth-guards-decorators"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Phase 2 of ADR-0025's implementation phasing (§"More Information"). The three new route decorators land —
@RequirePrivilege,@RequireRole,@RequireScope— alongside the migration of the legacy@RequireAdminto read fromprincipal.privileges. Each guard consumes the session-residentPrincipalbuilt by #206, evaluates its requirement, and either passes or emits a 403 + audit row.The drift-CI gate (phase 3) is not in this PR — it lands next, once the catalogue is being referenced from real route handlers.
What lands
Shared lib (
libs/shared/auth)src/lib/principal-matchers.tsprincipalHasAnyPrivilege/principalHasAnyRole/principalCoversResource. The matchers live in the shared lib so the SPA can render UI predicates with the same logic the BFF enforces server-side.ScopableResourcetype — optional FINESS / delegation / region / siege parentage. Callers populate the subset their route resource exposes; the matcher iterates and returns true on the first scope that covers a present field.src/lib/principal-matchers.spec.tsBFF guards + decorators (
apps/portal-bff/src/auth)require-privilege.{decorator,guard}.ts@RequirePrivilege('Portal.Admin', ...)— type-locked to the closedPrivilegecatalogue; multiple values OR-combine.require-role.{decorator,guard}.ts@RequireRole('rh', ...)— same shape, againstFunctionalRole.require-scope.{decorator,guard}.ts@RequireScope(req => extractResource(req))— extractor can be async (Prisma lookup pattern); returningnullis treated as denial with emptyrequired[].principal-extractor.tsPrincipaloff the session, with a legacy-session bridge for principals minted before #206 landed — filtersuser.rolesforPortal.*values and synthesises anunrestrictedscope. After the 12 h absolute-TTL window post-deploy, the bridge becomes dead code.principal-extractor.spec.tsrequire-{privilege,role,scope}.guard.spec.tsforbiddenresponse body (no role/privilege/resource hint leaks).auth-guards.persona-matrix.spec.tsAudit module
audit.types.tsAuthorizationDeniedInputdiscriminated bykind: 'privilege' | 'role' | 'scope'. Carriesrequired[]andheld[]arrays so an auditor can pivot on "tried admin while logged in as RH" patterns without joining anything.audit.service.tsauthorizationDenied()method emitting theauth.authorization_deniedevent type. Distinct from the existingadmin.access_deniedso admin-surface signals stay clean.audit.service.spec.tskindvalues.Legacy guard migration
admin/admin-role.guard.tsprincipal.privilegesinstead ofuser.roles. Public@RequireAdmin()API unchanged;admin.access_deniedevent type unchanged. The audit row'srolesHeldfield now carriesPortal.*values rather than the raw legacyrolesclaim.admin/admin-role.guard.spec.tssession.principal-shaped sessions, with a dedicated legacy-bridge sub-suite that asserts pre-ADR-0025 sessions still work.me/me.controller.tscapabilities().canAccessAdminreadsprincipal.privilegesvia the same extractor.me/me.controller.spec.tsAuthModule wiring
auth/auth.module.tsNotes for the reviewer
Composition semantics. Within a single decorator, values OR-combine (
@RequireRole('rh', 'comptable')matches anyone with either). Stacking decorators AND-combines at the Nest level —@RequireRole('rh') @RequirePrivilege('Portal.Admin')requires both, because each guard runs separately and a single denial stops the request. The empty-requirement case is rejected at the type level by the tuple[Privilege, ...Privilege[]]so a route can not opt out of authorization by passing zero values.Why
auth.authorization_deniedand notadmin.access_deniedfor the new guards. ADR-0025 §347 originally said "writes anadmin.access_deniedrow", but reusing theadmin.*namespace would have meant the future@RequireRole('rh')on a non-admin HR route ships an event whose type implies an admin surface. The new event type lives in theauth.*namespace where the guards live; akinddiscriminator on the payload tells privilege / role / scope denials apart. The legacyadmin.access_deniedevent continues unchanged forAdminRoleGuard.The legacy-session bridge is short-lived. After the 12 h absolute-TTL window from this PR's deploy, every session in Redis has a
principalfield and the bridge'selsebranch becomes unreachable. The bridge keeps@RequireAdmin+MeControllerfunctional during that window without needing a forced re-auth campaign.Why a
ScopableResourceshape rather than aResource | Resource | ...union per kind. Routes protect heterogeneous resource types (Etablissement,Dossier,Person), each exposing a different subset of the parentage chain. The optional-field shape lets each route populate what its resource carries and lets the matcher iterate over the principal's scopes once. The cost is that a route author who forgets to populatedelegationCodeon anEtablissementresource locks delegation-scoped users out — a typing exercise that the next round of work (concrete consumers) will surface naturally.Why
principalCoversResourcedeny on doubt. When a route's extractor returns a resource that lacks the parentagedelegationCodefield, adelegation:33scope no longer matches — deny is the safe direction. An over-restrictive deny shows up in the audit log (operator can fix the extractor); an over-permissive pass would silently leak data. The non-transitivity of the parentage chain is an intentional contract.Why migrate
MeControllerin the same PR. ThecanAccessAdminflag reads the same axis (Portal.Adminprivilege). Leaving it on the legacyuser.rolesshape while everything else moves toprincipal.privilegeswould create internal inconsistency; a future reviewer would rightfully ask "why?". The migration is three lines plus a spec rewrite.The drift-CI gate is the next PR. ADR-0025 §"More Information" step 3. ESLint custom rule (or a
pnpm runscript) grepping every@RequirePrivilege('...')/@RequireRole('...')/ scope-kind literal in the codebase and asserting each one exists in the catalogue. Now there is something to grep for (the decorators and their tests reference catalogue values), so the gate is well-scoped to land standalone.No real consumers of
@RequireScopeyet. The scope guard ships with a fully exercised contract (extractor signature, async support, audit shape, generic-forbidden body) but no live route. The first consumer surface lands with thePerson+Userschema (proposed ADR-0026) and the resource-loading routes that follow.Test plan
pnpm nx affected -t lint test build --base=main— 3 projects greenportal-bff: 741 tests pass (was 497 in #206 — +244 new: matchers/guards/persona-matrix/audit/extractor).shared-auth: 41 tests pass (was 17 in #206 — +24 new matcher tests).portal-bff-e2e: lint green.pnpm nx format:check— clean afterpnpm nx format:write.principal-extractor.tsand its tests; theadmin.access_deniedaudit payload now carriesPortal.*values rather than legacyroles(intentional, called out above); theauth.authorization_deniedevent-type rationale.What's next
Per ADR-0025 §"More Information" phasing:
@RequireAdminmigration.pnpm runscript) that asserts every@RequireX('...')literal in code is in the closed catalogue.user_scopesPrisma table + seed +PrismaScopeResolverreplacingStubScopeResolver, then the first concrete consumers of@RequireScope.Phase 2 of the ADR-0025 phasing per its §"More Information": the three new route decorators + guards land alongside the legacy @RequireAdmin migration. Each guard reads the session-resident Principal built by the previous PR's PrincipalBuilder, evaluates the route's requirement, and either passes or emits 403 + audit. Shared lib (libs/shared/auth): - principal-matchers.ts: pure principalHasAnyPrivilege / principalHasAnyRole / principalCoversResource. The matchers live in the shared lib so the SPA can render UI predicates with the same logic the BFF enforces server-side. 24 unit tests covering OR-composition, empty-requirement degenerate case, every scope kind, and the resource-side parentage chain. - ScopableResource type carrying optional FINESS / delegation / region / siege parentage that callers populate from the route's protected resource. BFF guards + decorators (apps/portal-bff/src/auth): - @RequirePrivilege('Portal.X', ...) + RequirePrivilegeGuard. - @RequireRole('rh', ...) + RequireRoleGuard. - @RequireScope(req => extract(req)) + RequireScopeGuard. The extractor can be async (Prisma lookup pattern); returning null is treated as a denial with empty required[]. - Multiple values within a single decorator are OR-combined; stacking decorators is AND-combined at the Nest layer. - All three guards 401 on anonymous, 403 + audit on authenticated denial, pass on match. The 403 body is the ADR-0021 structured envelope with a generic 'forbidden' code so an attacker cannot enumerate which privilege/role/resource a route gates. - principal-extractor.ts factors the session->Principal read with a legacy-session bridge for principals minted before the previous PR landed (filters user.roles for Portal.* values and synthesises an unrestricted scope). Audit module: - New AuthorizationDeniedInput type + audit.authorizationDenied() method emitting auth.authorization_denied with a kind discriminator (privilege / role / scope) plus required[] and held[] arrays. Distinct from the existing admin.access_denied event so admin-surface signals stay clean. Legacy guard migration: - AdminRoleGuard now reads principal.privileges instead of user.roles. The public @RequireAdmin() API and the admin.access_denied event type are unchanged; only the audit row's rolesHeld field now carries Portal.* values rather than the raw legacy roles claim. - MeController.capabilities() likewise reads principal.privileges for canAccessAdmin. Tests: - 41 shared-auth tests (24 new for matchers). - 244 new BFF tests covering the 3 new guards, the principal-extractor (incl. legacy bridge), the audit method, and the 19-persona matrix. - Total: 741 portal-bff tests, 41 shared-auth tests; full lint + build green on nx affected. Test plan: - pnpm nx affected -t lint test build --base=main: 3 projects green - pnpm nx format:check: clean