feat(auth): authorization catalogues + Principal builder skeleton (ADR-0025) (#206)
CI / commits (push) Has been skipped
CI / check (push) Failing after 20s
CI / a11y (push) Failing after 20s
CI / perf (push) Failing after 44s
CI / scan (push) Failing after 55s
Docs site / build (push) Failing after 29s

## Summary

First implementation PR after [ADR-0025](docs/decisions/0025-authorization-model-privileges-roles-scopes.md) was accepted in #205. Lands the closed-set catalogues + the OIDC-callback hook that composes the session-resident `Principal`. No new guards yet — those come in the next PR per the ADR's `§More Information` phasing.

## What lands

**New shared library: `libs/shared/auth/`** (`scope:shared`, `type:shared`, framework-agnostic, vitest)

| File | Role |
| --- | --- |
| `src/lib/authorization.types.ts` | Closed catalogues: `PRIVILEGES` (4), `FUNCTIONAL_ROLES` (24), `SCOPE_KINDS` (6). `Principal`, `Scope` types. `isPrivilege` / `isFunctionalRole` / `isScopeKind` type-guards for the drift gate. |
| `src/lib/entra-group-to-role.ts` | `parseEntraGroupMap` (validates GUID + slug closedness + dedup) + `EntraGroupToRoleResolver` (translates the Entra `groups` claim into ordered `apf-role-*` slugs, reports unknown GUIDs via callback). |
| `src/lib/*.spec.ts` | 17 unit tests against the catalogue counts + resolver contracts. |

**BFF wiring** (`apps/portal-bff/src/auth/` + `src/config/`)

| File | Role |
| --- | --- |
| `auth.service.ts` | Extracts the Entra `groups` claim. `AuthenticatedUser` grows `groups: readonly string[]`. |
| `principal-builder.ts` | Composes the three axes at sign-in. Filters `roles` claim through `isPrivilege` (drops + WARNs on unknown), resolves `groups` claim through the `EntraGroupToRoleResolver` (drops + WARNs on unknown). |
| `scope-resolver.ts` | `ScopeResolver` abstract class + `StubScopeResolver` returning `[{ kind: 'unrestricted' }]` (per ADR-0025 §331 — replaced by a Prisma implementation when ADR-0026's `user_scopes` table lands). |
| `session-establisher.service.ts` | Calls `PrincipalBuilder.build()` once, persists the result as `req.session.principal`. The legacy `req.session.user` shape is untouched (audit + AI bridge keep working). |
| `session.types.ts` | Adds optional `principal?: Principal` field on the express-session augmentation. |
| `entra-group-to-role.token.ts` | DI token for the lazily-loaded resolver. |
| `config/load-entra-group-map.ts` | Reads the gitignored `infra/<env>-tenant.entra.json` at boot. Missing path → empty resolver + WARN. Malformed file → hard fail (alternative would be silently dropping a role). |

**Tests against the 19 test-tenant personas** (`principal-builder.spec.ts`)

Every persona provisioned in `apfrd.onmicrosoft.com` on 2026-05-20 is exercised end-to-end: `admin`, `directeur-bordeaux`, `directeur-complexe`, `rh-aquitaine`, `rh-siege`, `collaborateur-simple`, `tresorier-bordeaux`, `dpo`, `it`, `benevole-aquitaine`, `chef-equipe-bordeaux`, `chef-service-bordeaux`, `directeur-territorial-aquitaine`, `juriste-siege`, `rssi`, `communication-siege`, `elu-ca-national`, `president-cd-aquitaine`, `secretaire-cd-aquitaine`. A coverage assertion confirms the personas cover all 4 privileges + 23 of 24 functional roles (the `partenaire` gap is intentional per ADR-0025).

**Infra + docs**

| File | Change |
| --- | --- |
| `infra/test-tenant.entra.example.json` | Template GUID → slug map (24 entries, full v1 catalogue). Real file (`*-tenant.entra.json`) gitignored. |
| `infra/README.md` | New row + "Entra group map" section documenting the load semantics + provisioning workflow. |
| `apps/portal-bff/.env.example` | New `ENTRA_GROUP_MAP_PATH` block. |
| `.gitignore` | `infra/*-tenant.entra.json` (except `.example.json`). |
| `tsconfig.base.json` | `shared-auth` path alias. |
| `notes/entra-groups-claim-activation.md` | Operator runbook for the Entra admin-centre step (Token configuration → Add groups claim → Security groups → Group ID). |

**ADR amendment**

| File | Change |
| --- | --- |
| `docs/decisions/0025-authorization-model-privileges-roles-scopes.md` | Path references updated `libs/feature/auth/...` → `libs/shared/auth/...` (4 occurrences), and the `ENTRA_GROUP_TO_ROLE` static-record example replaced by the actual `EntraGroupToRoleResolver` + JSON shape. |

## Notes for the reviewer

- **Why `libs/shared/auth/` and not `libs/feature/auth/` (as the ADR initially said).** The existing `libs/feature/auth/` carries `tags: ['scope:portal-shell', 'type:feature']`, which the Nx `enforce-module-boundaries` rule in `eslint.config.mjs:36-46` blocks the BFF from importing. The catalogue is needed by both sides (BFF builds the `Principal`, SPA will gate UI conditionally later), so a `scope:shared` lib is the correct home. The ADR is patched in the same PR so the document and the code agree.

- **Why drop unknown privileges with a WARN instead of preserving them.** `Portal.GhostRole` in the `roles` claim is either a leftover from a different app registration or a v1+1 experiment that hasn't been added to the catalogue yet. Both paths should not silently grant access — drop with a WARN so an operator notices, and the drift CI gate (next PR) catches the source-side equivalent before merge.

- **Why the principal builder uses a `ScopeResolver` seam now.** Per ADR-0025 §331 the v1 implementation returns `unrestricted` for everyone; the real version queries the `user_scopes` Prisma table that lands with ADR-0026. The seam is here so the next PR replaces only the implementation, not the call-site in `PrincipalBuilder` or its 24 tests. Per-persona stub scope data was deliberately not embedded in this PR — no guard consumes scopes yet, so it would be write-only documentation that drifts from the eventual Prisma seed.

- **Why `user.id` and `user.personId` placeholder to `entraOid`.** Same logic: the real UUIDs land with the `Person` + `User` Prisma schema (proposed ADR-0026). Until then, populating both fields with `entraOid` lets consumers key on `principal.user.id` today and get a real value later without branching on availability. The seam is one place (in the builder), not 24 guards.

- **Why session.types.ts carries both `user` and `principal`.** Additive instead of replacement: the audit module + the AI-bridge controller key on `user.oid` / `user.tid` directly, and migrating them in the same PR would balloon the diff for zero behavioural benefit. New consumers (`@RequirePrivilege` / `@RequireRole` / `@RequireScope` decorators, next PR) reach for `principal`.

- **Map file fails soft on absence, hard on malformation.** Path unset OR file missing → empty resolver + WARN. Sign-in still succeeds, every user gets `roles: []`. This is deliberately fail-soft so a fresh dev environment isn't blocked by an Entra-side dependency. **Bad JSON / unknown slug / duplicate GUID → throws at boot.** The alternative would silently mis-resolve a role.

- **Why `boot-time` validation only (no per-request)** for the map file. Catalogue drift is detected once at boot; per-request validation would re-read the file from disk on every sign-in. The trade-off is that a hot-edit to the file requires a BFF restart — acceptable because the file changes once per tenant lifecycle, not per session.

- **Entra `groups` claim must be activated** on the BFF's app registration before this code does anything useful. The runbook in `notes/entra-groups-claim-activation.md` walks through the steps; if the claim isn't activated, every user signs in with `principal.roles = []` and a future warn for every group GUID that would have arrived (none, in that case).

- **No drift gate yet.** ADR-0025's §"Confirmation" §346 calls for an ESLint custom rule (or `pnpm run` script) that greps every `@RequireRole('...')` literal and asserts membership in the catalogue. That's the third PR in the phasing — there's no `@RequireRole` consumer in the tree yet, so the gate would have nothing to assert against today.

## Test plan

- [x] `pnpm nx affected -t lint test build --base=main` — 13 projects green
  - 497 BFF tests (was 465 — added 4 new `groups`-claim tests in `auth.service.spec.ts`, 1 new test in `session-establisher.service.spec.ts`, 6 new tests in `load-entra-group-map.spec.ts`, 24 new tests in `principal-builder.spec.ts`)
  - 17 `shared-auth` tests (catalogue counts + resolver contracts)
  - 1 `shared-util` test (unchanged)
  - full lint + full build
- [x] `pnpm nx format:check` — clean after `pnpm nx format:write`
- [x] Manual: `tsc --build libs/shared/auth/tsconfig.lib.json` emits `.d.ts` files at the path `portal-bff`'s webpack expects.
- [ ] **Review focus** — the closed catalogues (`PRIVILEGES`, `FUNCTIONAL_ROLES`, `SCOPE_KINDS`) verbatim match ADR-0025; the 19 personas verbatim match `notes/test-tenant-role-assignments.md`; the `Principal` shape matches the ADR §"Principal shape" (modulo the `user.id` / `personId` placeholder); fail-soft on missing map file, hard on malformed.
- [ ] **After merge — operator step** : activate the Entra `groups` claim per `notes/entra-groups-claim-activation.md` and drop the real GUIDs into `infra/test-tenant.entra.json` (gitignored). Then a sign-in with `admin@apfrd.onmicrosoft.com` should land `principal.privileges = ['Portal.Admin']` and `principal.roles = ['collaborateur', 'rh']` in Redis.

## What's next

Per ADR-0025 §"More Information" phasing:

1.  **This PR** — types + Principal builder + group-to-role mapping skeleton.
2. **Next PR** — `@RequirePrivilege` + `@RequireRole` + `@RequireScope` decorators + guard integration tests against the 19 personas.
3. **PR after that** — drift CI gate (ESLint custom rule asserting every `@RequireX('...')` literal is in the catalogue).
4. **Depends on ADR-0026** — `user_scopes` Prisma table + seed + `PrismaScopeResolver` replacing `StubScopeResolver`.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #206
This commit was merged in pull request #206.
This commit is contained in:
2026-05-23 21:09:13 +02:00
parent c9a1e195fe
commit 12136f7a8a
35 changed files with 1709 additions and 29 deletions
+21
View File
@@ -0,0 +1,21 @@
export {
PRIVILEGES,
FUNCTIONAL_ROLES,
SCOPE_KINDS,
isPrivilege,
isFunctionalRole,
isScopeKind,
} from './lib/authorization.types';
export type {
Privilege,
FunctionalRole,
ScopeKind,
Scope,
Principal,
} from './lib/authorization.types';
export {
EntraGroupToRoleResolver,
parseEntraGroupMap,
EntraGroupMapError,
} from './lib/entra-group-to-role';
export type { EntraGroupMap, EntraGroupMapEntry } from './lib/entra-group-to-role';
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest';
import {
FUNCTIONAL_ROLES,
PRIVILEGES,
SCOPE_KINDS,
isFunctionalRole,
isPrivilege,
isScopeKind,
} from './authorization.types';
describe('authorization catalogues', () => {
// ADR-0025 §"Decision Outcome" promises specific counts. Locking
// them down here makes a silent slug addition fail loudly — drift
// between ADR text and code would defeat the closed-set posture.
it('contains exactly 4 privileges, 24 functional roles, 6 scope kinds', () => {
expect(PRIVILEGES).toHaveLength(4);
expect(FUNCTIONAL_ROLES).toHaveLength(24);
expect(SCOPE_KINDS).toHaveLength(6);
});
it('catalogues are deduplicated', () => {
expect(new Set(PRIVILEGES).size).toBe(PRIVILEGES.length);
expect(new Set(FUNCTIONAL_ROLES).size).toBe(FUNCTIONAL_ROLES.length);
expect(new Set(SCOPE_KINDS).size).toBe(SCOPE_KINDS.length);
});
it('privilege entries are the 4 Portal.* values provisioned in the test tenant', () => {
expect([...PRIVILEGES]).toEqual([
'Portal.Admin',
'Portal.Auditor',
'Portal.SecurityOfficer',
'Portal.DPO',
]);
});
it('isPrivilege narrows to known values, rejects unknown', () => {
expect(isPrivilege('Portal.Admin')).toBe(true);
expect(isPrivilege('Portal.RogueRole')).toBe(false);
// Case-sensitive — Entra claim values are exact.
expect(isPrivilege('portal.admin')).toBe(false);
});
it('isFunctionalRole narrows to known values, rejects unknown', () => {
expect(isFunctionalRole('collaborateur')).toBe(true);
expect(isFunctionalRole('directeur-etablissement')).toBe(true);
expect(isFunctionalRole('partenaire')).toBe(true);
expect(isFunctionalRole('unknown-role')).toBe(false);
});
it('isScopeKind narrows to known values, rejects unknown', () => {
expect(isScopeKind('etablissement')).toBe(true);
expect(isScopeKind('unrestricted')).toBe(true);
expect(isScopeKind('etablissement:0330800013')).toBe(false);
});
});
@@ -0,0 +1,176 @@
/**
* Authorization catalogues + types per
* [ADR-0025](../../../../../docs/decisions/0025-authorization-model-privileges-roles-scopes.md).
*
* The three axes are independent: a `Principal` carries a list per
* axis, and guards consume the structured shape. Privileges gate
* portal *surfaces* (admin app, audit viewer); functional roles
* encode *what work the person does*; scopes encode *where the role
* applies*. Composition happens at guard time, not at sign-in —
* sign-in just builds the three lists.
*
* The catalogues are **closed**: every literal that appears in a
* `@RequirePrivilege` / `@RequireRole` / `@RequireScope` call must
* be a member of the corresponding constant array. A CI drift gate
* (next PR per ADR-0025 §"More Information") enforces that contract
* against the source tree.
*/
/**
* Portal-level capabilities, materialised as Entra app roles on the
* BFF's app registration. Each privilege gates a portal surface —
* never a business operation in isolation. The four entries here
* are the entire v1 set; adding a fifth is an ADR-0025 amendment.
*
* The exact strings are the `value` field of the Entra app-role
* manifest and are what Entra emits in the ID-token `roles` claim.
*/
export const PRIVILEGES = [
'Portal.Admin',
'Portal.Auditor',
'Portal.SecurityOfficer',
'Portal.DPO',
] as const;
export type Privilege = (typeof PRIVILEGES)[number];
/**
* Functional roles, materialised as Entra security groups named
* `apf-role-<slug>`. The slug is what the BFF and SPA reason about;
* the Entra group GUID is tenant-private and resolved to a slug at
* the boundary (see `entra-group-to-role.ts`).
*
* Grouped here by APF org-chart category (workforce / governance /
* volunteer / external) for readability, but the union below is
* flat — guards don't care about the category.
*/
export const FUNCTIONAL_ROLES = [
// Workforce (15) — employees on payroll.
'collaborateur',
'chef-equipe',
'chef-service',
'directeur-etablissement',
'directeur-territorial',
'rh',
'responsable-paie',
'comptable',
'juriste',
'dpo',
'rssi',
'it',
'formation',
'qualite',
'communication',
// Governance (6) — élus + local delegates.
'elu-ca',
'elu-cd',
'elu-cd-president',
'elu-cd-tresorier',
'elu-cd-secretaire',
'delegue',
// Volunteer (2) — bénévoles with portal access.
'benevole',
'benevole-responsable',
// External (1) — placeholder, no consumer surface yet in v1.
'partenaire',
] as const;
export type FunctionalRole = (typeof FUNCTIONAL_ROLES)[number];
/**
* Scope kinds — the six discriminants of `Scope`. Three carry a
* value (`etablissement:<finess>`, `delegation:<dept>`,
* `region:<insee>`); three are valueless (`self`, `siege`,
* `unrestricted`).
*
* Adding a kind is an ADR-0025 amendment. Adding a value (a new
* établissement opening, a new délégation) is a data operation and
* does not touch this file.
*/
export const SCOPE_KINDS = [
'self',
'etablissement',
'delegation',
'region',
'siege',
'unrestricted',
] as const;
export type ScopeKind = (typeof SCOPE_KINDS)[number];
/**
* A scope on a principal. Three kinds carry a tenant-specific
* value, three do not. Discriminated union so consumers can
* exhaustively switch on `kind`.
*
* `value` semantics per kind:
* - `etablissement`: APE / FINESS code (`9` characters, stable
* across reorgs).
* - `delegation`: French department code (`2` characters,
* INSEE / postal — `'33'`, `'2A'`, `'971'`).
* - `region`: INSEE region code (`2` characters).
*/
export type Scope =
| { readonly kind: 'self' }
| { readonly kind: 'etablissement'; readonly value: string }
| { readonly kind: 'delegation'; readonly value: string }
| { readonly kind: 'region'; readonly value: string }
| { readonly kind: 'siege' }
| { readonly kind: 'unrestricted' };
/**
* The session-resident shape consumed by route guards. Built once
* at sign-in by the BFF's OIDC callback, persisted as part of the
* session payload, refreshed on every authenticated request from
* the session ID. Guards never re-derive it from claims at request
* time — that would re-do the Entra-group-to-slug mapping on every
* call.
*
* `amr` and `mfaVerifiedAt` live on the principal (not separately
* on the session) so the AI-service projector and any guard that
* wants to consult MFA freshness reads a single, coherent object.
*
* `user.id` and `user.personId` will become real portal UUIDs once
* the `User` + `Person` schema (proposed ADR-0026) lands. Until
* then the BFF-side builder populates both with the Entra `oid`
* as a stable placeholder so consumers can already key on a
* "portal identifier" without branching on availability.
*/
export interface Principal {
readonly user: {
readonly id: string;
readonly personId: string;
readonly entraOid: string;
readonly tenantId: string;
readonly displayName: string;
};
readonly privileges: ReadonlyArray<Privilege>;
readonly roles: ReadonlyArray<FunctionalRole>;
readonly scopes: ReadonlyArray<Scope>;
readonly amr: ReadonlyArray<string>;
readonly mfaVerifiedAt?: number;
}
/* ---------------------------------------------------------------- *
* Membership type-guards — runtime helpers callers use to validate
* arbitrary strings (claim values, config map values, drift-gate
* findings) against the closed catalogues. Returning a type
* predicate makes the post-check string narrow to the catalogue
* type, which keeps consumers honest about the closed-set rule.
* ---------------------------------------------------------------- */
const PRIVILEGE_SET: ReadonlySet<string> = new Set(PRIVILEGES);
const FUNCTIONAL_ROLE_SET: ReadonlySet<string> = new Set(FUNCTIONAL_ROLES);
const SCOPE_KIND_SET: ReadonlySet<string> = new Set(SCOPE_KINDS);
export function isPrivilege(value: string): value is Privilege {
return PRIVILEGE_SET.has(value);
}
export function isFunctionalRole(value: string): value is FunctionalRole {
return FUNCTIONAL_ROLE_SET.has(value);
}
export function isScopeKind(value: string): value is ScopeKind {
return SCOPE_KIND_SET.has(value);
}
@@ -0,0 +1,95 @@
import { describe, expect, it, vi } from 'vitest';
import {
EntraGroupMapError,
EntraGroupToRoleResolver,
parseEntraGroupMap,
} from './entra-group-to-role';
const GUID_A = '11111111-1111-1111-1111-111111111111';
const GUID_B = '22222222-2222-2222-2222-222222222222';
const GUID_C = '33333333-3333-3333-3333-333333333333';
describe('parseEntraGroupMap', () => {
it('builds a frozen lower-cased map from a valid record', () => {
const map = parseEntraGroupMap({
[GUID_A]: 'collaborateur',
[GUID_B]: 'rh',
});
expect(map.get(GUID_A)).toBe('collaborateur');
expect(map.get(GUID_B)).toBe('rh');
expect(map.size).toBe(2);
});
it('lower-cases GUIDs so claim-side case variance does not miss', () => {
const upper = '11111111-1111-1111-1111-1111111111AB';
const map = parseEntraGroupMap({ [upper]: 'collaborateur' });
expect(map.get(upper.toLowerCase())).toBe('collaborateur');
});
it('rejects a value that is not in the closed catalogue', () => {
expect(() => parseEntraGroupMap({ [GUID_A]: 'rogue-role' })).toThrow(EntraGroupMapError);
});
it('rejects a key that does not look like a GUID', () => {
expect(() => parseEntraGroupMap({ 'not-a-guid': 'collaborateur' })).toThrow(EntraGroupMapError);
});
it('rejects a duplicate GUID (case-insensitive)', () => {
// Use a GUID containing hex letters so upper/lower casing
// produces two distinct strings (object literals dedup
// identical keys before iteration).
const lower = 'aaaabbbb-cccc-dddd-eeee-ffff00001111';
const upper = lower.toUpperCase();
expect(() =>
parseEntraGroupMap({
[lower]: 'collaborateur',
[upper]: 'rh',
}),
).toThrow(EntraGroupMapError);
});
});
describe('EntraGroupToRoleResolver', () => {
const map = parseEntraGroupMap({
[GUID_A]: 'collaborateur',
[GUID_B]: 'directeur-etablissement',
});
const resolver = new EntraGroupToRoleResolver(map);
it('resolves known group GUIDs to catalogue role slugs', () => {
expect(resolver.resolve([GUID_A, GUID_B])).toEqual([
'collaborateur',
'directeur-etablissement',
]);
});
it('orders the output by FUNCTIONAL_ROLES declaration, not claim order', () => {
// Claim order is `directeur-etablissement` first, then
// `collaborateur`. Resolver re-orders for deterministic logs
// and snapshot tests.
expect(resolver.resolve([GUID_B, GUID_A])).toEqual([
'collaborateur',
'directeur-etablissement',
]);
});
it('deduplicates if the same GUID appears twice in the claim', () => {
expect(resolver.resolve([GUID_A, GUID_A])).toEqual(['collaborateur']);
});
it('skips unknown GUIDs and reports them via the optional callback', () => {
const onUnknown = vi.fn();
const out = resolver.resolve([GUID_A, GUID_C], onUnknown);
expect(out).toEqual(['collaborateur']);
expect(onUnknown).toHaveBeenCalledTimes(1);
expect(onUnknown).toHaveBeenCalledWith(GUID_C);
});
it('returns an empty list when the user has no groups', () => {
expect(resolver.resolve([])).toEqual([]);
});
it('coveredRoles() returns only mapped roles, in catalogue order', () => {
expect(resolver.coveredRoles()).toEqual(['collaborateur', 'directeur-etablissement']);
});
});
@@ -0,0 +1,146 @@
import { FUNCTIONAL_ROLES, isFunctionalRole, type FunctionalRole } from './authorization.types';
/**
* One entry in the GUID → role-slug map. Surfaced as an explicit
* type so callers can build the map programmatically (e.g. tests,
* a future Microsoft Graph sync) without resorting to a bare
* `Record<string, string>` that loses the closed-set guarantee.
*/
export interface EntraGroupMapEntry {
readonly groupId: string;
readonly role: FunctionalRole;
}
/**
* The resolved map keyed on Entra group GUID. Frozen at parse time
* so the resolver cannot mutate it. Lookup is `O(1)`; the values
* are guaranteed to belong to the closed `FUNCTIONAL_ROLES`
* catalogue by the parser.
*/
export type EntraGroupMap = ReadonlyMap<string, FunctionalRole>;
/**
* Thrown by `parseEntraGroupMap` when the operator-supplied
* configuration is malformed. The map is loaded once at BFF boot —
* a malformed file should crash the process, not silently degrade
* authorization. The error message points at the offending key /
* value so the runbook can fix the config without spelunking
* through stack traces.
*/
export class EntraGroupMapError extends Error {
override readonly name = 'EntraGroupMapError';
constructor(message: string) {
super(message);
}
}
/**
* Validates a raw `{ <guid>: <role-slug> }` mapping (as it would
* arrive from `JSON.parse` of an `infra/<env>-tenant.entra.json`
* file) into a frozen `EntraGroupMap`.
*
* Validation rules:
* - keys must look like a GUID (8-4-4-4-12 hex pattern, case-
* insensitive). Entra group GUIDs are stable; a typo here
* would silently strip a role from every sign-in.
* - values must be in `FUNCTIONAL_ROLES`. Catalogue drift is
* caught at boot, not at the first user who happens to be in
* the affected group.
* - duplicate GUIDs (case-insensitive) are rejected. Two slugs
* pointing at the same group would silently shadow one another
* depending on the JS engine's object-key iteration order.
*
* The function deliberately does *not* require every role in the
* catalogue to have a GUID — `partenaire` ships empty in v1 by
* design (ADR-0025 §"Test-tenant personas"), and dev tenants may
* provision a subset for fast iteration. The BFF logs a one-line
* boot summary listing which roles are mapped so the operator can
* spot accidental omissions.
*/
export function parseEntraGroupMap(raw: Readonly<Record<string, string>>): EntraGroupMap {
const map = new Map<string, FunctionalRole>();
const lowercased = new Set<string>();
for (const [groupId, role] of Object.entries(raw)) {
if (!GUID_RE.test(groupId)) {
throw new EntraGroupMapError(
`Invalid GUID "${groupId}" in Entra group map (expected 8-4-4-4-12 hex).`,
);
}
if (!isFunctionalRole(role)) {
throw new EntraGroupMapError(
`Unknown role slug "${role}" for group "${groupId}". ` +
`Catalogue is closed; legal slugs are: ${FUNCTIONAL_ROLES.join(', ')}.`,
);
}
const lower = groupId.toLowerCase();
if (lowercased.has(lower)) {
throw new EntraGroupMapError(`Duplicate group GUID "${groupId}" in Entra group map.`);
}
lowercased.add(lower);
map.set(lower, role);
}
return map;
}
/**
* Resolves the `groups` claim from an Entra ID token (a list of
* group GUIDs the user belongs to) into a list of catalogue-valid
* `FunctionalRole` slugs. The OIDC callback (`session-establisher`)
* calls `resolve()` once per sign-in.
*
* Unknown GUIDs are ignored at the role level — `resolve()` simply
* skips them — but reported via the `onUnknownGroup` callback so
* the host application can log a WARN (per ADR-0025
* §"Sources of truth — Entra-side configuration": "Unknown group
* GUIDs are logged at WARN and ignored — they do not break sign-in,
* but they signal a tenant misconfiguration that should be cleaned
* up").
*
* Output is deduplicated and ordered to match the catalogue's
* declaration order (`FUNCTIONAL_ROLES`). Deterministic output
* keeps snapshot tests stable and makes log inspection easier.
*/
export class EntraGroupToRoleResolver {
constructor(private readonly map: EntraGroupMap) {}
/**
* Number of entries in the map. Surfaced for the boot-time log
* line the BFF emits ("resolver loaded with N mappings").
*/
get size(): number {
return this.map.size;
}
/**
* Roles currently covered by the map. The BFF can log this set
* at boot to spot accidental omissions (e.g. a dev tenant that
* forgot to provision `dpo`). Returned sorted by catalogue order
* for stable logs.
*/
coveredRoles(): ReadonlyArray<FunctionalRole> {
const covered = new Set(this.map.values());
return FUNCTIONAL_ROLES.filter((role) => covered.has(role));
}
resolve(
groupIds: ReadonlyArray<string>,
onUnknownGroup?: (groupId: string) => void,
): ReadonlyArray<FunctionalRole> {
const matched = new Set<FunctionalRole>();
for (const raw of groupIds) {
const role = this.map.get(raw.toLowerCase());
if (role === undefined) {
if (onUnknownGroup) {
onUnknownGroup(raw);
}
continue;
}
matched.add(role);
}
return FUNCTIONAL_ROLES.filter((role) => matched.has(role));
}
}
const GUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;