chore: relocate ADRs from decisions/ to docs/decisions/ to consolidate documentation
Move the ADR folder under docs/ alongside the rest of the project
documentation. Convention (flat folder, globally-sequential 4-digit
numbering, tags-based categorization, MADR 4.0.0 format) is unchanged
- only the path moved.
- git mv decisions docs/decisions preserves history for all 18 ADRs +
README + template (19 files renamed in this commit).
- ADR-0001 amended in-place with a dated note documenting the
relocation. Status remains 'accepted' - the location detail
changed, the decision did not.
- All cross-references updated:
- CLAUDE.md (~17 ADR links + 3 mentions of decisions/ in the Project
rules section)
- docs/README.md (now references decisions/ as a sibling under docs/)
- docs/setup/03-angular-nx-monorepo.md (paths shortened from
../../decisions/ to ../decisions/, since setup/ and decisions/ are
now both inside docs/)
- docs/decisions/0003 ../CLAUDE.md adjusted to ../../CLAUDE.md
(one extra level of nesting)
- docs/decisions/template.md mention of the README path
- notes/asvs-level-decision-briefing-rssi.md mention of the index
Sanity verified: every ADR link in CLAUDE.md, docs/setup/03, and
docs/decisions/0001 resolves to an existing file. pnpm nx run-many
-t lint passes on 8 projects.
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
---
|
||||
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": "<actor_id_hash>",
|
||||
"aud": "<downstream-name>",
|
||||
"audience": "workforce" | "customer",
|
||||
"claims": { /* a curated subset */ },
|
||||
"exp": <now + 60s>,
|
||||
"iat": <now>,
|
||||
"trace_id": "<W3C 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.<service>.<verb>.<path>` 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` |
|
||||
| `<SERVICE>_API_BASE_URL` (per service) | service-specific base URL; required for any registered service |
|
||||
| `<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.<service>.<verb>.<path>` 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).
|
||||
Reference in New Issue
Block a user