feat(auth): authorization catalogues + Principal builder skeleton (ADR-0025) (#206)
## Summary First implementation PR after [ADR-0025](docs/decisions/0025-authorization-model-privileges-roles-scopes.md) was accepted in #205. Lands the closed-set catalogues + the OIDC-callback hook that composes the session-resident `Principal`. No new guards yet — those come in the next PR per the ADR's `§More Information` phasing. ## What lands **New shared library: `libs/shared/auth/`** (`scope:shared`, `type:shared`, framework-agnostic, vitest) | File | Role | | --- | --- | | `src/lib/authorization.types.ts` | Closed catalogues: `PRIVILEGES` (4), `FUNCTIONAL_ROLES` (24), `SCOPE_KINDS` (6). `Principal`, `Scope` types. `isPrivilege` / `isFunctionalRole` / `isScopeKind` type-guards for the drift gate. | | `src/lib/entra-group-to-role.ts` | `parseEntraGroupMap` (validates GUID + slug closedness + dedup) + `EntraGroupToRoleResolver` (translates the Entra `groups` claim into ordered `apf-role-*` slugs, reports unknown GUIDs via callback). | | `src/lib/*.spec.ts` | 17 unit tests against the catalogue counts + resolver contracts. | **BFF wiring** (`apps/portal-bff/src/auth/` + `src/config/`) | File | Role | | --- | --- | | `auth.service.ts` | Extracts the Entra `groups` claim. `AuthenticatedUser` grows `groups: readonly string[]`. | | `principal-builder.ts` | Composes the three axes at sign-in. Filters `roles` claim through `isPrivilege` (drops + WARNs on unknown), resolves `groups` claim through the `EntraGroupToRoleResolver` (drops + WARNs on unknown). | | `scope-resolver.ts` | `ScopeResolver` abstract class + `StubScopeResolver` returning `[{ kind: 'unrestricted' }]` (per ADR-0025 §331 — replaced by a Prisma implementation when ADR-0026's `user_scopes` table lands). | | `session-establisher.service.ts` | Calls `PrincipalBuilder.build()` once, persists the result as `req.session.principal`. The legacy `req.session.user` shape is untouched (audit + AI bridge keep working). | | `session.types.ts` | Adds optional `principal?: Principal` field on the express-session augmentation. | | `entra-group-to-role.token.ts` | DI token for the lazily-loaded resolver. | | `config/load-entra-group-map.ts` | Reads the gitignored `infra/<env>-tenant.entra.json` at boot. Missing path → empty resolver + WARN. Malformed file → hard fail (alternative would be silently dropping a role). | **Tests against the 19 test-tenant personas** (`principal-builder.spec.ts`) Every persona provisioned in `apfrd.onmicrosoft.com` on 2026-05-20 is exercised end-to-end: `admin`, `directeur-bordeaux`, `directeur-complexe`, `rh-aquitaine`, `rh-siege`, `collaborateur-simple`, `tresorier-bordeaux`, `dpo`, `it`, `benevole-aquitaine`, `chef-equipe-bordeaux`, `chef-service-bordeaux`, `directeur-territorial-aquitaine`, `juriste-siege`, `rssi`, `communication-siege`, `elu-ca-national`, `president-cd-aquitaine`, `secretaire-cd-aquitaine`. A coverage assertion confirms the personas cover all 4 privileges + 23 of 24 functional roles (the `partenaire` gap is intentional per ADR-0025). **Infra + docs** | File | Change | | --- | --- | | `infra/test-tenant.entra.example.json` | Template GUID → slug map (24 entries, full v1 catalogue). Real file (`*-tenant.entra.json`) gitignored. | | `infra/README.md` | New row + "Entra group map" section documenting the load semantics + provisioning workflow. | | `apps/portal-bff/.env.example` | New `ENTRA_GROUP_MAP_PATH` block. | | `.gitignore` | `infra/*-tenant.entra.json` (except `.example.json`). | | `tsconfig.base.json` | `shared-auth` path alias. | | `notes/entra-groups-claim-activation.md` | Operator runbook for the Entra admin-centre step (Token configuration → Add groups claim → Security groups → Group ID). | **ADR amendment** | File | Change | | --- | --- | | `docs/decisions/0025-authorization-model-privileges-roles-scopes.md` | Path references updated `libs/feature/auth/...` → `libs/shared/auth/...` (4 occurrences), and the `ENTRA_GROUP_TO_ROLE` static-record example replaced by the actual `EntraGroupToRoleResolver` + JSON shape. | ## Notes for the reviewer - **Why `libs/shared/auth/` and not `libs/feature/auth/` (as the ADR initially said).** The existing `libs/feature/auth/` carries `tags: ['scope:portal-shell', 'type:feature']`, which the Nx `enforce-module-boundaries` rule in `eslint.config.mjs:36-46` blocks the BFF from importing. The catalogue is needed by both sides (BFF builds the `Principal`, SPA will gate UI conditionally later), so a `scope:shared` lib is the correct home. The ADR is patched in the same PR so the document and the code agree. - **Why drop unknown privileges with a WARN instead of preserving them.** `Portal.GhostRole` in the `roles` claim is either a leftover from a different app registration or a v1+1 experiment that hasn't been added to the catalogue yet. Both paths should not silently grant access — drop with a WARN so an operator notices, and the drift CI gate (next PR) catches the source-side equivalent before merge. - **Why the principal builder uses a `ScopeResolver` seam now.** Per ADR-0025 §331 the v1 implementation returns `unrestricted` for everyone; the real version queries the `user_scopes` Prisma table that lands with ADR-0026. The seam is here so the next PR replaces only the implementation, not the call-site in `PrincipalBuilder` or its 24 tests. Per-persona stub scope data was deliberately not embedded in this PR — no guard consumes scopes yet, so it would be write-only documentation that drifts from the eventual Prisma seed. - **Why `user.id` and `user.personId` placeholder to `entraOid`.** Same logic: the real UUIDs land with the `Person` + `User` Prisma schema (proposed ADR-0026). Until then, populating both fields with `entraOid` lets consumers key on `principal.user.id` today and get a real value later without branching on availability. The seam is one place (in the builder), not 24 guards. - **Why session.types.ts carries both `user` and `principal`.** Additive instead of replacement: the audit module + the AI-bridge controller key on `user.oid` / `user.tid` directly, and migrating them in the same PR would balloon the diff for zero behavioural benefit. New consumers (`@RequirePrivilege` / `@RequireRole` / `@RequireScope` decorators, next PR) reach for `principal`. - **Map file fails soft on absence, hard on malformation.** Path unset OR file missing → empty resolver + WARN. Sign-in still succeeds, every user gets `roles: []`. This is deliberately fail-soft so a fresh dev environment isn't blocked by an Entra-side dependency. **Bad JSON / unknown slug / duplicate GUID → throws at boot.** The alternative would silently mis-resolve a role. - **Why `boot-time` validation only (no per-request)** for the map file. Catalogue drift is detected once at boot; per-request validation would re-read the file from disk on every sign-in. The trade-off is that a hot-edit to the file requires a BFF restart — acceptable because the file changes once per tenant lifecycle, not per session. - **Entra `groups` claim must be activated** on the BFF's app registration before this code does anything useful. The runbook in `notes/entra-groups-claim-activation.md` walks through the steps; if the claim isn't activated, every user signs in with `principal.roles = []` and a future warn for every group GUID that would have arrived (none, in that case). - **No drift gate yet.** ADR-0025's §"Confirmation" §346 calls for an ESLint custom rule (or `pnpm run` script) that greps every `@RequireRole('...')` literal and asserts membership in the catalogue. That's the third PR in the phasing — there's no `@RequireRole` consumer in the tree yet, so the gate would have nothing to assert against today. ## Test plan - [x] `pnpm nx affected -t lint test build --base=main` — 13 projects green - 497 BFF tests (was 465 — added 4 new `groups`-claim tests in `auth.service.spec.ts`, 1 new test in `session-establisher.service.spec.ts`, 6 new tests in `load-entra-group-map.spec.ts`, 24 new tests in `principal-builder.spec.ts`) - 17 `shared-auth` tests (catalogue counts + resolver contracts) - 1 `shared-util` test (unchanged) - full lint + full build - [x] `pnpm nx format:check` — clean after `pnpm nx format:write` - [x] Manual: `tsc --build libs/shared/auth/tsconfig.lib.json` emits `.d.ts` files at the path `portal-bff`'s webpack expects. - [ ] **Review focus** — the closed catalogues (`PRIVILEGES`, `FUNCTIONAL_ROLES`, `SCOPE_KINDS`) verbatim match ADR-0025; the 19 personas verbatim match `notes/test-tenant-role-assignments.md`; the `Principal` shape matches the ADR §"Principal shape" (modulo the `user.id` / `personId` placeholder); fail-soft on missing map file, hard on malformed. - [ ] **After merge — operator step** : activate the Entra `groups` claim per `notes/entra-groups-claim-activation.md` and drop the real GUIDs into `infra/test-tenant.entra.json` (gitignored). Then a sign-in with `admin@apfrd.onmicrosoft.com` should land `principal.privileges = ['Portal.Admin']` and `principal.roles = ['collaborateur', 'rh']` in Redis. ## What's next Per ADR-0025 §"More Information" phasing: 1. ✅ **This PR** — types + Principal builder + group-to-role mapping skeleton. 2. **Next PR** — `@RequirePrivilege` + `@RequireRole` + `@RequireScope` decorators + guard integration tests against the 19 personas. 3. **PR after that** — drift CI gate (ESLint custom rule asserting every `@RequireX('...')` literal is in the catalogue). 4. **Depends on ADR-0026** — `user_scopes` Prisma table + seed + `PrismaScopeResolver` replacing `StubScopeResolver`. --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #206
This commit was merged in pull request #206.
This commit is contained in:
@@ -31,6 +31,10 @@ pnpm-debug.log*
|
|||||||
*.pem
|
*.pem
|
||||||
*.key
|
*.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
|
# OS / editor scrap
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|||||||
@@ -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_REDIRECT_URI=http://localhost:3000/api/admin/auth/callback
|
||||||
ENTRA_ADMIN_POST_LOGOUT_REDIRECT_URI=http://localhost:4300/
|
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
|
# Cookie signing secret (per ADR-0009 §"Cookies"). Used to sign the
|
||||||
# transient pre-auth cookie that carries the OIDC `state` + PKCE
|
# transient pre-auth cookie that carries the OIDC `state` + PKCE
|
||||||
# verifier between the /auth/login redirect and the /auth/callback
|
# verifier between the /auth/login redirect and the /auth/callback
|
||||||
|
|||||||
@@ -150,11 +150,30 @@ function makeController(opts?: { completeAuthCodeFlow?: jest.Mock }): Controller
|
|||||||
// Real SessionEstablisher with the same mocks the legacy tests
|
// Real SessionEstablisher with the same mocks the legacy tests
|
||||||
// already wire — keeps the behavioural assertions on session
|
// already wire — keeps the behavioural assertions on session
|
||||||
// fields / audit calls untouched after the controller refactor.
|
// 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(
|
const sessionEstablisher = new SessionEstablisher(
|
||||||
logger as unknown as Logger,
|
logger as unknown as Logger,
|
||||||
userSessionIndex as unknown as UserSessionIndexService,
|
userSessionIndex as unknown as UserSessionIndexService,
|
||||||
audit as unknown as AuditWriter,
|
audit as unknown as AuditWriter,
|
||||||
userDirectory as unknown as UserDirectoryService,
|
userDirectory as unknown as UserDirectoryService,
|
||||||
|
principalBuilder as unknown as import('./principal-builder').PrincipalBuilder,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
controller: new AuthController(
|
controller: new AuthController(
|
||||||
|
|||||||
@@ -2,12 +2,16 @@ import { ConfidentialClientApplication, LogLevel } from '@azure/msal-node';
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { Logger } from 'nestjs-pino';
|
import { Logger } from 'nestjs-pino';
|
||||||
import { assertEntraConfig } from '../config/check-entra-config';
|
import { assertEntraConfig } from '../config/check-entra-config';
|
||||||
|
import { loadEntraGroupToRoleResolver } from '../config/load-entra-group-map';
|
||||||
import { SessionModule } from '../session/session.module';
|
import { SessionModule } from '../session/session.module';
|
||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { ENTRA_CONFIG, type EntraConfig } from './entra-config.token';
|
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 { MSAL_CLIENT } from './msal-client.token';
|
||||||
|
import { PrincipalBuilder } from './principal-builder';
|
||||||
import { RequireMfaGuard } from './require-mfa.guard';
|
import { RequireMfaGuard } from './require-mfa.guard';
|
||||||
|
import { ScopeResolver, StubScopeResolver } from './scope-resolver';
|
||||||
import { SessionEstablisher } from './session-establisher.service';
|
import { SessionEstablisher } from './session-establisher.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,10 +51,35 @@ import { SessionEstablisher } from './session-establisher.service';
|
|||||||
AuthService,
|
AuthService,
|
||||||
RequireMfaGuard,
|
RequireMfaGuard,
|
||||||
SessionEstablisher,
|
SessionEstablisher,
|
||||||
|
PrincipalBuilder,
|
||||||
|
{ provide: ScopeResolver, useClass: StubScopeResolver },
|
||||||
{
|
{
|
||||||
provide: ENTRA_CONFIG,
|
provide: ENTRA_CONFIG,
|
||||||
useFactory: () => assertEntraConfig(),
|
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,
|
provide: MSAL_CLIENT,
|
||||||
inject: [ENTRA_CONFIG, Logger],
|
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 {}
|
export class AuthModule {}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ function makeAuthResult(
|
|||||||
claims: Partial<{
|
claims: Partial<{
|
||||||
amr: string[];
|
amr: string[];
|
||||||
roles: unknown;
|
roles: unknown;
|
||||||
|
groups: unknown;
|
||||||
oid: string;
|
oid: string;
|
||||||
tid: string;
|
tid: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -58,6 +59,12 @@ function makeAuthResult(
|
|||||||
if (Object.prototype.hasOwnProperty.call(claims, 'roles')) {
|
if (Object.prototype.hasOwnProperty.call(claims, 'roles')) {
|
||||||
idTokenClaims['roles'] = 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 {
|
return {
|
||||||
idTokenClaims,
|
idTokenClaims,
|
||||||
account: { username: 'jane.doe@apf.example', name: 'Jane Doe' },
|
account: { username: 'jane.doe@apf.example', name: 'Jane Doe' },
|
||||||
@@ -124,6 +131,7 @@ describe('AuthService.completeAuthCodeFlow', () => {
|
|||||||
displayName: 'Jane Doe',
|
displayName: 'Jane Doe',
|
||||||
amr: ['pwd', 'mfa'],
|
amr: ['pwd', 'mfa'],
|
||||||
roles: [],
|
roles: [],
|
||||||
|
groups: [],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -220,6 +228,80 @@ describe('AuthService.completeAuthCodeFlow', () => {
|
|||||||
expect(user.roles).toEqual(['admin', 'editor']);
|
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 () => {
|
it('throws token-exchange-failed when MSAL throws', async () => {
|
||||||
const acquireTokenByCode = jest.fn().mockRejectedValue(new Error('AADSTS70008'));
|
const acquireTokenByCode = jest.fn().mockRejectedValue(new Error('AADSTS70008'));
|
||||||
const { service } = makeService({ acquireTokenByCode });
|
const { service } = makeService({ acquireTokenByCode });
|
||||||
|
|||||||
@@ -38,11 +38,23 @@ export interface AuthCodeFlowStart {
|
|||||||
* the BFF's MFA sanity-check (ADR-0011) and by `@RequireMfa`
|
* the BFF's MFA sanity-check (ADR-0011) and by `@RequireMfa`
|
||||||
* freshness checks once the session lands.
|
* freshness checks once the session lands.
|
||||||
* - `roles`: Entra app roles assigned to this user on the BFF's
|
* - `roles`: Entra app roles assigned to this user on the BFF's
|
||||||
* app registration. Surfaced for the future `@RequireAdmin` /
|
* app registration. These are the four `Portal.*` *privileges*
|
||||||
* `@RequireRole(...)` guards (ADR-0020 admin module). Always
|
* per ADR-0025 (`Portal.Admin`, `Portal.Auditor`,
|
||||||
* present in the shape — an empty array means the user has no
|
* `Portal.SecurityOfficer`, `Portal.DPO`). Surfaced for the
|
||||||
* app role on this app registration, not that the claim was
|
* existing `@RequireAdmin()` guard and the upcoming
|
||||||
* unparseable.
|
* `@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 {
|
export interface AuthenticatedUser {
|
||||||
readonly oid: string;
|
readonly oid: string;
|
||||||
@@ -51,6 +63,7 @@ export interface AuthenticatedUser {
|
|||||||
readonly displayName: string;
|
readonly displayName: string;
|
||||||
readonly amr: readonly string[];
|
readonly amr: readonly string[];
|
||||||
readonly roles: 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')
|
? (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 {
|
return {
|
||||||
oid: requireString(claims['oid'], 'oid'),
|
oid: requireString(claims['oid'], 'oid'),
|
||||||
tid: requireString(claims['tid'], 'tid'),
|
tid: requireString(claims['tid'], 'tid'),
|
||||||
@@ -225,6 +250,7 @@ export class AuthService {
|
|||||||
: (result.account?.name ?? ''),
|
: (result.account?.name ?? ''),
|
||||||
amr,
|
amr,
|
||||||
roles,
|
roles,
|
||||||
|
groups,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* DI token for the lazily-loaded `EntraGroupToRoleResolver`.
|
||||||
|
*
|
||||||
|
* The resolver wraps a `Map<groupGuid, FunctionalRole>` parsed
|
||||||
|
* once at boot from `infra/<env>-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');
|
||||||
@@ -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<FunctionalRole, string> = {
|
||||||
|
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<string, FunctionalRole> = {};
|
||||||
|
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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<Principal> {
|
||||||
|
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<Privilege> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -53,6 +53,7 @@ const USER = {
|
|||||||
displayName: 'Jane',
|
displayName: 'Jane',
|
||||||
amr: ['pwd', 'mfa'],
|
amr: ['pwd', 'mfa'],
|
||||||
roles: ['admin'],
|
roles: ['admin'],
|
||||||
|
groups: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('RequireMfaGuard', () => {
|
describe('RequireMfaGuard', () => {
|
||||||
|
|||||||
@@ -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<ReadonlyArray<Scope>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<ReadonlyArray<Scope>> {
|
||||||
|
return Promise.resolve([{ kind: 'unrestricted' }]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import type { Logger } from 'nestjs-pino';
|
import type { Logger } from 'nestjs-pino';
|
||||||
|
import type { Principal } from 'shared-auth';
|
||||||
import type { AuditWriter } from '../audit/audit.service';
|
import type { AuditWriter } from '../audit/audit.service';
|
||||||
import type { UserSessionIndexService } from '../session/user-session-index.service';
|
import type { UserSessionIndexService } from '../session/user-session-index.service';
|
||||||
import type { UserDirectoryService } from '../users/user-directory.service';
|
import type { UserDirectoryService } from '../users/user-directory.service';
|
||||||
import type { AuthenticatedUser } from './auth.service';
|
import type { AuthenticatedUser } from './auth.service';
|
||||||
|
import type { PrincipalBuilder } from './principal-builder';
|
||||||
import { SessionEstablisher } from './session-establisher.service';
|
import { SessionEstablisher } from './session-establisher.service';
|
||||||
|
|
||||||
const USER: AuthenticatedUser = {
|
const USER: AuthenticatedUser = {
|
||||||
@@ -13,6 +15,7 @@ const USER: AuthenticatedUser = {
|
|||||||
displayName: 'Jane Doe',
|
displayName: 'Jane Doe',
|
||||||
amr: ['pwd', 'mfa'],
|
amr: ['pwd', 'mfa'],
|
||||||
roles: [],
|
roles: [],
|
||||||
|
groups: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
function makeReqStub(opts?: { sessionID?: string; sessionUser?: AuthenticatedUser }): Request {
|
function makeReqStub(opts?: { sessionID?: string; sessionUser?: AuthenticatedUser }): Request {
|
||||||
@@ -53,8 +56,23 @@ interface Fixture {
|
|||||||
audit: { signIn: jest.Mock; signOut: jest.Mock };
|
audit: { signIn: jest.Mock; signOut: jest.Mock };
|
||||||
directory: { recordSignIn: jest.Mock };
|
directory: { recordSignIn: jest.Mock };
|
||||||
logger: ReturnType<typeof makeLoggerStub>;
|
logger: ReturnType<typeof makeLoggerStub>;
|
||||||
|
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 {
|
function makeFixture(): Fixture {
|
||||||
const index = {
|
const index = {
|
||||||
add: jest.fn().mockResolvedValue(undefined),
|
add: jest.fn().mockResolvedValue(undefined),
|
||||||
@@ -69,13 +87,17 @@ function makeFixture(): Fixture {
|
|||||||
recordSignIn: jest.fn().mockResolvedValue(undefined),
|
recordSignIn: jest.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
const logger = makeLoggerStub();
|
const logger = makeLoggerStub();
|
||||||
|
const principalBuilder = {
|
||||||
|
build: jest.fn().mockResolvedValue(STUB_PRINCIPAL),
|
||||||
|
};
|
||||||
const est = new SessionEstablisher(
|
const est = new SessionEstablisher(
|
||||||
logger as unknown as Logger,
|
logger as unknown as Logger,
|
||||||
index as unknown as UserSessionIndexService,
|
index as unknown as UserSessionIndexService,
|
||||||
audit as unknown as AuditWriter,
|
audit as unknown as AuditWriter,
|
||||||
directory as unknown as UserDirectoryService,
|
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', () => {
|
describe('SessionEstablisher.establish', () => {
|
||||||
@@ -97,6 +119,20 @@ describe('SessionEstablisher.establish', () => {
|
|||||||
expect((sess['csrfToken'] as string).length).toBeGreaterThan(20);
|
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<string, unknown> }).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 () => {
|
it('saves the session before returning (no race with the controller redirect)', async () => {
|
||||||
const { est } = makeFixture();
|
const { est } = makeFixture();
|
||||||
const req = makeReqStub();
|
const req = makeReqStub();
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { readSessionTimeouts } from '../session/session-cookie';
|
|||||||
import { UserSessionIndexService } from '../session/user-session-index.service';
|
import { UserSessionIndexService } from '../session/user-session-index.service';
|
||||||
import { UserDirectoryService } from '../users/user-directory.service';
|
import { UserDirectoryService } from '../users/user-directory.service';
|
||||||
import type { AuthenticatedUser } from './auth.service';
|
import type { AuthenticatedUser } from './auth.service';
|
||||||
|
import { PrincipalBuilder } from './principal-builder';
|
||||||
|
|
||||||
export type AuthSurface = 'user' | 'admin';
|
export type AuthSurface = 'user' | 'admin';
|
||||||
|
|
||||||
@@ -47,6 +48,7 @@ export class SessionEstablisher {
|
|||||||
private readonly userSessionIndex: UserSessionIndexService,
|
private readonly userSessionIndex: UserSessionIndexService,
|
||||||
private readonly audit: AuditWriter,
|
private readonly audit: AuditWriter,
|
||||||
private readonly userDirectory: UserDirectoryService,
|
private readonly userDirectory: UserDirectoryService,
|
||||||
|
private readonly principalBuilder: PrincipalBuilder,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async establish(opts: {
|
async establish(opts: {
|
||||||
@@ -79,6 +81,14 @@ export class SessionEstablisher {
|
|||||||
// does not re-validate factors. Refreshed by future step-up
|
// does not re-validate factors. Refreshed by future step-up
|
||||||
// re-auth flows.
|
// re-auth flows.
|
||||||
req.session.mfaVerifiedAt = now;
|
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);
|
await saveSession(req);
|
||||||
|
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, unknown>) => 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/<env>-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<string, string> = {};
|
||||||
|
for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -71,6 +71,7 @@ const USER: AuthenticatedUser = {
|
|||||||
displayName: 'Alice',
|
displayName: 'Alice',
|
||||||
amr: ['pwd', 'mfa'],
|
amr: ['pwd', 'mfa'],
|
||||||
roles: ['admin'],
|
roles: ['admin'],
|
||||||
|
groups: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---- Fake gRPC server (Chat + Rag + Models) -------------------
|
// ---- Fake gRPC server (Chat + Rag + Models) -------------------
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { Principal } from 'shared-auth';
|
||||||
import type { AuthenticatedUser } from '../auth/auth.service';
|
import type { AuthenticatedUser } from '../auth/auth.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -18,6 +19,26 @@ import type { AuthenticatedUser } from '../auth/auth.service';
|
|||||||
declare module 'express-session' {
|
declare module 'express-session' {
|
||||||
interface SessionData {
|
interface SessionData {
|
||||||
user?: AuthenticatedUser;
|
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
|
* Epoch ms at which the BFF created this session. Posed by the
|
||||||
* callback at the same time as `user`. Lets the absolute-timeout
|
* callback at the same time as `user`. Lets the absolute-timeout
|
||||||
|
|||||||
@@ -11,5 +11,10 @@
|
|||||||
"ignoreDeprecations": "5.0"
|
"ignoreDeprecations": "5.0"
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"],
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
**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-<role-slug>`. 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-<role-slug>`. 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.
|
**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.
|
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/<env>-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
|
```json
|
||||||
export const ENTRA_GROUP_TO_ROLE: Record<string, FunctionalRole> = {
|
{
|
||||||
// test tenant — sourced from `infra/test-tenant.entra.json`
|
"11111111-1111-1111-1111-111111111111": "collaborateur",
|
||||||
'11111111-1111-1111-1111-111111111111': 'collaborateur',
|
"22222222-2222-2222-2222-222222222222": "rh"
|
||||||
'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<string>,
|
||||||
|
onUnknownGroup?: (groupId: string) => void,
|
||||||
|
): ReadonlyArray<FunctionalRole>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
### 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.SecurityOfficer` | `6f50ce58-a1e1-496b-a3c0-655559c66a28` |
|
||||||
| `Portal.DPO` | `39b8815f-b3fd-4597-9679-77dcbf788a07` |
|
| `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.
|
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
|
### 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`.
|
- **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`.
|
- **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.
|
- **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
|
## More Information
|
||||||
|
|
||||||
- **Phasing.** This ADR is decision-only. The implementation phasing is:
|
- **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.
|
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.
|
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).
|
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).
|
||||||
|
|||||||
+34
-1
@@ -3,13 +3,14 @@
|
|||||||
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).
|
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 |
|
| 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) |
|
| 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) |
|
| 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 |
|
| CI runners convenience script | [`ci-runners.sh`](ci-runners.sh) | See "Convenience script" below |
|
||||||
| Runtime state of the runners | `data/` (git-ignored after `.gitignore`) | — |
|
| Runtime state of the runners | `data/` (git-ignored after `.gitignore`) | — |
|
||||||
| Env-vars template for the runners | `.env.example` (`.env` is git-ignored) | — |
|
| 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) |
|
| 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:
|
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>` 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 → <apf-role-*> → 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/<env>-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
|
## Future infra concerns — placeholders
|
||||||
|
|
||||||
These are listed here so a contributor knows where to expect related files; they don't exist yet.
|
These are listed here so a contributor knows where to expect related files; they don't exist yet.
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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/<env>-tenant.entra.json` (gitignored —
|
||||||
|
GUIDs are tenant-private).
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
Run `pnpm nx build shared-auth` to build the library.
|
||||||
|
|
||||||
|
## Running unit tests
|
||||||
|
|
||||||
|
Run `pnpm nx test shared-auth` to execute the unit tests via [Vitest](https://vitest.dev/).
|
||||||
@@ -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'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
export {
|
||||||
|
PRIVILEGES,
|
||||||
|
FUNCTIONAL_ROLES,
|
||||||
|
SCOPE_KINDS,
|
||||||
|
isPrivilege,
|
||||||
|
isFunctionalRole,
|
||||||
|
isScopeKind,
|
||||||
|
} from './lib/authorization.types';
|
||||||
|
export type {
|
||||||
|
Privilege,
|
||||||
|
FunctionalRole,
|
||||||
|
ScopeKind,
|
||||||
|
Scope,
|
||||||
|
Principal,
|
||||||
|
} from './lib/authorization.types';
|
||||||
|
export {
|
||||||
|
EntraGroupToRoleResolver,
|
||||||
|
parseEntraGroupMap,
|
||||||
|
EntraGroupMapError,
|
||||||
|
} from './lib/entra-group-to-role';
|
||||||
|
export type { EntraGroupMap, EntraGroupMapEntry } from './lib/entra-group-to-role';
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
FUNCTIONAL_ROLES,
|
||||||
|
PRIVILEGES,
|
||||||
|
SCOPE_KINDS,
|
||||||
|
isFunctionalRole,
|
||||||
|
isPrivilege,
|
||||||
|
isScopeKind,
|
||||||
|
} from './authorization.types';
|
||||||
|
|
||||||
|
describe('authorization catalogues', () => {
|
||||||
|
// ADR-0025 §"Decision Outcome" promises specific counts. Locking
|
||||||
|
// them down here makes a silent slug addition fail loudly — drift
|
||||||
|
// between ADR text and code would defeat the closed-set posture.
|
||||||
|
it('contains exactly 4 privileges, 24 functional roles, 6 scope kinds', () => {
|
||||||
|
expect(PRIVILEGES).toHaveLength(4);
|
||||||
|
expect(FUNCTIONAL_ROLES).toHaveLength(24);
|
||||||
|
expect(SCOPE_KINDS).toHaveLength(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('catalogues are deduplicated', () => {
|
||||||
|
expect(new Set(PRIVILEGES).size).toBe(PRIVILEGES.length);
|
||||||
|
expect(new Set(FUNCTIONAL_ROLES).size).toBe(FUNCTIONAL_ROLES.length);
|
||||||
|
expect(new Set(SCOPE_KINDS).size).toBe(SCOPE_KINDS.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('privilege entries are the 4 Portal.* values provisioned in the test tenant', () => {
|
||||||
|
expect([...PRIVILEGES]).toEqual([
|
||||||
|
'Portal.Admin',
|
||||||
|
'Portal.Auditor',
|
||||||
|
'Portal.SecurityOfficer',
|
||||||
|
'Portal.DPO',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isPrivilege narrows to known values, rejects unknown', () => {
|
||||||
|
expect(isPrivilege('Portal.Admin')).toBe(true);
|
||||||
|
expect(isPrivilege('Portal.RogueRole')).toBe(false);
|
||||||
|
// Case-sensitive — Entra claim values are exact.
|
||||||
|
expect(isPrivilege('portal.admin')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isFunctionalRole narrows to known values, rejects unknown', () => {
|
||||||
|
expect(isFunctionalRole('collaborateur')).toBe(true);
|
||||||
|
expect(isFunctionalRole('directeur-etablissement')).toBe(true);
|
||||||
|
expect(isFunctionalRole('partenaire')).toBe(true);
|
||||||
|
expect(isFunctionalRole('unknown-role')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isScopeKind narrows to known values, rejects unknown', () => {
|
||||||
|
expect(isScopeKind('etablissement')).toBe(true);
|
||||||
|
expect(isScopeKind('unrestricted')).toBe(true);
|
||||||
|
expect(isScopeKind('etablissement:0330800013')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
/**
|
||||||
|
* Authorization catalogues + types per
|
||||||
|
* [ADR-0025](../../../../../docs/decisions/0025-authorization-model-privileges-roles-scopes.md).
|
||||||
|
*
|
||||||
|
* The three axes are independent: a `Principal` carries a list per
|
||||||
|
* axis, and guards consume the structured shape. Privileges gate
|
||||||
|
* portal *surfaces* (admin app, audit viewer); functional roles
|
||||||
|
* encode *what work the person does*; scopes encode *where the role
|
||||||
|
* applies*. Composition happens at guard time, not at sign-in —
|
||||||
|
* sign-in just builds the three lists.
|
||||||
|
*
|
||||||
|
* The catalogues are **closed**: every literal that appears in a
|
||||||
|
* `@RequirePrivilege` / `@RequireRole` / `@RequireScope` call must
|
||||||
|
* be a member of the corresponding constant array. A CI drift gate
|
||||||
|
* (next PR per ADR-0025 §"More Information") enforces that contract
|
||||||
|
* against the source tree.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Portal-level capabilities, materialised as Entra app roles on the
|
||||||
|
* BFF's app registration. Each privilege gates a portal surface —
|
||||||
|
* never a business operation in isolation. The four entries here
|
||||||
|
* are the entire v1 set; adding a fifth is an ADR-0025 amendment.
|
||||||
|
*
|
||||||
|
* The exact strings are the `value` field of the Entra app-role
|
||||||
|
* manifest and are what Entra emits in the ID-token `roles` claim.
|
||||||
|
*/
|
||||||
|
export const PRIVILEGES = [
|
||||||
|
'Portal.Admin',
|
||||||
|
'Portal.Auditor',
|
||||||
|
'Portal.SecurityOfficer',
|
||||||
|
'Portal.DPO',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type Privilege = (typeof PRIVILEGES)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Functional roles, materialised as Entra security groups named
|
||||||
|
* `apf-role-<slug>`. The slug is what the BFF and SPA reason about;
|
||||||
|
* the Entra group GUID is tenant-private and resolved to a slug at
|
||||||
|
* the boundary (see `entra-group-to-role.ts`).
|
||||||
|
*
|
||||||
|
* Grouped here by APF org-chart category (workforce / governance /
|
||||||
|
* volunteer / external) for readability, but the union below is
|
||||||
|
* flat — guards don't care about the category.
|
||||||
|
*/
|
||||||
|
export const FUNCTIONAL_ROLES = [
|
||||||
|
// Workforce (15) — employees on payroll.
|
||||||
|
'collaborateur',
|
||||||
|
'chef-equipe',
|
||||||
|
'chef-service',
|
||||||
|
'directeur-etablissement',
|
||||||
|
'directeur-territorial',
|
||||||
|
'rh',
|
||||||
|
'responsable-paie',
|
||||||
|
'comptable',
|
||||||
|
'juriste',
|
||||||
|
'dpo',
|
||||||
|
'rssi',
|
||||||
|
'it',
|
||||||
|
'formation',
|
||||||
|
'qualite',
|
||||||
|
'communication',
|
||||||
|
// Governance (6) — élus + local delegates.
|
||||||
|
'elu-ca',
|
||||||
|
'elu-cd',
|
||||||
|
'elu-cd-president',
|
||||||
|
'elu-cd-tresorier',
|
||||||
|
'elu-cd-secretaire',
|
||||||
|
'delegue',
|
||||||
|
// Volunteer (2) — bénévoles with portal access.
|
||||||
|
'benevole',
|
||||||
|
'benevole-responsable',
|
||||||
|
// External (1) — placeholder, no consumer surface yet in v1.
|
||||||
|
'partenaire',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type FunctionalRole = (typeof FUNCTIONAL_ROLES)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope kinds — the six discriminants of `Scope`. Three carry a
|
||||||
|
* value (`etablissement:<finess>`, `delegation:<dept>`,
|
||||||
|
* `region:<insee>`); three are valueless (`self`, `siege`,
|
||||||
|
* `unrestricted`).
|
||||||
|
*
|
||||||
|
* Adding a kind is an ADR-0025 amendment. Adding a value (a new
|
||||||
|
* établissement opening, a new délégation) is a data operation and
|
||||||
|
* does not touch this file.
|
||||||
|
*/
|
||||||
|
export const SCOPE_KINDS = [
|
||||||
|
'self',
|
||||||
|
'etablissement',
|
||||||
|
'delegation',
|
||||||
|
'region',
|
||||||
|
'siege',
|
||||||
|
'unrestricted',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type ScopeKind = (typeof SCOPE_KINDS)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A scope on a principal. Three kinds carry a tenant-specific
|
||||||
|
* value, three do not. Discriminated union so consumers can
|
||||||
|
* exhaustively switch on `kind`.
|
||||||
|
*
|
||||||
|
* `value` semantics per kind:
|
||||||
|
* - `etablissement`: APE / FINESS code (`9` characters, stable
|
||||||
|
* across reorgs).
|
||||||
|
* - `delegation`: French department code (`2` characters,
|
||||||
|
* INSEE / postal — `'33'`, `'2A'`, `'971'`).
|
||||||
|
* - `region`: INSEE region code (`2` characters).
|
||||||
|
*/
|
||||||
|
export type Scope =
|
||||||
|
| { readonly kind: 'self' }
|
||||||
|
| { readonly kind: 'etablissement'; readonly value: string }
|
||||||
|
| { readonly kind: 'delegation'; readonly value: string }
|
||||||
|
| { readonly kind: 'region'; readonly value: string }
|
||||||
|
| { readonly kind: 'siege' }
|
||||||
|
| { readonly kind: 'unrestricted' };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The session-resident shape consumed by route guards. Built once
|
||||||
|
* at sign-in by the BFF's OIDC callback, persisted as part of the
|
||||||
|
* session payload, refreshed on every authenticated request from
|
||||||
|
* the session ID. Guards never re-derive it from claims at request
|
||||||
|
* time — that would re-do the Entra-group-to-slug mapping on every
|
||||||
|
* call.
|
||||||
|
*
|
||||||
|
* `amr` and `mfaVerifiedAt` live on the principal (not separately
|
||||||
|
* on the session) so the AI-service projector and any guard that
|
||||||
|
* wants to consult MFA freshness reads a single, coherent object.
|
||||||
|
*
|
||||||
|
* `user.id` and `user.personId` will become real portal UUIDs once
|
||||||
|
* the `User` + `Person` schema (proposed ADR-0026) lands. Until
|
||||||
|
* then the BFF-side builder populates both with the Entra `oid`
|
||||||
|
* as a stable placeholder so consumers can already key on a
|
||||||
|
* "portal identifier" without branching on availability.
|
||||||
|
*/
|
||||||
|
export interface Principal {
|
||||||
|
readonly user: {
|
||||||
|
readonly id: string;
|
||||||
|
readonly personId: string;
|
||||||
|
readonly entraOid: string;
|
||||||
|
readonly tenantId: string;
|
||||||
|
readonly displayName: string;
|
||||||
|
};
|
||||||
|
readonly privileges: ReadonlyArray<Privilege>;
|
||||||
|
readonly roles: ReadonlyArray<FunctionalRole>;
|
||||||
|
readonly scopes: ReadonlyArray<Scope>;
|
||||||
|
readonly amr: ReadonlyArray<string>;
|
||||||
|
readonly mfaVerifiedAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- *
|
||||||
|
* Membership type-guards — runtime helpers callers use to validate
|
||||||
|
* arbitrary strings (claim values, config map values, drift-gate
|
||||||
|
* findings) against the closed catalogues. Returning a type
|
||||||
|
* predicate makes the post-check string narrow to the catalogue
|
||||||
|
* type, which keeps consumers honest about the closed-set rule.
|
||||||
|
* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
|
const PRIVILEGE_SET: ReadonlySet<string> = new Set(PRIVILEGES);
|
||||||
|
const FUNCTIONAL_ROLE_SET: ReadonlySet<string> = new Set(FUNCTIONAL_ROLES);
|
||||||
|
const SCOPE_KIND_SET: ReadonlySet<string> = new Set(SCOPE_KINDS);
|
||||||
|
|
||||||
|
export function isPrivilege(value: string): value is Privilege {
|
||||||
|
return PRIVILEGE_SET.has(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFunctionalRole(value: string): value is FunctionalRole {
|
||||||
|
return FUNCTIONAL_ROLE_SET.has(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isScopeKind(value: string): value is ScopeKind {
|
||||||
|
return SCOPE_KIND_SET.has(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
EntraGroupMapError,
|
||||||
|
EntraGroupToRoleResolver,
|
||||||
|
parseEntraGroupMap,
|
||||||
|
} from './entra-group-to-role';
|
||||||
|
|
||||||
|
const GUID_A = '11111111-1111-1111-1111-111111111111';
|
||||||
|
const GUID_B = '22222222-2222-2222-2222-222222222222';
|
||||||
|
const GUID_C = '33333333-3333-3333-3333-333333333333';
|
||||||
|
|
||||||
|
describe('parseEntraGroupMap', () => {
|
||||||
|
it('builds a frozen lower-cased map from a valid record', () => {
|
||||||
|
const map = parseEntraGroupMap({
|
||||||
|
[GUID_A]: 'collaborateur',
|
||||||
|
[GUID_B]: 'rh',
|
||||||
|
});
|
||||||
|
expect(map.get(GUID_A)).toBe('collaborateur');
|
||||||
|
expect(map.get(GUID_B)).toBe('rh');
|
||||||
|
expect(map.size).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lower-cases GUIDs so claim-side case variance does not miss', () => {
|
||||||
|
const upper = '11111111-1111-1111-1111-1111111111AB';
|
||||||
|
const map = parseEntraGroupMap({ [upper]: 'collaborateur' });
|
||||||
|
expect(map.get(upper.toLowerCase())).toBe('collaborateur');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a value that is not in the closed catalogue', () => {
|
||||||
|
expect(() => parseEntraGroupMap({ [GUID_A]: 'rogue-role' })).toThrow(EntraGroupMapError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a key that does not look like a GUID', () => {
|
||||||
|
expect(() => parseEntraGroupMap({ 'not-a-guid': 'collaborateur' })).toThrow(EntraGroupMapError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a duplicate GUID (case-insensitive)', () => {
|
||||||
|
// Use a GUID containing hex letters so upper/lower casing
|
||||||
|
// produces two distinct strings (object literals dedup
|
||||||
|
// identical keys before iteration).
|
||||||
|
const lower = 'aaaabbbb-cccc-dddd-eeee-ffff00001111';
|
||||||
|
const upper = lower.toUpperCase();
|
||||||
|
expect(() =>
|
||||||
|
parseEntraGroupMap({
|
||||||
|
[lower]: 'collaborateur',
|
||||||
|
[upper]: 'rh',
|
||||||
|
}),
|
||||||
|
).toThrow(EntraGroupMapError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('EntraGroupToRoleResolver', () => {
|
||||||
|
const map = parseEntraGroupMap({
|
||||||
|
[GUID_A]: 'collaborateur',
|
||||||
|
[GUID_B]: 'directeur-etablissement',
|
||||||
|
});
|
||||||
|
const resolver = new EntraGroupToRoleResolver(map);
|
||||||
|
|
||||||
|
it('resolves known group GUIDs to catalogue role slugs', () => {
|
||||||
|
expect(resolver.resolve([GUID_A, GUID_B])).toEqual([
|
||||||
|
'collaborateur',
|
||||||
|
'directeur-etablissement',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('orders the output by FUNCTIONAL_ROLES declaration, not claim order', () => {
|
||||||
|
// Claim order is `directeur-etablissement` first, then
|
||||||
|
// `collaborateur`. Resolver re-orders for deterministic logs
|
||||||
|
// and snapshot tests.
|
||||||
|
expect(resolver.resolve([GUID_B, GUID_A])).toEqual([
|
||||||
|
'collaborateur',
|
||||||
|
'directeur-etablissement',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deduplicates if the same GUID appears twice in the claim', () => {
|
||||||
|
expect(resolver.resolve([GUID_A, GUID_A])).toEqual(['collaborateur']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips unknown GUIDs and reports them via the optional callback', () => {
|
||||||
|
const onUnknown = vi.fn();
|
||||||
|
const out = resolver.resolve([GUID_A, GUID_C], onUnknown);
|
||||||
|
expect(out).toEqual(['collaborateur']);
|
||||||
|
expect(onUnknown).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onUnknown).toHaveBeenCalledWith(GUID_C);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty list when the user has no groups', () => {
|
||||||
|
expect(resolver.resolve([])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('coveredRoles() returns only mapped roles, in catalogue order', () => {
|
||||||
|
expect(resolver.coveredRoles()).toEqual(['collaborateur', 'directeur-etablissement']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { FUNCTIONAL_ROLES, isFunctionalRole, type FunctionalRole } from './authorization.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One entry in the GUID → role-slug map. Surfaced as an explicit
|
||||||
|
* type so callers can build the map programmatically (e.g. tests,
|
||||||
|
* a future Microsoft Graph sync) without resorting to a bare
|
||||||
|
* `Record<string, string>` that loses the closed-set guarantee.
|
||||||
|
*/
|
||||||
|
export interface EntraGroupMapEntry {
|
||||||
|
readonly groupId: string;
|
||||||
|
readonly role: FunctionalRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The resolved map keyed on Entra group GUID. Frozen at parse time
|
||||||
|
* so the resolver cannot mutate it. Lookup is `O(1)`; the values
|
||||||
|
* are guaranteed to belong to the closed `FUNCTIONAL_ROLES`
|
||||||
|
* catalogue by the parser.
|
||||||
|
*/
|
||||||
|
export type EntraGroupMap = ReadonlyMap<string, FunctionalRole>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown by `parseEntraGroupMap` when the operator-supplied
|
||||||
|
* configuration is malformed. The map is loaded once at BFF boot —
|
||||||
|
* a malformed file should crash the process, not silently degrade
|
||||||
|
* authorization. The error message points at the offending key /
|
||||||
|
* value so the runbook can fix the config without spelunking
|
||||||
|
* through stack traces.
|
||||||
|
*/
|
||||||
|
export class EntraGroupMapError extends Error {
|
||||||
|
override readonly name = 'EntraGroupMapError';
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a raw `{ <guid>: <role-slug> }` mapping (as it would
|
||||||
|
* arrive from `JSON.parse` of an `infra/<env>-tenant.entra.json`
|
||||||
|
* file) into a frozen `EntraGroupMap`.
|
||||||
|
*
|
||||||
|
* Validation rules:
|
||||||
|
* - keys must look like a GUID (8-4-4-4-12 hex pattern, case-
|
||||||
|
* insensitive). Entra group GUIDs are stable; a typo here
|
||||||
|
* would silently strip a role from every sign-in.
|
||||||
|
* - values must be in `FUNCTIONAL_ROLES`. Catalogue drift is
|
||||||
|
* caught at boot, not at the first user who happens to be in
|
||||||
|
* the affected group.
|
||||||
|
* - duplicate GUIDs (case-insensitive) are rejected. Two slugs
|
||||||
|
* pointing at the same group would silently shadow one another
|
||||||
|
* depending on the JS engine's object-key iteration order.
|
||||||
|
*
|
||||||
|
* The function deliberately does *not* require every role in the
|
||||||
|
* catalogue to have a GUID — `partenaire` ships empty in v1 by
|
||||||
|
* design (ADR-0025 §"Test-tenant personas"), and dev tenants may
|
||||||
|
* provision a subset for fast iteration. The BFF logs a one-line
|
||||||
|
* boot summary listing which roles are mapped so the operator can
|
||||||
|
* spot accidental omissions.
|
||||||
|
*/
|
||||||
|
export function parseEntraGroupMap(raw: Readonly<Record<string, string>>): EntraGroupMap {
|
||||||
|
const map = new Map<string, FunctionalRole>();
|
||||||
|
const lowercased = new Set<string>();
|
||||||
|
|
||||||
|
for (const [groupId, role] of Object.entries(raw)) {
|
||||||
|
if (!GUID_RE.test(groupId)) {
|
||||||
|
throw new EntraGroupMapError(
|
||||||
|
`Invalid GUID "${groupId}" in Entra group map (expected 8-4-4-4-12 hex).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!isFunctionalRole(role)) {
|
||||||
|
throw new EntraGroupMapError(
|
||||||
|
`Unknown role slug "${role}" for group "${groupId}". ` +
|
||||||
|
`Catalogue is closed; legal slugs are: ${FUNCTIONAL_ROLES.join(', ')}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const lower = groupId.toLowerCase();
|
||||||
|
if (lowercased.has(lower)) {
|
||||||
|
throw new EntraGroupMapError(`Duplicate group GUID "${groupId}" in Entra group map.`);
|
||||||
|
}
|
||||||
|
lowercased.add(lower);
|
||||||
|
map.set(lower, role);
|
||||||
|
}
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the `groups` claim from an Entra ID token (a list of
|
||||||
|
* group GUIDs the user belongs to) into a list of catalogue-valid
|
||||||
|
* `FunctionalRole` slugs. The OIDC callback (`session-establisher`)
|
||||||
|
* calls `resolve()` once per sign-in.
|
||||||
|
*
|
||||||
|
* Unknown GUIDs are ignored at the role level — `resolve()` simply
|
||||||
|
* skips them — but reported via the `onUnknownGroup` callback so
|
||||||
|
* the host application can log a WARN (per ADR-0025
|
||||||
|
* §"Sources of truth — Entra-side configuration": "Unknown group
|
||||||
|
* GUIDs are logged at WARN and ignored — they do not break sign-in,
|
||||||
|
* but they signal a tenant misconfiguration that should be cleaned
|
||||||
|
* up").
|
||||||
|
*
|
||||||
|
* Output is deduplicated and ordered to match the catalogue's
|
||||||
|
* declaration order (`FUNCTIONAL_ROLES`). Deterministic output
|
||||||
|
* keeps snapshot tests stable and makes log inspection easier.
|
||||||
|
*/
|
||||||
|
export class EntraGroupToRoleResolver {
|
||||||
|
constructor(private readonly map: EntraGroupMap) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of entries in the map. Surfaced for the boot-time log
|
||||||
|
* line the BFF emits ("resolver loaded with N mappings").
|
||||||
|
*/
|
||||||
|
get size(): number {
|
||||||
|
return this.map.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Roles currently covered by the map. The BFF can log this set
|
||||||
|
* at boot to spot accidental omissions (e.g. a dev tenant that
|
||||||
|
* forgot to provision `dpo`). Returned sorted by catalogue order
|
||||||
|
* for stable logs.
|
||||||
|
*/
|
||||||
|
coveredRoles(): ReadonlyArray<FunctionalRole> {
|
||||||
|
const covered = new Set(this.map.values());
|
||||||
|
return FUNCTIONAL_ROLES.filter((role) => covered.has(role));
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(
|
||||||
|
groupIds: ReadonlyArray<string>,
|
||||||
|
onUnknownGroup?: (groupId: string) => void,
|
||||||
|
): ReadonlyArray<FunctionalRole> {
|
||||||
|
const matched = new Set<FunctionalRole>();
|
||||||
|
for (const raw of groupIds) {
|
||||||
|
const role = this.map.get(raw.toLowerCase());
|
||||||
|
if (role === undefined) {
|
||||||
|
if (onUnknownGroup) {
|
||||||
|
onUnknownGroup(raw);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
matched.add(role);
|
||||||
|
}
|
||||||
|
return FUNCTIONAL_ROLES.filter((role) => matched.has(role));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const GUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"rootDir": ".",
|
"rootDir": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
|
"shared-auth": ["./libs/shared/auth/src/index.ts"],
|
||||||
"shared-tokens": ["./libs/shared/tokens/src/index.ts"],
|
"shared-tokens": ["./libs/shared/tokens/src/index.ts"],
|
||||||
"shared-util": ["./libs/shared/util/src/index.ts"],
|
"shared-util": ["./libs/shared/util/src/index.ts"],
|
||||||
"shared-ui": ["./libs/shared/ui/src/index.ts"],
|
"shared-ui": ["./libs/shared/ui/src/index.ts"],
|
||||||
|
|||||||
Reference in New Issue
Block a user