3c0b5cab58
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
shared-auth
Framework-agnostic authorization primitives shared across portal-bff and
portal-shell per ADR-0025:
- Catalogues (
authorization.types.ts) — closed-set lists of the four privileges (Portal.*Entra app roles), the 24 functional roles (apf-role-*Entra security groups), and the six scope kinds. Adding an entry is an ADR amendment, not a code change in isolation. Principaltype — the session-resident shape consumed by route guards (@RequirePrivilege,@RequireRole,@RequireScope) and projected to the AI service's flatroles[]contract.EntraGroupToRoleResolver— turns the tenant-specific Entra group GUIDs carried in the OIDCgroupsclaim into the catalogue's role slugs. Configured at boot frominfra/<env>-tenant.entra.json(gitignored — GUIDs are tenant-private).
Building
Run pnpm nx build shared-auth to build the library.
Running unit tests
Run pnpm nx test shared-auth to execute the unit tests via Vitest.