feat(auth): @RequirePrivilege/@RequireRole/@RequireScope guards (ADR-0025)
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
This commit is contained in:
@@ -19,3 +19,9 @@ export {
|
||||
EntraGroupMapError,
|
||||
} from './lib/entra-group-to-role';
|
||||
export type { EntraGroupMap, EntraGroupMapEntry } from './lib/entra-group-to-role';
|
||||
export {
|
||||
principalHasAnyPrivilege,
|
||||
principalHasAnyRole,
|
||||
principalCoversResource,
|
||||
} from './lib/principal-matchers';
|
||||
export type { ScopableResource } from './lib/principal-matchers';
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Principal, Scope } from './authorization.types';
|
||||
import {
|
||||
principalCoversResource,
|
||||
principalHasAnyPrivilege,
|
||||
principalHasAnyRole,
|
||||
} from './principal-matchers';
|
||||
|
||||
function makePrincipal(overrides: Partial<Principal> = {}): Principal {
|
||||
return {
|
||||
user: {
|
||||
id: 'user-1',
|
||||
personId: 'person-1',
|
||||
entraOid: 'oid-1',
|
||||
tenantId: 'tenant-1',
|
||||
displayName: 'Alice',
|
||||
},
|
||||
privileges: [],
|
||||
roles: [],
|
||||
scopes: [],
|
||||
amr: ['pwd', 'mfa'],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('principalHasAnyPrivilege', () => {
|
||||
it('returns true when the principal carries one of the required privileges', () => {
|
||||
const p = makePrincipal({ privileges: ['Portal.Admin'] });
|
||||
expect(principalHasAnyPrivilege(p, ['Portal.Admin'])).toBe(true);
|
||||
});
|
||||
|
||||
it('OR-combines multiple required privileges', () => {
|
||||
const p = makePrincipal({ privileges: ['Portal.Auditor'] });
|
||||
expect(principalHasAnyPrivilege(p, ['Portal.Admin', 'Portal.Auditor'])).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when none of the required privileges is held', () => {
|
||||
const p = makePrincipal({ privileges: ['Portal.DPO'] });
|
||||
expect(principalHasAnyPrivilege(p, ['Portal.Admin'])).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when the principal has no privileges at all', () => {
|
||||
const p = makePrincipal();
|
||||
expect(principalHasAnyPrivilege(p, ['Portal.Admin'])).toBe(false);
|
||||
});
|
||||
|
||||
// An empty requirement list is a degenerate case: a guard
|
||||
// configured with no privilege constraint is equivalent to no
|
||||
// guard at all. Document the contract so a caller cannot
|
||||
// accidentally lock everyone out by passing an empty array.
|
||||
it('returns true on an empty requirement list (no constraint)', () => {
|
||||
const p = makePrincipal();
|
||||
expect(principalHasAnyPrivilege(p, [])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('principalHasAnyRole', () => {
|
||||
it('returns true when the principal carries one of the required roles', () => {
|
||||
const p = makePrincipal({ roles: ['rh'] });
|
||||
expect(principalHasAnyRole(p, ['rh'])).toBe(true);
|
||||
});
|
||||
|
||||
it('OR-combines multiple required roles', () => {
|
||||
const p = makePrincipal({ roles: ['comptable'] });
|
||||
expect(principalHasAnyRole(p, ['rh', 'comptable', 'responsable-paie'])).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when none of the required roles is held', () => {
|
||||
const p = makePrincipal({ roles: ['collaborateur'] });
|
||||
expect(principalHasAnyRole(p, ['directeur-etablissement'])).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true on an empty requirement list (no constraint)', () => {
|
||||
const p = makePrincipal({ roles: ['collaborateur'] });
|
||||
expect(principalHasAnyRole(p, [])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('principalCoversResource — unrestricted', () => {
|
||||
it('covers every resource regardless of its fields', () => {
|
||||
const p = makePrincipal({ scopes: [{ kind: 'unrestricted' }] });
|
||||
expect(principalCoversResource(p, {})).toBe(true);
|
||||
expect(principalCoversResource(p, { etablissementFiness: '0330800013' })).toBe(true);
|
||||
expect(principalCoversResource(p, { isSiege: true })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('principalCoversResource — self', () => {
|
||||
it('covers a resource whose personId matches the principal user', () => {
|
||||
const p = makePrincipal({ scopes: [{ kind: 'self' }] });
|
||||
expect(principalCoversResource(p, { personId: 'person-1' })).toBe(true);
|
||||
});
|
||||
|
||||
it('does not cover a resource owned by a different Person', () => {
|
||||
const p = makePrincipal({ scopes: [{ kind: 'self' }] });
|
||||
expect(principalCoversResource(p, { personId: 'person-2' })).toBe(false);
|
||||
});
|
||||
|
||||
it('does not cover a resource that has no personId (collective resource)', () => {
|
||||
const p = makePrincipal({ scopes: [{ kind: 'self' }] });
|
||||
expect(principalCoversResource(p, { etablissementFiness: '0330800013' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('principalCoversResource — etablissement / delegation / region', () => {
|
||||
it('etablissement scope covers a resource with the matching FINESS', () => {
|
||||
const p = makePrincipal({
|
||||
scopes: [{ kind: 'etablissement', value: '0330800013' }],
|
||||
});
|
||||
expect(principalCoversResource(p, { etablissementFiness: '0330800013' })).toBe(true);
|
||||
});
|
||||
|
||||
it('etablissement scope does not cover a different FINESS', () => {
|
||||
const p = makePrincipal({
|
||||
scopes: [{ kind: 'etablissement', value: '0330800013' }],
|
||||
});
|
||||
expect(principalCoversResource(p, { etablissementFiness: '0330800021' })).toBe(false);
|
||||
});
|
||||
|
||||
it('delegation scope covers a resource in the same department', () => {
|
||||
const p = makePrincipal({
|
||||
scopes: [{ kind: 'delegation', value: '33' }],
|
||||
});
|
||||
expect(principalCoversResource(p, { delegationCode: '33' })).toBe(true);
|
||||
});
|
||||
|
||||
it('region scope covers a resource in the same region', () => {
|
||||
const p = makePrincipal({
|
||||
scopes: [{ kind: 'region', value: '75' }],
|
||||
});
|
||||
expect(principalCoversResource(p, { regionCode: '75' })).toBe(true);
|
||||
});
|
||||
|
||||
// The matcher is intentionally non-transitive on the parentage
|
||||
// chain: a `delegation:33` scope does NOT cover an etablissement
|
||||
// unless the etablissement's `delegationCode` is supplied. The
|
||||
// expansion lives in the caller (which knows the parentage of
|
||||
// the resource it is protecting); the matcher just compares
|
||||
// what the caller supplies.
|
||||
it('delegation scope covers an etablissement when the etablissement carries delegationCode', () => {
|
||||
const p = makePrincipal({
|
||||
scopes: [{ kind: 'delegation', value: '33' }],
|
||||
});
|
||||
expect(
|
||||
principalCoversResource(p, {
|
||||
etablissementFiness: '0330800013',
|
||||
delegationCode: '33',
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('delegation scope does NOT cover an etablissement that omits delegationCode', () => {
|
||||
const p = makePrincipal({
|
||||
scopes: [{ kind: 'delegation', value: '33' }],
|
||||
});
|
||||
expect(principalCoversResource(p, { etablissementFiness: '0330800013' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('principalCoversResource — siege', () => {
|
||||
it('covers a resource flagged isSiege: true', () => {
|
||||
const p = makePrincipal({ scopes: [{ kind: 'siege' }] });
|
||||
expect(principalCoversResource(p, { isSiege: true })).toBe(true);
|
||||
});
|
||||
|
||||
it('does not cover a non-siege resource', () => {
|
||||
const p = makePrincipal({ scopes: [{ kind: 'siege' }] });
|
||||
expect(principalCoversResource(p, { delegationCode: '75' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('principalCoversResource — composition', () => {
|
||||
it('returns true on the first matching scope (OR-combine)', () => {
|
||||
const p = makePrincipal({
|
||||
scopes: [
|
||||
{ kind: 'etablissement', value: '0330800013' },
|
||||
{ kind: 'delegation', value: '75' },
|
||||
],
|
||||
});
|
||||
expect(principalCoversResource(p, { delegationCode: '75' })).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when an empty scope list — guards must deny safely on no coverage', () => {
|
||||
const p = makePrincipal({ scopes: [] });
|
||||
expect(principalCoversResource(p, { etablissementFiness: '0330800013' })).toBe(false);
|
||||
});
|
||||
|
||||
it('exhaustive switch — every scope kind is handled (compile-time check)', () => {
|
||||
// Iterate every kind by constructing one scope of each, with
|
||||
// matching resource data. The matcher must return true for
|
||||
// each; a missing case in the switch would fail at least one.
|
||||
const scopes: Scope[] = [
|
||||
{ kind: 'unrestricted' },
|
||||
{ kind: 'self' },
|
||||
{ kind: 'etablissement', value: 'f1' },
|
||||
{ kind: 'delegation', value: '33' },
|
||||
{ kind: 'region', value: '75' },
|
||||
{ kind: 'siege' },
|
||||
];
|
||||
for (const scope of scopes) {
|
||||
const p = makePrincipal({ scopes: [scope] });
|
||||
const resource = {
|
||||
personId: 'person-1',
|
||||
etablissementFiness: 'f1',
|
||||
delegationCode: '33',
|
||||
regionCode: '75',
|
||||
isSiege: true,
|
||||
};
|
||||
expect(principalCoversResource(p, resource)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
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<Privilege>,
|
||||
): 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<FunctionalRole>,
|
||||
): 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:<finess>` scope. */
|
||||
readonly etablissementFiness?: string;
|
||||
/** French department code, matched against `delegation:<dept>` scope. */
|
||||
readonly delegationCode?: string;
|
||||
/** INSEE region code, matched against `region:<insee>` 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:<finess>` covers a resource whose
|
||||
* `etablissementFiness` equals the scope value.
|
||||
* - `delegation:<dept>` covers a resource whose
|
||||
* `delegationCode` equals the scope value.
|
||||
* - `region:<insee>` 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;
|
||||
}
|
||||
Reference in New Issue
Block a user