diff --git a/apps/portal-bff/.env.example b/apps/portal-bff/.env.example index 05e496c..15eef63 100644 --- a/apps/portal-bff/.env.example +++ b/apps/portal-bff/.env.example @@ -84,6 +84,16 @@ ENTRA_ADMIN_POST_LOGOUT_REDIRECT_URI=http://localhost:4300/ # GUIDs from the Entra admin centre. ENTRA_GROUP_MAP_PATH=infra/test-tenant.entra.json +# Test-tenant per-persona Entra `oid` map (per ADR-0026 PR 2). +# Consumed by `apps/portal-bff/prisma/seed.ts` to upsert Person + +# User + UserScope rows for the 19 test personas. Distinct from +# `ENTRA_GROUP_MAP_PATH` which points at the 24 functional-role +# *group* guids. Unset means `prisma db seed` skips the test-tenant +# section (no Person/User/UserScope rows seeded — lazy creation at +# first sign-in still works). See +# `infra/test-tenant.personas.example.json` for the schema. +TEST_TENANT_PERSONAS_PATH=infra/test-tenant.personas.json + # Cookie signing secret (per ADR-0009 §"Cookies"). Used to sign the # transient pre-auth cookie that carries the OIDC `state` + PKCE # verifier between the /auth/login redirect and the /auth/callback diff --git a/apps/portal-bff/prisma/seed.ts b/apps/portal-bff/prisma/seed.ts new file mode 100644 index 0000000..8416399 --- /dev/null +++ b/apps/portal-bff/prisma/seed.ts @@ -0,0 +1,291 @@ +/** + * 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 = [ + { + 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 { + const prisma = new PrismaClient(); + try { + await seedTestTenant(prisma); + } finally { + await prisma.$disconnect(); + } +} + +async function seedTestTenant(prisma: PrismaClient): Promise { + 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; + try { + const raw = readFileSync(personasPath, 'utf8'); + entraOidBySlug = JSON.parse(raw) as Record; + } 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 { + 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); +}); diff --git a/apps/portal-bff/src/auth/auth.module.ts b/apps/portal-bff/src/auth/auth.module.ts index 378055e..e7e131a 100644 --- a/apps/portal-bff/src/auth/auth.module.ts +++ b/apps/portal-bff/src/auth/auth.module.ts @@ -10,11 +10,12 @@ 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, StubScopeResolver } from './scope-resolver'; +import { ScopeResolver } from './scope-resolver'; import { SessionEstablisher } from './session-establisher.service'; /** @@ -58,7 +59,11 @@ import { SessionEstablisher } from './session-establisher.service'; RequireScopeGuard, SessionEstablisher, PrincipalBuilder, - { provide: ScopeResolver, useClass: StubScopeResolver }, + // 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(), diff --git a/apps/portal-bff/src/auth/prisma-scope-resolver.spec.ts b/apps/portal-bff/src/auth/prisma-scope-resolver.spec.ts new file mode 100644 index 0000000..8487a78 --- /dev/null +++ b/apps/portal-bff/src/auth/prisma-scope-resolver.spec.ts @@ -0,0 +1,129 @@ +import type { Logger } from 'nestjs-pino'; +import type { PrismaService } from 'nestjs-prisma'; +import { PrismaScopeResolver, toScope } from './prisma-scope-resolver'; + +interface PrismaStub { + userScope: { + findMany: jest.Mock; + }; +} + +function makeLogger() { + return { log: jest.fn(), warn: jest.fn(), error: jest.fn() } as unknown as Logger & { + log: jest.Mock; + warn: jest.Mock; + error: jest.Mock; + }; +} + +function makeSubject(opts?: { findMany?: jest.Mock }): { + resolver: PrismaScopeResolver; + prisma: PrismaStub; + logger: ReturnType; +} { + const prisma: PrismaStub = { + userScope: { + findMany: opts?.findMany ?? jest.fn().mockResolvedValue([]), + }, + }; + const logger = makeLogger(); + const resolver = new PrismaScopeResolver(prisma as unknown as PrismaService, logger); + return { resolver, prisma, logger }; +} + +describe('PrismaScopeResolver.resolve — query shape', () => { + it('filters by userId and excludes rows whose expiresAt has passed', async () => { + const { resolver, prisma } = makeSubject(); + await resolver.resolve({ userId: 'user-uuid-1' }); + expect(prisma.userScope.findMany).toHaveBeenCalledTimes(1); + const args = prisma.userScope.findMany.mock.calls[0]?.[0] as { + where: { + userId: string; + OR: ReadonlyArray<{ expiresAt: Date | { gt: Date } | null }>; + }; + select: { kind: boolean; value: boolean }; + }; + expect(args.where.userId).toBe('user-uuid-1'); + expect(args.where.OR).toHaveLength(2); + expect(args.where.OR[0]).toEqual({ expiresAt: null }); + // The second predicate carries a `gt: ` matching "expiresAt > NOW()". + expect(args.where.OR[1]).toMatchObject({ expiresAt: { gt: expect.any(Date) } }); + expect(args.select).toEqual({ kind: true, value: true }); + }); + + it('returns an empty array when the user has no scope rows', async () => { + const { resolver } = makeSubject(); + const result = await resolver.resolve({ userId: 'user-uuid-1' }); + expect(result).toEqual([]); + }); +}); + +describe('PrismaScopeResolver.resolve — row-to-Scope mapping', () => { + it('maps valueless kinds to `{ kind }`', async () => { + const findMany = jest.fn().mockResolvedValue([ + { kind: 'self', value: '' }, + { kind: 'siege', value: '' }, + { kind: 'unrestricted', value: '' }, + ]); + const { resolver } = makeSubject({ findMany }); + const result = await resolver.resolve({ userId: 'u' }); + expect(result).toEqual([{ kind: 'self' }, { kind: 'siege' }, { kind: 'unrestricted' }]); + }); + + it('maps value-bearing kinds to `{ kind, value }`', async () => { + const findMany = jest.fn().mockResolvedValue([ + { kind: 'etablissement', value: '0330800013' }, + { kind: 'delegation', value: '33' }, + { kind: 'region', value: '75' }, + ]); + const { resolver } = makeSubject({ findMany }); + const result = await resolver.resolve({ userId: 'u' }); + expect(result).toEqual([ + { kind: 'etablissement', value: '0330800013' }, + { kind: 'delegation', value: '33' }, + { kind: 'region', value: '75' }, + ]); + }); + + it('skips + warns on off-catalogue kinds (defense in depth — drift gate is the canonical guard)', async () => { + const findMany = jest.fn().mockResolvedValue([ + { kind: 'self', value: '' }, + { kind: 'phantom-kind', value: '' }, + { kind: 'unrestricted', value: '' }, + ]); + const { resolver, logger } = makeSubject({ findMany }); + const result = await resolver.resolve({ userId: 'u' }); + expect(result).toEqual([{ kind: 'self' }, { kind: 'unrestricted' }]); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'scope_resolver.unknown_kind', + userId: 'u', + kind: 'phantom-kind', + }), + 'PrismaScopeResolver', + ); + }); +}); + +describe('toScope — pure mapping helper', () => { + it('returns null for off-catalogue kinds', () => { + expect(toScope('phantom-kind', '')).toBeNull(); + expect(toScope('', '')).toBeNull(); + expect(toScope('Etablissement', '0330800013')).toBeNull(); // case-sensitive + }); + + it('drops the value for valueless kinds (even if a stale value is in the row)', () => { + expect(toScope('self', 'stale')).toEqual({ kind: 'self' }); + expect(toScope('siege', 'stale')).toEqual({ kind: 'siege' }); + expect(toScope('unrestricted', 'stale')).toEqual({ kind: 'unrestricted' }); + }); + + it('preserves the value verbatim for value-bearing kinds', () => { + expect(toScope('etablissement', '0330800013')).toEqual({ + kind: 'etablissement', + value: '0330800013', + }); + expect(toScope('delegation', '2A')).toEqual({ kind: 'delegation', value: '2A' }); + expect(toScope('region', '75')).toEqual({ kind: 'region', value: '75' }); + }); +}); diff --git a/apps/portal-bff/src/auth/prisma-scope-resolver.ts b/apps/portal-bff/src/auth/prisma-scope-resolver.ts new file mode 100644 index 0000000..5cd5ffb --- /dev/null +++ b/apps/portal-bff/src/auth/prisma-scope-resolver.ts @@ -0,0 +1,91 @@ +import { Injectable } from '@nestjs/common'; +import { Logger } from 'nestjs-pino'; +import { PrismaService } from 'nestjs-prisma'; +import { isScopeKind, type Scope } from 'shared-auth'; +import { ScopeResolver } from './scope-resolver'; + +/** + * `PrismaScopeResolver` — production implementation of + * [`ScopeResolver`](scope-resolver.ts) backed by the `user_scopes` + * table introduced in ADR-0026 PR 1. + * + * Replaces `StubScopeResolver` (which always returned + * `[{ kind: 'unrestricted' }]`) per ADR-0025 §"Sources of truth — + * apf_portal-side `user_scopes` table". Called once per sign-in by + * `PrincipalBuilder.build`; the resolved scopes ride on the + * session-resident `Principal` so guards never re-query at request + * time. + * + * **Expiry handling.** Rows with a non-null `expiresAt` in the past + * are filtered server-side (`expiresAt IS NULL OR expiresAt > NOW()`). + * The unique-tuple constraint on `(userId, kind, value)` means a + * persona with a `delegation:33` scope that just expired can be + * re-granted by inserting a new row — the resolver picks up the + * fresh one on the next sign-in. No background cleanup job in v1; + * expired rows are harmless until either a re-grant fires the + * unique constraint or an admin manually purges them. + * + * **Catalogue defense.** Rows with an unknown `kind` (off-catalogue + * — should be impossible given the drift gate, but possible if a + * future migration writes raw SQL or a future operator runs an + * unguarded INSERT) are skipped + logged. Defensive read; the write + * path is the canonical place to enforce catalogue membership. + */ +@Injectable() +export class PrismaScopeResolver extends ScopeResolver { + constructor( + private readonly prisma: PrismaService, + private readonly logger: Logger, + ) { + super(); + } + + async resolve({ userId }: { userId: string }): Promise> { + const now = new Date(); + const rows = await this.prisma.userScope.findMany({ + where: { + userId, + OR: [{ expiresAt: null }, { expiresAt: { gt: now } }], + }, + select: { kind: true, value: true }, + }); + + const scopes: Scope[] = []; + for (const row of rows) { + const scope = toScope(row.kind, row.value); + if (scope === null) { + this.logger.warn( + { + event: 'scope_resolver.unknown_kind', + userId, + kind: row.kind, + }, + 'PrismaScopeResolver', + ); + continue; + } + scopes.push(scope); + } + return scopes; + } +} + +/** + * Map a `(kind, value)` DB row to a typed `Scope`. Returns null for + * off-catalogue kinds so the resolver can log + skip them without a + * throw. Exported for the spec — call sites should always go through + * the resolver. + */ +export function toScope(kind: string, value: string): Scope | null { + if (!isScopeKind(kind)) return null; + switch (kind) { + case 'self': + case 'siege': + case 'unrestricted': + return { kind }; + case 'etablissement': + case 'delegation': + case 'region': + return { kind, value }; + } +} diff --git a/infra/test-tenant.personas.example.json b/infra/test-tenant.personas.example.json new file mode 100644 index 0000000..07fa2d8 --- /dev/null +++ b/infra/test-tenant.personas.example.json @@ -0,0 +1,22 @@ +{ + "_README": "Per-persona Entra `oid` map for the apf-portal test tenant. Consumed by `apps/portal-bff/prisma/seed.ts` to upsert Person + User + UserScope rows for the 19 test personas. Distinct from `test-tenant.entra.json` which carries the 24 functional-role GROUP guids. Copy this file to `test-tenant.personas.json` (gitignored) and fill in the real per-user oids from the Entra portal. Keys must match the persona slugs hardcoded in `prisma/seed.ts`; missing keys cause the seed to skip that persona (logged at warn).", + "admin": "00000000-0000-0000-0000-000000000200", + "directeur-bordeaux": "00000000-0000-0000-0000-000000000201", + "directeur-complexe": "00000000-0000-0000-0000-000000000202", + "rh-aquitaine": "00000000-0000-0000-0000-000000000203", + "rh-siege": "00000000-0000-0000-0000-000000000204", + "collaborateur-simple": "00000000-0000-0000-0000-000000000205", + "tresorier-bordeaux": "00000000-0000-0000-0000-000000000206", + "dpo": "00000000-0000-0000-0000-000000000207", + "it": "00000000-0000-0000-0000-000000000208", + "benevole-aquitaine": "00000000-0000-0000-0000-000000000209", + "chef-equipe-bordeaux": "00000000-0000-0000-0000-00000000020a", + "chef-service-bordeaux": "00000000-0000-0000-0000-00000000020b", + "directeur-territorial-aquitaine": "00000000-0000-0000-0000-00000000020c", + "juriste-siege": "00000000-0000-0000-0000-00000000020d", + "rssi": "00000000-0000-0000-0000-00000000020e", + "communication-siege": "00000000-0000-0000-0000-00000000020f", + "elu-ca-national": "00000000-0000-0000-0000-000000000210", + "president-cd-aquitaine": "00000000-0000-0000-0000-000000000211", + "secretaire-cd-aquitaine": "00000000-0000-0000-0000-000000000212" +} diff --git a/package.json b/package.json index 81f301d..6d5e3a9 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,8 @@ } }, "prisma": { - "schema": "apps/portal-bff/prisma/schema.prisma" + "schema": "apps/portal-bff/prisma/schema.prisma", + "seed": "ts-node --transpile-only --compiler-options {\"module\":\"CommonJS\",\"esModuleInterop\":true,\"target\":\"ES2020\"} apps/portal-bff/prisma/seed.ts" }, "devDependencies": { "@analogjs/vite-plugin-angular": "~2.5.0",