From e2fae027310ab5cb26c9aa638e7001ac82931a34 Mon Sep 17 00:00:00 2001 From: Julien Gautier Date: Wed, 13 May 2026 23:41:51 +0200 Subject: [PATCH] docs(adr-0021): phase-2 security baseline (helmet, CORS, CSRF, rate-limit, error envelope) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents the security middleware stack shipped across the five most recent BFF PRs (#115, #117, #120, #122, #123) as a single MADR ADR. Today the rationale lives in code comments and PR descriptions; the next contributor reaching for `csurf`, cookie-only CSRF, or a hardcoded localhost CORS fallback didn't have one place to read why those are wrong here. Contents: - Response envelope: { error: { code, message, traceId } }, single contract across Nest's filter and raw middlewares via the errorResponse() helper. Status-code mapping documented. 500s never leak the underlying exception. - CSRF session-bound double-submit, not cookie-vs-header. The cookie is the SPA's read-only mirror; the source of truth is req.session.csrfToken — subdomain-takeover injection can't bypass. - CORS allowlist env-driven, mandatory, no fallback. Catches the "works in dev breaks in prod" misconfiguration at boot. - Rate limit: sessionID-then-IP bucket key, 10/min auth + 120/min general, /api/health skipped, in-memory v1 store. - Helmet defaults + three overrides (HSTS prod-only, COR cross-origin, CSP prod-only) with code-anchored justifications. Considered options mirror the actual debate — each rejected alternative has its "Bad, because" line so a reader sees why we didn't go that way. Each Decision Outcome line cross-references the file that enforces it. Scope is the IMPLEMENTATION-level baseline. The STRATEGIC baseline (ASVS reference level, HDS / GDPR / NIS 2 framing, RSSI sign-off) remains paused per CLAUDE.md. When it lands it confirms or supersedes pieces of 0021; the ADR is explicit about which choices are tactical. Index entry in docs/decisions/README.md updated in the same commit per the project convention. CLAUDE.md §"Repository status" still says the workspace is "not yet bootstrapped" and refs ADRs up to 0020 — that paragraph is stale (22 ADRs in place, project fully scaffolded). Worth a small dedicated docs PR; out of scope here. --- .../0021-phase-2-security-baseline.md | 244 ++++++++++++++++++ docs/decisions/README.md | 45 ++-- 2 files changed, 267 insertions(+), 22 deletions(-) create mode 100644 docs/decisions/0021-phase-2-security-baseline.md diff --git a/docs/decisions/0021-phase-2-security-baseline.md b/docs/decisions/0021-phase-2-security-baseline.md new file mode 100644 index 0000000..e08f83a --- /dev/null +++ b/docs/decisions/0021-phase-2-security-baseline.md @@ -0,0 +1,244 @@ +--- +status: accepted +date: 2026-05-13 +decision-makers: R&D Lead +tags: [security, backend] +--- + +# Phase-2 security baseline — helmet, CORS allowlist, double-submit CSRF, rate limiting, structured error envelope + +## Context and Problem Statement + +[ADR-0009](0009-auth-flow-oidc-pkce-msal-node.md) noted that the BFF in v1 ships with a minimal CORS allowlist and that the security-hardening stack — helmet, real allowlist, CSRF, rate limiting, structured error filter — would land "with the phase-2 security ADR". That phase-2 security ADR (the **baseline**, in the sense of "which ASVS level does the project target, which adjacent frameworks apply") is **paused** until the RSSI clarifies the reference level and the regulatory perimeter (HDS, GDPR, possibly PCI DSS / NIS 2). + +In the meantime, the BFF accumulated five PRs that landed concrete security middleware (#115 absolute-timeout, #117 SPA route guard + credentials interceptor, #122 helmet + CORS + CSRF, #123 rate-limit + structured error filter). Each PR captured its own rationale, but the choices live in code comments and PR descriptions — there is no single piece of documentation that says "this is why CSRF is session-bound", "this is the response envelope contract", "this is the rate-limit bucket strategy". A future contributor reaching for `csurf` or `cookie-only CSRF` because they don't know better is the kind of slip this ADR is meant to prevent. + +This ADR records **the concrete implementation choices** made in those five PRs. It is deliberately one notch below the strategic baseline (ASVS level, regulatory framing, RSSI sign-off) — that one lands separately when the RSSI input arrives, and this one becomes its v1 reference for "what the BFF actually does today". + +## Decision Drivers + +- Capture rationale while the memory is fresh — most of these choices are non-obvious without their context. +- Provide a single landing point for a security review (internal or external). +- Single response contract for every error so the SPA has one shape to parse and so 5xx never leak internals. +- Defense in depth: each middleware addresses a distinct attack class, and removing one shouldn't open the others. +- Sensible-by-default in production, fail-loudly-on-misconfiguration at boot. + +## Considered Options + +### Response envelope on errors + +- **`{ error: { code, message, traceId } }` shared by Nest's exception filter and raw middleware (chosen).** +- Per-route ad-hoc shapes (the pre-PR baseline). +- Nest's default `{ statusCode, message, error }` at the top level. + +### CSRF strategy + +- **Session-bound double-submit (server-side session token + JS-readable cookie + `X-CSRF-Token` header, comparison against the session token, not the cookie) (chosen).** +- Pure cookie-vs-header double-submit (no server-side storage). +- Synchronizer token pattern (server-side token, rendered into HTML — N/A, the BFF doesn't render HTML). +- `csurf` package. + +### CORS allowlist + +- **Mandatory env-driven allowlist via `CORS_ALLOWED_ORIGINS`, parsed and validated at boot, no fallback (chosen).** +- Hardcoded localhost fallback with env override. +- Open `*` policy gated by `credentials: false`. + +### Rate limiting bucket key + +- **`sessionID` when authenticated, remote IP otherwise (chosen).** +- IP only. +- User id (Entra `oid`) when authenticated. + +### Rate limiting tiers + +- **Two tiers via dynamic `max` per request: 10/min strict on `/auth/login` + `/auth/callback`, 120/min general (chosen).** +- One uniform limit. +- Per-route limits via separate `rateLimit()` instances. + +### Rate limiting store + +- **In-memory (single-instance default of `express-rate-limit`) (chosen for v1).** +- Redis-backed (`rate-limit-redis`). + +### Helmet CSP + +- **`helmet()` defaults in production, `contentSecurityPolicy: false` in development (chosen).** +- Custom CSP tailored to the BFF. +- Disabled everywhere (Nest's default has no CSP either). + +## Decision Outcome + +The BFF runs five middleware in this order, mounted in `apps/portal-bff/src/main.ts`: + +1. **`StructuredErrorFilter`** (registered via `app.useGlobalFilters` at the top of `bootstrap()`) — normalises every NestJS exception to `{ error: { code, message, traceId } }`. Source of truth for the response envelope. +2. **`helmet()`** — security headers. Three overrides: + - `hsts` only in production (dev runs on plain HTTP), + - `crossOriginResourcePolicy: 'cross-origin'` (SPA on its own origin reads JSON from the BFF), + - `contentSecurityPolicy: false` outside production (the BFF returns JSON; the default CSP triggers noisy `connect-src` violations in devtools without protecting anything we care about on JSON responses). +3. **CORS allowlist** — `CORS_ALLOWED_ORIGINS` env-driven, mandatory at boot. Parsed + validated (`http(s):` only, bare origins, no path/query). No localhost fallback. `X-CSRF-Token` declared in `allowedHeaders` alongside `Content-Type / Accept / Authorization / traceparent / tracestate`. +4. **Rate limiting** (`express-rate-limit`) — dynamic `max` per request: 10/min on the auth-flow entry routes, 120/min everywhere else, `/api/health` skipped. Bucket key = session id when `req.session.user` is set, else remote IP. In-memory store; v2 swaps for `rate-limit-redis` when we scale BFF horizontally. Response = same envelope as the filter (`code: 'rate_limited'`). +5. **CSRF middleware** (custom; not `csurf`, which is unmaintained) — double-submit, **session-bound**: token minted at session creation in `/auth/callback`, stored on `req.session.csrfToken` AND mirrored to a JS-readable cookie (`__Host-portal_csrf` prod / `portal_csrf` dev). Request middleware compares the `X-CSRF-Token` header against the **session-stored** token with `crypto.timingSafeEqual`. Skips safe methods (`GET / HEAD / OPTIONS`), unauthenticated requests, and the auth entry routes that mint the token (`/api/auth/login`, `/api/auth/callback`). + +The order is intentional: + +- The filter is registered first so it catches errors from any subsequent middleware initialisation. +- helmet → CORS → … so headers are set on every response, including pre-flight `OPTIONS`. +- Rate limit → CSRF: rate-limit rejects without paying the CSRF comparison cost; on an already-blocked client we don't burn cycles. +- Both raw middlewares (rate-limit, CSRF) use the shared `errorResponse()` helper exported by `StructuredErrorFilter` so the wire format is identical whether the response came through the filter or through a middleware short-circuit. + +### Response envelope contract + +```json +{ + "error": { + "code": "csrf", + "message": "CSRF token missing or invalid", + "traceId": "abc123…" + } +} +``` + +- **`code`** is the SPA's switch-on key. Stable strings: `unauthenticated`, `forbidden`, `not_found`, `csrf`, `rate_limited`, `bad_request`, `internal`, `service_unavailable`, and any custom code an `HttpException` declares via its response object (`new UnauthorizedException({ code: 'unauthenticated', message: '…' })`). +- **`message`** is safe, human-readable text. 5xx exceptions never put their internal message in the response — the full exception goes to the Pino `error` log line via the `err: exception` field; the response body says "Internal server error". +- **`traceId`** is the active OTel trace id (or `null`). Lets the user paste it into a support ticket and have ops correlate against Jaeger + audit rows + Pino lines in one query. + +### Configuration (env-driven) + +| Variable | Required | Purpose | +| ---------------------------- | ---------------- | --------------------------------------------------------------------------------------- | +| `CORS_ALLOWED_ORIGINS` | **yes** | Comma-separated `scheme://host[:port]` list. BFF refuses to boot if absent / malformed. | +| `RATE_LIMIT_PER_MINUTE` | no — default 120 | General bucket ceiling. | +| `RATE_LIMIT_AUTH_PER_MINUTE` | no — default 10 | Stricter bucket for `/auth/login` + `/auth/callback`. | + +`LOG_USER_ID_SALT`, `SESSION_SECRET`, `SESSION_ENCRYPTION_KEY` were promoted to mandatory at boot in earlier PRs; this ADR doesn't re-state them. + +### Consequences + +- Good, because every error response has the same shape; the SPA writes one branch to parse it. +- Good, because 5xx exception details never leak — the secure-by-default for unhandled errors. +- Good, because the CSRF cookie isn't trusted: an attacker who plants a cookie via subdomain takeover would still need the session-stored token, which is also the authenticated identity. Stronger than the canonical double-submit recipe. +- Good, because rate-limit buckets are per-account when authenticated; rotating sessions doesn't dodge the limit. +- Good, because boot-time validators on `CORS_ALLOWED_ORIGINS` catch the misconfiguration in dev / staging instead of in production. +- Bad, because in-memory rate-limit store means a multi-instance BFF would have per-instance counters. Mitigated by single-instance v1; the Redis-backed migration is one constructor arg. +- Bad, because the CSRF cookie must be readable by JS (`HttpOnly: false`), so an XSS that reads `document.cookie` can lift it. Mitigated by: (a) the session cookie remains `HttpOnly`, so an XSS still needs to call the BFF from the victim's browser, which means the user has to be present; (b) CSP and helmet defaults harden the SPA delivery; (c) the SPA bundle is small + audited; (d) tighten further by introducing a CSP nonce when admin SPA renders. +- Bad, because the structured envelope doesn't include a stable per-error machine-readable detail object yet (e.g. for ValidationPipe rejections, the `message` is a semicolon-joined string). A `details` field can be added without breaking the contract. +- Neutral, because no rate limiting on anonymous mutating routes today — none exist yet. The check will move to "per-IP for unauthenticated requests" if and when those routes appear. + +### Confirmation + +- `apps/portal-bff/src/main.ts` mounts the five middleware in the declared order; the comment block in `bootstrap()` lists their roles and rationale. +- `apps/portal-bff/src/security/structured-error.filter.ts` carries the `ErrorResponseBody` interface + `errorResponse()` helper. Used by `csrf.middleware.ts` (line: `res.status(403).json(errorResponse('csrf', '…'))`) and by `rate-limit.middleware.ts` (handler line). +- `apps/portal-bff/src/security/csrf.middleware.ts` reads from `req.session.csrfToken` (set in `apps/portal-bff/src/auth/auth.controller.ts` at the end of `/auth/callback`); the spec pins constant-time comparison + the exempt-paths list. +- `apps/portal-bff/src/security/rate-limit.middleware.ts` exports `readRateLimitConfig()` (env parsing + validation) and `createRateLimitMiddleware()` (factory); specs cover the bucket key strategy, the strict-vs-general tiers, the `/api/health` skip, and the 429 envelope shape. +- `apps/portal-bff/src/config/check-cors-allowlist.ts` enforces the boot-time validation; spec covers every rejected case (empty, non-URL, non-http(s), path/query present). +- The SPA `csrfInterceptor` in `libs/feature/auth/src/lib/csrf.interceptor.ts` reads the cookie and copies it into the header on every mutating BFF request; the spec asserts the interceptor never touches non-BFF origins or safe methods. +- Integration tests sit in the next phase: today the contract is held by unit-level specs (199 / 199 BFF, 28 / 28 feature-auth) under a clean-env repro (`env -u` every config var). A future "BFF smoke" test will black-box-verify all five middleware on a running stack. + +## Pros and Cons of the Options + +### Response envelope + +#### `{ error: { code, message, traceId } }` (chosen) + +- Good, because single shape across status codes and across Nest-vs-middleware origins. +- Good, because `traceId` makes any reported error joinable to the full request trace. +- Good, because the `code` field lets the SPA write a `switch` instead of pattern-matching on free-form messages. +- Bad, because no machine-readable `details` for field-level validation errors yet (mitigated by adding it later without changing the envelope). + +#### Nest default `{ statusCode, message, error }` + +- Good, because zero work. +- Bad, because `message` may be an array (ValidationPipe), a string, or an object depending on the exception path. Inconsistent for the SPA. +- Bad, because 500s leak the exception class name in `error`. + +#### Per-route ad-hoc shapes + +- Bad, because every route invents its own contract; the SPA has to special-case each path. + +### CSRF strategy + +#### Session-bound double-submit (chosen) + +- Good, because resistant to subdomain cookie injection: a planted cookie doesn't match the session-stored token. +- Good, because no shared-state needed beyond the session payload we already store in Redis. +- Bad, because requires server-side storage (we have it anyway). + +#### Pure cookie-vs-header double-submit + +- Good, because completely stateless on the server. +- Bad, because vulnerable to subdomain cookie injection: an attacker who can write `__Host-portal_csrf` cookie value (e.g. via subdomain takeover) can also generate the matching header. + +#### `csurf` package + +- Bad, because deprecated / unmaintained since 2022. Not an option. + +#### Synchronizer token rendered in HTML + +- Bad, because the BFF doesn't render HTML — the SPA is a separate static bundle. + +### CORS allowlist + +#### Mandatory env-driven, no fallback (chosen) + +- Good, because forces explicit configuration in every environment. +- Good, because boot-time validator catches the misconfiguration before request handling. +- Bad, because slightly less ergonomic in fresh-clone setup (developers need to read `.env.example`; mitigated by the validator's error message pointing straight at the file to edit). + +#### Hardcoded localhost fallback + +- Good, because zero-config dev. +- Bad, because "works in dev, breaks in prod" trap — production silently allows nothing if the env is forgotten. + +#### `*` policy + +- Bad, because `credentials: true` + `*` is incompatible per the CORS spec, and `credentials: false` defeats the session cookie. + +### Rate limiting bucket key + +#### `sessionID` if auth, else IP (chosen) + +- Good, because per-account fairness for authenticated users (one user can't drown other users from the same NAT'd IP). +- Good, because IP fallback protects against unauthenticated brute force on `/auth/login`. + +#### IP only + +- Bad, because a single NAT-shared IP (corporate proxy, mobile carrier) shares its quota across users — one noisy user blocks everyone. + +#### Entra `oid` + +- Good, because tied to the user identity rather than the session, so logout + login doesn't reset the quota. +- Bad, because `oid` is only available post-resolution. `sessionID` is available immediately and resolves to the same user via the session payload. Equivalent in practice; sessionID is what `express-rate-limit` already has on `req`. + +### Rate limiting store + +#### In-memory (chosen for v1) + +- Good, because zero infrastructure. +- Bad, because per-instance counters when we scale out (mitigated by single-instance v1). + +#### Redis-backed + +- Good, because shared counters across BFF instances. +- Bad, because new infra dep + each request adds a Redis hop. + +### Helmet CSP + +#### Defaults in prod, disabled in dev (chosen) + +- Good, because production responses carry a sensible CSP without bespoke tuning. +- Good, because dev devtools stay clean (Helmet's default `connect-src 'self'` triggers cosmetic violations on JSON responses). +- Bad, because dev / prod differ — a CSP misconfiguration won't be caught locally. + +#### Custom CSP + +- Good, because matches the actual BFF response shape (JSON, no scripts, no images). +- Bad, because more surface to maintain; revisited when admin SPA is served by Caddy with a real CSP nonce. + +## More Information + +- OWASP CSRF Cheat Sheet (double-submit + synchronizer token comparison): https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html +- OWASP Rate Limiting Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Denial_of_Service_Cheat_Sheet.html +- `express-rate-limit` docs: https://express-rate-limit.mintlify.app/ +- `helmet` defaults reference: https://helmetjs.github.io/ +- Related ADRs: [ADR-0009](0009-auth-flow-oidc-pkce-msal-node.md) (session cookie + CSRF design), [ADR-0010](0010-session-management-redis.md) (session storage), [ADR-0012](0012-observability-pino-opentelemetry.md) (trace id propagation, used here as the error envelope's correlation key), [ADR-0015](0015-cicd-gitea-actions.md) (DoS mitigation reference); future: the **paused** strategic security baseline ADR (ASVS level + regulatory framing), the infrastructure ADR (Redis-backed rate-limit store, CSP for admin SPA). diff --git a/docs/decisions/README.md b/docs/decisions/README.md index bfc2464..999c32d 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -42,25 +42,26 @@ The vocabulary below is the source of truth. It is intentionally coarse — prop ADRs are listed in numerical order. To slice by topic, filter on the `Tags` column. -| # | Title | Status | Tags | Date | -| ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------- | -------- | -------------------------------------------------------------- | ---------- | -| [0001](0001-use-adrs-to-record-architectural-decisions.md) | Use ADRs to record architectural decisions | accepted | `process` | 2026-04-29 | -| [0002](0002-adopt-nx-monorepo-apps-preset.md) | Adopt Nx monorepo with the `apps` preset | accepted | `infrastructure`, `frontend`, `backend` | 2026-04-29 | -| [0003](0003-workspace-and-app-naming-convention.md) | Workspace and app naming convention | accepted | `process` | 2026-04-29 | -| [0004](0004-frontend-stack-angular-csr-zoneless-signals.md) | Frontend stack — Angular (latest LTS), standalone, zoneless, Signals, CSR-only, Vitest | accepted | `frontend` | 2026-04-29 | -| [0005](0005-backend-stack-nestjs.md) | Backend stack — NestJS over Express, Fastify, Hono | accepted | `backend` | 2026-04-29 | -| [0006](0006-persistence-postgresql-prisma.md) | Persistence — PostgreSQL with Prisma | accepted | `data`, `backend` | 2026-04-29 | -| [0007](0007-pre-commit-hooks-and-conventional-commits.md) | Pre-commit hooks and Conventional Commits | accepted | `process` | 2026-04-29 | -| [0008](0008-identity-model-entra-workforce-dual-audience.md) | Identity model — multi-tenant Entra ID for workforce, dual-audience design for future External ID | accepted | `security`, `data` | 2026-04-29 | -| [0009](0009-auth-flow-oidc-pkce-msal-node.md) | Authentication flow — OIDC Authorization Code + PKCE via MSAL Node, BFF session pattern | accepted | `security`, `backend` | 2026-04-29 | -| [0010](0010-session-management-redis.md) | Session management — opaque session IDs in cookies, payload in self-hosted Redis with AES-GCM at rest | accepted | `security`, `backend`, `infrastructure` | 2026-04-29 | -| [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 | -| [0015](0015-cicd-gitea-actions.md) | CI/CD pipeline — Gitea Actions, trunk-based + squash-merge, thin YAML over portable scripts | accepted | `infrastructure`, `process` | 2026-04-30 | -| [0016](0016-accessibility-baseline-wcag-aa-targeted-aaa.md) | Accessibility baseline — WCAG 2.2 AA + targeted AAA, Angular CDK + spartan-ng + Tailwind, APF panel testing | accepted | `accessibility`, `frontend`, `process` | 2026-04-30 | -| [0017](0017-performance-budgets-lighthouse-ci.md) | Performance budgets — Core Web Vitals + Lighthouse CI gates, bundle budgets, BFF p95/p99 SLOs | accepted | `performance`, `frontend`, `backend`, `process` | 2026-04-30 | -| [0018](0018-environment-configuration-strategy.md) | Environment configuration — Angular `environment.ts`, BFF env vars, audit pool split | accepted | `frontend`, `backend`, `infrastructure`, `process` | 2026-05-10 | -| [0019](0019-internationalisation-angular-localize.md) | Internationalisation — `@angular/localize`, build-time per-locale bundles, `/fr` + `/en` path-based routing | accepted | `frontend`, `accessibility`, `performance`, `process` | 2026-05-11 | -| [0020](0020-portal-admin-app.md) | `portal-admin` — dedicated SPA for portal administration, sharing the existing BFF | accepted | `frontend`, `backend`, `security`, `infrastructure`, `process` | 2026-05-11 | +| # | Title | Status | Tags | Date | +| ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- | -------- | -------------------------------------------------------------- | ---------- | +| [0001](0001-use-adrs-to-record-architectural-decisions.md) | Use ADRs to record architectural decisions | accepted | `process` | 2026-04-29 | +| [0002](0002-adopt-nx-monorepo-apps-preset.md) | Adopt Nx monorepo with the `apps` preset | accepted | `infrastructure`, `frontend`, `backend` | 2026-04-29 | +| [0003](0003-workspace-and-app-naming-convention.md) | Workspace and app naming convention | accepted | `process` | 2026-04-29 | +| [0004](0004-frontend-stack-angular-csr-zoneless-signals.md) | Frontend stack — Angular (latest LTS), standalone, zoneless, Signals, CSR-only, Vitest | accepted | `frontend` | 2026-04-29 | +| [0005](0005-backend-stack-nestjs.md) | Backend stack — NestJS over Express, Fastify, Hono | accepted | `backend` | 2026-04-29 | +| [0006](0006-persistence-postgresql-prisma.md) | Persistence — PostgreSQL with Prisma | accepted | `data`, `backend` | 2026-04-29 | +| [0007](0007-pre-commit-hooks-and-conventional-commits.md) | Pre-commit hooks and Conventional Commits | accepted | `process` | 2026-04-29 | +| [0008](0008-identity-model-entra-workforce-dual-audience.md) | Identity model — multi-tenant Entra ID for workforce, dual-audience design for future External ID | accepted | `security`, `data` | 2026-04-29 | +| [0009](0009-auth-flow-oidc-pkce-msal-node.md) | Authentication flow — OIDC Authorization Code + PKCE via MSAL Node, BFF session pattern | accepted | `security`, `backend` | 2026-04-29 | +| [0010](0010-session-management-redis.md) | Session management — opaque session IDs in cookies, payload in self-hosted Redis with AES-GCM at rest | accepted | `security`, `backend`, `infrastructure` | 2026-04-29 | +| [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 | +| [0015](0015-cicd-gitea-actions.md) | CI/CD pipeline — Gitea Actions, trunk-based + squash-merge, thin YAML over portable scripts | accepted | `infrastructure`, `process` | 2026-04-30 | +| [0016](0016-accessibility-baseline-wcag-aa-targeted-aaa.md) | Accessibility baseline — WCAG 2.2 AA + targeted AAA, Angular CDK + spartan-ng + Tailwind, APF panel testing | accepted | `accessibility`, `frontend`, `process` | 2026-04-30 | +| [0017](0017-performance-budgets-lighthouse-ci.md) | Performance budgets — Core Web Vitals + Lighthouse CI gates, bundle budgets, BFF p95/p99 SLOs | accepted | `performance`, `frontend`, `backend`, `process` | 2026-04-30 | +| [0018](0018-environment-configuration-strategy.md) | Environment configuration — Angular `environment.ts`, BFF env vars, audit pool split | accepted | `frontend`, `backend`, `infrastructure`, `process` | 2026-05-10 | +| [0019](0019-internationalisation-angular-localize.md) | Internationalisation — `@angular/localize`, build-time per-locale bundles, `/fr` + `/en` path-based routing | accepted | `frontend`, `accessibility`, `performance`, `process` | 2026-05-11 | +| [0020](0020-portal-admin-app.md) | `portal-admin` — dedicated SPA for portal administration, sharing the existing BFF | accepted | `frontend`, `backend`, `security`, `infrastructure`, `process` | 2026-05-11 | +| [0021](0021-phase-2-security-baseline.md) | Phase-2 security baseline — helmet, CORS allowlist, double-submit CSRF, rate limiting, structured error envelope | accepted | `security`, `backend` | 2026-05-13 | -- 2.30.2