From 12136f7a8a6e59f075d71afefd99f51c5226c96b Mon Sep 17 00:00:00 2001 From: Julien Gautier Date: Sat, 23 May 2026 21:09:13 +0200 Subject: [PATCH] feat(auth): authorization catalogues + Principal builder skeleton (ADR-0025) (#206) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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/-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 Reviewed-on: https://git.unespace.com/julien/apf_portal/pulls/206 --- .gitignore | 4 + apps/portal-bff/.env.example | 12 + .../src/auth/auth.controller.spec.ts | 19 + apps/portal-bff/src/auth/auth.module.ts | 40 ++- apps/portal-bff/src/auth/auth.service.spec.ts | 82 +++++ apps/portal-bff/src/auth/auth.service.ts | 36 +- .../src/auth/entra-group-to-role.token.ts | 12 + .../src/auth/principal-builder.spec.ts | 340 ++++++++++++++++++ apps/portal-bff/src/auth/principal-builder.ts | 107 ++++++ .../src/auth/require-mfa.guard.spec.ts | 1 + apps/portal-bff/src/auth/scope-resolver.ts | 47 +++ .../auth/session-establisher.service.spec.ts | 38 +- .../src/auth/session-establisher.service.ts | 10 + .../src/config/load-entra-group-map.spec.ts | 93 +++++ .../src/config/load-entra-group-map.ts | 98 +++++ .../ai-bridge/ai-bridge.controller.spec.ts | 1 + apps/portal-bff/src/session/session.types.ts | 21 ++ apps/portal-bff/tsconfig.app.json | 7 +- ...orization-model-privileges-roles-scopes.md | 34 +- infra/README.md | 49 ++- infra/test-tenant.entra.example.json | 26 ++ libs/shared/auth/README.md | 24 ++ libs/shared/auth/eslint.config.mjs | 22 ++ libs/shared/auth/package.json | 13 + libs/shared/auth/project.json | 19 + libs/shared/auth/src/index.ts | 21 ++ .../auth/src/lib/authorization.types.spec.ts | 55 +++ .../auth/src/lib/authorization.types.ts | 176 +++++++++ .../auth/src/lib/entra-group-to-role.spec.ts | 95 +++++ .../auth/src/lib/entra-group-to-role.ts | 146 ++++++++ libs/shared/auth/tsconfig.json | 23 ++ libs/shared/auth/tsconfig.lib.json | 23 ++ libs/shared/auth/tsconfig.spec.json | 22 ++ libs/shared/auth/vitest.config.mts | 21 ++ tsconfig.base.json | 1 + 35 files changed, 1709 insertions(+), 29 deletions(-) create mode 100644 apps/portal-bff/src/auth/entra-group-to-role.token.ts create mode 100644 apps/portal-bff/src/auth/principal-builder.spec.ts create mode 100644 apps/portal-bff/src/auth/principal-builder.ts create mode 100644 apps/portal-bff/src/auth/scope-resolver.ts create mode 100644 apps/portal-bff/src/config/load-entra-group-map.spec.ts create mode 100644 apps/portal-bff/src/config/load-entra-group-map.ts create mode 100644 infra/test-tenant.entra.example.json create mode 100644 libs/shared/auth/README.md create mode 100644 libs/shared/auth/eslint.config.mjs create mode 100644 libs/shared/auth/package.json create mode 100644 libs/shared/auth/project.json create mode 100644 libs/shared/auth/src/index.ts create mode 100644 libs/shared/auth/src/lib/authorization.types.spec.ts create mode 100644 libs/shared/auth/src/lib/authorization.types.ts create mode 100644 libs/shared/auth/src/lib/entra-group-to-role.spec.ts create mode 100644 libs/shared/auth/src/lib/entra-group-to-role.ts create mode 100644 libs/shared/auth/tsconfig.json create mode 100644 libs/shared/auth/tsconfig.lib.json create mode 100644 libs/shared/auth/tsconfig.spec.json create mode 100644 libs/shared/auth/vitest.config.mts diff --git a/.gitignore b/.gitignore index ff5e7f5..338bc35 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,10 @@ pnpm-debug.log* *.pem *.key +# Tenant-private Entra group GUIDs per ADR-0025 (commit only the .example.json) +infra/*-tenant.entra.json +!infra/*-tenant.entra.example.json + # OS / editor scrap .DS_Store Thumbs.db diff --git a/apps/portal-bff/.env.example b/apps/portal-bff/.env.example index 1b85a54..05e496c 100644 --- a/apps/portal-bff/.env.example +++ b/apps/portal-bff/.env.example @@ -72,6 +72,18 @@ ENTRA_POST_LOGOUT_REDIRECT_URI=http://localhost:4200/ ENTRA_ADMIN_REDIRECT_URI=http://localhost:3000/api/admin/auth/callback ENTRA_ADMIN_POST_LOGOUT_REDIRECT_URI=http://localhost:4300/ +# Authorization model (per ADR-0025). Points at the JSON file that +# maps tenant-private Entra security-group GUIDs to the closed +# catalogue of `apf-role-*` slugs. The BFF loads it at boot through +# `EntraGroupToRoleResolver` (libs/shared/auth). Unset means the +# resolver runs empty — sign-in still succeeds but every user gets +# zero functional roles (no `apf-role-*` UI). A WARN is logged at +# boot so an operator can spot the missing config. See +# `infra/test-tenant.entra.example.json` for the schema; copy to +# `infra/test-tenant.entra.json` (gitignored) and fill in the real +# GUIDs from the Entra admin centre. +ENTRA_GROUP_MAP_PATH=infra/test-tenant.entra.json + # Cookie signing secret (per ADR-0009 §"Cookies"). Used to sign the # transient pre-auth cookie that carries the OIDC `state` + PKCE # verifier between the /auth/login redirect and the /auth/callback diff --git a/apps/portal-bff/src/auth/auth.controller.spec.ts b/apps/portal-bff/src/auth/auth.controller.spec.ts index 5c5dd2a..7fdce9c 100644 --- a/apps/portal-bff/src/auth/auth.controller.spec.ts +++ b/apps/portal-bff/src/auth/auth.controller.spec.ts @@ -150,11 +150,30 @@ function makeController(opts?: { completeAuthCodeFlow?: jest.Mock }): Controller // Real SessionEstablisher with the same mocks the legacy tests // already wire — keeps the behavioural assertions on session // fields / audit calls untouched after the controller refactor. + const principalBuilder = { + // The auth.controller specs do not assert on the principal + // shape; the resolved value just has to be a valid `Principal` + // so SessionEstablisher persists it without throwing. + build: jest.fn().mockResolvedValue({ + user: { + id: 'oid', + personId: 'oid', + entraOid: 'oid', + tenantId: 'tid', + displayName: 'Jane', + }, + privileges: [], + roles: [], + scopes: [{ kind: 'unrestricted' }], + amr: [], + }), + }; const sessionEstablisher = new SessionEstablisher( logger as unknown as Logger, userSessionIndex as unknown as UserSessionIndexService, audit as unknown as AuditWriter, userDirectory as unknown as UserDirectoryService, + principalBuilder as unknown as import('./principal-builder').PrincipalBuilder, ); return { controller: new AuthController( diff --git a/apps/portal-bff/src/auth/auth.module.ts b/apps/portal-bff/src/auth/auth.module.ts index 0d574c8..3071c51 100644 --- a/apps/portal-bff/src/auth/auth.module.ts +++ b/apps/portal-bff/src/auth/auth.module.ts @@ -2,12 +2,16 @@ import { ConfidentialClientApplication, LogLevel } from '@azure/msal-node'; import { Module } from '@nestjs/common'; import { Logger } from 'nestjs-pino'; import { assertEntraConfig } from '../config/check-entra-config'; +import { loadEntraGroupToRoleResolver } from '../config/load-entra-group-map'; import { SessionModule } from '../session/session.module'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { ENTRA_CONFIG, type EntraConfig } from './entra-config.token'; +import { ENTRA_GROUP_TO_ROLE_RESOLVER } from './entra-group-to-role.token'; import { MSAL_CLIENT } from './msal-client.token'; +import { PrincipalBuilder } from './principal-builder'; import { RequireMfaGuard } from './require-mfa.guard'; +import { ScopeResolver, StubScopeResolver } from './scope-resolver'; import { SessionEstablisher } from './session-establisher.service'; /** @@ -47,10 +51,35 @@ import { SessionEstablisher } from './session-establisher.service'; AuthService, RequireMfaGuard, SessionEstablisher, + PrincipalBuilder, + { provide: ScopeResolver, useClass: StubScopeResolver }, { provide: ENTRA_CONFIG, useFactory: () => assertEntraConfig(), }, + { + provide: ENTRA_GROUP_TO_ROLE_RESOLVER, + inject: [Logger], + useFactory: (logger: Logger) => { + const { resolver, sourcePath } = loadEntraGroupToRoleResolver({ + onWarn: (event, payload) => logger.warn({ event, ...payload }, 'AuthModule'), + }); + // Log the post-load summary unconditionally so an operator + // grepping the boot log can confirm how many functional + // roles are wired without inspecting the JSON file. The + // file path (if any) helps diagnose env-var / cwd mismatches. + logger.log( + { + event: 'auth.entra_group_map_loaded', + sourcePath, + mappingCount: resolver.size, + coveredRoles: resolver.coveredRoles(), + }, + 'AuthModule', + ); + return resolver; + }, + }, { provide: MSAL_CLIENT, inject: [ENTRA_CONFIG, Logger], @@ -92,6 +121,15 @@ import { SessionEstablisher } from './session-establisher.service'; }), }, ], - exports: [ENTRA_CONFIG, MSAL_CLIENT, RequireMfaGuard, AuthService, SessionEstablisher], + exports: [ + ENTRA_CONFIG, + ENTRA_GROUP_TO_ROLE_RESOLVER, + MSAL_CLIENT, + RequireMfaGuard, + AuthService, + PrincipalBuilder, + ScopeResolver, + SessionEstablisher, + ], }) export class AuthModule {} diff --git a/apps/portal-bff/src/auth/auth.service.spec.ts b/apps/portal-bff/src/auth/auth.service.spec.ts index 685e872..8fa4b4f 100644 --- a/apps/portal-bff/src/auth/auth.service.spec.ts +++ b/apps/portal-bff/src/auth/auth.service.spec.ts @@ -38,6 +38,7 @@ function makeAuthResult( claims: Partial<{ amr: string[]; roles: unknown; + groups: unknown; oid: string; tid: string; name: string; @@ -58,6 +59,12 @@ function makeAuthResult( if (Object.prototype.hasOwnProperty.call(claims, 'roles')) { idTokenClaims['roles'] = claims.roles; } + // Same opt-in pattern for `groups` — Entra emits it only when + // the app registration sets `groupMembershipClaims: 'SecurityGroup'` + // and the user is in at least one group. + if (Object.prototype.hasOwnProperty.call(claims, 'groups')) { + idTokenClaims['groups'] = claims.groups; + } return { idTokenClaims, account: { username: 'jane.doe@apf.example', name: 'Jane Doe' }, @@ -124,6 +131,7 @@ describe('AuthService.completeAuthCodeFlow', () => { displayName: 'Jane Doe', amr: ['pwd', 'mfa'], roles: [], + groups: [], }); }); @@ -220,6 +228,80 @@ describe('AuthService.completeAuthCodeFlow', () => { expect(user.roles).toEqual(['admin', 'editor']); }); + // `groups` claim — extracted identically to `roles`. The raw + // GUIDs are surfaced here; resolution to `apf-role-*` slugs + // happens downstream in `PrincipalBuilder` per ADR-0025. + it('surfaces the `groups` claim when Entra includes it (security-group member)', async () => { + const acquireTokenByCode = jest.fn().mockResolvedValue( + makeAuthResult({ + groups: ['11111111-1111-1111-1111-111111111111', '22222222-2222-2222-2222-222222222222'], + }), + ); + const { service } = makeService({ acquireTokenByCode }); + const user = await service.completeAuthCodeFlow( + 'code', + PRE_AUTH_OK.state, + PRE_AUTH_OK, + ENTRA.redirectUri, + PRE_AUTH_OK.createdAt, + ); + expect(user.groups).toEqual([ + '11111111-1111-1111-1111-111111111111', + '22222222-2222-2222-2222-222222222222', + ]); + }); + + it('returns an empty `groups` array when the claim is absent (no group membership)', async () => { + const acquireTokenByCode = jest.fn().mockResolvedValue(makeAuthResult({})); + const { service } = makeService({ acquireTokenByCode }); + const user = await service.completeAuthCodeFlow( + 'code', + PRE_AUTH_OK.state, + PRE_AUTH_OK, + ENTRA.redirectUri, + PRE_AUTH_OK.createdAt, + ); + expect(user.groups).toEqual([]); + }); + + it('returns an empty `groups` array when the claim is non-array (defensive)', async () => { + const acquireTokenByCode = jest.fn().mockResolvedValue(makeAuthResult({ groups: 'oops' })); + const { service } = makeService({ acquireTokenByCode }); + const user = await service.completeAuthCodeFlow( + 'code', + PRE_AUTH_OK.state, + PRE_AUTH_OK, + ENTRA.redirectUri, + PRE_AUTH_OK.createdAt, + ); + expect(user.groups).toEqual([]); + }); + + it('drops non-string entries from `groups` (defensive)', async () => { + const acquireTokenByCode = jest.fn().mockResolvedValue( + makeAuthResult({ + groups: [ + '11111111-1111-1111-1111-111111111111', + 42, + null, + '22222222-2222-2222-2222-222222222222', + ], + }), + ); + const { service } = makeService({ acquireTokenByCode }); + const user = await service.completeAuthCodeFlow( + 'code', + PRE_AUTH_OK.state, + PRE_AUTH_OK, + ENTRA.redirectUri, + PRE_AUTH_OK.createdAt, + ); + expect(user.groups).toEqual([ + '11111111-1111-1111-1111-111111111111', + '22222222-2222-2222-2222-222222222222', + ]); + }); + it('throws token-exchange-failed when MSAL throws', async () => { const acquireTokenByCode = jest.fn().mockRejectedValue(new Error('AADSTS70008')); const { service } = makeService({ acquireTokenByCode }); diff --git a/apps/portal-bff/src/auth/auth.service.ts b/apps/portal-bff/src/auth/auth.service.ts index c2aa9b4..7b37d63 100644 --- a/apps/portal-bff/src/auth/auth.service.ts +++ b/apps/portal-bff/src/auth/auth.service.ts @@ -38,11 +38,23 @@ export interface AuthCodeFlowStart { * the BFF's MFA sanity-check (ADR-0011) and by `@RequireMfa` * freshness checks once the session lands. * - `roles`: Entra app roles assigned to this user on the BFF's - * app registration. Surfaced for the future `@RequireAdmin` / - * `@RequireRole(...)` guards (ADR-0020 admin module). Always - * present in the shape — an empty array means the user has no - * app role on this app registration, not that the claim was - * unparseable. + * app registration. These are the four `Portal.*` *privileges* + * per ADR-0025 (`Portal.Admin`, `Portal.Auditor`, + * `Portal.SecurityOfficer`, `Portal.DPO`). Surfaced for the + * existing `@RequireAdmin()` guard and the upcoming + * `@RequirePrivilege()` decorator. Always present in the shape + * — an empty array means the user has no app role on this app + * registration, not that the claim was unparseable. + * - `groups`: Entra security-group GUIDs the user belongs to. + * Emitted by the `groups` claim once the app registration sets + * `groupMembershipClaims: 'SecurityGroup'` (per ADR-0025 + * §"Sources of truth — Entra-side configuration"). The + * `PrincipalBuilder` resolves these GUIDs to `apf-role-*` slugs + * via the `EntraGroupToRoleResolver`. Always present — empty + * means either the user has no group membership or the claim + * was not configured on the app registration. Unknown GUIDs + * are dropped at resolve time, not at extraction time, so an + * audit reader still sees the raw claim. */ export interface AuthenticatedUser { readonly oid: string; @@ -51,6 +63,7 @@ export interface AuthenticatedUser { readonly displayName: string; readonly amr: readonly string[]; readonly roles: readonly string[]; + readonly groups: readonly string[]; } /** @@ -212,6 +225,18 @@ export class AuthService { ? (claims['roles'] as unknown[]).filter((v): v is string => typeof v === 'string') : []; + // `groups` is an optional claim (per ADR-0025 §"Sources of truth + // — Entra-side configuration"). Present only when the app + // registration sets `groupMembershipClaims: 'SecurityGroup'`. + // Empty array if the user is in zero security groups or the + // claim is not configured — both are valid "no functional + // roles" states the rest of the BFF must tolerate. Same + // string-filter as `roles` to defend against a non-string value + // smuggled in via a malformed token. + const groups = Array.isArray(claims['groups']) + ? (claims['groups'] as unknown[]).filter((v): v is string => typeof v === 'string') + : []; + return { oid: requireString(claims['oid'], 'oid'), tid: requireString(claims['tid'], 'tid'), @@ -225,6 +250,7 @@ export class AuthService { : (result.account?.name ?? ''), amr, roles, + groups, }; } } diff --git a/apps/portal-bff/src/auth/entra-group-to-role.token.ts b/apps/portal-bff/src/auth/entra-group-to-role.token.ts new file mode 100644 index 0000000..f31a39b --- /dev/null +++ b/apps/portal-bff/src/auth/entra-group-to-role.token.ts @@ -0,0 +1,12 @@ +/** + * DI token for the lazily-loaded `EntraGroupToRoleResolver`. + * + * The resolver wraps a `Map` parsed + * once at boot from `infra/-tenant.entra.json` (per ADR-0025 + * §"Sources of truth — Entra-side configuration"). The token is + * separate from the class import so the BFF can swap the + * implementation (an empty-map stub when no file is configured, + * a real one when `ENTRA_GROUP_MAP_PATH` resolves) without the + * consumers caring. + */ +export const ENTRA_GROUP_TO_ROLE_RESOLVER = Symbol('ENTRA_GROUP_TO_ROLE_RESOLVER'); diff --git a/apps/portal-bff/src/auth/principal-builder.spec.ts b/apps/portal-bff/src/auth/principal-builder.spec.ts new file mode 100644 index 0000000..3a7676e --- /dev/null +++ b/apps/portal-bff/src/auth/principal-builder.spec.ts @@ -0,0 +1,340 @@ +import type { Logger } from 'nestjs-pino'; +import { + EntraGroupToRoleResolver, + parseEntraGroupMap, + type FunctionalRole, + type Scope, +} from 'shared-auth'; +import type { AuthenticatedUser } from './auth.service'; +import { PrincipalBuilder } from './principal-builder'; +import type { ScopeResolver } from './scope-resolver'; + +/** + * Mirrors the 24-entry `apf-role-*` catalogue: one synthetic GUID + * per slug, in catalogue order. The exact GUIDs do not matter to + * any assertion below — what matters is that the test resolver + * maps them deterministically so a persona's `groups` claim can + * be assembled by slug. + */ +const ROLE_GUID: Record = { + collaborateur: '00000000-0000-0000-0000-000000000101', + 'chef-equipe': '00000000-0000-0000-0000-000000000102', + 'chef-service': '00000000-0000-0000-0000-000000000103', + 'directeur-etablissement': '00000000-0000-0000-0000-000000000104', + 'directeur-territorial': '00000000-0000-0000-0000-000000000105', + rh: '00000000-0000-0000-0000-000000000106', + 'responsable-paie': '00000000-0000-0000-0000-000000000107', + comptable: '00000000-0000-0000-0000-000000000108', + juriste: '00000000-0000-0000-0000-000000000109', + dpo: '00000000-0000-0000-0000-00000000010a', + rssi: '00000000-0000-0000-0000-00000000010b', + it: '00000000-0000-0000-0000-00000000010c', + formation: '00000000-0000-0000-0000-00000000010d', + qualite: '00000000-0000-0000-0000-00000000010e', + communication: '00000000-0000-0000-0000-00000000010f', + 'elu-ca': '00000000-0000-0000-0000-000000000110', + 'elu-cd': '00000000-0000-0000-0000-000000000111', + 'elu-cd-president': '00000000-0000-0000-0000-000000000112', + 'elu-cd-tresorier': '00000000-0000-0000-0000-000000000113', + 'elu-cd-secretaire': '00000000-0000-0000-0000-000000000114', + delegue: '00000000-0000-0000-0000-000000000115', + benevole: '00000000-0000-0000-0000-000000000116', + 'benevole-responsable': '00000000-0000-0000-0000-000000000117', + partenaire: '00000000-0000-0000-0000-000000000118', +}; + +/** + * The 19 personas provisioned in the `apfrd.onmicrosoft.com` test + * tenant on 2026-05-20, transcribed from + * `notes/test-tenant-role-assignments.md` (reverse view). The + * builder's contract is that these inputs round-trip to the + * expected `(privileges, roles, scopes)` triple — when the spec + * passes, every persona we documented in the ADR is exercised + * end-to-end. + * + * Scopes are stubbed to `[{ kind: 'unrestricted' }]` for every + * persona in v1 per ADR-0025 §"Sources of truth — apf_portal-side + * `user_scopes` table". The intended per-persona scopes + * (`etablissement:…`, `delegation:33`, …) land with the Prisma + * `user_scopes` table — the next PR after this skeleton. + */ +interface Persona { + readonly label: string; + readonly username: string; + readonly privileges: readonly string[]; // raw `roles` claim values + readonly roles: readonly FunctionalRole[]; +} + +const PERSONAS: readonly Persona[] = [ + { + label: 'admin', + username: 'admin@apfrd.onmicrosoft.com', + privileges: ['Portal.Admin'], + roles: ['collaborateur', 'rh'], + }, + { + label: 'directeur-bordeaux', + username: 'directeur-bordeaux@apfrd.onmicrosoft.com', + privileges: [], + roles: ['collaborateur', 'directeur-etablissement'], + }, + { + label: 'directeur-complexe', + username: 'directeur-complexe@apfrd.onmicrosoft.com', + privileges: [], + roles: ['collaborateur', 'directeur-etablissement'], + }, + { + label: 'rh-aquitaine', + username: 'rh-aquitaine@apfrd.onmicrosoft.com', + privileges: [], + roles: ['collaborateur', 'rh', 'formation'], + }, + { + label: 'rh-siege', + username: 'rh-siege@apfrd.onmicrosoft.com', + privileges: [], + roles: ['collaborateur', 'rh', 'responsable-paie', 'comptable'], + }, + { + label: 'collaborateur-simple', + username: 'collaborateur-simple@apfrd.onmicrosoft.com', + privileges: [], + roles: ['collaborateur'], + }, + { + label: 'tresorier-bordeaux', + username: 'tresorier-bordeaux@apfrd.onmicrosoft.com', + privileges: [], + roles: ['elu-cd', 'elu-cd-tresorier'], + }, + { + label: 'dpo', + username: 'dpo@apfrd.onmicrosoft.com', + privileges: ['Portal.DPO', 'Portal.Auditor'], + roles: ['collaborateur', 'dpo', 'qualite'], + }, + { + label: 'it', + username: 'it@apfrd.onmicrosoft.com', + privileges: [], + roles: ['collaborateur', 'it'], + }, + { + label: 'benevole-aquitaine', + username: 'benevole-aquitaine@apfrd.onmicrosoft.com', + privileges: [], + // Catalogue order: delegue (governance) precedes benevole + + // benevole-responsable (volunteer). The resolver re-sorts on + // catalogue order regardless of the Entra claim's order. + roles: ['delegue', 'benevole', 'benevole-responsable'], + }, + { + label: 'chef-equipe-bordeaux', + username: 'chef-equipe-bordeaux@apfrd.onmicrosoft.com', + privileges: [], + roles: ['collaborateur', 'chef-equipe'], + }, + { + label: 'chef-service-bordeaux', + username: 'chef-service-bordeaux@apfrd.onmicrosoft.com', + privileges: [], + roles: ['collaborateur', 'chef-service'], + }, + { + label: 'directeur-territorial-aquitaine', + username: 'directeur-territorial-aquitaine@apfrd.onmicrosoft.com', + privileges: [], + roles: ['collaborateur', 'directeur-territorial'], + }, + { + label: 'juriste-siege', + username: 'juriste-siege@apfrd.onmicrosoft.com', + privileges: [], + roles: ['collaborateur', 'juriste'], + }, + { + label: 'rssi', + username: 'rssi@apfrd.onmicrosoft.com', + privileges: ['Portal.SecurityOfficer'], + roles: ['collaborateur', 'rssi'], + }, + { + label: 'communication-siege', + username: 'communication-siege@apfrd.onmicrosoft.com', + privileges: [], + roles: ['collaborateur', 'communication'], + }, + { + label: 'elu-ca-national', + username: 'elu-ca-national@apfrd.onmicrosoft.com', + privileges: [], + roles: ['elu-ca'], + }, + { + label: 'president-cd-aquitaine', + username: 'president-cd-aquitaine@apfrd.onmicrosoft.com', + privileges: [], + roles: ['elu-cd', 'elu-cd-president'], + }, + { + label: 'secretaire-cd-aquitaine', + username: 'secretaire-cd-aquitaine@apfrd.onmicrosoft.com', + privileges: [], + roles: ['elu-cd', 'elu-cd-secretaire'], + }, +]; + +function makeUser(p: Persona): AuthenticatedUser { + return { + oid: `oid-${p.label}`, + tid: 'tenant-1', + username: p.username, + displayName: p.label, + amr: ['pwd', 'mfa'], + roles: [...p.privileges], + groups: p.roles.map((slug) => ROLE_GUID[slug]), + }; +} + +function makeBuilder(): { + builder: PrincipalBuilder; + scopeResolver: { resolve: jest.Mock }; + logger: { log: jest.Mock; warn: jest.Mock; error: jest.Mock }; +} { + const rawMap: Record = {}; + for (const [slug, guid] of Object.entries(ROLE_GUID) as Array<[FunctionalRole, string]>) { + rawMap[guid] = slug; + } + const resolver = new EntraGroupToRoleResolver(parseEntraGroupMap(rawMap)); + const scopeResolver = { + resolve: jest.fn().mockResolvedValue([{ kind: 'unrestricted' } as Scope]), + }; + const logger = { log: jest.fn(), warn: jest.fn(), error: jest.fn() }; + const builder = new PrincipalBuilder( + resolver, + scopeResolver as unknown as ScopeResolver, + logger as unknown as Logger, + ); + return { builder, scopeResolver, logger }; +} + +describe('PrincipalBuilder — 19 test-tenant personas', () => { + for (const persona of PERSONAS) { + it(`builds the principal for ${persona.label}`, async () => { + const { builder } = makeBuilder(); + const principal = await builder.build(makeUser(persona)); + expect(principal.privileges).toEqual(persona.privileges); + expect(principal.roles).toEqual(persona.roles); + expect(principal.scopes).toEqual([{ kind: 'unrestricted' }]); + expect(principal.user.entraOid).toBe(`oid-${persona.label}`); + expect(principal.user.tenantId).toBe('tenant-1'); + // amr passes through verbatim — MFA freshness checks read it + // off the principal per ADR-0011. + expect(principal.amr).toEqual(['pwd', 'mfa']); + }); + } + + it('asserts the 19 personas cover all 4 privileges + 23 of 24 functional roles', () => { + // Coverage check baked into the spec so a regression that + // drops a role from a persona is caught here, not at PR review. + // The intentional gap is `partenaire` (placeholder per ADR-0025). + const privilegesUsed = new Set(PERSONAS.flatMap((p) => p.privileges)); + expect(privilegesUsed).toEqual( + new Set(['Portal.Admin', 'Portal.Auditor', 'Portal.SecurityOfficer', 'Portal.DPO']), + ); + const rolesUsed = new Set(PERSONAS.flatMap((p) => p.roles)); + expect(rolesUsed.size).toBe(23); + expect(rolesUsed.has('partenaire')).toBe(false); + }); +}); + +describe('PrincipalBuilder — edge cases', () => { + it('returns an empty privileges array when the user has no app role assignment', async () => { + const { builder } = makeBuilder(); + const user = makeUser({ + label: 'anon', + username: 'anon@example', + privileges: [], + roles: ['collaborateur'], + }); + const principal = await builder.build(user); + expect(principal.privileges).toEqual([]); + }); + + it('drops + warns on a claim value that is not a known privilege', async () => { + const { builder, logger } = makeBuilder(); + const user = makeUser({ + label: 'stale', + username: 'stale@example', + privileges: [], + roles: [], + }); + // Force in a non-catalogue value the way an old assignment would. + const userWithDrift: AuthenticatedUser = { + ...user, + roles: ['Portal.Admin', 'Portal.GhostRole'], + }; + const principal = await builder.build(userWithDrift); + expect(principal.privileges).toEqual(['Portal.Admin']); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'auth.unknown_privilege_claim', + value: 'Portal.GhostRole', + }), + 'PrincipalBuilder', + ); + }); + + it('drops + warns on an unknown group GUID (tenant misconfiguration)', async () => { + const { builder, logger } = makeBuilder(); + const user = makeUser({ + label: 'ghost-group', + username: 'ghost-group@example', + privileges: [], + roles: ['collaborateur'], + }); + const userWithUnknownGroup: AuthenticatedUser = { + ...user, + groups: [...user.groups, 'ffffffff-ffff-ffff-ffff-ffffffffffff'], + }; + const principal = await builder.build(userWithUnknownGroup); + expect(principal.roles).toEqual(['collaborateur']); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'auth.unknown_group_claim', + groupId: 'ffffffff-ffff-ffff-ffff-ffffffffffff', + }), + 'PrincipalBuilder', + ); + }); + + it('builds an empty-permissions principal when the user has no groups or roles', async () => { + const { builder } = makeBuilder(); + const user = makeUser({ + label: 'empty', + username: 'empty@example', + privileges: [], + roles: [], + }); + const principal = await builder.build(user); + expect(principal.privileges).toEqual([]); + expect(principal.roles).toEqual([]); + // Scope resolver stub returns unrestricted — the v1 behaviour + // per ADR-0025 §331 until the user_scopes table lands. + expect(principal.scopes).toEqual([{ kind: 'unrestricted' }]); + }); + + it('asks the scope resolver to resolve by entraOid', async () => { + const { builder, scopeResolver } = makeBuilder(); + await builder.build( + makeUser({ + label: 'sr', + username: 'sr@example', + privileges: [], + roles: ['collaborateur'], + }), + ); + expect(scopeResolver.resolve).toHaveBeenCalledWith({ entraOid: 'oid-sr' }); + }); +}); diff --git a/apps/portal-bff/src/auth/principal-builder.ts b/apps/portal-bff/src/auth/principal-builder.ts new file mode 100644 index 0000000..666d998 --- /dev/null +++ b/apps/portal-bff/src/auth/principal-builder.ts @@ -0,0 +1,107 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Logger } from 'nestjs-pino'; +import { EntraGroupToRoleResolver, isPrivilege, type Principal, type Privilege } from 'shared-auth'; +import { ENTRA_GROUP_TO_ROLE_RESOLVER } from './entra-group-to-role.token'; +import { ScopeResolver } from './scope-resolver'; +import type { AuthenticatedUser } from './auth.service'; + +/** + * Builds the session-resident `Principal` per + * [ADR-0025 §"Principal shape"](../../../../docs/decisions/0025-authorization-model-privileges-roles-scopes.md). + * + * Composes the three orthogonal axes once at sign-in: + * - **Privileges** — pulled from the `roles` claim, filtered to + * the closed `PRIVILEGES` catalogue. Unknown values are + * dropped (with a WARN) rather than honoured: the BFF only + * reasons about catalogue privileges, anything else is + * either a tenant misconfiguration or a leftover from a v1+1 + * experiment that should not silently grant access. + * - **Functional roles** — resolved from the `groups` claim via + * `EntraGroupToRoleResolver`. Unknown GUIDs are logged at + * WARN (per ADR-0025 §"Sources of truth — Entra-side + * configuration") and ignored. + * - **Scopes** — resolved by `ScopeResolver` from the portal-side + * `user_scopes` table once ADR-0026 lands. v1 stubs to + * `[{ kind: 'unrestricted' }]`. + * + * Built once per sign-in, not per request: the session payload + * carries the resolved `Principal` so guards can read it without + * re-doing the GUID lookup on every API call. + * + * `user.id` and `user.personId` are placeholders (= `entraOid`) + * until the `User` + `Person` schema (proposed ADR-0026) lands. + * The seam is here so the next PR populates the real UUIDs in one + * place, not in every guard. + */ +@Injectable() +export class PrincipalBuilder { + constructor( + @Inject(ENTRA_GROUP_TO_ROLE_RESOLVER) + private readonly groupToRole: EntraGroupToRoleResolver, + private readonly scopes: ScopeResolver, + private readonly logger: Logger, + ) {} + + async build(user: AuthenticatedUser): Promise { + const privileges = filterPrivileges(user.roles, user.oid, this.logger); + + const roles = this.groupToRole.resolve(user.groups, (groupId: string) => { + this.logger.warn( + { + event: 'auth.unknown_group_claim', + oid: user.oid, + groupId, + }, + 'PrincipalBuilder', + ); + }); + + const scopes = await this.scopes.resolve({ entraOid: user.oid }); + + return { + user: { + // Real UUIDs land with ADR-0026's User + Person schema. + // Until then `entraOid` is the closest thing to a stable + // portal identity, so callers that key on `principal.user.id` + // get a usable value today and a real one later. + id: user.oid, + personId: user.oid, + entraOid: user.oid, + tenantId: user.tid, + displayName: user.displayName, + }, + privileges, + roles, + scopes, + amr: user.amr, + }; + } +} + +function filterPrivileges( + rawRoles: readonly string[], + oid: string, + logger: Logger, +): ReadonlyArray { + const out: Privilege[] = []; + for (const value of rawRoles) { + if (isPrivilege(value)) { + out.push(value); + } else { + // A value in the `roles` claim that is not a known privilege + // is either a leftover from a different app registration, a + // typo in the Entra manifest, or a future privilege not yet + // in the catalogue. None of those should silently grant + // access — drop with a WARN so an operator can investigate. + logger.warn( + { + event: 'auth.unknown_privilege_claim', + oid, + value, + }, + 'PrincipalBuilder', + ); + } + } + return out; +} diff --git a/apps/portal-bff/src/auth/require-mfa.guard.spec.ts b/apps/portal-bff/src/auth/require-mfa.guard.spec.ts index 1233347..9f81bad 100644 --- a/apps/portal-bff/src/auth/require-mfa.guard.spec.ts +++ b/apps/portal-bff/src/auth/require-mfa.guard.spec.ts @@ -53,6 +53,7 @@ const USER = { displayName: 'Jane', amr: ['pwd', 'mfa'], roles: ['admin'], + groups: [], }; describe('RequireMfaGuard', () => { diff --git a/apps/portal-bff/src/auth/scope-resolver.ts b/apps/portal-bff/src/auth/scope-resolver.ts new file mode 100644 index 0000000..9924f65 --- /dev/null +++ b/apps/portal-bff/src/auth/scope-resolver.ts @@ -0,0 +1,47 @@ +import { Injectable } from '@nestjs/common'; +import type { Scope } from 'shared-auth'; + +/** + * Resolves the portal-side scopes for a user being signed in. + * + * Per [ADR-0025 §"Sources of truth — apf_portal-side `user_scopes` + * table"](../../../../docs/decisions/0025-authorization-model-privileges-roles-scopes.md), + * scopes are *not* carried by Entra: they will live in a portal + * `user_scopes` Prisma table (proposed ADR-0026), queried by Entra + * `oid` at sign-in. Each row materialises one `Scope` entry on the + * session principal. + * + * The seam is here in v1 so the next PR replaces only the + * implementation, leaving the call-site in `PrincipalBuilder` + * untouched. The v1 implementation (`StubScopeResolver`) always + * returns `[{ kind: 'unrestricted' }]` per ADR-0025 §331 — until + * the Prisma table lands the test tenant runs against a single + * "see everything" scope; the absence of fine-grained scoping is + * acceptable because guards consuming `@RequireScope` are not in + * the codebase yet. + */ +export abstract class ScopeResolver { + abstract resolve(input: { entraOid: string }): Promise>; +} + +/** + * v1 stub — returns `unrestricted` for every user. Per ADR-0025 + * §"Sources of truth — apf_portal-side `user_scopes` table" the + * real implementation queries `user_scopes WHERE userId = ?`; that + * lands with the `Person` + `User` schema (proposed ADR-0026) and + * the seed PR that follows. + * + * Deliberately not an in-memory hard-coded persona-keyed map: the + * 19 test personas have *intended* scopes (see + * `notes/test-tenant-role-assignments.md`) but those scopes have + * no guard consuming them yet, so per-persona stub data would be + * write-only documentation. When ADR-0026 lands, the seed PR + * populates `user_scopes`; this class is replaced by a + * Prisma-backed implementation in the same change. + */ +@Injectable() +export class StubScopeResolver extends ScopeResolver { + override resolve(): Promise> { + return Promise.resolve([{ kind: 'unrestricted' }]); + } +} diff --git a/apps/portal-bff/src/auth/session-establisher.service.spec.ts b/apps/portal-bff/src/auth/session-establisher.service.spec.ts index 51eef13..f241011 100644 --- a/apps/portal-bff/src/auth/session-establisher.service.spec.ts +++ b/apps/portal-bff/src/auth/session-establisher.service.spec.ts @@ -1,9 +1,11 @@ import type { Request, Response } from 'express'; import type { Logger } from 'nestjs-pino'; +import type { Principal } from 'shared-auth'; import type { AuditWriter } from '../audit/audit.service'; import type { UserSessionIndexService } from '../session/user-session-index.service'; import type { UserDirectoryService } from '../users/user-directory.service'; import type { AuthenticatedUser } from './auth.service'; +import type { PrincipalBuilder } from './principal-builder'; import { SessionEstablisher } from './session-establisher.service'; const USER: AuthenticatedUser = { @@ -13,6 +15,7 @@ const USER: AuthenticatedUser = { displayName: 'Jane Doe', amr: ['pwd', 'mfa'], roles: [], + groups: [], }; function makeReqStub(opts?: { sessionID?: string; sessionUser?: AuthenticatedUser }): Request { @@ -53,8 +56,23 @@ interface Fixture { audit: { signIn: jest.Mock; signOut: jest.Mock }; directory: { recordSignIn: jest.Mock }; logger: ReturnType; + principalBuilder: { build: jest.Mock }; } +const STUB_PRINCIPAL: Principal = { + user: { + id: USER.oid, + personId: USER.oid, + entraOid: USER.oid, + tenantId: USER.tid, + displayName: USER.displayName, + }, + privileges: [], + roles: [], + scopes: [{ kind: 'unrestricted' }], + amr: USER.amr, +}; + function makeFixture(): Fixture { const index = { add: jest.fn().mockResolvedValue(undefined), @@ -69,13 +87,17 @@ function makeFixture(): Fixture { recordSignIn: jest.fn().mockResolvedValue(undefined), }; const logger = makeLoggerStub(); + const principalBuilder = { + build: jest.fn().mockResolvedValue(STUB_PRINCIPAL), + }; const est = new SessionEstablisher( logger as unknown as Logger, index as unknown as UserSessionIndexService, audit as unknown as AuditWriter, directory as unknown as UserDirectoryService, + principalBuilder as unknown as PrincipalBuilder, ); - return { est, index, audit, directory, logger }; + return { est, index, audit, directory, logger, principalBuilder }; } describe('SessionEstablisher.establish', () => { @@ -97,6 +119,20 @@ describe('SessionEstablisher.establish', () => { expect((sess['csrfToken'] as string).length).toBeGreaterThan(20); }); + it('builds the authorization principal and stamps mfaVerifiedAt on it (ADR-0025)', async () => { + const { est, principalBuilder } = makeFixture(); + const req = makeReqStub(); + const res = makeResStub(); + await est.establish({ user: USER, req, res, surface: 'user' }); + expect(principalBuilder.build).toHaveBeenCalledWith(USER); + const sess = (req as unknown as { session: Record }).session; + const principal = sess['principal'] as Principal; + expect(principal).toBeDefined(); + expect(principal.user.entraOid).toBe(USER.oid); + expect(principal.scopes).toEqual([{ kind: 'unrestricted' }]); + expect(principal.mfaVerifiedAt).toBe(sess['createdAt']); + }); + it('saves the session before returning (no race with the controller redirect)', async () => { const { est } = makeFixture(); const req = makeReqStub(); diff --git a/apps/portal-bff/src/auth/session-establisher.service.ts b/apps/portal-bff/src/auth/session-establisher.service.ts index 6c4bf2d..3d68f69 100644 --- a/apps/portal-bff/src/auth/session-establisher.service.ts +++ b/apps/portal-bff/src/auth/session-establisher.service.ts @@ -8,6 +8,7 @@ import { readSessionTimeouts } from '../session/session-cookie'; import { UserSessionIndexService } from '../session/user-session-index.service'; import { UserDirectoryService } from '../users/user-directory.service'; import type { AuthenticatedUser } from './auth.service'; +import { PrincipalBuilder } from './principal-builder'; export type AuthSurface = 'user' | 'admin'; @@ -47,6 +48,7 @@ export class SessionEstablisher { private readonly userSessionIndex: UserSessionIndexService, private readonly audit: AuditWriter, private readonly userDirectory: UserDirectoryService, + private readonly principalBuilder: PrincipalBuilder, ) {} async establish(opts: { @@ -79,6 +81,14 @@ export class SessionEstablisher { // does not re-validate factors. Refreshed by future step-up // re-auth flows. req.session.mfaVerifiedAt = now; + // Authorization principal per ADR-0025: composes the three + // axes (privileges / functional roles / scopes) once at + // sign-in so guards read a single coherent shape instead of + // re-parsing claims on every request. Built before the + // session is persisted so a Redis hiccup either persists + // everything or nothing. + const principal = await this.principalBuilder.build(user); + req.session.principal = { ...principal, mfaVerifiedAt: now }; await saveSession(req); diff --git a/apps/portal-bff/src/config/load-entra-group-map.spec.ts b/apps/portal-bff/src/config/load-entra-group-map.spec.ts new file mode 100644 index 0000000..9fe2ddf --- /dev/null +++ b/apps/portal-bff/src/config/load-entra-group-map.spec.ts @@ -0,0 +1,93 @@ +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { EntraGroupMapError } from 'shared-auth'; +import { loadEntraGroupToRoleResolver } from './load-entra-group-map'; + +function tmpFile(name: string, contents: string): string { + const dir = mkdtempSync(join(tmpdir(), 'entra-map-')); + const path = join(dir, name); + writeFileSync(path, contents, 'utf8'); + return path; +} + +function noopWarn(): { onWarn: jest.Mock } { + return { onWarn: jest.fn() }; +} + +describe('loadEntraGroupToRoleResolver', () => { + it('returns an empty resolver and warns when ENTRA_GROUP_MAP_PATH is unset', () => { + const { onWarn } = noopWarn(); + const { resolver, sourcePath } = loadEntraGroupToRoleResolver({ + env: {}, + onWarn, + }); + expect(resolver.size).toBe(0); + expect(sourcePath).toBeNull(); + expect(onWarn).toHaveBeenCalledWith('auth.entra_group_map_path_unset', expect.any(Object)); + }); + + it('returns an empty resolver and warns when the file cannot be read', () => { + const { onWarn } = noopWarn(); + const { resolver, sourcePath } = loadEntraGroupToRoleResolver({ + env: { ENTRA_GROUP_MAP_PATH: '/tmp/definitely-not-a-real-path-xyz.json' }, + onWarn, + }); + expect(resolver.size).toBe(0); + expect(sourcePath).toBe('/tmp/definitely-not-a-real-path-xyz.json'); + expect(onWarn).toHaveBeenCalledWith( + 'auth.entra_group_map_file_unreadable', + expect.objectContaining({ path: '/tmp/definitely-not-a-real-path-xyz.json' }), + ); + }); + + it('throws on malformed JSON', () => { + const path = tmpFile('bad.json', '{ not json'); + expect(() => + loadEntraGroupToRoleResolver({ + env: { ENTRA_GROUP_MAP_PATH: path }, + onWarn: jest.fn(), + }), + ).toThrow(EntraGroupMapError); + }); + + it('throws when the JSON is not an object', () => { + const path = tmpFile('arr.json', '["collaborateur"]'); + expect(() => + loadEntraGroupToRoleResolver({ + env: { ENTRA_GROUP_MAP_PATH: path }, + onWarn: jest.fn(), + }), + ).toThrow(EntraGroupMapError); + }); + + it('throws when a value is not a string', () => { + const path = tmpFile( + 'bad-val.json', + JSON.stringify({ '11111111-1111-1111-1111-111111111111': 42 }), + ); + expect(() => + loadEntraGroupToRoleResolver({ + env: { ENTRA_GROUP_MAP_PATH: path }, + onWarn: jest.fn(), + }), + ).toThrow(EntraGroupMapError); + }); + + it('returns a populated resolver on a valid map', () => { + const path = tmpFile( + 'good.json', + JSON.stringify({ + '11111111-1111-1111-1111-111111111111': 'collaborateur', + '22222222-2222-2222-2222-222222222222': 'rh', + }), + ); + const { resolver, sourcePath } = loadEntraGroupToRoleResolver({ + env: { ENTRA_GROUP_MAP_PATH: path }, + onWarn: jest.fn(), + }); + expect(resolver.size).toBe(2); + expect(sourcePath).toBe(path); + expect(resolver.coveredRoles()).toEqual(['collaborateur', 'rh']); + }); +}); diff --git a/apps/portal-bff/src/config/load-entra-group-map.ts b/apps/portal-bff/src/config/load-entra-group-map.ts new file mode 100644 index 0000000..5fb13e6 --- /dev/null +++ b/apps/portal-bff/src/config/load-entra-group-map.ts @@ -0,0 +1,98 @@ +import { readFileSync } from 'node:fs'; +import { isAbsolute, resolve } from 'node:path'; +import { EntraGroupMapError, EntraGroupToRoleResolver, parseEntraGroupMap } from 'shared-auth'; + +/** + * Outcome of `loadEntraGroupToRoleResolver` — paired with the + * source path (when set) so the boot log can name the file that + * was loaded, even when the resolver itself ends up empty. + */ +export interface EntraGroupResolverLoadResult { + readonly resolver: EntraGroupToRoleResolver; + /** `null` when no path was configured (env var unset). */ + readonly sourcePath: string | null; +} + +/** + * Loads the Entra group-GUID → role-slug map at BFF boot per + * [ADR-0025 §"Sources of truth — Entra-side configuration"](../../../../docs/decisions/0025-authorization-model-privileges-roles-scopes.md). + * + * Sourcing precedence: + * 1. `ENTRA_GROUP_MAP_PATH` env var → JSON file path (absolute, + * or relative to `cwd`). + * 2. Unset → empty resolver, WARN logged at boot. Sign-in still + * works but every user gets an empty `roles[]` until the + * operator wires up the file. Acceptable for fresh dev + * environments; production should always set the var. + * + * Failure modes: + * - File configured but unreadable / missing → empty resolver, + * WARN. Same fail-soft posture: an operator pointing at the + * wrong path should not block every sign-in. + * - File present but malformed (bad JSON, wrong shape, unknown + * slug, duplicate GUID) → throws. A misconfigured map is a + * hard-fail because the alternative is silently mis-resolving + * a role. + * + * The function never reads `process.env` outside the documented + * key and never mutates it. + */ +export function loadEntraGroupToRoleResolver(opts: { + cwd?: string; + env?: NodeJS.ProcessEnv; + onWarn: (event: string, payload: Record) => void; +}): EntraGroupResolverLoadResult { + const env = opts.env ?? process.env; + const cwd = opts.cwd ?? process.cwd(); + const rawPath = env['ENTRA_GROUP_MAP_PATH']; + + if (typeof rawPath !== 'string' || rawPath === '') { + opts.onWarn('auth.entra_group_map_path_unset', { + hint: 'set ENTRA_GROUP_MAP_PATH to infra/-tenant.entra.json — sign-in will succeed but resolve zero functional roles until configured', + }); + return { resolver: new EntraGroupToRoleResolver(new Map()), sourcePath: null }; + } + + const absolutePath = isAbsolute(rawPath) ? rawPath : resolve(cwd, rawPath); + + let raw: string; + try { + raw = readFileSync(absolutePath, 'utf8'); + } catch (err) { + opts.onWarn('auth.entra_group_map_file_unreadable', { + path: absolutePath, + message: err instanceof Error ? err.message : String(err), + }); + return { resolver: new EntraGroupToRoleResolver(new Map()), sourcePath: absolutePath }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err) { + throw new EntraGroupMapError( + `Entra group map at ${absolutePath} is not valid JSON: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new EntraGroupMapError( + `Entra group map at ${absolutePath} must be a JSON object keyed on group GUID.`, + ); + } + + const stringified: Record = {}; + for (const [key, value] of Object.entries(parsed as Record)) { + if (typeof value !== 'string') { + throw new EntraGroupMapError( + `Entra group map entry "${key}" in ${absolutePath} must be a string (the role slug); got ${typeof value}.`, + ); + } + stringified[key] = value; + } + + const map = parseEntraGroupMap(stringified); + return { resolver: new EntraGroupToRoleResolver(map), sourcePath: absolutePath }; +} diff --git a/apps/portal-bff/src/grpc/ai-bridge/ai-bridge.controller.spec.ts b/apps/portal-bff/src/grpc/ai-bridge/ai-bridge.controller.spec.ts index bb324fc..938c527 100644 --- a/apps/portal-bff/src/grpc/ai-bridge/ai-bridge.controller.spec.ts +++ b/apps/portal-bff/src/grpc/ai-bridge/ai-bridge.controller.spec.ts @@ -71,6 +71,7 @@ const USER: AuthenticatedUser = { displayName: 'Alice', amr: ['pwd', 'mfa'], roles: ['admin'], + groups: [], }; // ---- Fake gRPC server (Chat + Rag + Models) ------------------- diff --git a/apps/portal-bff/src/session/session.types.ts b/apps/portal-bff/src/session/session.types.ts index 3c222c6..e6de8a7 100644 --- a/apps/portal-bff/src/session/session.types.ts +++ b/apps/portal-bff/src/session/session.types.ts @@ -1,3 +1,4 @@ +import type { Principal } from 'shared-auth'; import type { AuthenticatedUser } from '../auth/auth.service'; /** @@ -18,6 +19,26 @@ import type { AuthenticatedUser } from '../auth/auth.service'; declare module 'express-session' { interface SessionData { user?: AuthenticatedUser; + /** + * Authorization principal per + * [ADR-0025 §"Principal shape"](../../../../docs/decisions/0025-authorization-model-privileges-roles-scopes.md). + * Built once at sign-in by `PrincipalBuilder` from the three + * source claims (Entra `roles` for privileges, Entra `groups` + * for functional roles, portal `user_scopes` for scopes) and + * read by the upcoming `@RequirePrivilege` / `@RequireRole` / + * `@RequireScope` guards on every request. + * + * Kept alongside `user` rather than replacing it: the audit + * module and the AI-bridge controller still key on + * `user.oid` / `user.tid` directly and would be churn-only + * to migrate. New consumers should reach for `principal`. + * + * Optional in the type — sessions persisted before this field + * existed will have it undefined. Guards must treat the + * missing-principal case the same as "no privileges, no roles, + * no scopes" rather than crashing. + */ + principal?: Principal; /** * Epoch ms at which the BFF created this session. Posed by the * callback at the same time as `user`. Lets the absolute-timeout diff --git a/apps/portal-bff/tsconfig.app.json b/apps/portal-bff/tsconfig.app.json index 800bc44..71fd9d6 100644 --- a/apps/portal-bff/tsconfig.app.json +++ b/apps/portal-bff/tsconfig.app.json @@ -11,5 +11,10 @@ "ignoreDeprecations": "5.0" }, "include": ["src/**/*.ts"], - "exclude": ["jest.config.ts", "jest.config.cts", "src/**/*.spec.ts", "src/**/*.test.ts"] + "exclude": ["jest.config.ts", "jest.config.cts", "src/**/*.spec.ts", "src/**/*.test.ts"], + "references": [ + { + "path": "../../libs/shared/auth" + } + ] } diff --git a/docs/decisions/0025-authorization-model-privileges-roles-scopes.md b/docs/decisions/0025-authorization-model-privileges-roles-scopes.md index e48c9af..1f0060b 100644 --- a/docs/decisions/0025-authorization-model-privileges-roles-scopes.md +++ b/docs/decisions/0025-authorization-model-privileges-roles-scopes.md @@ -63,7 +63,7 @@ The four privileges above are the entire v1 catalogue. The last three were provi **Definition.** A _functional role_ is what someone does in APF. Multiple per user is the norm (a Directeur is also a Collaborateur; an RH-Aquitaine might also be a Bénévole de la Délégation 33). Functional roles carry **no** privilege flags and **no** scope by themselves — those live on the other axes. -**Source of truth.** Entra security groups, named `apf-role-`. Memberships emitted in the `groups` claim. The BFF's OIDC callback resolves each Entra group GUID to a role slug via a static mapping (`libs/feature/auth/src/lib/entra-group-to-role.ts`) and populates `Principal.roles`. +**Source of truth.** Entra security groups, named `apf-role-`. Memberships emitted in the `groups` claim. The BFF's OIDC callback resolves each Entra group GUID to a role slug via a static mapping (`libs/shared/auth/src/lib/entra-group-to-role.ts`) and populates `Principal.roles`. **v1 catalogue.** Grouped for readability; the slugs are kebab-case and intent-bearing. @@ -234,18 +234,26 @@ Two configurations live on the Entra app registration: so the ID token includes the user's group GUIDs in the `groups` claim. The BFF's OIDC callback resolves group GUIDs to role slugs via a static map (committed to the repo). 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. -**The group GUIDs are tenant-specific.** The map file (`libs/feature/auth/src/lib/entra-group-to-role.ts`) keys the slug on the GUID _per environment_ — the dev/test/preprod/prod tenants all have distinct GUIDs for the same role slug. The map is structured as: +**The group GUIDs are tenant-specific.** The map _data_ is keyed on Entra group GUID _per environment_ — the dev/test/preprod/prod tenants all have distinct GUIDs for the same role slug. The data lives in a gitignored `infra/-tenant.entra.json` (see `infra/test-tenant.entra.example.json` for the schema). The BFF loads it at boot through the `EntraGroupToRoleResolver` exported from `libs/shared/auth/src/lib/entra-group-to-role.ts`: -```ts -export const ENTRA_GROUP_TO_ROLE: Record = { - // test tenant — sourced from `infra/test-tenant.entra.json` - '11111111-1111-1111-1111-111111111111': 'collaborateur', - '22222222-2222-2222-2222-222222222222': 'rh', - // … -}; +```json +{ + "11111111-1111-1111-1111-111111111111": "collaborateur", + "22222222-2222-2222-2222-222222222222": "rh" +} ``` -A dedicated config file per environment (`apps/portal-bff/.env.*`) holds an override token if needed. The `groups`-claim overage scenario (Entra emits a `_claim_sources` hint instead of the full list when the user is in too many groups) is handled by the BFF calling Microsoft Graph at sign-in — out of scope for v1's small test tenant, lands when the production rollout brings real population in. +```ts +// libs/shared/auth/src/lib/entra-group-to-role.ts +export class EntraGroupToRoleResolver { + resolve( + groupIds: ReadonlyArray, + onUnknownGroup?: (groupId: string) => void, + ): ReadonlyArray; +} +``` + +The map file path is passed via `ENTRA_GROUP_MAP_PATH` (per-environment in `apps/portal-bff/.env.*`). The `groups`-claim overage scenario (Entra emits a `_claim_sources` hint instead of the full list when the user is in too many groups) is handled by the BFF calling Microsoft Graph at sign-in — out of scope for v1's small test tenant, lands when the production rollout brings real population in. ### Sources of truth — apf_portal-side `user_scopes` table @@ -326,7 +334,7 @@ The four privileges live in the `apfrd.onmicrosoft.com` app registration with th | `Portal.SecurityOfficer` | `6f50ce58-a1e1-496b-a3c0-655559c66a28` | | `Portal.DPO` | `39b8815f-b3fd-4597-9679-77dcbf788a07` | -The 24 `apf-role-*` security groups were provisioned with the membership matrix above. Their GUIDs are tenant-specific and stay out of the repo; the implementation PR captures them in a gitignored `infra/test-tenant.entra.json` and references them by name in `libs/feature/auth/src/lib/entra-group-to-role.ts`. +The 24 `apf-role-*` security groups were provisioned with the membership matrix above. Their GUIDs are tenant-specific and stay out of the repo; the implementation PR captures them in a gitignored `infra/test-tenant.entra.json` and references them by name in `libs/shared/auth/src/lib/entra-group-to-role.ts`. Scopes are **not** carried by Entra. They live in the portal-side `user_scopes` table, populated by `prisma/seed.ts` once the `Person` + `User` schema (proposed ADR-0026) lands. Until then the implementation skeleton honours the `unrestricted` default for testing. @@ -343,7 +351,7 @@ Scopes are **not** carried by Entra. They live in the portal-side `user_scopes` ### Confirmation - **Schema test** in `apps/portal-bff/src/auth/principal.spec.ts` (lands with the auth wiring PR): every guard composition produces the expected allow/deny on each of the 10 test personas. Theory-style test matrix mirrors `apf-ai-service/tests/Apf.Ai.Tests/Rbac/RbacMatrix.cs`. -- **Catalogue-vs-code drift check** in CI: a small ESLint custom rule (or a `pnpm run` script) greps every `@RequireRole('...')` / `@RequirePrivilege('...')` / scope literal in the codebase and asserts each one exists in the catalogue constants exported from `libs/feature/auth/src/lib/authorization.types.ts`. Fails the build on drift. +- **Catalogue-vs-code drift check** in CI: a small ESLint custom rule (or a `pnpm run` script) greps every `@RequireRole('...')` / `@RequirePrivilege('...')` / scope literal in the codebase and asserts each one exists in the catalogue constants exported from `libs/shared/auth/src/lib/authorization.types.ts`. Fails the build on drift. - **Audit-event linkage**: every `403 Forbidden` from `@RequireRole` / `@RequireScope` writes an `admin.access_denied` row (per [ADR-0013](0013-audit-trail-separated-postgres-append-only.md) §"v1 events"), with the missing role/scope in the payload. Auditors can spot privilege-escalation attempts by pivoting on `outcome=denied`. - **PrincipalProjector test**: snapshot test asserting that the AI-service projection of a known persona produces a known flat-list output. Same fixture is reused on the AI service side as part of its RBAC matrix. @@ -393,7 +401,7 @@ This door is recorded here so a future contributor does not feel they have to re ## More Information - **Phasing.** This ADR is decision-only. The implementation phasing is: - 1. **PR — `Authorization` types + Principal builder + `entra-group-to-role` mapping skeleton.** Lands `libs/feature/auth/src/lib/authorization.types.ts` (the catalogues) and the OIDC callback hook that populates the new `privileges` / `roles` / `scopes` fields on the session principal. No new guards yet. + 1. **PR — `Authorization` types + Principal builder + `entra-group-to-role` mapping skeleton.** Lands `libs/shared/auth/src/lib/authorization.types.ts` (the catalogues) and the OIDC callback hook that populates the new `privileges` / `roles` / `scopes` fields on the session principal. No new guards yet. 2. **PR — `@RequireRole` + `@RequireScope` decorators + guard tests.** Adds the guards against a stubbed principal; integration with the actual session lands in the same PR. 3. **PR — Drift CI gate.** ESLint rule (or `pnpm run` script) that asserts every role / privilege / scope literal in the codebase is in the catalogue. 4. **PR — Test-tenant seed.** `prisma/seed.ts` populating the 10 test personas' `user_scopes` rows. Depends on the `Person` + `User` schema landing first (proposed ADR-0026). diff --git a/infra/README.md b/infra/README.md index 09dfabc..7d6dddd 100644 --- a/infra/README.md +++ b/infra/README.md @@ -2,14 +2,15 @@ Infrastructure-as-code artefacts for the project. Separate from application code and from documentation: this folder contains the recipes and configs that the team and ops use to stand up running infrastructure (CI runners, future local-dev databases, future on-prem deploy assets). -| Subject | File / Folder | ADR / Reference | -| -------------------------------------- | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Self-hosted CI runners (Gitea Actions) | [`ci-runners.compose.yml`](ci-runners.compose.yml) | [ADR-0015 §"Runners"](../docs/decisions/0015-cicd-gitea-actions.md) | -| Shared `act_runner` configuration | [`runner-config.yaml`](runner-config.yaml) | [ADR-0015 §"Runners"](../docs/decisions/0015-cicd-gitea-actions.md) | -| CI runners convenience script | [`ci-runners.sh`](ci-runners.sh) | See "Convenience script" below | -| Runtime state of the runners | `data/` (git-ignored after `.gitignore`) | — | -| Env-vars template for the runners | `.env.example` (`.env` is git-ignored) | — | -| Local-dev runtime stack | [`local/`](local/) | [ADR-0006](../docs/decisions/0006-persistence-postgresql-prisma.md), [ADR-0010](../docs/decisions/0010-session-management-redis.md), [ADR-0012](../docs/decisions/0012-observability-pino-opentelemetry.md), [ADR-0013](../docs/decisions/0013-audit-trail-separated-postgres-append-only.md) | +| Subject | File / Folder | ADR / Reference | +| -------------------------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Self-hosted CI runners (Gitea Actions) | [`ci-runners.compose.yml`](ci-runners.compose.yml) | [ADR-0015 §"Runners"](../docs/decisions/0015-cicd-gitea-actions.md) | +| Shared `act_runner` configuration | [`runner-config.yaml`](runner-config.yaml) | [ADR-0015 §"Runners"](../docs/decisions/0015-cicd-gitea-actions.md) | +| CI runners convenience script | [`ci-runners.sh`](ci-runners.sh) | See "Convenience script" below | +| Runtime state of the runners | `data/` (git-ignored after `.gitignore`) | — | +| Env-vars template for the runners | `.env.example` (`.env` is git-ignored) | — | +| Local-dev runtime stack | [`local/`](local/) | [ADR-0006](../docs/decisions/0006-persistence-postgresql-prisma.md), [ADR-0010](../docs/decisions/0010-session-management-redis.md), [ADR-0012](../docs/decisions/0012-observability-pino-opentelemetry.md), [ADR-0013](../docs/decisions/0013-audit-trail-separated-postgres-append-only.md) | +| Entra group GUID → role slug map | [`test-tenant.entra.example.json`](test-tenant.entra.example.json) (`*-tenant.entra.json` is git-ignored) | [ADR-0025 §"Sources of truth — Entra-side configuration"](../docs/decisions/0025-authorization-model-privileges-roles-scopes.md) | Future folders / files that will land here as the corresponding ADRs ship: @@ -230,6 +231,38 @@ This stack is **dev-only**. The corresponding production layout (HA Postgres, Re --- +## Entra group map — `test-tenant.entra.example.json` + +Pure JSON object keyed on Entra security-group GUID (lower-case), valued by an `apf-role-` slug from the ADR-0025 functional-role catalogue. The BFF loads it at boot through `EntraGroupToRoleResolver` (from `shared-auth`) and uses it on every sign-in to translate the `groups` claim into the 24-entry catalogue's role slugs. + +The 24 entries below cover the entire v1 catalogue — including `partenaire`, which ships empty in the test tenant by design but is kept in the schema so a typo or omission fails the parser at boot rather than silently dropping the role. + +### Provisioning a real file + +```bash +cp infra/test-tenant.entra.example.json infra/test-tenant.entra.json + +# Then for each role replace the placeholder GUID with the real one +# from Entra: +# Microsoft Entra admin centre → Groups → → Object ID. +# Point the BFF at the file via apps/portal-bff/.env: +# ENTRA_GROUP_MAP_PATH=infra/test-tenant.entra.json +``` + +The real file (`infra/-tenant.entra.json`) is git-ignored because the group GUIDs are tenant-private — leaking them does not authorize anything by itself, but it does reveal the tenant's internal authorization topology. Each environment (test / preprod / prod) carries its own file; the slugs are stable across environments, the GUIDs are not. + +If `ENTRA_GROUP_MAP_PATH` is unset, the resolver runs with an empty map: every user signs in successfully but receives an empty `roles[]` (and consequently no `apf-role-*` UI). The BFF logs a WARN at boot so an operator can spot the missing config; this is a deliberate fail-soft posture so a fresh dev environment is not blocked by an Entra-side dependency. + +Validation rules enforced at boot by `parseEntraGroupMap` (in `libs/shared/auth/`): + +- keys must look like a GUID (`8-4-4-4-12` hex); +- values must be members of `FUNCTIONAL_ROLES`; +- the same GUID cannot map to two different slugs (case-insensitive). + +A malformed file crashes the BFF at startup. The error message names the offending key / value. + +--- + ## Future infra concerns — placeholders These are listed here so a contributor knows where to expect related files; they don't exist yet. diff --git a/infra/test-tenant.entra.example.json b/infra/test-tenant.entra.example.json new file mode 100644 index 0000000..d490ab5 --- /dev/null +++ b/infra/test-tenant.entra.example.json @@ -0,0 +1,26 @@ +{ + "00000000-0000-0000-0000-000000000001": "collaborateur", + "00000000-0000-0000-0000-000000000002": "chef-equipe", + "00000000-0000-0000-0000-000000000003": "chef-service", + "00000000-0000-0000-0000-000000000004": "directeur-etablissement", + "00000000-0000-0000-0000-000000000005": "directeur-territorial", + "00000000-0000-0000-0000-000000000006": "rh", + "00000000-0000-0000-0000-000000000007": "responsable-paie", + "00000000-0000-0000-0000-000000000008": "comptable", + "00000000-0000-0000-0000-000000000009": "juriste", + "00000000-0000-0000-0000-00000000000a": "dpo", + "00000000-0000-0000-0000-00000000000b": "rssi", + "00000000-0000-0000-0000-00000000000c": "it", + "00000000-0000-0000-0000-00000000000d": "formation", + "00000000-0000-0000-0000-00000000000e": "qualite", + "00000000-0000-0000-0000-00000000000f": "communication", + "00000000-0000-0000-0000-000000000010": "elu-ca", + "00000000-0000-0000-0000-000000000011": "elu-cd", + "00000000-0000-0000-0000-000000000012": "elu-cd-president", + "00000000-0000-0000-0000-000000000013": "elu-cd-tresorier", + "00000000-0000-0000-0000-000000000014": "elu-cd-secretaire", + "00000000-0000-0000-0000-000000000015": "delegue", + "00000000-0000-0000-0000-000000000016": "benevole", + "00000000-0000-0000-0000-000000000017": "benevole-responsable", + "00000000-0000-0000-0000-000000000018": "partenaire" +} diff --git a/libs/shared/auth/README.md b/libs/shared/auth/README.md new file mode 100644 index 0000000..88c75ec --- /dev/null +++ b/libs/shared/auth/README.md @@ -0,0 +1,24 @@ +# shared-auth + +Framework-agnostic authorization primitives shared across `portal-bff` and +`portal-shell` per [ADR-0025](../../../docs/decisions/0025-authorization-model-privileges-roles-scopes.md): + +- **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. +- **`Principal` type** — the session-resident shape consumed by route + guards (`@RequirePrivilege`, `@RequireRole`, `@RequireScope`) and projected + to the AI service's flat `roles[]` contract. +- **`EntraGroupToRoleResolver`** — turns the tenant-specific Entra group + GUIDs carried in the OIDC `groups` claim into the catalogue's role slugs. + Configured at boot from `infra/-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](https://vitest.dev/). diff --git a/libs/shared/auth/eslint.config.mjs b/libs/shared/auth/eslint.config.mjs new file mode 100644 index 0000000..36838af --- /dev/null +++ b/libs/shared/auth/eslint.config.mjs @@ -0,0 +1,22 @@ +import baseConfig from '../../../eslint.config.mjs'; + +export default [ + ...baseConfig, + { + files: ['**/*.json'], + rules: { + '@nx/dependency-checks': [ + 'error', + { + ignoredFiles: [ + '{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}', + '{projectRoot}/vite.config.{js,ts,mjs,mts}', + ], + }, + ], + }, + languageOptions: { + parser: await import('jsonc-eslint-parser'), + }, + }, +]; diff --git a/libs/shared/auth/package.json b/libs/shared/auth/package.json new file mode 100644 index 0000000..1bbddfd --- /dev/null +++ b/libs/shared/auth/package.json @@ -0,0 +1,13 @@ +{ + "name": "shared-auth", + "version": "0.0.1", + "private": true, + "type": "commonjs", + "main": "./src/index.js", + "types": "./src/index.d.ts", + "dependencies": { + "tslib": "^2.3.0", + "vitest": "^4.0.8", + "@nx/vite": "^22.7.1" + } +} diff --git a/libs/shared/auth/project.json b/libs/shared/auth/project.json new file mode 100644 index 0000000..0b01023 --- /dev/null +++ b/libs/shared/auth/project.json @@ -0,0 +1,19 @@ +{ + "name": "shared-auth", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/shared/auth/src", + "projectType": "library", + "tags": ["scope:shared", "type:shared"], + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/libs/shared/auth", + "main": "libs/shared/auth/src/index.ts", + "tsConfig": "libs/shared/auth/tsconfig.lib.json", + "assets": ["libs/shared/auth/*.md"] + } + } + } +} diff --git a/libs/shared/auth/src/index.ts b/libs/shared/auth/src/index.ts new file mode 100644 index 0000000..8ebd404 --- /dev/null +++ b/libs/shared/auth/src/index.ts @@ -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'; diff --git a/libs/shared/auth/src/lib/authorization.types.spec.ts b/libs/shared/auth/src/lib/authorization.types.spec.ts new file mode 100644 index 0000000..07b29f8 --- /dev/null +++ b/libs/shared/auth/src/lib/authorization.types.spec.ts @@ -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); + }); +}); diff --git a/libs/shared/auth/src/lib/authorization.types.ts b/libs/shared/auth/src/lib/authorization.types.ts new file mode 100644 index 0000000..e7af1f0 --- /dev/null +++ b/libs/shared/auth/src/lib/authorization.types.ts @@ -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-`. 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:`, `delegation:`, + * `region:`); 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; + readonly roles: ReadonlyArray; + readonly scopes: ReadonlyArray; + readonly amr: ReadonlyArray; + 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 = new Set(PRIVILEGES); +const FUNCTIONAL_ROLE_SET: ReadonlySet = new Set(FUNCTIONAL_ROLES); +const SCOPE_KIND_SET: ReadonlySet = 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); +} diff --git a/libs/shared/auth/src/lib/entra-group-to-role.spec.ts b/libs/shared/auth/src/lib/entra-group-to-role.spec.ts new file mode 100644 index 0000000..69bb1b2 --- /dev/null +++ b/libs/shared/auth/src/lib/entra-group-to-role.spec.ts @@ -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']); + }); +}); diff --git a/libs/shared/auth/src/lib/entra-group-to-role.ts b/libs/shared/auth/src/lib/entra-group-to-role.ts new file mode 100644 index 0000000..873a41a --- /dev/null +++ b/libs/shared/auth/src/lib/entra-group-to-role.ts @@ -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` 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; + +/** + * 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 `{ : }` mapping (as it would + * arrive from `JSON.parse` of an `infra/-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>): EntraGroupMap { + const map = new Map(); + const lowercased = new Set(); + + 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 { + const covered = new Set(this.map.values()); + return FUNCTIONAL_ROLES.filter((role) => covered.has(role)); + } + + resolve( + groupIds: ReadonlyArray, + onUnknownGroup?: (groupId: string) => void, + ): ReadonlyArray { + const matched = new Set(); + 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; diff --git a/libs/shared/auth/tsconfig.json b/libs/shared/auth/tsconfig.json new file mode 100644 index 0000000..6d04f86 --- /dev/null +++ b/libs/shared/auth/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "module": "esnext", + "forceConsistentCasingInFileNames": true, + "strict": true, + "importHelpers": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noPropertyAccessFromIndexSignature": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/shared/auth/tsconfig.lib.json b/libs/shared/auth/tsconfig.lib.json new file mode 100644 index 0000000..190d53f --- /dev/null +++ b/libs/shared/auth/tsconfig.lib.json @@ -0,0 +1,23 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": [ + "vite.config.ts", + "vite.config.mts", + "vitest.config.ts", + "vitest.config.mts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.test.tsx", + "src/**/*.spec.tsx", + "src/**/*.test.js", + "src/**/*.spec.js", + "src/**/*.test.jsx", + "src/**/*.spec.jsx" + ] +} diff --git a/libs/shared/auth/tsconfig.spec.json b/libs/shared/auth/tsconfig.spec.json new file mode 100644 index 0000000..eb111c7 --- /dev/null +++ b/libs/shared/auth/tsconfig.spec.json @@ -0,0 +1,22 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "types": ["vitest/globals", "vitest/importMeta", "vite/client", "node", "vitest"] + }, + "include": [ + "vite.config.ts", + "vite.config.mts", + "vitest.config.ts", + "vitest.config.mts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.test.tsx", + "src/**/*.spec.tsx", + "src/**/*.test.js", + "src/**/*.spec.js", + "src/**/*.test.jsx", + "src/**/*.spec.jsx", + "src/**/*.d.ts" + ] +} diff --git a/libs/shared/auth/vitest.config.mts b/libs/shared/auth/vitest.config.mts new file mode 100644 index 0000000..504a0e1 --- /dev/null +++ b/libs/shared/auth/vitest.config.mts @@ -0,0 +1,21 @@ +import { defineConfig } from 'vitest/config'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; +import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin'; + +export default defineConfig(() => ({ + root: __dirname, + cacheDir: '../../../node_modules/.vite/libs/shared/auth', + plugins: [nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])], + test: { + name: 'shared-auth', + watch: false, + globals: true, + environment: 'node', + include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + reporters: ['default'], + coverage: { + reportsDirectory: '../../../coverage/libs/shared/auth', + provider: 'v8' as const, + }, + }, +})); diff --git a/tsconfig.base.json b/tsconfig.base.json index c18fafd..1340f70 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -2,6 +2,7 @@ "compilerOptions": { "rootDir": ".", "paths": { + "shared-auth": ["./libs/shared/auth/src/index.ts"], "shared-tokens": ["./libs/shared/tokens/src/index.ts"], "shared-util": ["./libs/shared/util/src/index.ts"], "shared-ui": ["./libs/shared/ui/src/index.ts"],