feat(auth): swap stub for PrismaScopeResolver + add test-tenant seed (ADR-0026 PR 2a) #233

Merged
julien merged 1 commits from feat/adr-0026-pr2a-prisma-scope-resolver-and-seed into main 2026-05-26 14:36:19 +02:00
Owner

Summary

ADR-0026 PR 2 (first half — backend). Ships:

  • PrismaScopeResolver replacing StubScopeResolver in AuthModule. Queries user_scopes WHERE userId = ? AND (expiresAt IS NULL OR expiresAt > NOW()) and maps each row to a typed Scope from shared-auth.
  • prisma/seed.ts populating Person + User + UserScope rows for the 19 test-tenant personas per notes/test-tenant-role-assignments.md. Idempotent — re-running preserves existing rows.
  • infra/test-tenant.personas.example.json — schema template for the gitignored per-persona Entra oid map the seed reads.

The admin UI scope-seeding screen /admin/users/:id/scopes is split into PR 2b (Angular work, follows separately).

What lands

File Change
apps/portal-bff/src/auth/prisma-scope-resolver.ts + .spec.ts New Prisma-backed resolver. Server-side expiresAt filter (null OR > NOW()). Maps (kind, value) rows to the discriminated Scope union via a pure toScope helper. Off-catalogue kind values are skipped + WARN-logged (defense in depth — the drift gate is the canonical write-side guard). 10 spec tests covering query shape, valueless / value-bearing kinds, defensive skip on off-catalogue, edge cases on toScope.
apps/portal-bff/src/auth/auth.module.ts { provide: ScopeResolver, useClass: StubScopeResolver }useClass: PrismaScopeResolver. The StubScopeResolver class stays exported from scope-resolver.ts (handy for spec fixtures / future "force-unrestricted" dev modes) but is no longer the default wiring.
apps/portal-bff/prisma/seed.ts New seed script. Hardcoded PERSONAS array (19 entries, slug + email + displayName + scope tuples — transcribed from notes/test-tenant-role-assignments.md). For each persona: reads its Entra oid from TEST_TENANT_PERSONAS_PATH (env var, default infra/test-tenant.personas.json); if missing → skip with WARN; otherwise idempotent upsert (findUnique by entraOid → reuse, or create Person + User in nested-create transaction with Person.source = 'seed'). Then idempotent upserts for each scope (findUnique by (userId, kind, value) → skip, or create with UserScope.source = 'seed'). Final log: counts of rows created + personas skipped.
infra/test-tenant.personas.example.json New schema template — flat { slug: entra-oid } map for the 19 personas + a _README field documenting the shape. Distinct from test-tenant.entra.json (the 24-entry GROUP-guid map for EntraGroupToRoleResolver). Real file is gitignored.
apps/portal-bff/.env.example New TEST_TENANT_PERSONAS_PATH block with the same documentation pattern as ENTRA_GROUP_MAP_PATH.
package.json prisma.seed field added: ts-node --transpile-only --compiler-options '...' apps/portal-bff/prisma/seed.ts. Lets pnpm exec prisma db seed run the script without a project-specific tsconfig dance.

Key choices

  • PrismaScopeResolver swap is the default for AuthModule. No "fallback to stub when DB is unreachable" — a Postgres outage that prevents reading user_scopes should fail the sign-in (same posture as PersonAndUserProvisioner.ensureUser, which is also blocking). The StubScopeResolver class remains in scope-resolver.ts for spec fixtures + as a hint of how to wire a "force-everyone-unrestricted" dev override if we ever want one.
  • Defensive toScope mapping. The drift gate enforces catalogue membership at the write site (the future admin scope-seeding UI from PR 2b). The Prisma read side defends in depth by skipping off-catalogue rows — a row with kind = 'something-bogus' (e.g. a future migration mistake) is dropped from the resolved scopes + logged, rather than throwing on every sign-in for that user. The unit test 'skips + warns on off-catalogue kinds' pins the behaviour.
  • Seed reads Entra oids from a gitignored file. Same pattern as EntraGroupToRoleResolver (path via env var, file is gitignored, schema template in infra/*.example.json). The oids themselves are tenant-private; the 19 persona slugs + their scope tuples are project-wide and live in seed.ts.
  • Seed is idempotent on every level. The unique constraint on User.entraOid lets us "create if missing" without locks; the unique on (userId, kind, value) does the same for UserScope. Re-running the seed after a partial run picks up where it stopped — no --reset needed.
  • No spec for seed.ts itself. It's an integration data loader; meaningful test would require a real Prisma client + DB (or a heavy mock fixture). The persona matrix is hand-verified at PR review; the seed's correctness is exercised by running it on the dev DB (test-plan checkbox below).

Verification path

  • node scripts/check-catalogue-drift.mjs — clean (4 / 24 / 7 / 3).
  • node --test scripts/check-catalogue-drift.spec.mjs — 29 tests passing.
  • pnpm exec nx lint portal-bff0 errors, 13 warnings (all pre-existing).
  • pnpm exec nx test portal-bff (affected specs filtered) — 774 tests passing (was 766; +8 for prisma-scope-resolver.spec.ts).
  • Locally / on dev DB:
    • cp infra/test-tenant.personas.example.json infra/test-tenant.personas.json + fill in real oids from the Entra admin centre.
    • cd apps/portal-bff && pnpm exec prisma db seed — should report created 19 Person + 19 User + N UserScope rows; skipped 0 personas on a fresh DB.
    • Re-run the seed → second invocation reports 0 / 0 / 0 created; skipped 0 (idempotent).
    • Sign in via the user portal as one of the 19 personas → principal.scopes on the session contains the seeded scopes (e.g. directeur-bordeaux sees [{ kind: 'etablissement', value: '0330800013' }]).
  • Review focus — the toScope mapping (especially the defensive null branch on off-catalogue kinds), the seed's idempotency invariants, the prisma.seed command in package.json, the comments-only fields in test-tenant.personas.example.json.

What's next

PR 2b — Admin /admin/users/:id/scopes screen. Angular admin-app SPA screen + BFF read/write controllers, against the schema this PR + ADR-0026 PR 1 + ADR-0027 PR 1 have now stood up. A11y review per ADR-0016 §"Manual testing cadence". Operator workflow only at that point — the seed in this PR is the bootstrap path for the test tenant.

Once both PR 2a + PR 2b ship: the @RequireScope stack is end-to-end live for the first time. ADR-0025's stubs (StubScopeResolver, the entraOid placeholder on Principal.user.{id, personId}) are then fully retired.

## Summary ADR-0026 PR 2 (first half — backend). Ships: - **`PrismaScopeResolver`** replacing `StubScopeResolver` in `AuthModule`. Queries `user_scopes WHERE userId = ? AND (expiresAt IS NULL OR expiresAt > NOW())` and maps each row to a typed `Scope` from `shared-auth`. - **`prisma/seed.ts`** populating Person + User + UserScope rows for the 19 test-tenant personas per `notes/test-tenant-role-assignments.md`. Idempotent — re-running preserves existing rows. - **`infra/test-tenant.personas.example.json`** — schema template for the gitignored per-persona Entra `oid` map the seed reads. The admin UI scope-seeding screen `/admin/users/:id/scopes` is split into **PR 2b** (Angular work, follows separately). ## What lands | File | Change | | --- | --- | | `apps/portal-bff/src/auth/prisma-scope-resolver.ts` + `.spec.ts` | **New** Prisma-backed resolver. Server-side `expiresAt` filter (`null OR > NOW()`). Maps `(kind, value)` rows to the discriminated `Scope` union via a pure `toScope` helper. Off-catalogue `kind` values are skipped + WARN-logged (defense in depth — the drift gate is the canonical write-side guard). 10 spec tests covering query shape, valueless / value-bearing kinds, defensive skip on off-catalogue, edge cases on `toScope`. | | `apps/portal-bff/src/auth/auth.module.ts` | `{ provide: ScopeResolver, useClass: StubScopeResolver }` → `useClass: PrismaScopeResolver`. The `StubScopeResolver` class stays exported from `scope-resolver.ts` (handy for spec fixtures / future "force-unrestricted" dev modes) but is no longer the default wiring. | | `apps/portal-bff/prisma/seed.ts` | **New** seed script. Hardcoded `PERSONAS` array (19 entries, slug + email + displayName + scope tuples — transcribed from `notes/test-tenant-role-assignments.md`). For each persona: reads its Entra `oid` from `TEST_TENANT_PERSONAS_PATH` (env var, default `infra/test-tenant.personas.json`); if missing → skip with WARN; otherwise idempotent upsert (findUnique by `entraOid` → reuse, or create Person + User in nested-create transaction with `Person.source = 'seed'`). Then idempotent upserts for each scope (findUnique by `(userId, kind, value)` → skip, or create with `UserScope.source = 'seed'`). Final log: counts of rows created + personas skipped. | | `infra/test-tenant.personas.example.json` | **New** schema template — flat `{ slug: entra-oid }` map for the 19 personas + a `_README` field documenting the shape. Distinct from `test-tenant.entra.json` (the 24-entry GROUP-guid map for `EntraGroupToRoleResolver`). Real file is gitignored. | | `apps/portal-bff/.env.example` | New `TEST_TENANT_PERSONAS_PATH` block with the same documentation pattern as `ENTRA_GROUP_MAP_PATH`. | | `package.json` | `prisma.seed` field added: `ts-node --transpile-only --compiler-options '...' apps/portal-bff/prisma/seed.ts`. Lets `pnpm exec prisma db seed` run the script without a project-specific tsconfig dance. | ## Key choices - **`PrismaScopeResolver` swap is the default for AuthModule.** No "fallback to stub when DB is unreachable" — a Postgres outage that prevents reading `user_scopes` should fail the sign-in (same posture as `PersonAndUserProvisioner.ensureUser`, which is also blocking). The `StubScopeResolver` class remains in `scope-resolver.ts` for spec fixtures + as a hint of how to wire a "force-everyone-unrestricted" dev override if we ever want one. - **Defensive `toScope` mapping.** The drift gate enforces catalogue membership at the write site (the future admin scope-seeding UI from PR 2b). The Prisma read side defends in depth by skipping off-catalogue rows — a row with `kind = 'something-bogus'` (e.g. a future migration mistake) is dropped from the resolved scopes + logged, rather than throwing on every sign-in for that user. The unit test `'skips + warns on off-catalogue kinds'` pins the behaviour. - **Seed reads Entra `oid`s from a gitignored file.** Same pattern as `EntraGroupToRoleResolver` (path via env var, file is gitignored, schema template in `infra/*.example.json`). The `oid`s themselves are tenant-private; the 19 persona slugs + their scope tuples are project-wide and live in `seed.ts`. - **Seed is idempotent on every level.** The unique constraint on `User.entraOid` lets us "create if missing" without locks; the unique on `(userId, kind, value)` does the same for `UserScope`. Re-running the seed after a partial run picks up where it stopped — no `--reset` needed. - **No spec for `seed.ts` itself.** It's an integration data loader; meaningful test would require a real Prisma client + DB (or a heavy mock fixture). The persona matrix is hand-verified at PR review; the seed's correctness is exercised by running it on the dev DB (test-plan checkbox below). ## Verification path - [x] `node scripts/check-catalogue-drift.mjs` — clean (`4 / 24 / 7 / 3`). - [x] `node --test scripts/check-catalogue-drift.spec.mjs` — 29 tests passing. - [x] `pnpm exec nx lint portal-bff` — **0 errors**, 13 warnings (all pre-existing). - [x] `pnpm exec nx test portal-bff` (affected specs filtered) — **774 tests passing** (was 766; +8 for `prisma-scope-resolver.spec.ts`). - [ ] **Locally / on dev DB**: - `cp infra/test-tenant.personas.example.json infra/test-tenant.personas.json` + fill in real `oid`s from the Entra admin centre. - `cd apps/portal-bff && pnpm exec prisma db seed` — should report `created 19 Person + 19 User + N UserScope rows; skipped 0 personas` on a fresh DB. - Re-run the seed → second invocation reports `0 / 0 / 0 created; skipped 0` (idempotent). - Sign in via the user portal as one of the 19 personas → `principal.scopes` on the session contains the seeded scopes (e.g. `directeur-bordeaux` sees `[{ kind: 'etablissement', value: '0330800013' }]`). - [ ] **Review focus** — the `toScope` mapping (especially the defensive `null` branch on off-catalogue kinds), the seed's idempotency invariants, the `prisma.seed` command in `package.json`, the comments-only fields in `test-tenant.personas.example.json`. ## What's next **PR 2b — Admin `/admin/users/:id/scopes` screen.** Angular admin-app SPA screen + BFF read/write controllers, against the schema this PR + ADR-0026 PR 1 + ADR-0027 PR 1 have now stood up. A11y review per ADR-0016 §"Manual testing cadence". Operator workflow only at that point — the seed in this PR is the bootstrap path for the test tenant. Once both PR 2a + PR 2b ship: **the `@RequireScope` stack is end-to-end live** for the first time. ADR-0025's stubs (`StubScopeResolver`, the entraOid placeholder on `Principal.user.{id, personId}`) are then fully retired.
julien added 1 commit 2026-05-26 14:33:29 +02:00
feat(auth): swap stub for PrismaScopeResolver + add test-tenant seed (ADR-0026 PR 2a)
CI / commits (pull_request) Successful in 4m25s
CI / scan (pull_request) Successful in 4m36s
CI / check (pull_request) Successful in 4m51s
CI / a11y (pull_request) Successful in 3m28s
Docs site / build (pull_request) Successful in 4m55s
CI / perf (pull_request) Successful in 8m51s
2725436801
PrismaScopeResolver replaces StubScopeResolver in AuthModule. Queries
user_scopes WHERE userId AND (expiresAt IS NULL OR expiresAt > NOW())
and maps each row to a typed Scope via a pure toScope helper.
Defensive: off-catalogue kinds are skipped + warn-logged rather than
thrown (write path is the canonical guard via the drift gate).

prisma/seed.ts populates Person + User + UserScope rows for the 19
test-tenant personas per notes/test-tenant-role-assignments.md. The
persona matrix (slug + email + displayName + scope tuples) is
hardcoded in the seed; tenant-private Entra oids are read from
TEST_TENANT_PERSONAS_PATH (default infra/test-tenant.personas.json,
gitignored). Idempotent on every level: User unique on entraOid,
UserScope unique on (userId, kind, value).

StubScopeResolver class stays exported from scope-resolver.ts for
spec fixtures + as a future force-everyone-unrestricted dev hint,
but is no longer wired by default.

The admin /admin/users/:id/scopes screen is split into the follow-up
PR 2b - the Angular SPA work is a different mode from this backend +
data PR.

Local: drift gate 4/24/7/3 clean, 29 drift-gate specs pass, portal-bff
lint 0 errors, 774 portal-bff specs pass (+8 for the resolver spec).
julien merged commit 9b4b4b90d0 into main 2026-05-26 14:36:19 +02:00
julien deleted branch feat/adr-0026-pr2a-prisma-scope-resolver-and-seed 2026-05-26 14:36:21 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: julien/apf_portal#233