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
292 lines
8.9 KiB
TypeScript
292 lines
8.9 KiB
TypeScript
/**
|
|
* Test-tenant seed per ADR-0026 PR 2.
|
|
*
|
|
* Run via:
|
|
* pnpm exec prisma db seed
|
|
*
|
|
* Idempotent: re-running this script preserves existing rows. The
|
|
* checks for "row already there" run against the unique constraints:
|
|
* - User.entraOid (skipping the cold path of
|
|
* PersonAndUserProvisioner if the row exists),
|
|
* - UserScope.(userId, kind, value).
|
|
*
|
|
* The seed creates Person + User + UserScope rows for the 19 personas
|
|
* provisioned in the `apfrd.onmicrosoft.com` test tenant. The
|
|
* persona matrix below is transcribed from
|
|
* `notes/test-tenant-role-assignments.md` (gitignored) — that file
|
|
* remains the human-readable reference; this constant is the source
|
|
* of truth for the seed.
|
|
*
|
|
* **Test-tenant Entra `oid`s** are read from a JSON file pointed at
|
|
* by `TEST_TENANT_PERSONAS_PATH` (or `infra/test-tenant.personas.json`
|
|
* by default). The file is gitignored — copy
|
|
* `infra/test-tenant.personas.example.json` and fill in real values
|
|
* from the Entra admin centre. Missing file → seed skips the
|
|
* test-tenant section cleanly (no error, just a log line).
|
|
*
|
|
* **`Person.source = 'seed'`** for every row created here, per the
|
|
* PERSON_SOURCES catalogue. Differentiates these rows from
|
|
* 'self-signin' (lazy-created at first sign-in) and 'admin-ui' (future
|
|
* scope-seeding admin screen, ADR-0026 PR 2b).
|
|
*
|
|
* **Privileges + functional roles are NOT seeded** — those come from
|
|
* Entra at sign-in (Entra `roles` claim + `groups` claim resolved by
|
|
* `EntraGroupToRoleResolver`). Only scopes are portal-side data.
|
|
*/
|
|
import { existsSync, readFileSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
import { PrismaClient } from '@prisma/client';
|
|
|
|
interface PersonaSeed {
|
|
readonly slug: string;
|
|
readonly email: string;
|
|
readonly displayName: string;
|
|
/**
|
|
* Scope tuples per `notes/test-tenant-role-assignments.md`. The
|
|
* `value` is omitted for valueless kinds (self / siege /
|
|
* unrestricted) — the seed writes an empty string to the DB
|
|
* `value` column, matching the column's default.
|
|
*/
|
|
readonly scopes: ReadonlyArray<{ readonly kind: string; readonly value?: string }>;
|
|
}
|
|
|
|
const TENANT_DOMAIN = 'apfrd.onmicrosoft.com';
|
|
|
|
const PERSONAS: ReadonlyArray<PersonaSeed> = [
|
|
{
|
|
slug: 'admin',
|
|
email: `admin@${TENANT_DOMAIN}`,
|
|
displayName: 'Admin User',
|
|
scopes: [{ kind: 'unrestricted' }],
|
|
},
|
|
{
|
|
slug: 'directeur-bordeaux',
|
|
email: `directeur-bordeaux@${TENANT_DOMAIN}`,
|
|
displayName: 'Directeur Bordeaux',
|
|
scopes: [{ kind: 'etablissement', value: '0330800013' }],
|
|
},
|
|
{
|
|
slug: 'directeur-complexe',
|
|
email: `directeur-complexe@${TENANT_DOMAIN}`,
|
|
displayName: 'Directeur Complexe',
|
|
scopes: [
|
|
{ kind: 'etablissement', value: '0330800013' },
|
|
{ kind: 'etablissement', value: '0330800021' },
|
|
],
|
|
},
|
|
{
|
|
slug: 'rh-aquitaine',
|
|
email: `rh-aquitaine@${TENANT_DOMAIN}`,
|
|
displayName: 'RH Aquitaine',
|
|
scopes: [{ kind: 'delegation', value: '33' }],
|
|
},
|
|
{
|
|
slug: 'rh-siege',
|
|
email: `rh-siege@${TENANT_DOMAIN}`,
|
|
displayName: 'RH Siège',
|
|
scopes: [{ kind: 'unrestricted' }],
|
|
},
|
|
{
|
|
slug: 'collaborateur-simple',
|
|
email: `collaborateur-simple@${TENANT_DOMAIN}`,
|
|
displayName: 'Collaborateur Simple',
|
|
scopes: [{ kind: 'self' }],
|
|
},
|
|
{
|
|
slug: 'tresorier-bordeaux',
|
|
email: `tresorier-bordeaux@${TENANT_DOMAIN}`,
|
|
displayName: 'Trésorier Bordeaux',
|
|
scopes: [{ kind: 'delegation', value: '33' }],
|
|
},
|
|
{
|
|
slug: 'dpo',
|
|
email: `dpo@${TENANT_DOMAIN}`,
|
|
displayName: 'DPO',
|
|
scopes: [{ kind: 'unrestricted' }],
|
|
},
|
|
{
|
|
slug: 'it',
|
|
email: `it@${TENANT_DOMAIN}`,
|
|
displayName: 'IT',
|
|
scopes: [{ kind: 'unrestricted' }],
|
|
},
|
|
{
|
|
slug: 'benevole-aquitaine',
|
|
email: `benevole-aquitaine@${TENANT_DOMAIN}`,
|
|
displayName: 'Bénévole Aquitaine',
|
|
scopes: [{ kind: 'delegation', value: '33' }],
|
|
},
|
|
{
|
|
slug: 'chef-equipe-bordeaux',
|
|
email: `chef-equipe-bordeaux@${TENANT_DOMAIN}`,
|
|
displayName: 'Chef Equipe Bordeaux',
|
|
scopes: [{ kind: 'etablissement', value: '0330800013' }],
|
|
},
|
|
{
|
|
slug: 'chef-service-bordeaux',
|
|
email: `chef-service-bordeaux@${TENANT_DOMAIN}`,
|
|
displayName: 'Chef Service Bordeaux',
|
|
scopes: [{ kind: 'etablissement', value: '0330800013' }],
|
|
},
|
|
{
|
|
slug: 'directeur-territorial-aquitaine',
|
|
email: `directeur-territorial-aquitaine@${TENANT_DOMAIN}`,
|
|
displayName: 'Directeur Territorial Aquitaine',
|
|
scopes: [{ kind: 'delegation', value: '33' }],
|
|
},
|
|
{
|
|
slug: 'juriste-siege',
|
|
email: `juriste-siege@${TENANT_DOMAIN}`,
|
|
displayName: 'Juriste Siège',
|
|
scopes: [{ kind: 'unrestricted' }],
|
|
},
|
|
{
|
|
slug: 'rssi',
|
|
email: `rssi@${TENANT_DOMAIN}`,
|
|
displayName: 'RSSI',
|
|
scopes: [{ kind: 'unrestricted' }],
|
|
},
|
|
{
|
|
slug: 'communication-siege',
|
|
email: `communication-siege@${TENANT_DOMAIN}`,
|
|
displayName: 'Communication Siège',
|
|
scopes: [{ kind: 'unrestricted' }],
|
|
},
|
|
{
|
|
slug: 'elu-ca-national',
|
|
email: `elu-ca-national@${TENANT_DOMAIN}`,
|
|
displayName: 'Elu CA National',
|
|
scopes: [{ kind: 'siege' }],
|
|
},
|
|
{
|
|
slug: 'president-cd-aquitaine',
|
|
email: `president-cd-aquitaine@${TENANT_DOMAIN}`,
|
|
displayName: 'Président CD Aquitaine',
|
|
scopes: [{ kind: 'delegation', value: '33' }],
|
|
},
|
|
{
|
|
slug: 'secretaire-cd-aquitaine',
|
|
email: `secretaire-cd-aquitaine@${TENANT_DOMAIN}`,
|
|
displayName: 'Secrétaire CD Aquitaine',
|
|
scopes: [{ kind: 'delegation', value: '33' }],
|
|
},
|
|
];
|
|
|
|
async function main(): Promise<void> {
|
|
const prisma = new PrismaClient();
|
|
try {
|
|
await seedTestTenant(prisma);
|
|
} finally {
|
|
await prisma.$disconnect();
|
|
}
|
|
}
|
|
|
|
async function seedTestTenant(prisma: PrismaClient): Promise<void> {
|
|
const personasPath = resolve(
|
|
process.env['TEST_TENANT_PERSONAS_PATH'] ?? 'infra/test-tenant.personas.json',
|
|
);
|
|
if (!existsSync(personasPath)) {
|
|
console.log(
|
|
`[seed] TEST_TENANT_PERSONAS_PATH (${personasPath}) not found — skipping test-tenant seed.`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
let entraOidBySlug: Record<string, string>;
|
|
try {
|
|
const raw = readFileSync(personasPath, 'utf8');
|
|
entraOidBySlug = JSON.parse(raw) as Record<string, string>;
|
|
} catch (err) {
|
|
console.error(
|
|
`[seed] could not parse ${personasPath}: ${err instanceof Error ? err.message : String(err)}`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
const tenantId = process.env['ENTRA_TENANT_ID'] ?? TENANT_DOMAIN;
|
|
const counts = { personsCreated: 0, usersCreated: 0, scopesCreated: 0, skipped: 0 };
|
|
|
|
for (const persona of PERSONAS) {
|
|
const entraOid = entraOidBySlug[persona.slug];
|
|
if (entraOid === undefined || typeof entraOid !== 'string' || entraOid === '') {
|
|
console.warn(`[seed] persona "${persona.slug}" missing oid in ${personasPath} — skipping.`);
|
|
counts.skipped += 1;
|
|
continue;
|
|
}
|
|
|
|
const { userId, created } = await ensurePersonAndUser(prisma, persona, entraOid, tenantId);
|
|
if (created) {
|
|
counts.personsCreated += 1;
|
|
counts.usersCreated += 1;
|
|
}
|
|
for (const scope of persona.scopes) {
|
|
const seededNew = await ensureUserScope(prisma, userId, scope.kind, scope.value ?? '');
|
|
if (seededNew) counts.scopesCreated += 1;
|
|
}
|
|
}
|
|
|
|
console.log(
|
|
`[seed] test-tenant complete — created ${counts.personsCreated} Person + ${counts.usersCreated} User + ${counts.scopesCreated} UserScope rows; skipped ${counts.skipped} personas (missing oid).`,
|
|
);
|
|
}
|
|
|
|
async function ensurePersonAndUser(
|
|
prisma: PrismaClient,
|
|
persona: PersonaSeed,
|
|
entraOid: string,
|
|
tenantId: string,
|
|
): Promise<{ userId: string; personId: string; created: boolean }> {
|
|
const existing = await prisma.user.findUnique({
|
|
where: { entraOid },
|
|
select: { id: true, personId: true },
|
|
});
|
|
if (existing) {
|
|
return { userId: existing.id, personId: existing.personId, created: false };
|
|
}
|
|
const [firstName, lastName] = splitDisplayName(persona.displayName);
|
|
const created = await prisma.user.create({
|
|
data: {
|
|
entraOid,
|
|
tenantId,
|
|
person: {
|
|
create: {
|
|
firstName,
|
|
lastName,
|
|
email: persona.email,
|
|
source: 'seed',
|
|
},
|
|
},
|
|
},
|
|
select: { id: true, personId: true },
|
|
});
|
|
return { userId: created.id, personId: created.personId, created: true };
|
|
}
|
|
|
|
async function ensureUserScope(
|
|
prisma: PrismaClient,
|
|
userId: string,
|
|
kind: string,
|
|
value: string,
|
|
): Promise<boolean> {
|
|
const existing = await prisma.userScope.findUnique({
|
|
where: { userId_kind_value: { userId, kind, value } },
|
|
});
|
|
if (existing) return false;
|
|
await prisma.userScope.create({
|
|
data: { userId, kind, value, source: 'seed' },
|
|
});
|
|
return true;
|
|
}
|
|
|
|
function splitDisplayName(displayName: string): [string, string] {
|
|
const trimmed = displayName.trim();
|
|
const space = trimmed.indexOf(' ');
|
|
if (space < 0) return [trimmed, ''];
|
|
return [trimmed.slice(0, space), trimmed.slice(space + 1).trim()];
|
|
}
|
|
|
|
main().catch((err: unknown) => {
|
|
console.error('[seed] failed:', err);
|
|
process.exit(1);
|
|
});
|