import { Inject, Injectable } from '@nestjs/common'; import { Logger } from 'nestjs-pino'; import { EntraGroupToRoleResolver, isPrivilege, type Principal, type Privilege } from 'shared-auth'; import { ENTRA_GROUP_TO_ROLE_RESOLVER } from './entra-group-to-role.token'; import { ScopeResolver } from './scope-resolver'; import type { AuthenticatedUser } from './auth.service'; /** * Builds the session-resident `Principal` per * [ADR-0025 §"Principal shape"](../../../../docs/decisions/0025-authorization-model-privileges-roles-scopes.md). * * Composes the three orthogonal axes once at sign-in: * - **Privileges** — pulled from the `roles` claim, filtered to * the closed `PRIVILEGES` catalogue. Unknown values are * dropped (with a WARN) rather than honoured: the BFF only * reasons about catalogue privileges, anything else is * either a tenant misconfiguration or a leftover from a v1+1 * experiment that should not silently grant access. * - **Functional roles** — resolved from the `groups` claim via * `EntraGroupToRoleResolver`. Unknown GUIDs are logged at * WARN (per ADR-0025 §"Sources of truth — Entra-side * configuration") and ignored. * - **Scopes** — resolved by `ScopeResolver` from the portal-side * `user_scopes` table (schema landed in ADR-0026 PR 1; the * `PrismaScopeResolver` consumer lands in PR 2). v1 stubs to * `[{ kind: 'unrestricted' }]`. * * Built once per sign-in, not per request: the session payload * carries the resolved `Principal` so guards can read it without * re-doing the GUID lookup on every API call. * * The `identity` parameter (UUIDs from `PersonAndUserProvisioner`) * populates `Principal.user.{id, personId}` with the real portal- * side identifiers introduced in ADR-0026 PR 1 — pre-PR-1 the BFF * reused the Entra `oid` as a placeholder. */ @Injectable() export class PrincipalBuilder { constructor( @Inject(ENTRA_GROUP_TO_ROLE_RESOLVER) private readonly groupToRole: EntraGroupToRoleResolver, private readonly scopes: ScopeResolver, private readonly logger: Logger, ) {} async build( user: AuthenticatedUser, identity: { userId: string; personId: string }, ): Promise { const privileges = filterPrivileges(user.roles, user.oid, this.logger); const roles = this.groupToRole.resolve(user.groups, (groupId: string) => { this.logger.warn( { event: 'auth.unknown_group_claim', oid: user.oid, groupId, }, 'PrincipalBuilder', ); }); const scopes = await this.scopes.resolve({ userId: identity.userId }); return { user: { id: identity.userId, personId: identity.personId, entraOid: user.oid, tenantId: user.tid, displayName: user.displayName, }, privileges, roles, scopes, amr: user.amr, }; } } function filterPrivileges( rawRoles: readonly string[], oid: string, logger: Logger, ): ReadonlyArray { const out: Privilege[] = []; for (const value of rawRoles) { if (isPrivilege(value)) { out.push(value); } else { // A value in the `roles` claim that is not a known privilege // is either a leftover from a different app registration, a // typo in the Entra manifest, or a future privilege not yet // in the catalogue. None of those should silently grant // access — drop with a WARN so an operator can investigate. logger.warn( { event: 'auth.unknown_privilege_claim', oid, value, }, 'PrincipalBuilder', ); } } return out; }