feat(auth): swap stub for PrismaScopeResolver + add test-tenant seed (ADR-0026 PR 2a)
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
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
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).
This commit is contained in:
@@ -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<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);
|
||||
});
|
||||
Reference in New Issue
Block a user