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