feat(auth): swap stub for PrismaScopeResolver + add test-tenant seed (ADR-0026 PR 2a) #233
Reference in New Issue
Block a user
Delete Branch "feat/adr-0026-pr2a-prisma-scope-resolver-and-seed"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
ADR-0026 PR 2 (first half — backend). Ships:
PrismaScopeResolverreplacingStubScopeResolverinAuthModule. Queriesuser_scopes WHERE userId = ? AND (expiresAt IS NULL OR expiresAt > NOW())and maps each row to a typedScopefromshared-auth.prisma/seed.tspopulating Person + User + UserScope rows for the 19 test-tenant personas pernotes/test-tenant-role-assignments.md. Idempotent — re-running preserves existing rows.infra/test-tenant.personas.example.json— schema template for the gitignored per-persona Entraoidmap the seed reads.The admin UI scope-seeding screen
/admin/users/:id/scopesis split into PR 2b (Angular work, follows separately).What lands
apps/portal-bff/src/auth/prisma-scope-resolver.ts+.spec.tsexpiresAtfilter (null OR > NOW()). Maps(kind, value)rows to the discriminatedScopeunion via a puretoScopehelper. Off-cataloguekindvalues 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 ontoScope.apps/portal-bff/src/auth/auth.module.ts{ provide: ScopeResolver, useClass: StubScopeResolver }→useClass: PrismaScopeResolver. TheStubScopeResolverclass stays exported fromscope-resolver.ts(handy for spec fixtures / future "force-unrestricted" dev modes) but is no longer the default wiring.apps/portal-bff/prisma/seed.tsPERSONASarray (19 entries, slug + email + displayName + scope tuples — transcribed fromnotes/test-tenant-role-assignments.md). For each persona: reads its EntraoidfromTEST_TENANT_PERSONAS_PATH(env var, defaultinfra/test-tenant.personas.json); if missing → skip with WARN; otherwise idempotent upsert (findUnique byentraOid→ reuse, or create Person + User in nested-create transaction withPerson.source = 'seed'). Then idempotent upserts for each scope (findUnique by(userId, kind, value)→ skip, or create withUserScope.source = 'seed'). Final log: counts of rows created + personas skipped.infra/test-tenant.personas.example.json{ slug: entra-oid }map for the 19 personas + a_READMEfield documenting the shape. Distinct fromtest-tenant.entra.json(the 24-entry GROUP-guid map forEntraGroupToRoleResolver). Real file is gitignored.apps/portal-bff/.env.exampleTEST_TENANT_PERSONAS_PATHblock with the same documentation pattern asENTRA_GROUP_MAP_PATH.package.jsonprisma.seedfield added:ts-node --transpile-only --compiler-options '...' apps/portal-bff/prisma/seed.ts. Letspnpm exec prisma db seedrun the script without a project-specific tsconfig dance.Key choices
PrismaScopeResolverswap is the default for AuthModule. No "fallback to stub when DB is unreachable" — a Postgres outage that prevents readinguser_scopesshould fail the sign-in (same posture asPersonAndUserProvisioner.ensureUser, which is also blocking). TheStubScopeResolverclass remains inscope-resolver.tsfor spec fixtures + as a hint of how to wire a "force-everyone-unrestricted" dev override if we ever want one.toScopemapping. 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 withkind = '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.oids from a gitignored file. Same pattern asEntraGroupToRoleResolver(path via env var, file is gitignored, schema template ininfra/*.example.json). Theoids themselves are tenant-private; the 19 persona slugs + their scope tuples are project-wide and live inseed.ts.User.entraOidlets us "create if missing" without locks; the unique on(userId, kind, value)does the same forUserScope. Re-running the seed after a partial run picks up where it stopped — no--resetneeded.seed.tsitself. 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-bff— 0 errors, 13 warnings (all pre-existing).pnpm exec nx test portal-bff(affected specs filtered) — 774 tests passing (was 766; +8 forprisma-scope-resolver.spec.ts).cp infra/test-tenant.personas.example.json infra/test-tenant.personas.json+ fill in realoids from the Entra admin centre.cd apps/portal-bff && pnpm exec prisma db seed— should reportcreated 19 Person + 19 User + N UserScope rows; skipped 0 personason a fresh DB.0 / 0 / 0 created; skipped 0(idempotent).principal.scopeson the session contains the seeded scopes (e.g.directeur-bordeauxsees[{ kind: 'etablissement', value: '0330800013' }]).toScopemapping (especially the defensivenullbranch on off-catalogue kinds), the seed's idempotency invariants, theprisma.seedcommand inpackage.json, the comments-only fields intest-tenant.personas.example.json.What's next
PR 2b — Admin
/admin/users/:id/scopesscreen. 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
@RequireScopestack is end-to-end live for the first time. ADR-0025's stubs (StubScopeResolver, the entraOid placeholder onPrincipal.user.{id, personId}) are then fully retired.