diff --git a/CLAUDE.md b/CLAUDE.md index a08bc2b..51257f4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,6 +42,7 @@ The structural choices are recorded as ADRs and summarized below. Any change to - **Authentication flow:** OIDC Authorization Code + PKCE via `@azure/msal-node`, executed entirely on the BFF; SPA never holds tokens; `__Host-` prefixed cookies, double-submit CSRF, RP-initiated logout — see [ADR-0009](decisions/0009-auth-flow-oidc-pkce-msal-node.md). - **Observability:** Pino + `nestjs-pino` for structured JSON logs, OpenTelemetry SDK + auto-instrumentations for traces, W3C Trace Context propagation across SPA → BFF → DB → Redis, `nestjs-cls` for request-scoped context (`trace_id`, `session_id`, `user_id_hash`, `audience`), 100 % sampling at the app with tail sampling deferred to the OTel Collector, stdout + OTLP shipping — see [ADR-0012](decisions/0012-observability-pino-opentelemetry.md). - **Audit trail:** dedicated `audit.events` schema in the same Postgres instance, append-only by Postgres role grants (`audit_writer` INSERT, `audit_reader` SELECT, `audit_archiver` DELETE older than retention; no `UPDATE`/`TRUNCATE` to anyone); 365-day retention default; cross-referenced with app logs via `trace_id` and `actor_id_hash` (same salt); blocking writes (no audit ⇒ no action) — see [ADR-0013](decisions/0013-audit-trail-separated-postgres-append-only.md). +- **Downstream API access:** unified `DownstreamApiClient` (`@nestjs/axios` + `cockatiel`), per-service `DownstreamApiConfig`; default auth strategy is **OBO via MSAL Node** for Entra-protected APIs (downstream-scoped tokens cached in Redis with AES-256-GCM under a dedicated key); fallback strategy is service credential + signed `X-User-Assertion` JWT (BFF JWKS at `/.well-known/jwks.json`); per-call audience pre-check; no `axios`/`fetch` outside `src/downstream/` — see [ADR-0014](decisions/0014-downstream-api-access-obo-pattern.md). - **Local quality gates:** Husky + lint-staged + commitlint with Conventional Commits — see [ADR-0007](decisions/0007-pre-commit-hooks-and-conventional-commits.md). - **Runtime:** Node.js latest LTS major. diff --git a/decisions/0014-downstream-api-access-obo-pattern.md b/decisions/0014-downstream-api-access-obo-pattern.md new file mode 100644 index 0000000..49a18fc --- /dev/null +++ b/decisions/0014-downstream-api-access-obo-pattern.md @@ -0,0 +1,248 @@ +--- +status: accepted +date: 2026-04-29 +decision-makers: R&D Lead +tags: [security, backend] +--- + +# Downstream API access — On-Behalf-Of pattern, unified `DownstreamApiClient`, audience-aware authorization + +## Context and Problem Statement + +The portal will integrate access to existing applications. Some of those applications expose APIs the BFF will call on behalf of the authenticated user; some are slated to be re-developed as portal features and will expose their own APIs. The downstream auth landscape will be heterogeneous — some Entra-protected, some with legacy auth (API key, mTLS, custom OAuth client-credentials), some willing to trust a signed identity assertion from the BFF. + +The concrete list of downstream services is not known yet (the project lead deferred those decisions). This ADR fixes the **framework** in which any future integration plugs in. Without a fixed framework, the day a developer needs to call a downstream the answer becomes a fresh debate or — worse — an ad-hoc `axios.post` in a controller. + +## Decision Drivers + +* No token ever leaves the BFF except through the legitimate downstream-call path. +* A single canonical place where downstream HTTP calls are made — never a `fetch`/`axios` directly in a controller or a service. +* Resilience: a slow or failing downstream cannot cascade into the BFF's general latency or saturate its event loop. +* Observability: every downstream call is part of the trace started by the user click ([ADR-0012](0012-observability-pino-opentelemetry.md)). +* Audience-aware: a downstream that is workforce-only must not be reachable by a customer-audience session, even by mistake. The dual-audience design ([ADR-0008](0008-identity-model-entra-workforce-dual-audience.md)) is honoured at the call site. +* Forward-compatible: a new downstream is added by configuration, not refactor. +* Trust-realistic: legacy downstreams that can't (yet) speak Entra must have a path that is not "store the user's password somewhere". + +## Considered Options + +### Authentication strategy +* **OBO (On-Behalf-Of) for Entra-protected downstreams.** (Chosen as default.) +* **Service credential + signed user-assertion header for non-Entra downstreams.** (Chosen as fallback.) +* Token relay (the BFF passes the user's access token unchanged). +* Per-user credential mapping (the BFF stores each user's credentials for each legacy downstream). + +### HTTP client +* **`@nestjs/axios`.** (Chosen.) +* `got`, `undici`, native `fetch`. + +### Resilience +* **`cockatiel`** — TypeScript-native composable policies (timeout, retry, circuit breaker, bulkhead). (Chosen.) +* `p-retry` / `opossum` / hand-rolled. + +### Token cache (for OBO) +* **Redis with AES-256-GCM, dedicated key.** (Chosen.) +* In-memory only. +* No cache. + +### Service registration +* **Module-level typed config.** (Chosen.) +* Decorator-based registration. + +## Decision Outcome + +A `DownstreamApisModule` provides a `DownstreamApiClientFactory` that produces a typed client per registered downstream. A service is declared via a `DownstreamApiConfig` block: + +```ts +export type DownstreamApiConfig = { + name: string; + baseUrl: string; // env-driven + auth: + | { strategy: 'obo'; resource: string; scopes: string[] } + | { strategy: 'service'; credential: ServiceCredential; userAssertion: SignedAssertionConfig | null } + | { strategy: 'none' }; + timeoutMs: number; + retry: { attempts: number; baseDelayMs: number; maxDelayMs: number; jitter: boolean }; + circuitBreaker: { halfOpenAfterMs: number; threshold: { ratio: number; samples: number } }; + bulkhead: { maxConcurrent: number }; + audienceConstraint: ReadonlyArray<'workforce' | 'customer'>; +}; +``` + +### OBO strategy (Entra-protected downstreams) + +The factory composes an OBO authenticator that uses MSAL Node's `acquireTokenOnBehalfOf` with the user's current Entra access token (read from session via CLS) to obtain a downstream-scoped token. The downstream-scoped token is **cached in Redis** under `obo:{user_id_hash}:{resource}` with TTL equal to the token's expiry minus a safety buffer (60 s). The cached value is encrypted with **AES-256-GCM** using a **dedicated key** (`OBO_CACHE_ENCRYPTION_KEY`), distinct from `SESSION_ENCRYPTION_KEY` ([ADR-0010](0010-session-management-redis.md)) so a cache-key compromise does not cascade into session compromise. + +On cache miss or imminent expiry, the BFF calls Entra's OBO endpoint and re-caches the result. Failures (Entra unreachable, OBO refused, the user's access token expired) emit an audit event (`auth.token.validation.failed` from [ADR-0013](0013-audit-trail-separated-postgres-append-only.md)) and surface as a 502 to the caller — the BFF does **not** silently fall back to the user's original token. + +### Service strategy (non-Entra downstreams) + +The BFF authenticates *as itself* with the configured `ServiceCredential` (API key, OAuth client-credentials grant, mTLS, …). The user identity is propagated via a **signed assertion header** — `X-User-Assertion`, a short-lived JWT signed by the BFF's private key, carrying a minimal claim set: + +```jsonc +{ + "iss": "portal-bff", + "sub": "", + "aud": "", + "audience": "workforce" | "customer", + "claims": { /* a curated subset */ }, + "exp": , + "iat": , + "trace_id": "" +} +``` + +The downstream verifies the signature against the BFF's published JWKS at `/.well-known/jwks.json` and then makes its own authorization decision based on the assertion. This requires the downstream to **trust the BFF as an identity authority** — that trust is established per integration, in a written agreement, before the integration ships. It is not a free lunch. + +### Rejected — token relay + +Token relay (passing the user's access token unchanged to a downstream) is rejected as a default because it requires the downstream to share the BFF's audience/resource — fragile and non-portable. It can be considered as a per-integration deviation when the downstream is, in fact, the same Entra-registered API as the BFF (rare, ADR per case). + +### Rejected — per-user credential mapping + +Per-user credential mapping is **rejected for v1**. If a downstream cannot be served by either OBO or service+assertion, the integration becomes its own ADR with its own justification. Storing user credentials for legacy systems is the kind of operational liability the project has explicitly excluded. + +### HTTP client + +`@nestjs/axios` (axios under the hood). One client instance per registered service; configured with the per-service `baseUrl`, the resolved auth header from the active strategy, and the per-call timeout. NestJS interceptors apply tracing, the audience pre-check, and error mapping uniformly. + +### Resilience policies (composed via `cockatiel`, outermost first) + +1. **Timeout** — every call has a hard deadline (default 5 s). +2. **Retry** — only on idempotent verbs (`GET`, `PUT`, `DELETE`) and retriable error classes (network errors, HTTP 5xx, HTTP 429 with `Retry-After` honoured). `POST` retries only when the caller explicitly opts in with an idempotency key. Exponential backoff with jitter. +3. **Circuit breaker** — per service, opens when the failure ratio exceeds the configured threshold over a sample window; half-open after `halfOpenAfterMs`. +4. **Bulkhead** — per service, caps concurrent in-flight calls so a slow service can't starve the BFF. + +### Audience pre-check + +Before any call leaves the BFF, the client reads the current request's `audience` from CLS (ADR-0012) and rejects with HTTP 403 + an `authz.deny` audit event (ADR-0013) if the audience is not in the service's `audienceConstraint`. In v1 every service is `audienceConstraint: ['workforce']`; the constraint is checked anyway so the dual-audience design is honoured from the first integration. The check is at the *call site*, not at controller entry — even a missed authorization guard upstream cannot bypass it. + +### Observability + +Each call opens a custom OpenTelemetry span `downstream...` with attributes: `service.name`, `attempt`, `http.status_code`, `retry.count`, `breaker.state`. The HTTP auto-instrumentation propagates `traceparent` to the downstream automatically (W3C — ADR-0012). Pino logs include `service`, `attempt`, `status`, `duration_ms`. Auth-related failures emit audit events. + +### Error translation + +Downstream errors are translated to BFF errors at the client boundary — **never** bubbled with the raw payload. The translation table lives with each service config (e.g. a downstream 404 may be a 502 in the BFF if the resource was supposed to exist, or a 404 if the BFF route exposes it). This keeps downstream internals out of the SPA and out of error logs accessible by SPA consumers. + +### Per-integration ADRs + +A downstream that requires a non-default auth strategy, a custom retry policy, a different audience model, or a special trust relationship gets its own ADR. Trivial integrations (Entra OBO, default policies, workforce-only) live in code config without a dedicated ADR. The threshold is *non-trivial deviation*, not *every integration*. + +### Configuration (env-driven) + +| Variable | Purpose | +| --- | --- | +| `OBO_CACHE_ENCRYPTION_KEY` | 32-byte base64 AES-GCM key for the OBO token cache; refused at startup if malformed | +| `BFF_JWKS_PRIVATE_KEY_PATH` | path to the BFF's private key for signing user-assertion JWTs | +| `BFF_JWKS_KID` | key id published in `/.well-known/jwks.json` | +| `_API_BASE_URL` (per service) | service-specific base URL; required for any registered service | +| `_TIMEOUT_MS` (per service, optional) | overrides the default 5 s timeout | + +### Consequences + +* Good, because every downstream call goes through the same plumbing — same observability, same resilience, same audit posture, same audience check. Drift requires leaving the framework, which review will not accept. +* Good, because OBO + cached tokens keeps Entra rate limits low and call latency predictable. +* Good, because the audience constraint at the call site enforces the dual-audience design at the *narrowest* point that actually emits a request. +* Good, because the signed-assertion strategy gives a clean answer for non-Entra downstreams without storing user credentials. +* Good, because resilience composition is explicit and testable — tail latency under partial failure is bounded. +* Good, because a per-service translation of error codes prevents downstream details from leaking into BFF error responses. +* Bad, because the framework is non-trivial — contributors must learn it before adding a downstream. Mitigated by a one-page "how to add a downstream" guide in `docs/`, written when the first integration ships. +* Bad, because the signed-assertion strategy requires the downstream to validate the BFF's JWT — operational coordination per integration, not a magic switch. +* Bad, because the OBO cache adds a second tier-1 secret to manage (`OBO_CACHE_ENCRYPTION_KEY`). Folded into the future ops/secret-rotation ADR. +* Bad, because the framework is forward-looking — there is no concrete v1 caller. Risk of drift between framework and real needs. Mitigated by writing the framework code only in the same iteration as the first concrete integration; until then, this ADR plus mock-driven unit tests on the strategies (OBO, signed-assertion) keep the design honest. + +### Confirmation + +* `apps/portal-bff/src/downstream/downstream.module.ts` exposes `DownstreamApiClientFactory`. +* `apps/portal-bff/src/downstream/strategies/obo.strategy.ts` and `service-with-assertion.strategy.ts` exist with unit tests against MSAL Node mocks (OBO) and JWT-verification mocks (assertion). +* `apps/portal-bff/src/downstream/token-cache.service.ts` reads/writes Redis with AES-256-GCM via the dedicated key; an integration test asserts the ciphertext is unrecoverable without the key and that tampering is rejected. +* `cockatiel` policies are composed in `apps/portal-bff/src/downstream/resilience.ts`. Tests cover: timeout firing, retry on 5xx, circuit-breaker open/half-open transitions, bulkhead saturation. +* The audience pre-check rejects with 403 and emits `authz.deny` (ADR-0013); a test exercises a customer-audience principal targeting a workforce-only service. +* Each call produces a `downstream...` span; an integration test asserts trace continuity from the BFF's incoming request to the downstream span. +* The BFF refuses to start if `OBO_CACHE_ENCRYPTION_KEY`, `BFF_JWKS_PRIVATE_KEY_PATH`, or `BFF_JWKS_KID` is missing or malformed. +* No production controller imports `axios` or `fetch` directly — enforced by an ESLint rule that flags those imports outside `apps/portal-bff/src/downstream/`. (Rule lands with the future CI ADR.) + +## Pros and Cons of the Options + +### Authentication strategy + +#### OBO for Entra (chosen default) + +* Good, because it is Microsoft's recommended pattern for confidential clients calling Entra-protected APIs on behalf of a user. +* Good, because preserves the user's audience, claims, and tenancy in the downstream token. +* Bad, because requires Entra-protected downstreams. + +#### Service credential + signed user-assertion (chosen for non-Entra) + +* Good, because retains user identity for downstream authorization without giving the downstream a token it could replay against the IdP. +* Good, because the BFF acts as a credentialed gateway — the standard pattern when downstreams cannot be brought into the IdP. +* Bad, because requires per-integration trust setup (publishing JWKS, having the downstream verify). + +#### Token relay + +* Good, because zero exchange. +* Bad, because only works if the downstream and the BFF share the exact same audience/resource. Non-portable. +* Bad, because a leaky downstream that logs Authorization headers leaks the user's BFF-scoped token. + +#### Per-user credential mapping + +* Bad, because operationally toxic — storing user passwords or tokens for legacy systems, violating user secrecy. Rejected for v1. + +### HTTP client + +#### `@nestjs/axios` (chosen) + +* Good, because mainstream, integrates with NestJS DI, easy to wrap with interceptors and OTel. + +#### `got` / `undici` / native `fetch` + +* Good, because lighter or more modern. +* Bad, because no NestJS-blessed wrapper; we'd reinvent the surface for marginal gain. + +### Resilience + +#### `cockatiel` (chosen) + +* Good, because TypeScript-native, composable policies, well-maintained. +* Good, because exposes timeout, retry, circuit breaker, bulkhead, fallback as first-class concepts. + +#### `p-retry` / `opossum` + +* Good, because simpler. +* Bad, because narrower (one concern each) — composing them into a coherent policy stack reproduces what `cockatiel` already gives. + +### Token cache + +#### Redis encrypted (chosen) + +* Good, because shared across BFF replicas, survives restart, encrypted at rest. +* Bad, because adds a Redis round-trip per OBO acquisition. Negligible vs. an Entra round-trip. + +#### In-memory only + +* Bad, because per-replica caches duplicate work and break cache hit-rate under scale-out. + +#### No cache + +* Bad, because hits Entra rate limits and adds 100–300 ms per downstream call. + +### Service registration + +#### Typed config (chosen) + +* Good, because explicit, reviewable, IDE-discoverable. +* Bad, because every new service requires a config commit. Acceptable. + +#### Decorator-based + +* Good, because feels Nest-native. +* Bad, because hides registration in source — harder to grep, harder to audit. Defer. + +## More Information + +* Microsoft On-Behalf-Of flow: https://learn.microsoft.com/entra/identity-platform/v2-oauth2-on-behalf-of-flow +* MSAL Node `acquireTokenOnBehalfOf`: https://learn.microsoft.com/javascript/api/@azure/msal-node/confidentialclientapplication +* `@nestjs/axios`: https://docs.nestjs.com/techniques/http-module +* `cockatiel`: https://github.com/connor4312/cockatiel +* RFC 7517 (JWKS): https://datatracker.ietf.org/doc/html/rfc7517 +* Related ADRs: [ADR-0005](0005-backend-stack-nestjs.md), [ADR-0008](0008-identity-model-entra-workforce-dual-audience.md), [ADR-0009](0009-auth-flow-oidc-pkce-msal-node.md), [ADR-0010](0010-session-management-redis.md), [ADR-0012](0012-observability-pino-opentelemetry.md), [ADR-0013](0013-audit-trail-separated-postgres-append-only.md). diff --git a/decisions/README.md b/decisions/README.md index 5af4615..33c6cd7 100644 --- a/decisions/README.md +++ b/decisions/README.md @@ -57,3 +57,4 @@ ADRs are listed in numerical order. To slice by topic, filter on the `Tags` colu | [0011](0011-mfa-enforcement-entra-conditional-access.md) | MFA enforcement — Entra ID Conditional Access baseline, BFF claim sanity-check, step-up hooks designed-in | accepted | `security` | 2026-04-29 | | [0012](0012-observability-pino-opentelemetry.md) | Observability — Pino structured logs + OpenTelemetry tracing, W3C Trace Context propagation, stdout + collector | accepted | `observability`, `backend`, `frontend` | 2026-04-29 | | [0013](0013-audit-trail-separated-postgres-append-only.md) | Audit trail — separated append-only Postgres schema, decoupled from app logs | accepted | `security`, `observability`, `data` | 2026-04-29 | +| [0014](0014-downstream-api-access-obo-pattern.md) | Downstream API access — On-Behalf-Of pattern, unified `DownstreamApiClient`, audience-aware authorization | accepted | `security`, `backend` | 2026-04-29 |