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