cca3b767713934a9d8f92ff74436d216c81555d6
2 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
24071a63ec |
feat(users): add Person + User + UserScope + lazy provisioner (ADR-0026 PR 1) (#232)
## Summary
First implementation PR for [ADR-0026](docs/decisions/0026-person-user-portal-data-model.md) (portal-side identity model). Ships:
- `Person` golden record + `User` portal-account overlay + `UserScope` Prisma models;
- `PersonAndUserProvisioner` service called from `SessionEstablisher.establish` (blocking — lazy-creates `Person` + `User` at first OIDC callback);
- `PERSON_SOURCES` closed-set catalogue + drift-gate extension;
- `PrincipalBuilder.build(user, identity)` signature update + `ScopeResolver.resolve({ userId })` seam change — `Principal.user.{id, personId}` now carry real portal UUIDs, no longer the `entraOid` placeholder.
No consumer for `UserScope` yet — `PrismaScopeResolver` lands in ADR-0026 PR 2. The `StubScopeResolver` continues to return `[{ kind: 'unrestricted' }]` for everyone.
## What lands
| File | Change |
| --- | --- |
| `apps/portal-bff/prisma/schema.prisma` | **+3 models** in the `public` schema: `Person` (UUID PK + PII + nullable email indexed-not-unique + source catalogue + nullable externalId + back-ref to User), `User` (UUID PK + 1-to-1 personId FK + unique entraOid + tenantId + lastSignInAt + scopes[]), `UserScope` (UUID PK + userId FK with `onDelete: Cascade` + kind + opaque value with `@default("")` + source + nullable expiresAt + `@@unique([userId, kind, value])`). All comments explain the cross-references to ADR-0025 / ADR-0027 / ADR-0029. |
| `apps/portal-bff/prisma/migrations/20260526210000_add_person_user_userscope/migration.sql` | **New** hand-written migration. DDL for the 3 tables, indexes (Person: source/externalId/email; User: unique personId + unique entraOid; UserScope: unique composite + plain userId), FKs with the correct `ON DELETE` actions (User.personId → RESTRICT; UserScope.userId → CASCADE — both Prisma defaults given the schema's `Delegation?` / explicit `onDelete: Cascade`). |
| `apps/portal-bff/src/users/person-source.ts` + `.spec.ts` | **New** catalogue. `PERSON_SOURCES = ['self-signin', 'admin-ui', 'seed'] as const`, `PersonSource` type union, `isPersonSource` type guard. Spec follows the structure-kind shape (catalogue content, no duplicates, type guard true/false, narrowing test via `String()` to widen). |
| `apps/portal-bff/src/users/person-and-user-provisioner.service.ts` + `.spec.ts` | **New** blocking provisioner. Fast path: `findUnique by entraOid` + `update lastSignInAt`. Cold path: nested `create` of Person + linked User in a single transaction. Race-condition handling: catches P2002 on `User.entraOid` and re-runs `ensureUser` (loser of a concurrent first-sign-in falls through to the now-warm fast path). Defense-in-depth `isPersonSource` check on the constant. Spec covers cold/warm/race/non-P2002-propagation paths + `splitDisplayName` cases (single token / trailing whitespace / empty / multi-word). |
| `scripts/check-catalogue-drift.mjs` | Property-literal scanner generalised. Adds `extractPersonSources` + `findPersonSourceViolationsInFile` (property `source` instead of `kind`, otherwise identical to the structure-kind scanner). The two extractors share `extractAsConstArray(path, constName)` and the two property scanners share `findPropertyLiteralViolations(path, validValues, sourceText, opts)`. Error-message formatter unchanged. Closing hint extended to reference all three catalogue locations. |
| `scripts/check-catalogue-drift.spec.mjs` | Fixture now writes a `person-source.ts` alongside `structure-kind.ts`. 7 new tests: extractPersonSources (2), findPersonSourceViolationsInFile (5: skip-no-import, no-violation, flag, non-literal, line/col). Aggregation test updated to assert decorator + Structure.kind + Person.source violations all surface together. **29 tests total, all passing.** |
| `apps/portal-bff/src/auth/scope-resolver.ts` | `resolve(input: { entraOid })` → `resolve(input: { userId })`. Stub still ignores its argument; PR 2's `PrismaScopeResolver` will key queries on `User.id`. Comment block updated to reflect that ADR-0026 PR 1 is now landed (no longer "proposed"). |
| `apps/portal-bff/src/auth/principal-builder.ts` | Signature: `build(user)` → `build(user, identity: { userId, personId })`. `Principal.user.id` / `Principal.user.personId` populated from `identity` instead of `user.oid`. Scope resolver called with `{ userId: identity.userId }`. Doc comment block updated to remove the "placeholder" caveat and document the new wiring. |
| `apps/portal-bff/src/auth/principal-builder.spec.ts` | New `TEST_IDENTITY` constant. Every `builder.build(...)` call gets the second arg. New assertions on `principal.user.id` / `principal.user.personId` carrying the test identity. Edge-case test "asks the scope resolver to resolve by entraOid" renamed + retargeted to `{ userId }`. **All 19 persona tests + 5 edge cases pass.** |
| `apps/portal-bff/src/auth/session-establisher.service.ts` | New constructor arg `personUserProvisioner: PersonAndUserProvisioner`. `establish()` calls `ensureUser({ oid, tenantId, displayName, email: user.username })` **before** `principalBuilder.build` so the identity is available. Comment block explains the Entra `preferred_username` → `Person.email` mapping and the blocking-vs-best-effort distinction with `UserDirectoryService.recordSignIn`. |
| `apps/portal-bff/src/auth/session-establisher.service.spec.ts` | Fixture extended with `provisioner` mock + `PROVISIONED_IDENTITY` constant. `STUB_PRINCIPAL` now carries those UUIDs instead of `user.oid`. New tests: (1) provisioner is called with the right input shape; (2) provisioner runs **before** `principalBuilder.build` (`mock.invocationCallOrder` assertion); (3) provisioner failure propagates and nothing downstream runs (build / audit / directory). |
| `apps/portal-bff/src/auth/auth.controller.spec.ts` | Provisioner mock added to the controller fixture (the controller's specs don't exercise provisioning themselves but `SessionEstablisher`'s constructor needs the arg). Principal stub's UUIDs aligned with the new provisioned shape. |
| `apps/portal-bff/src/users/users.module.ts` | `PersonAndUserProvisioner` registered + exported alongside `UserDirectoryService`. `@Global` so the auth module's `SessionEstablisher` can inject both without re-routing the module graph. Comment block updated to document the blocking/best-effort distinction between the two. |
## Key choices
- **Provisioner is blocking**, not best-effort. Distinct from `UserDirectoryService.recordSignIn` which is still best-effort (ADR-0020 admin-list cache, swallows its own errors). Both run from `SessionEstablisher.establish` per the new ordering: (1) provisioner — blocking, (2) build principal — uses identity, (3) save session, (4) cookie, (5) user-session index (best-effort), (6) audit (blocking ADR-0013), (7) UserDirectoryService.recordSignIn (best-effort), (8) log. A provisioner failure short-circuits the whole flow before any of (3)..(8) — the spec asserts this explicitly.
- **No email-based dedup in v1.** `Person.email` is indexed (not unique). The provisioner keys ONLY on `entraOid`. ADR-0026 §"Why no email-based merging in v1" — two distinct humans genuinely share emails; ADR-0029's sync flow handles operator-confirmed reconciliation.
- **Race-condition handling on first sign-in.** Two concurrent first-sign-ins for the same `entraOid` both fall through to `create`. The unique constraint on `User.entraOid` rejects the loser with P2002; the provisioner catches that specific code and re-runs `ensureUser` — fast path now warm. Pathological infinite-loop guarded by the warm-path behaviour on the second attempt. Spec covers the success retry + the non-P2002 propagation paths.
- **ScopeResolver seam moved from `{ entraOid }` to `{ userId }`.** The stub doesn't care about its argument either way; the change is the seam for ADR-0026 PR 2's `PrismaScopeResolver`, which keys `userScope` queries on `User.id` (UUID). Doing the rename now keeps PR 2 to "swap the implementation" only.
- **`PersonAndUserProvisioner.SELF_SIGNIN_SOURCE`** is a typed constant inside the class, not a magic string. Defense in depth: TS type union (compile-time) + drift gate (`source: 'self-signin'` literal is in a file importing `person-source.ts` — flagged if it ever drifts off-catalogue) + runtime `isPersonSource` check (error log + throw if the constant is mutated to an off-catalogue value).
- **UserDirectoryService stays.** ADR-0020's admin-list cache is functionally redundant with Person + User now, but folding it would extend the PR scope (DTO + reader + admin UI + migration of existing rows). Out of scope here — flagged as a follow-up.
## Local verification
- [x] `node scripts/check-catalogue-drift.mjs` — clean (`4 privileges, 24 roles, 7 structure kinds, 3 person sources`).
- [x] `node --test scripts/check-catalogue-drift.spec.mjs` — **29 tests passing** (was 22 — +7 for PERSON_SOURCES).
- [x] `pnpm exec nx lint portal-bff` — **0 errors**, 13 warnings (all pre-existing, none from this PR).
- [x] `pnpm exec nx test portal-bff` (filtered to the 6 affected spec files) — **766 tests passing**.
- [x] `pnpm exec prisma generate` — client regenerated with the 3 new models.
## Test plan — remaining (on a fresh dev DB)
- [ ] `./infra/local/dev.sh down -v && ./infra/local/dev.sh up` (wipe + reboot) then `cd apps/portal-bff && pnpm exec prisma migrate dev` — applies the new migration cleanly, no drift prompt.
- [ ] `pnpm exec prisma studio` — `persons` / `users` / `user_scopes` tables visible, all empty (no seed in this PR).
- [ ] Sign in via `apf-portal` against the test tenant — `users` table gets one row, `persons` table gets one row, `user_scopes` stays empty. Principal in the session carries the provisioned UUIDs (not the `entraOid`).
- [ ] **Review focus** — the provisioner's race-handling logic; the `Entra preferred_username → Person.email` mapping in `SessionEstablisher`; the ScopeResolver seam change rationale; the FK actions in the migration SQL (especially CASCADE on UserScope.userId vs RESTRICT on User.personId).
## What's next
- **ADR-0026 PR 2** — `PrismaScopeResolver` replacing `StubScopeResolver` + `/admin/users/:id/scopes` admin-app screen + `prisma/seed.ts` populating the 19 test personas' `user_scopes` per `notes/test-tenant-role-assignments.md` (referencing this PR's `User.id` and ADR-0027 PR 1's `Structure.code` values).
- **Future PR (out of this scope)** — fold `UserDirectoryEntry` into Person + User now that the latter exists. Touches AdminUsersReader, the DTO, the admin SPA, and a data migration for existing rows. Defer until ADR-0026 PR 2 stabilises.
---------
Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #232
|
||
|
|
12136f7a8a |
feat(auth): authorization catalogues + Principal builder skeleton (ADR-0025) (#206)
## Summary First implementation PR after [ADR-0025](docs/decisions/0025-authorization-model-privileges-roles-scopes.md) was accepted in #205. Lands the closed-set catalogues + the OIDC-callback hook that composes the session-resident `Principal`. No new guards yet — those come in the next PR per the ADR's `§More Information` phasing. ## What lands **New shared library: `libs/shared/auth/`** (`scope:shared`, `type:shared`, framework-agnostic, vitest) | File | Role | | --- | --- | | `src/lib/authorization.types.ts` | Closed catalogues: `PRIVILEGES` (4), `FUNCTIONAL_ROLES` (24), `SCOPE_KINDS` (6). `Principal`, `Scope` types. `isPrivilege` / `isFunctionalRole` / `isScopeKind` type-guards for the drift gate. | | `src/lib/entra-group-to-role.ts` | `parseEntraGroupMap` (validates GUID + slug closedness + dedup) + `EntraGroupToRoleResolver` (translates the Entra `groups` claim into ordered `apf-role-*` slugs, reports unknown GUIDs via callback). | | `src/lib/*.spec.ts` | 17 unit tests against the catalogue counts + resolver contracts. | **BFF wiring** (`apps/portal-bff/src/auth/` + `src/config/`) | File | Role | | --- | --- | | `auth.service.ts` | Extracts the Entra `groups` claim. `AuthenticatedUser` grows `groups: readonly string[]`. | | `principal-builder.ts` | Composes the three axes at sign-in. Filters `roles` claim through `isPrivilege` (drops + WARNs on unknown), resolves `groups` claim through the `EntraGroupToRoleResolver` (drops + WARNs on unknown). | | `scope-resolver.ts` | `ScopeResolver` abstract class + `StubScopeResolver` returning `[{ kind: 'unrestricted' }]` (per ADR-0025 §331 — replaced by a Prisma implementation when ADR-0026's `user_scopes` table lands). | | `session-establisher.service.ts` | Calls `PrincipalBuilder.build()` once, persists the result as `req.session.principal`. The legacy `req.session.user` shape is untouched (audit + AI bridge keep working). | | `session.types.ts` | Adds optional `principal?: Principal` field on the express-session augmentation. | | `entra-group-to-role.token.ts` | DI token for the lazily-loaded resolver. | | `config/load-entra-group-map.ts` | Reads the gitignored `infra/<env>-tenant.entra.json` at boot. Missing path → empty resolver + WARN. Malformed file → hard fail (alternative would be silently dropping a role). | **Tests against the 19 test-tenant personas** (`principal-builder.spec.ts`) Every persona provisioned in `apfrd.onmicrosoft.com` on 2026-05-20 is exercised end-to-end: `admin`, `directeur-bordeaux`, `directeur-complexe`, `rh-aquitaine`, `rh-siege`, `collaborateur-simple`, `tresorier-bordeaux`, `dpo`, `it`, `benevole-aquitaine`, `chef-equipe-bordeaux`, `chef-service-bordeaux`, `directeur-territorial-aquitaine`, `juriste-siege`, `rssi`, `communication-siege`, `elu-ca-national`, `president-cd-aquitaine`, `secretaire-cd-aquitaine`. A coverage assertion confirms the personas cover all 4 privileges + 23 of 24 functional roles (the `partenaire` gap is intentional per ADR-0025). **Infra + docs** | File | Change | | --- | --- | | `infra/test-tenant.entra.example.json` | Template GUID → slug map (24 entries, full v1 catalogue). Real file (`*-tenant.entra.json`) gitignored. | | `infra/README.md` | New row + "Entra group map" section documenting the load semantics + provisioning workflow. | | `apps/portal-bff/.env.example` | New `ENTRA_GROUP_MAP_PATH` block. | | `.gitignore` | `infra/*-tenant.entra.json` (except `.example.json`). | | `tsconfig.base.json` | `shared-auth` path alias. | | `notes/entra-groups-claim-activation.md` | Operator runbook for the Entra admin-centre step (Token configuration → Add groups claim → Security groups → Group ID). | **ADR amendment** | File | Change | | --- | --- | | `docs/decisions/0025-authorization-model-privileges-roles-scopes.md` | Path references updated `libs/feature/auth/...` → `libs/shared/auth/...` (4 occurrences), and the `ENTRA_GROUP_TO_ROLE` static-record example replaced by the actual `EntraGroupToRoleResolver` + JSON shape. | ## Notes for the reviewer - **Why `libs/shared/auth/` and not `libs/feature/auth/` (as the ADR initially said).** The existing `libs/feature/auth/` carries `tags: ['scope:portal-shell', 'type:feature']`, which the Nx `enforce-module-boundaries` rule in `eslint.config.mjs:36-46` blocks the BFF from importing. The catalogue is needed by both sides (BFF builds the `Principal`, SPA will gate UI conditionally later), so a `scope:shared` lib is the correct home. The ADR is patched in the same PR so the document and the code agree. - **Why drop unknown privileges with a WARN instead of preserving them.** `Portal.GhostRole` in the `roles` claim is either a leftover from a different app registration or a v1+1 experiment that hasn't been added to the catalogue yet. Both paths should not silently grant access — drop with a WARN so an operator notices, and the drift CI gate (next PR) catches the source-side equivalent before merge. - **Why the principal builder uses a `ScopeResolver` seam now.** Per ADR-0025 §331 the v1 implementation returns `unrestricted` for everyone; the real version queries the `user_scopes` Prisma table that lands with ADR-0026. The seam is here so the next PR replaces only the implementation, not the call-site in `PrincipalBuilder` or its 24 tests. Per-persona stub scope data was deliberately not embedded in this PR — no guard consumes scopes yet, so it would be write-only documentation that drifts from the eventual Prisma seed. - **Why `user.id` and `user.personId` placeholder to `entraOid`.** Same logic: the real UUIDs land with the `Person` + `User` Prisma schema (proposed ADR-0026). Until then, populating both fields with `entraOid` lets consumers key on `principal.user.id` today and get a real value later without branching on availability. The seam is one place (in the builder), not 24 guards. - **Why session.types.ts carries both `user` and `principal`.** Additive instead of replacement: the audit module + the AI-bridge controller key on `user.oid` / `user.tid` directly, and migrating them in the same PR would balloon the diff for zero behavioural benefit. New consumers (`@RequirePrivilege` / `@RequireRole` / `@RequireScope` decorators, next PR) reach for `principal`. - **Map file fails soft on absence, hard on malformation.** Path unset OR file missing → empty resolver + WARN. Sign-in still succeeds, every user gets `roles: []`. This is deliberately fail-soft so a fresh dev environment isn't blocked by an Entra-side dependency. **Bad JSON / unknown slug / duplicate GUID → throws at boot.** The alternative would silently mis-resolve a role. - **Why `boot-time` validation only (no per-request)** for the map file. Catalogue drift is detected once at boot; per-request validation would re-read the file from disk on every sign-in. The trade-off is that a hot-edit to the file requires a BFF restart — acceptable because the file changes once per tenant lifecycle, not per session. - **Entra `groups` claim must be activated** on the BFF's app registration before this code does anything useful. The runbook in `notes/entra-groups-claim-activation.md` walks through the steps; if the claim isn't activated, every user signs in with `principal.roles = []` and a future warn for every group GUID that would have arrived (none, in that case). - **No drift gate yet.** ADR-0025's §"Confirmation" §346 calls for an ESLint custom rule (or `pnpm run` script) that greps every `@RequireRole('...')` literal and asserts membership in the catalogue. That's the third PR in the phasing — there's no `@RequireRole` consumer in the tree yet, so the gate would have nothing to assert against today. ## Test plan - [x] `pnpm nx affected -t lint test build --base=main` — 13 projects green - 497 BFF tests (was 465 — added 4 new `groups`-claim tests in `auth.service.spec.ts`, 1 new test in `session-establisher.service.spec.ts`, 6 new tests in `load-entra-group-map.spec.ts`, 24 new tests in `principal-builder.spec.ts`) - 17 `shared-auth` tests (catalogue counts + resolver contracts) - 1 `shared-util` test (unchanged) - full lint + full build - [x] `pnpm nx format:check` — clean after `pnpm nx format:write` - [x] Manual: `tsc --build libs/shared/auth/tsconfig.lib.json` emits `.d.ts` files at the path `portal-bff`'s webpack expects. - [ ] **Review focus** — the closed catalogues (`PRIVILEGES`, `FUNCTIONAL_ROLES`, `SCOPE_KINDS`) verbatim match ADR-0025; the 19 personas verbatim match `notes/test-tenant-role-assignments.md`; the `Principal` shape matches the ADR §"Principal shape" (modulo the `user.id` / `personId` placeholder); fail-soft on missing map file, hard on malformed. - [ ] **After merge — operator step** : activate the Entra `groups` claim per `notes/entra-groups-claim-activation.md` and drop the real GUIDs into `infra/test-tenant.entra.json` (gitignored). Then a sign-in with `admin@apfrd.onmicrosoft.com` should land `principal.privileges = ['Portal.Admin']` and `principal.roles = ['collaborateur', 'rh']` in Redis. ## What's next Per ADR-0025 §"More Information" phasing: 1. ✅ **This PR** — types + Principal builder + group-to-role mapping skeleton. 2. **Next PR** — `@RequirePrivilege` + `@RequireRole` + `@RequireScope` decorators + guard integration tests against the 19 personas. 3. **PR after that** — drift CI gate (ESLint custom rule asserting every `@RequireX('...')` literal is in the catalogue). 4. **Depends on ADR-0026** — `user_scopes` Prisma table + seed + `PrismaScopeResolver` replacing `StubScopeResolver`. --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #206 |