import { readFileSync } from 'node:fs'; import { isAbsolute, resolve } from 'node:path'; import { EntraGroupMapError, EntraGroupToRoleResolver, parseEntraGroupMap } from 'shared-auth'; /** * Outcome of `loadEntraGroupToRoleResolver` — paired with the * source path (when set) so the boot log can name the file that * was loaded, even when the resolver itself ends up empty. */ export interface EntraGroupResolverLoadResult { readonly resolver: EntraGroupToRoleResolver; /** `null` when no path was configured (env var unset). */ readonly sourcePath: string | null; } /** * Loads the Entra group-GUID → role-slug map at BFF boot per * [ADR-0025 §"Sources of truth — Entra-side configuration"](../../../../docs/decisions/0025-authorization-model-privileges-roles-scopes.md). * * Sourcing precedence: * 1. `ENTRA_GROUP_MAP_PATH` env var → JSON file path (absolute, * or relative to `cwd`). * 2. Unset → empty resolver, WARN logged at boot. Sign-in still * works but every user gets an empty `roles[]` until the * operator wires up the file. Acceptable for fresh dev * environments; production should always set the var. * * Failure modes: * - File configured but unreadable / missing → empty resolver, * WARN. Same fail-soft posture: an operator pointing at the * wrong path should not block every sign-in. * - File present but malformed (bad JSON, wrong shape, unknown * slug, duplicate GUID) → throws. A misconfigured map is a * hard-fail because the alternative is silently mis-resolving * a role. * * The function never reads `process.env` outside the documented * key and never mutates it. */ export function loadEntraGroupToRoleResolver(opts: { cwd?: string; env?: NodeJS.ProcessEnv; onWarn: (event: string, payload: Record) => void; }): EntraGroupResolverLoadResult { const env = opts.env ?? process.env; const cwd = opts.cwd ?? process.cwd(); const rawPath = env['ENTRA_GROUP_MAP_PATH']; if (typeof rawPath !== 'string' || rawPath === '') { opts.onWarn('auth.entra_group_map_path_unset', { hint: 'set ENTRA_GROUP_MAP_PATH to infra/-tenant.entra.json — sign-in will succeed but resolve zero functional roles until configured', }); return { resolver: new EntraGroupToRoleResolver(new Map()), sourcePath: null }; } const absolutePath = isAbsolute(rawPath) ? rawPath : resolve(cwd, rawPath); let raw: string; try { raw = readFileSync(absolutePath, 'utf8'); } catch (err) { opts.onWarn('auth.entra_group_map_file_unreadable', { path: absolutePath, message: err instanceof Error ? err.message : String(err), }); return { resolver: new EntraGroupToRoleResolver(new Map()), sourcePath: absolutePath }; } let parsed: unknown; try { parsed = JSON.parse(raw); } catch (err) { throw new EntraGroupMapError( `Entra group map at ${absolutePath} is not valid JSON: ${ err instanceof Error ? err.message : String(err) }`, ); } if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { throw new EntraGroupMapError( `Entra group map at ${absolutePath} must be a JSON object keyed on group GUID.`, ); } const stringified: Record = {}; for (const [key, value] of Object.entries(parsed as Record)) { if (typeof value !== 'string') { throw new EntraGroupMapError( `Entra group map entry "${key}" in ${absolutePath} must be a string (the role slug); got ${typeof value}.`, ); } stringified[key] = value; } const map = parseEntraGroupMap(stringified); return { resolver: new EntraGroupToRoleResolver(map), sourcePath: absolutePath }; }