9b4b4b90d0
## 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.
---------
Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #233
150 lines
5.6 KiB
TypeScript
150 lines
5.6 KiB
TypeScript
import { ConfidentialClientApplication, LogLevel } from '@azure/msal-node';
|
|
import { Module } from '@nestjs/common';
|
|
import { Logger } from 'nestjs-pino';
|
|
import { assertEntraConfig } from '../config/check-entra-config';
|
|
import { loadEntraGroupToRoleResolver } from '../config/load-entra-group-map';
|
|
import { SessionModule } from '../session/session.module';
|
|
import { AuthController } from './auth.controller';
|
|
import { AuthService } from './auth.service';
|
|
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 { PrincipalBuilder } from './principal-builder';
|
|
import { PrismaScopeResolver } from './prisma-scope-resolver';
|
|
import { RequireMfaGuard } from './require-mfa.guard';
|
|
import { RequirePrivilegeGuard } from './require-privilege.guard';
|
|
import { RequireRoleGuard } from './require-role.guard';
|
|
import { RequireScopeGuard } from './require-scope.guard';
|
|
import { ScopeResolver } from './scope-resolver';
|
|
import { SessionEstablisher } from './session-establisher.service';
|
|
|
|
/**
|
|
* Auth module — owns the Entra ID configuration, the MSAL Node
|
|
* confidential client, and (in subsequent PRs) the OIDC routes, the
|
|
* session integration, and the route guards. Per ADR-0009.
|
|
*
|
|
* v1 providers:
|
|
*
|
|
* - `ENTRA_CONFIG` — the parsed, validated Entra app-registration
|
|
* config (PR #102).
|
|
* - `MSAL_CLIENT` — a `ConfidentialClientApplication` instance
|
|
* wired with that config plus a logger callback that forwards
|
|
* MSAL's internal log lines into the Pino stream so auth flow
|
|
* diagnostics land alongside the rest of the BFF logs (per
|
|
* ADR-0012). PII logging is disabled by default — MSAL won't
|
|
* include tokens or user identifiers in the messages it emits.
|
|
* - `AuthService` — first-leg-of-the-flow logic: PKCE + state
|
|
* generation, MSAL auth-code URL building. The controller
|
|
* stays a thin shell around it.
|
|
*
|
|
* v1 routes (per ADR-0009):
|
|
*
|
|
* - `GET /api/auth/login` — 302 to Entra's authorize endpoint
|
|
* with the freshly-generated state + code challenge; sets a
|
|
* short-lived signed cookie carrying the state + PKCE
|
|
* verifier so the next-PR callback can verify the round-trip.
|
|
*
|
|
* The module stays non-global: modules state "I depend on auth" by
|
|
* importing it. Re-exports both tokens so a single
|
|
* `imports: [AuthModule]` is enough to consume either.
|
|
*/
|
|
@Module({
|
|
imports: [SessionModule],
|
|
controllers: [AuthController],
|
|
providers: [
|
|
AuthService,
|
|
RequireMfaGuard,
|
|
RequirePrivilegeGuard,
|
|
RequireRoleGuard,
|
|
RequireScopeGuard,
|
|
SessionEstablisher,
|
|
PrincipalBuilder,
|
|
// PrismaScopeResolver replaces StubScopeResolver per ADR-0026 PR 2.
|
|
// The stub class stays exported from scope-resolver.ts as a
|
|
// fallback for spec fixtures / future "force-unrestricted" dev
|
|
// modes, but it is no longer the default wiring.
|
|
{ provide: ScopeResolver, useClass: PrismaScopeResolver },
|
|
{
|
|
provide: ENTRA_CONFIG,
|
|
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,
|
|
inject: [ENTRA_CONFIG, Logger],
|
|
useFactory: (config: EntraConfig, logger: Logger) =>
|
|
new ConfidentialClientApplication({
|
|
auth: {
|
|
clientId: config.clientId,
|
|
authority: config.authority,
|
|
clientSecret: config.clientSecret,
|
|
},
|
|
system: {
|
|
loggerOptions: {
|
|
piiLoggingEnabled: false,
|
|
logLevel: LogLevel.Info,
|
|
loggerCallback: (level, message) => {
|
|
// MSAL levels: 0=Error, 1=Warning, 2=Info, 3=Verbose, 4=Trace.
|
|
// Forward to Pino with the matching level. The whole
|
|
// payload lives under the `msal` context key so a log
|
|
// search filtering on `msal` returns every MSAL line
|
|
// without false positives from app code.
|
|
const ctx = 'msal';
|
|
switch (level) {
|
|
case LogLevel.Error:
|
|
logger.error(message, ctx);
|
|
break;
|
|
case LogLevel.Warning:
|
|
logger.warn(message, ctx);
|
|
break;
|
|
case LogLevel.Verbose:
|
|
case LogLevel.Trace:
|
|
logger.debug(message, ctx);
|
|
break;
|
|
default:
|
|
logger.log(message, ctx);
|
|
}
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
},
|
|
],
|
|
exports: [
|
|
ENTRA_CONFIG,
|
|
ENTRA_GROUP_TO_ROLE_RESOLVER,
|
|
MSAL_CLIENT,
|
|
RequireMfaGuard,
|
|
RequirePrivilegeGuard,
|
|
RequireRoleGuard,
|
|
RequireScopeGuard,
|
|
AuthService,
|
|
PrincipalBuilder,
|
|
ScopeResolver,
|
|
SessionEstablisher,
|
|
],
|
|
})
|
|
export class AuthModule {}
|