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:
@@ -84,6 +84,16 @@ ENTRA_ADMIN_POST_LOGOUT_REDIRECT_URI=http://localhost:4300/
|
|||||||
# GUIDs from the Entra admin centre.
|
# GUIDs from the Entra admin centre.
|
||||||
ENTRA_GROUP_MAP_PATH=infra/test-tenant.entra.json
|
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
|
# Cookie signing secret (per ADR-0009 §"Cookies"). Used to sign the
|
||||||
# transient pre-auth cookie that carries the OIDC `state` + PKCE
|
# transient pre-auth cookie that carries the OIDC `state` + PKCE
|
||||||
# verifier between the /auth/login redirect and the /auth/callback
|
# verifier between the /auth/login redirect and the /auth/callback
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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 { ENTRA_GROUP_TO_ROLE_RESOLVER } from './entra-group-to-role.token';
|
||||||
import { MSAL_CLIENT } from './msal-client.token';
|
import { MSAL_CLIENT } from './msal-client.token';
|
||||||
import { PrincipalBuilder } from './principal-builder';
|
import { PrincipalBuilder } from './principal-builder';
|
||||||
|
import { PrismaScopeResolver } from './prisma-scope-resolver';
|
||||||
import { RequireMfaGuard } from './require-mfa.guard';
|
import { RequireMfaGuard } from './require-mfa.guard';
|
||||||
import { RequirePrivilegeGuard } from './require-privilege.guard';
|
import { RequirePrivilegeGuard } from './require-privilege.guard';
|
||||||
import { RequireRoleGuard } from './require-role.guard';
|
import { RequireRoleGuard } from './require-role.guard';
|
||||||
import { RequireScopeGuard } from './require-scope.guard';
|
import { RequireScopeGuard } from './require-scope.guard';
|
||||||
import { ScopeResolver, StubScopeResolver } from './scope-resolver';
|
import { ScopeResolver } from './scope-resolver';
|
||||||
import { SessionEstablisher } from './session-establisher.service';
|
import { SessionEstablisher } from './session-establisher.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,7 +59,11 @@ import { SessionEstablisher } from './session-establisher.service';
|
|||||||
RequireScopeGuard,
|
RequireScopeGuard,
|
||||||
SessionEstablisher,
|
SessionEstablisher,
|
||||||
PrincipalBuilder,
|
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,
|
provide: ENTRA_CONFIG,
|
||||||
useFactory: () => assertEntraConfig(),
|
useFactory: () => assertEntraConfig(),
|
||||||
|
|||||||
@@ -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<typeof makeLogger>;
|
||||||
|
} {
|
||||||
|
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: <Date>` 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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<ReadonlyArray<Scope>> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
+2
-1
@@ -57,7 +57,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"prisma": {
|
"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": {
|
"devDependencies": {
|
||||||
"@analogjs/vite-plugin-angular": "~2.5.0",
|
"@analogjs/vite-plugin-angular": "~2.5.0",
|
||||||
|
|||||||
Reference in New Issue
Block a user