feat(auth): authorization catalogues + Principal builder skeleton (ADR-0025) #206

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