Files
apf_portal/apps/portal-bff/prisma/seed.ts
T
Julien Gautier 2725436801
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
feat(auth): swap stub for PrismaScopeResolver + add test-tenant seed (ADR-0026 PR 2a)
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).
2026-05-26 14:29:37 +02:00

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);
});