feat(auth): authorization catalogues + Principal builder skeleton (ADR-0025)
CI / check (pull_request) Failing after 28s
CI / commits (pull_request) Failing after 25s
CI / scan (pull_request) Failing after 56s
CI / a11y (pull_request) Failing after 33s
CI / perf (pull_request) Failing after 47s
Docs site / build (pull_request) Failing after 26s
CI / check (pull_request) Failing after 28s
CI / commits (pull_request) Failing after 25s
CI / scan (pull_request) Failing after 56s
CI / a11y (pull_request) Failing after 33s
CI / perf (pull_request) Failing after 47s
Docs site / build (pull_request) Failing after 26s
Per ADR-0025's implementation phasing, lands the closed-set catalogues + the OIDC-callback hook that composes the session-resident Principal. No new guards yet. - libs/shared/auth: framework-agnostic lib hosting authorization.types.ts (4 privileges + 24 functional roles + 6 scope kinds + Principal type) and entra-group-to-role.ts (validated GUID -> slug resolver). 17 unit tests against the catalogues + resolver contracts. - BFF: PrincipalBuilder composes the three axes once at sign-in; ScopeResolver seam (StubScopeResolver returns unrestricted in v1, replaced by a Prisma-backed implementation when ADR-0026's user_scopes table lands). 19 persona-driven tests covering the test tenant's full provisioning + edge cases (unknown privilege drift, unknown group GUID, empty permissions). - AuthService extracts the Entra "groups" claim; AuthenticatedUser grows the groups: readonly string[] field. - SessionEstablisher builds + persists the principal alongside the legacy user shape (additive — guards consuming user.oid unchanged). - ENTRA_GROUP_MAP_PATH env var + load-entra-group-map.ts read the tenant-private map from infra/<env>-tenant.entra.json (gitignored; .example.json committed with the schema). - ADR-0025 path references updated from libs/feature/auth (Angular-scoped, scope:portal-shell) to libs/shared/auth (consumable by both BFF and SPA). - notes/entra-groups-claim-activation.md: operator runbook for the "Token configuration -> Add groups claim" step in the Entra admin centre. Test plan: - pnpm nx affected -t lint test build : 13 projects green (497 BFF tests + 17 shared-auth tests + 1 shared-util test + full lint + full build).
This commit is contained in:
@@ -72,6 +72,18 @@ ENTRA_POST_LOGOUT_REDIRECT_URI=http://localhost:4200/
|
||||
ENTRA_ADMIN_REDIRECT_URI=http://localhost:3000/api/admin/auth/callback
|
||||
ENTRA_ADMIN_POST_LOGOUT_REDIRECT_URI=http://localhost:4300/
|
||||
|
||||
# Authorization model (per ADR-0025). Points at the JSON file that
|
||||
# maps tenant-private Entra security-group GUIDs to the closed
|
||||
# catalogue of `apf-role-*` slugs. The BFF loads it at boot through
|
||||
# `EntraGroupToRoleResolver` (libs/shared/auth). Unset means the
|
||||
# resolver runs empty — sign-in still succeeds but every user gets
|
||||
# zero functional roles (no `apf-role-*` UI). A WARN is logged at
|
||||
# boot so an operator can spot the missing config. See
|
||||
# `infra/test-tenant.entra.example.json` for the schema; copy to
|
||||
# `infra/test-tenant.entra.json` (gitignored) and fill in the real
|
||||
# GUIDs from the Entra admin centre.
|
||||
ENTRA_GROUP_MAP_PATH=infra/test-tenant.entra.json
|
||||
|
||||
# Cookie signing secret (per ADR-0009 §"Cookies"). Used to sign the
|
||||
# transient pre-auth cookie that carries the OIDC `state` + PKCE
|
||||
# verifier between the /auth/login redirect and the /auth/callback
|
||||
|
||||
@@ -150,11 +150,30 @@ function makeController(opts?: { completeAuthCodeFlow?: jest.Mock }): Controller
|
||||
// Real SessionEstablisher with the same mocks the legacy tests
|
||||
// already wire — keeps the behavioural assertions on session
|
||||
// fields / audit calls untouched after the controller refactor.
|
||||
const principalBuilder = {
|
||||
// The auth.controller specs do not assert on the principal
|
||||
// shape; the resolved value just has to be a valid `Principal`
|
||||
// so SessionEstablisher persists it without throwing.
|
||||
build: jest.fn().mockResolvedValue({
|
||||
user: {
|
||||
id: 'oid',
|
||||
personId: 'oid',
|
||||
entraOid: 'oid',
|
||||
tenantId: 'tid',
|
||||
displayName: 'Jane',
|
||||
},
|
||||
privileges: [],
|
||||
roles: [],
|
||||
scopes: [{ kind: 'unrestricted' }],
|
||||
amr: [],
|
||||
}),
|
||||
};
|
||||
const sessionEstablisher = new SessionEstablisher(
|
||||
logger as unknown as Logger,
|
||||
userSessionIndex as unknown as UserSessionIndexService,
|
||||
audit as unknown as AuditWriter,
|
||||
userDirectory as unknown as UserDirectoryService,
|
||||
principalBuilder as unknown as import('./principal-builder').PrincipalBuilder,
|
||||
);
|
||||
return {
|
||||
controller: new AuthController(
|
||||
|
||||
@@ -2,12 +2,16 @@ import { ConfidentialClientApplication, LogLevel } from '@azure/msal-node';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Logger } from 'nestjs-pino';
|
||||
import { assertEntraConfig } from '../config/check-entra-config';
|
||||
import { loadEntraGroupToRoleResolver } from '../config/load-entra-group-map';
|
||||
import { SessionModule } from '../session/session.module';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { ENTRA_CONFIG, type EntraConfig } from './entra-config.token';
|
||||
import { ENTRA_GROUP_TO_ROLE_RESOLVER } from './entra-group-to-role.token';
|
||||
import { MSAL_CLIENT } from './msal-client.token';
|
||||
import { PrincipalBuilder } from './principal-builder';
|
||||
import { RequireMfaGuard } from './require-mfa.guard';
|
||||
import { ScopeResolver, StubScopeResolver } from './scope-resolver';
|
||||
import { SessionEstablisher } from './session-establisher.service';
|
||||
|
||||
/**
|
||||
@@ -47,10 +51,35 @@ import { SessionEstablisher } from './session-establisher.service';
|
||||
AuthService,
|
||||
RequireMfaGuard,
|
||||
SessionEstablisher,
|
||||
PrincipalBuilder,
|
||||
{ provide: ScopeResolver, useClass: StubScopeResolver },
|
||||
{
|
||||
provide: ENTRA_CONFIG,
|
||||
useFactory: () => assertEntraConfig(),
|
||||
},
|
||||
{
|
||||
provide: ENTRA_GROUP_TO_ROLE_RESOLVER,
|
||||
inject: [Logger],
|
||||
useFactory: (logger: Logger) => {
|
||||
const { resolver, sourcePath } = loadEntraGroupToRoleResolver({
|
||||
onWarn: (event, payload) => logger.warn({ event, ...payload }, 'AuthModule'),
|
||||
});
|
||||
// Log the post-load summary unconditionally so an operator
|
||||
// grepping the boot log can confirm how many functional
|
||||
// roles are wired without inspecting the JSON file. The
|
||||
// file path (if any) helps diagnose env-var / cwd mismatches.
|
||||
logger.log(
|
||||
{
|
||||
event: 'auth.entra_group_map_loaded',
|
||||
sourcePath,
|
||||
mappingCount: resolver.size,
|
||||
coveredRoles: resolver.coveredRoles(),
|
||||
},
|
||||
'AuthModule',
|
||||
);
|
||||
return resolver;
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: MSAL_CLIENT,
|
||||
inject: [ENTRA_CONFIG, Logger],
|
||||
@@ -92,6 +121,15 @@ import { SessionEstablisher } from './session-establisher.service';
|
||||
}),
|
||||
},
|
||||
],
|
||||
exports: [ENTRA_CONFIG, MSAL_CLIENT, RequireMfaGuard, AuthService, SessionEstablisher],
|
||||
exports: [
|
||||
ENTRA_CONFIG,
|
||||
ENTRA_GROUP_TO_ROLE_RESOLVER,
|
||||
MSAL_CLIENT,
|
||||
RequireMfaGuard,
|
||||
AuthService,
|
||||
PrincipalBuilder,
|
||||
ScopeResolver,
|
||||
SessionEstablisher,
|
||||
],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -38,6 +38,7 @@ function makeAuthResult(
|
||||
claims: Partial<{
|
||||
amr: string[];
|
||||
roles: unknown;
|
||||
groups: unknown;
|
||||
oid: string;
|
||||
tid: string;
|
||||
name: string;
|
||||
@@ -58,6 +59,12 @@ function makeAuthResult(
|
||||
if (Object.prototype.hasOwnProperty.call(claims, 'roles')) {
|
||||
idTokenClaims['roles'] = claims.roles;
|
||||
}
|
||||
// Same opt-in pattern for `groups` — Entra emits it only when
|
||||
// the app registration sets `groupMembershipClaims: 'SecurityGroup'`
|
||||
// and the user is in at least one group.
|
||||
if (Object.prototype.hasOwnProperty.call(claims, 'groups')) {
|
||||
idTokenClaims['groups'] = claims.groups;
|
||||
}
|
||||
return {
|
||||
idTokenClaims,
|
||||
account: { username: 'jane.doe@apf.example', name: 'Jane Doe' },
|
||||
@@ -124,6 +131,7 @@ describe('AuthService.completeAuthCodeFlow', () => {
|
||||
displayName: 'Jane Doe',
|
||||
amr: ['pwd', 'mfa'],
|
||||
roles: [],
|
||||
groups: [],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -220,6 +228,80 @@ describe('AuthService.completeAuthCodeFlow', () => {
|
||||
expect(user.roles).toEqual(['admin', 'editor']);
|
||||
});
|
||||
|
||||
// `groups` claim — extracted identically to `roles`. The raw
|
||||
// GUIDs are surfaced here; resolution to `apf-role-*` slugs
|
||||
// happens downstream in `PrincipalBuilder` per ADR-0025.
|
||||
it('surfaces the `groups` claim when Entra includes it (security-group member)', async () => {
|
||||
const acquireTokenByCode = jest.fn().mockResolvedValue(
|
||||
makeAuthResult({
|
||||
groups: ['11111111-1111-1111-1111-111111111111', '22222222-2222-2222-2222-222222222222'],
|
||||
}),
|
||||
);
|
||||
const { service } = makeService({ acquireTokenByCode });
|
||||
const user = await service.completeAuthCodeFlow(
|
||||
'code',
|
||||
PRE_AUTH_OK.state,
|
||||
PRE_AUTH_OK,
|
||||
ENTRA.redirectUri,
|
||||
PRE_AUTH_OK.createdAt,
|
||||
);
|
||||
expect(user.groups).toEqual([
|
||||
'11111111-1111-1111-1111-111111111111',
|
||||
'22222222-2222-2222-2222-222222222222',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns an empty `groups` array when the claim is absent (no group membership)', async () => {
|
||||
const acquireTokenByCode = jest.fn().mockResolvedValue(makeAuthResult({}));
|
||||
const { service } = makeService({ acquireTokenByCode });
|
||||
const user = await service.completeAuthCodeFlow(
|
||||
'code',
|
||||
PRE_AUTH_OK.state,
|
||||
PRE_AUTH_OK,
|
||||
ENTRA.redirectUri,
|
||||
PRE_AUTH_OK.createdAt,
|
||||
);
|
||||
expect(user.groups).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns an empty `groups` array when the claim is non-array (defensive)', async () => {
|
||||
const acquireTokenByCode = jest.fn().mockResolvedValue(makeAuthResult({ groups: 'oops' }));
|
||||
const { service } = makeService({ acquireTokenByCode });
|
||||
const user = await service.completeAuthCodeFlow(
|
||||
'code',
|
||||
PRE_AUTH_OK.state,
|
||||
PRE_AUTH_OK,
|
||||
ENTRA.redirectUri,
|
||||
PRE_AUTH_OK.createdAt,
|
||||
);
|
||||
expect(user.groups).toEqual([]);
|
||||
});
|
||||
|
||||
it('drops non-string entries from `groups` (defensive)', async () => {
|
||||
const acquireTokenByCode = jest.fn().mockResolvedValue(
|
||||
makeAuthResult({
|
||||
groups: [
|
||||
'11111111-1111-1111-1111-111111111111',
|
||||
42,
|
||||
null,
|
||||
'22222222-2222-2222-2222-222222222222',
|
||||
],
|
||||
}),
|
||||
);
|
||||
const { service } = makeService({ acquireTokenByCode });
|
||||
const user = await service.completeAuthCodeFlow(
|
||||
'code',
|
||||
PRE_AUTH_OK.state,
|
||||
PRE_AUTH_OK,
|
||||
ENTRA.redirectUri,
|
||||
PRE_AUTH_OK.createdAt,
|
||||
);
|
||||
expect(user.groups).toEqual([
|
||||
'11111111-1111-1111-1111-111111111111',
|
||||
'22222222-2222-2222-2222-222222222222',
|
||||
]);
|
||||
});
|
||||
|
||||
it('throws token-exchange-failed when MSAL throws', async () => {
|
||||
const acquireTokenByCode = jest.fn().mockRejectedValue(new Error('AADSTS70008'));
|
||||
const { service } = makeService({ acquireTokenByCode });
|
||||
|
||||
@@ -38,11 +38,23 @@ export interface AuthCodeFlowStart {
|
||||
* the BFF's MFA sanity-check (ADR-0011) and by `@RequireMfa`
|
||||
* freshness checks once the session lands.
|
||||
* - `roles`: Entra app roles assigned to this user on the BFF's
|
||||
* app registration. Surfaced for the future `@RequireAdmin` /
|
||||
* `@RequireRole(...)` guards (ADR-0020 admin module). Always
|
||||
* present in the shape — an empty array means the user has no
|
||||
* app role on this app registration, not that the claim was
|
||||
* unparseable.
|
||||
* app registration. These are the four `Portal.*` *privileges*
|
||||
* per ADR-0025 (`Portal.Admin`, `Portal.Auditor`,
|
||||
* `Portal.SecurityOfficer`, `Portal.DPO`). Surfaced for the
|
||||
* existing `@RequireAdmin()` guard and the upcoming
|
||||
* `@RequirePrivilege()` decorator. Always present in the shape
|
||||
* — an empty array means the user has no app role on this app
|
||||
* registration, not that the claim was unparseable.
|
||||
* - `groups`: Entra security-group GUIDs the user belongs to.
|
||||
* Emitted by the `groups` claim once the app registration sets
|
||||
* `groupMembershipClaims: 'SecurityGroup'` (per ADR-0025
|
||||
* §"Sources of truth — Entra-side configuration"). The
|
||||
* `PrincipalBuilder` resolves these GUIDs to `apf-role-*` slugs
|
||||
* via the `EntraGroupToRoleResolver`. Always present — empty
|
||||
* means either the user has no group membership or the claim
|
||||
* was not configured on the app registration. Unknown GUIDs
|
||||
* are dropped at resolve time, not at extraction time, so an
|
||||
* audit reader still sees the raw claim.
|
||||
*/
|
||||
export interface AuthenticatedUser {
|
||||
readonly oid: string;
|
||||
@@ -51,6 +63,7 @@ export interface AuthenticatedUser {
|
||||
readonly displayName: string;
|
||||
readonly amr: readonly string[];
|
||||
readonly roles: readonly string[];
|
||||
readonly groups: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -212,6 +225,18 @@ export class AuthService {
|
||||
? (claims['roles'] as unknown[]).filter((v): v is string => typeof v === 'string')
|
||||
: [];
|
||||
|
||||
// `groups` is an optional claim (per ADR-0025 §"Sources of truth
|
||||
// — Entra-side configuration"). Present only when the app
|
||||
// registration sets `groupMembershipClaims: 'SecurityGroup'`.
|
||||
// Empty array if the user is in zero security groups or the
|
||||
// claim is not configured — both are valid "no functional
|
||||
// roles" states the rest of the BFF must tolerate. Same
|
||||
// string-filter as `roles` to defend against a non-string value
|
||||
// smuggled in via a malformed token.
|
||||
const groups = Array.isArray(claims['groups'])
|
||||
? (claims['groups'] as unknown[]).filter((v): v is string => typeof v === 'string')
|
||||
: [];
|
||||
|
||||
return {
|
||||
oid: requireString(claims['oid'], 'oid'),
|
||||
tid: requireString(claims['tid'], 'tid'),
|
||||
@@ -225,6 +250,7 @@ export class AuthService {
|
||||
: (result.account?.name ?? ''),
|
||||
amr,
|
||||
roles,
|
||||
groups,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
amr: ['pwd', 'mfa'],
|
||||
roles: ['admin'],
|
||||
groups: [],
|
||||
};
|
||||
|
||||
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 { Logger } from 'nestjs-pino';
|
||||
import type { Principal } from 'shared-auth';
|
||||
import type { AuditWriter } from '../audit/audit.service';
|
||||
import type { UserSessionIndexService } from '../session/user-session-index.service';
|
||||
import type { UserDirectoryService } from '../users/user-directory.service';
|
||||
import type { AuthenticatedUser } from './auth.service';
|
||||
import type { PrincipalBuilder } from './principal-builder';
|
||||
import { SessionEstablisher } from './session-establisher.service';
|
||||
|
||||
const USER: AuthenticatedUser = {
|
||||
@@ -13,6 +15,7 @@ const USER: AuthenticatedUser = {
|
||||
displayName: 'Jane Doe',
|
||||
amr: ['pwd', 'mfa'],
|
||||
roles: [],
|
||||
groups: [],
|
||||
};
|
||||
|
||||
function makeReqStub(opts?: { sessionID?: string; sessionUser?: AuthenticatedUser }): Request {
|
||||
@@ -53,8 +56,23 @@ interface Fixture {
|
||||
audit: { signIn: jest.Mock; signOut: jest.Mock };
|
||||
directory: { recordSignIn: jest.Mock };
|
||||
logger: ReturnType<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 {
|
||||
const index = {
|
||||
add: jest.fn().mockResolvedValue(undefined),
|
||||
@@ -69,13 +87,17 @@ function makeFixture(): Fixture {
|
||||
recordSignIn: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const logger = makeLoggerStub();
|
||||
const principalBuilder = {
|
||||
build: jest.fn().mockResolvedValue(STUB_PRINCIPAL),
|
||||
};
|
||||
const est = new SessionEstablisher(
|
||||
logger as unknown as Logger,
|
||||
index as unknown as UserSessionIndexService,
|
||||
audit as unknown as AuditWriter,
|
||||
directory as unknown as UserDirectoryService,
|
||||
principalBuilder as unknown as PrincipalBuilder,
|
||||
);
|
||||
return { est, index, audit, directory, logger };
|
||||
return { est, index, audit, directory, logger, principalBuilder };
|
||||
}
|
||||
|
||||
describe('SessionEstablisher.establish', () => {
|
||||
@@ -97,6 +119,20 @@ describe('SessionEstablisher.establish', () => {
|
||||
expect((sess['csrfToken'] as string).length).toBeGreaterThan(20);
|
||||
});
|
||||
|
||||
it('builds the authorization principal and stamps mfaVerifiedAt on it (ADR-0025)', async () => {
|
||||
const { est, principalBuilder } = makeFixture();
|
||||
const req = makeReqStub();
|
||||
const res = makeResStub();
|
||||
await est.establish({ user: USER, req, res, surface: 'user' });
|
||||
expect(principalBuilder.build).toHaveBeenCalledWith(USER);
|
||||
const sess = (req as unknown as { session: Record<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 () => {
|
||||
const { est } = makeFixture();
|
||||
const req = makeReqStub();
|
||||
|
||||
@@ -8,6 +8,7 @@ import { readSessionTimeouts } from '../session/session-cookie';
|
||||
import { UserSessionIndexService } from '../session/user-session-index.service';
|
||||
import { UserDirectoryService } from '../users/user-directory.service';
|
||||
import type { AuthenticatedUser } from './auth.service';
|
||||
import { PrincipalBuilder } from './principal-builder';
|
||||
|
||||
export type AuthSurface = 'user' | 'admin';
|
||||
|
||||
@@ -47,6 +48,7 @@ export class SessionEstablisher {
|
||||
private readonly userSessionIndex: UserSessionIndexService,
|
||||
private readonly audit: AuditWriter,
|
||||
private readonly userDirectory: UserDirectoryService,
|
||||
private readonly principalBuilder: PrincipalBuilder,
|
||||
) {}
|
||||
|
||||
async establish(opts: {
|
||||
@@ -79,6 +81,14 @@ export class SessionEstablisher {
|
||||
// does not re-validate factors. Refreshed by future step-up
|
||||
// re-auth flows.
|
||||
req.session.mfaVerifiedAt = now;
|
||||
// Authorization principal per ADR-0025: composes the three
|
||||
// axes (privileges / functional roles / scopes) once at
|
||||
// sign-in so guards read a single coherent shape instead of
|
||||
// re-parsing claims on every request. Built before the
|
||||
// session is persisted so a Redis hiccup either persists
|
||||
// everything or nothing.
|
||||
const principal = await this.principalBuilder.build(user);
|
||||
req.session.principal = { ...principal, mfaVerifiedAt: now };
|
||||
|
||||
await saveSession(req);
|
||||
|
||||
|
||||
@@ -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',
|
||||
amr: ['pwd', 'mfa'],
|
||||
roles: ['admin'],
|
||||
groups: [],
|
||||
};
|
||||
|
||||
// ---- Fake gRPC server (Chat + Rag + Models) -------------------
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Principal } from 'shared-auth';
|
||||
import type { AuthenticatedUser } from '../auth/auth.service';
|
||||
|
||||
/**
|
||||
@@ -18,6 +19,26 @@ import type { AuthenticatedUser } from '../auth/auth.service';
|
||||
declare module 'express-session' {
|
||||
interface SessionData {
|
||||
user?: AuthenticatedUser;
|
||||
/**
|
||||
* Authorization principal per
|
||||
* [ADR-0025 §"Principal shape"](../../../../docs/decisions/0025-authorization-model-privileges-roles-scopes.md).
|
||||
* Built once at sign-in by `PrincipalBuilder` from the three
|
||||
* source claims (Entra `roles` for privileges, Entra `groups`
|
||||
* for functional roles, portal `user_scopes` for scopes) and
|
||||
* read by the upcoming `@RequirePrivilege` / `@RequireRole` /
|
||||
* `@RequireScope` guards on every request.
|
||||
*
|
||||
* Kept alongside `user` rather than replacing it: the audit
|
||||
* module and the AI-bridge controller still key on
|
||||
* `user.oid` / `user.tid` directly and would be churn-only
|
||||
* to migrate. New consumers should reach for `principal`.
|
||||
*
|
||||
* Optional in the type — sessions persisted before this field
|
||||
* existed will have it undefined. Guards must treat the
|
||||
* missing-principal case the same as "no privileges, no roles,
|
||||
* no scopes" rather than crashing.
|
||||
*/
|
||||
principal?: Principal;
|
||||
/**
|
||||
* Epoch ms at which the BFF created this session. Posed by the
|
||||
* callback at the same time as `user`. Lets the absolute-timeout
|
||||
|
||||
@@ -11,5 +11,10 @@
|
||||
"ignoreDeprecations": "5.0"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["jest.config.ts", "jest.config.cts", "src/**/*.spec.ts", "src/**/*.test.ts"]
|
||||
"exclude": ["jest.config.ts", "jest.config.cts", "src/**/*.spec.ts", "src/**/*.test.ts"],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../libs/shared/auth"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user