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