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:
@@ -21,32 +21,32 @@ These constraints were set by the project lead at kickoff. They apply to every c
|
|||||||
|
|
||||||
## Architectural Decision Records (ADRs)
|
## Architectural Decision Records (ADRs)
|
||||||
|
|
||||||
- Format: **MADR 4.0.0** (https://adr.github.io/, https://github.com/adr/madr). Template at [decisions/template.md](decisions/template.md).
|
- Format: **MADR 4.0.0** (https://adr.github.io/, https://github.com/adr/madr). Template at [docs/decisions/template.md](docs/decisions/template.md).
|
||||||
- Location: flat folder [decisions/](decisions/), indexed by [decisions/README.md](decisions/README.md).
|
- Location: flat folder [docs/decisions/](docs/decisions/), indexed by [docs/decisions/README.md](docs/decisions/README.md).
|
||||||
- Filename convention: `NNNN-kebab-title.md` with **globally sequential** 4-digit numbers. Numbers are never reset and never reused — even when an ADR is superseded or deprecated.
|
- Filename convention: `NNNN-kebab-title.md` with **globally sequential** 4-digit numbers. Numbers are never reset and never reused — even when an ADR is superseded or deprecated.
|
||||||
- Categorization: via the `tags:` array in the MADR frontmatter (e.g. `[frontend, security]`). The canonical tag vocabulary lives in `decisions/README.md`; never invent ad-hoc tags inline.
|
- Categorization: via the `tags:` array in the MADR frontmatter (e.g. `[frontend, security]`). The canonical tag vocabulary lives in `docs/decisions/README.md`; never invent ad-hoc tags inline.
|
||||||
- **Proactivity.** Any non-trivial development decision (tool/library choice, framework pattern, security control, perf budget, a11y target, naming convention, deprecation, breaking change) warrants proposing an ADR before implementation. Don't wait to be asked. Update the index in the same change.
|
- **Proactivity.** Any non-trivial development decision (tool/library choice, framework pattern, security control, perf budget, a11y target, naming convention, deprecation, breaking change) warrants proposing an ADR before implementation. Don't wait to be asked. Update the index in the same change.
|
||||||
|
|
||||||
## Architecture (recorded in ADRs)
|
## Architecture (recorded in ADRs)
|
||||||
|
|
||||||
The structural, security, observability, and quality choices are recorded as ADRs and summarized below. Any change to these requires updating the corresponding ADR.
|
The structural, security, observability, and quality choices are recorded as ADRs and summarized below. Any change to these requires updating the corresponding ADR.
|
||||||
|
|
||||||
- **Workspace:** Nx monorepo with the `apps` preset, managed by pnpm — see [ADR-0002](decisions/0002-adopt-nx-monorepo-apps-preset.md).
|
- **Workspace:** Nx monorepo with the `apps` preset, managed by pnpm — see [ADR-0002](docs/decisions/0002-adopt-nx-monorepo-apps-preset.md).
|
||||||
- **Naming:** workspace `apf-portal`; apps `portal-shell` (frontend) and `portal-bff` (backend); libs `feature-<name>` and `shared-<scope>` — see [ADR-0003](decisions/0003-workspace-and-app-naming-convention.md).
|
- **Naming:** workspace `apf-portal`; apps `portal-shell` (frontend) and `portal-bff` (backend); libs `feature-<name>` and `shared-<scope>` — see [ADR-0003](docs/decisions/0003-workspace-and-app-naming-convention.md).
|
||||||
- **Frontend (`portal-shell`):** Angular at the latest LTS major — standalone APIs, zoneless change detection, Signals, **CSR only (no SSR)**, Vitest, SCSS — see [ADR-0004](decisions/0004-frontend-stack-angular-csr-zoneless-signals.md).
|
- **Frontend (`portal-shell`):** Angular at the latest LTS major — standalone APIs, zoneless change detection, Signals, **CSR only (no SSR)**, Vitest, SCSS — see [ADR-0004](docs/decisions/0004-frontend-stack-angular-csr-zoneless-signals.md).
|
||||||
- **Backend (`portal-bff`):** NestJS at the latest stable major, mounted on the Express adapter (Fastify adapter swappable later) — see [ADR-0005](decisions/0005-backend-stack-nestjs.md).
|
- **Backend (`portal-bff`):** NestJS at the latest stable major, mounted on the Express adapter (Fastify adapter swappable later) — see [ADR-0005](docs/decisions/0005-backend-stack-nestjs.md).
|
||||||
- **Persistence:** PostgreSQL (latest stable major) via Prisma — see [ADR-0006](decisions/0006-persistence-postgresql-prisma.md).
|
- **Persistence:** PostgreSQL (latest stable major) via Prisma — see [ADR-0006](docs/decisions/0006-persistence-postgresql-prisma.md).
|
||||||
- **Sessions:** opaque session id in `__Host-portal_session`, payload in self-hosted Redis (Sentinel HA in prod, single node in dev), tokens encrypted at rest with AES-256-GCM, idle 30 min sliding + absolute 12 h — see [ADR-0010](decisions/0010-session-management-redis.md).
|
- **Sessions:** opaque session id in `__Host-portal_session`, payload in self-hosted Redis (Sentinel HA in prod, single node in dev), tokens encrypted at rest with AES-256-GCM, idle 30 min sliding + absolute 12 h — see [ADR-0010](docs/decisions/0010-session-management-redis.md).
|
||||||
- **MFA:** enforced by Entra ID Conditional Access (org-side policy, P1 licensing required); BFF sanity-checks the `amr` claim at session creation; `@RequireMfa()` decorator and freshness-based step-up are designed-in for future sensitive routes (no v1 consumer) — see [ADR-0011](decisions/0011-mfa-enforcement-entra-conditional-access.md).
|
- **MFA:** enforced by Entra ID Conditional Access (org-side policy, P1 licensing required); BFF sanity-checks the `amr` claim at session creation; `@RequireMfa()` decorator and freshness-based step-up are designed-in for future sensitive routes (no v1 consumer) — see [ADR-0011](docs/decisions/0011-mfa-enforcement-entra-conditional-access.md).
|
||||||
- **Identity:** multi-tenant Microsoft Entra ID with B2B invitation for workforce in v1, dual-audience design ready for future External ID activation — see [ADR-0008](decisions/0008-identity-model-entra-workforce-dual-audience.md).
|
- **Identity:** multi-tenant Microsoft Entra ID with B2B invitation for workforce in v1, dual-audience design ready for future External ID activation — see [ADR-0008](docs/decisions/0008-identity-model-entra-workforce-dual-audience.md).
|
||||||
- **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).
|
- **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](docs/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).
|
- **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](docs/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).
|
- **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](docs/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).
|
- **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](docs/decisions/0014-downstream-api-access-obo-pattern.md).
|
||||||
- **CI/CD:** **Gitea Actions** (level-2 implementation; will be superseded by a GitLab migration ADR within 6-18 months). Trunk-based with squash-merge, branch protection on `main`, all CI gates blocking. Thin YAML — orchestration logic lives in `package.json` scripts (`ci:check`, `ci:scan`, `ci:commits`) and Nx targets, runnable locally. Gates: format / lint / type-check / test / build / audit / secret-scan / commit-lint, plus `a11y` (per ADR-0016) and future `perf`. Self-hosted `act_runner` on-prem. Conventional Commits validated locally (hook) and in CI (defense in depth). Required reviewer count = 0 in v1, raised to ≥1 once a second contributor joins. Signed commits recommended, revisited at GitLab migration — see [ADR-0015](decisions/0015-cicd-gitea-actions.md).
|
- **CI/CD:** **Gitea Actions** (level-2 implementation; will be superseded by a GitLab migration ADR within 6-18 months). Trunk-based with squash-merge, branch protection on `main`, all CI gates blocking. Thin YAML — orchestration logic lives in `package.json` scripts (`ci:check`, `ci:scan`, `ci:commits`) and Nx targets, runnable locally. Gates: format / lint / type-check / test / build / audit / secret-scan / commit-lint, plus `a11y` (per ADR-0016) and future `perf`. Self-hosted `act_runner` on-prem. Conventional Commits validated locally (hook) and in CI (defense in depth). Required reviewer count = 0 in v1, raised to ≥1 once a second contributor joins. Signed commits recommended, revisited at GitLab migration — see [ADR-0015](docs/decisions/0015-cicd-gitea-actions.md).
|
||||||
- **Accessibility:** **WCAG 2.2 AA baseline + targeted AAA** on criteria with high impact for APF's user base (1.4.6 Contrast Enhanced, 2.2.3 No Timing, 2.3.3 Animation, 3.1.5 Reading Level, 1.4.8 Visual Presentation, 2.4.9 Link Purpose, 3.3.5 Help). RGAA 4.1 alignment for French audit. UI stack: **Angular CDK + spartan-ng + Tailwind** (no Angular Material; no React libs). User-preferences panel (contrast / text size / motion / spacing / cognitive simplification / reading focus) persisted in session. Tooling: `@angular-eslint/template/*` lint, `@axe-core/playwright` e2e (blocking on critical/serious), token-contrast CI check, touch-target check (44×44 min). Manual testing cadence with APF's internal user panel before each major release. Public accessibility statement page at `/accessibility` and `/accessibilite` — see [ADR-0016](decisions/0016-accessibility-baseline-wcag-aa-targeted-aaa.md).
|
- **Accessibility:** **WCAG 2.2 AA baseline + targeted AAA** on criteria with high impact for APF's user base (1.4.6 Contrast Enhanced, 2.2.3 No Timing, 2.3.3 Animation, 3.1.5 Reading Level, 1.4.8 Visual Presentation, 2.4.9 Link Purpose, 3.3.5 Help). RGAA 4.1 alignment for French audit. UI stack: **Angular CDK + spartan-ng + Tailwind** (no Angular Material; no React libs). User-preferences panel (contrast / text size / motion / spacing / cognitive simplification / reading focus) persisted in session. Tooling: `@angular-eslint/template/*` lint, `@axe-core/playwright` e2e (blocking on critical/serious), token-contrast CI check, touch-target check (44×44 min). Manual testing cadence with APF's internal user panel before each major release. Public accessibility statement page at `/accessibility` and `/accessibilite` — see [ADR-0016](docs/decisions/0016-accessibility-baseline-wcag-aa-targeted-aaa.md).
|
||||||
- **Performance budgets:** Core Web Vitals at Google "Good" thresholds (LCP ≤ 2.5 s, INP ≤ 200 ms, CLS ≤ 0.1, TBT ≤ 200 ms, TTFB ≤ 800 ms), Lighthouse Performance ≥ 90 on critical routes. **Lighthouse CI** (`@lhci/cli`) runs in CI with median-of-3 mitigation, blocking on threshold breach. Angular bundle `budgets` (`type: "error"`): initial ≤ 300 KB gzip, lazy chunks ≤ 100 KB gzip. BFF p95/p99 SLOs per endpoint family observed via OTel (advisory in CI, alerting in prod). Weekly scheduled Lighthouse run on prod env. **a11y wins over perf** when they conflict — see [ADR-0017](decisions/0017-performance-budgets-lighthouse-ci.md).
|
- **Performance budgets:** Core Web Vitals at Google "Good" thresholds (LCP ≤ 2.5 s, INP ≤ 200 ms, CLS ≤ 0.1, TBT ≤ 200 ms, TTFB ≤ 800 ms), Lighthouse Performance ≥ 90 on critical routes. **Lighthouse CI** (`@lhci/cli`) runs in CI with median-of-3 mitigation, blocking on threshold breach. Angular bundle `budgets` (`type: "error"`): initial ≤ 300 KB gzip, lazy chunks ≤ 100 KB gzip. BFF p95/p99 SLOs per endpoint family observed via OTel (advisory in CI, alerting in prod). Weekly scheduled Lighthouse run on prod env. **a11y wins over perf** when they conflict — see [ADR-0017](docs/decisions/0017-performance-budgets-lighthouse-ci.md).
|
||||||
- **Local quality gates:** Husky + lint-staged + commitlint with Conventional Commits — see [ADR-0007](decisions/0007-pre-commit-hooks-and-conventional-commits.md).
|
- **Local quality gates:** Husky + lint-staged + commitlint with Conventional Commits — see [ADR-0007](docs/decisions/0007-pre-commit-hooks-and-conventional-commits.md).
|
||||||
- **Runtime:** Node.js latest LTS major.
|
- **Runtime:** Node.js latest LTS major.
|
||||||
|
|
||||||
## Repository status
|
## Repository status
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
---
|
|
||||||
status: accepted
|
|
||||||
date: 2026-04-29
|
|
||||||
decision-makers: R&D Lead
|
|
||||||
tags: [process]
|
|
||||||
---
|
|
||||||
|
|
||||||
# Use ADRs to record architectural decisions
|
|
||||||
|
|
||||||
## Context and Problem Statement
|
|
||||||
|
|
||||||
Architecturally-significant decisions will be taken throughout the lifecycle of `apf-portal`. Without a written, durable trace of *why* a decision was made — context, drivers, alternatives, trade-offs — that knowledge fades within months. New contributors re-litigate settled debates, the rationale of constraints (security, performance, accessibility) gets lost, and reversing a choice later becomes risky because nobody remembers what it was protecting against.
|
|
||||||
|
|
||||||
How do we record decisions in a way that is light enough to be sustained, durable enough to outlive contributor turnover, and integrated into the development workflow?
|
|
||||||
|
|
||||||
## Decision Drivers
|
|
||||||
|
|
||||||
* Preserve the *why* of each decision so future contributors can understand or revisit it without re-deriving it.
|
|
||||||
* Make architecture reviewable — both at the time of a decision and during retrospective audits.
|
|
||||||
* Keep the format light enough that writing an ADR isn't a bureaucratic deterrent.
|
|
||||||
* Live alongside the code in version control, so decisions evolve with what they govern.
|
|
||||||
* Match the project's stated values: high technical bar, no bricolage, security/performance/accessibility as first-class concerns whose rationale must be traceable.
|
|
||||||
|
|
||||||
## Considered Options
|
|
||||||
|
|
||||||
* No formal records — rely on commit messages and tribal knowledge.
|
|
||||||
* External wiki / Confluence — decisions live outside the codebase.
|
|
||||||
* ADRs in markdown using the [MADR 4.0.0](https://github.com/adr/madr) format, in-repo.
|
|
||||||
* ADRs in another format (Nygard, Y-Statement, Cockburn).
|
|
||||||
|
|
||||||
## Decision Outcome
|
|
||||||
|
|
||||||
Chosen option: **ADRs in markdown using MADR 4.0.0**, stored in `decisions/` at the repository root.
|
|
||||||
|
|
||||||
Conventions are defined in [README.md](README.md):
|
|
||||||
- flat folder layout (no nested category folders);
|
|
||||||
- globally sequential 4-digit numeric prefix (`NNNN-kebab-title.md`);
|
|
||||||
- categorization via the `tags:` field in the MADR frontmatter, drawn from a canonical vocabulary;
|
|
||||||
- status lifecycle: `proposed` → `accepted` → optionally `deprecated` / `superseded by ADR-NNNN`;
|
|
||||||
- index maintained in [README.md](README.md) alongside any ADR change.
|
|
||||||
|
|
||||||
### Consequences
|
|
||||||
|
|
||||||
* Good, because the *why* of every architecturally-significant decision is captured at the moment it is made and travels with the code.
|
|
||||||
* Good, because ADRs are reviewed via the normal pull-request flow — architecture becomes an explicit team conversation.
|
|
||||||
* Good, because MADR provides enough structure (drivers, options, trade-offs) without being heavy.
|
|
||||||
* Good, because flat-folder + tags scales better than nested category folders as the count grows.
|
|
||||||
* Bad, because writing an ADR adds friction; this must be balanced against the cost of *not* writing one.
|
|
||||||
* Bad, because the index is maintained manually until tooling or a CI check is added.
|
|
||||||
|
|
||||||
### Confirmation
|
|
||||||
|
|
||||||
* Every change introducing or transitioning the status of an ADR updates the index table in [README.md](README.md) in the same commit.
|
|
||||||
* PR review explicitly checks for ADR proposal whenever a non-trivial decision is being introduced.
|
|
||||||
* (Future) A CI lint job validates filename pattern, frontmatter shape, and tag vocabulary.
|
|
||||||
|
|
||||||
## More Information
|
|
||||||
|
|
||||||
* MADR 4.0.0 specification: https://github.com/adr/madr
|
|
||||||
* General ADR resources: https://adr.github.io/
|
|
||||||
* Template: [template.md](template.md)
|
|
||||||
* Tag vocabulary: see "Tag vocabulary" in [README.md](README.md).
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
---
|
|
||||||
status: accepted
|
|
||||||
date: 2026-04-29
|
|
||||||
decision-makers: R&D Lead
|
|
||||||
tags: [process]
|
|
||||||
---
|
|
||||||
|
|
||||||
# Workspace and app naming convention
|
|
||||||
|
|
||||||
## Context and Problem Statement
|
|
||||||
|
|
||||||
Default Nx scaffolding offers placeholder names like `my-workspace` and `web`, which are unacceptable for a long-lived enterprise project. Naming has to survive two foreseeable changes: the product's marketing name may evolve (the working title `apf_portal` is provisional), and the project may sit alongside other R&D projects in the same Gitea organization, where collisions on generic names like `web` or `api` would cause confusion.
|
|
||||||
|
|
||||||
What naming convention do we adopt for the workspace, the apps, and future libraries so names are explicit, function-anchored, and stable against rebranding?
|
|
||||||
|
|
||||||
## Decision Drivers
|
|
||||||
|
|
||||||
* The product's marketing name is provisional — names anchored on it become stale at rebrand.
|
|
||||||
* Names should reflect *function*, not *brand*.
|
|
||||||
* Multiple R&D projects may share the org — generic names (`web`, `api`, `frontend`) cause friction.
|
|
||||||
* Compliance with npm package naming rules (lowercase, kebab-case).
|
|
||||||
* Readability inside Nx's project graph and CLI output.
|
|
||||||
|
|
||||||
## Considered Options
|
|
||||||
|
|
||||||
* **Defaults** — `my-workspace`, `web`. (Rejected up-front.)
|
|
||||||
* **Brand-anchored** — `apf-front`, `apf-back`. Fragile to further rebranding.
|
|
||||||
* **Function-prefixed** — `portal-shell`, `portal-bff`. (Chosen.)
|
|
||||||
* **Generic** — `shell`, `bff`. Risk of org-wide collision; reduced readability.
|
|
||||||
|
|
||||||
## Decision Outcome
|
|
||||||
|
|
||||||
Chosen option: **function-prefixed naming**.
|
|
||||||
|
|
||||||
| Scope | Name | Rationale |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| Workspace / repo / npm package root | `apf-portal` | matches the Gitea repository name; the only place a brand-ish name lives, so a rebrand is a one-line change |
|
|
||||||
| Frontend app | `portal-shell` | "shell" describes the function — the Angular host that loads features |
|
|
||||||
| Backend app | `portal-bff` | explicit role: backend-for-frontend |
|
|
||||||
| Feature libraries | `feature-<name>` | e.g. `feature-auth`, `feature-billing` |
|
|
||||||
| Shared libraries | `shared-<scope>` | e.g. `shared-ui`, `shared-data-access`, `shared-util` |
|
|
||||||
|
|
||||||
The `portal-` prefix encodes the *function* (a portal). The host organisation's brand can change without invalidating "portal" — the project was originally proposed as `adastra_portal` and renamed to `apf_portal` on 2026-04-30 once the host organisation (APF France Handicap) was confirmed; the apps `portal-shell` / `portal-bff` were unaffected by the rename, validating this convention. The brand-ish token `apf` is confined to the workspace root and a tiny number of metadata files (root `package.json`, repo URL).
|
|
||||||
|
|
||||||
### Consequences
|
|
||||||
|
|
||||||
* Good, because names survive a brand change with minimal churn.
|
|
||||||
* Good, because `portal-shell` / `portal-bff` are unambiguous in CLI output and in the Nx project graph.
|
|
||||||
* Good, because the prefix scales when more apps appear (e.g. `portal-admin`, `portal-jobs`).
|
|
||||||
* Bad, because the prefix adds verbosity (`pnpm nx serve portal-shell` vs `pnpm nx serve web`). Acceptable.
|
|
||||||
* Neutral, because if "portal" itself ceases to be accurate (e.g. the system pivots to something other than a portal), the prefix would also need updating — but that would be a deeper architectural pivot anyway.
|
|
||||||
|
|
||||||
### Confirmation
|
|
||||||
|
|
||||||
* Nx project names match the convention in `nx.json` and in each project's `project.json`.
|
|
||||||
* `@nx/eslint/enforce-module-boundaries` is configured with tags aligned to the convention (`scope:portal-shell`, `scope:portal-bff`, `type:feature`, `type:shared`).
|
|
||||||
* PR review rejects new project names that don't match.
|
|
||||||
|
|
||||||
## More Information
|
|
||||||
|
|
||||||
* Related ADRs: [ADR-0002](0002-adopt-nx-monorepo-apps-preset.md), and the "Project name" rule in [CLAUDE.md](../CLAUDE.md).
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
---
|
|
||||||
status: accepted
|
|
||||||
date: 2026-04-29
|
|
||||||
decision-makers: R&D Lead
|
|
||||||
tags: [frontend]
|
|
||||||
---
|
|
||||||
|
|
||||||
# Frontend stack — Angular (latest LTS), standalone, zoneless, Signals, CSR-only, Vitest
|
|
||||||
|
|
||||||
## Context and Problem Statement
|
|
||||||
|
|
||||||
The portal's frontend (`portal-shell`) is a single-page application that aggregates access to existing applications and progressively integrates re-developed features. It sits fully behind authentication: no public content. We need to fix the framework, the rendering mode, the change-detection model, and the test runner now, so subsequent ADRs (auth flow, observability, accessibility, performance budgets) can build on a stable foundation.
|
|
||||||
|
|
||||||
What frontend stack maximizes alignment with our backend (NestJS), gives us first-class accessibility and performance hooks, and is enterprise-stable for a long-lived project?
|
|
||||||
|
|
||||||
## Decision Drivers
|
|
||||||
|
|
||||||
* Architectural alignment with NestJS (DI, decorators, modules, RxJS) — minimizes cognitive distance for the team.
|
|
||||||
* Performance and accessibility as first-class concerns from day one.
|
|
||||||
* Long-term enterprise stability (no pre-1.0, no exotic stack).
|
|
||||||
* End-to-end type safety.
|
|
||||||
* No public surface — all content gated by authentication, which removes the usual SSR drivers (SEO, anonymous LCP).
|
|
||||||
|
|
||||||
## Considered Options
|
|
||||||
|
|
||||||
* **Angular (latest LTS) — standalone, zoneless, Signals, CSR, Vitest.** (Chosen.)
|
|
||||||
* Angular with SSR (`@angular/ssr`).
|
|
||||||
* React + Next.js (or Vite + React Router).
|
|
||||||
* Vue + Nuxt.
|
|
||||||
* Svelte + SvelteKit.
|
|
||||||
|
|
||||||
## Decision Outcome
|
|
||||||
|
|
||||||
Chosen option: **Angular at the latest LTS major**, with the following modern defaults:
|
|
||||||
|
|
||||||
* **Standalone APIs** — no `NgModule`s. Configuration is composed via providers; routing is functional.
|
|
||||||
* **Zoneless change detection** — no `Zone.js`. Change detection is driven by Signals and explicit `markForCheck` where needed.
|
|
||||||
* **Signals** — primary reactive primitive for component state. RxJS retained for async streams (HTTP, events).
|
|
||||||
* **Vitest** — unit and component test runner (Karma is deprecated upstream).
|
|
||||||
* **CSR only — no SSR** for v1. Reconsider only if a measured performance metric requires it.
|
|
||||||
* **SCSS** for styles. Design-token strategy to be defined in a future ADR.
|
|
||||||
* **Strict TypeScript** across the workspace (`strict: true`, `noUncheckedIndexedAccess: true`).
|
|
||||||
|
|
||||||
### Consequences
|
|
||||||
|
|
||||||
* Good, because the team works with a single mental model across front and back (DI, decorators, RxJS).
|
|
||||||
* Good, because zoneless + Signals removes Zone.js overhead and gives finer-grained, more predictable reactivity.
|
|
||||||
* Good, because Vitest is significantly faster than Karma and shares the Vite toolchain Nx already uses.
|
|
||||||
* Good, because CSR keeps the BFF free of SSR concerns (no auth-aware rendering, no double-fetch, no hydration debugging) — simpler operational surface.
|
|
||||||
* Bad, because Angular's release cadence is brisk; the project commits to staying on LTS rather than skipping versions.
|
|
||||||
* Bad, because zoneless and Signals are recent — though stable, the broader Angular ecosystem still includes Zone-dependent libraries that must be vetted before adoption.
|
|
||||||
* Bad, because CSR yields a minimal initial HTML payload; this must be paired with strong performance budgets (covered by ADR-0015, future).
|
|
||||||
|
|
||||||
### Confirmation
|
|
||||||
|
|
||||||
* `package.json` pins Angular to the current LTS major.
|
|
||||||
* `app.config.ts` registers `provideZonelessChangeDetection()` (or its current stable equivalent) and standalone routing.
|
|
||||||
* `tsconfig.base.json` sets `strict: true` and `noUncheckedIndexedAccess: true`.
|
|
||||||
* CI runs `pnpm nx test portal-shell` (Vitest).
|
|
||||||
* No `import 'zone.js'` anywhere in the codebase.
|
|
||||||
* `@angular/ssr` is not installed.
|
|
||||||
|
|
||||||
## Pros and Cons of the Options
|
|
||||||
|
|
||||||
### Angular CSR (chosen)
|
|
||||||
|
|
||||||
* Good, because alignment with NestJS minimizes context-switching for the team.
|
|
||||||
* Good, because mature, enterprise-supported (Google + community).
|
|
||||||
* Good, because Signals + zoneless make Angular's reactivity competitive with React/Vue.
|
|
||||||
* Bad, because verbosity is higher than minimalist frameworks.
|
|
||||||
|
|
||||||
### Angular SSR
|
|
||||||
|
|
||||||
* Good, because better TTFB and accessibility on first paint for unauthenticated content.
|
|
||||||
* Bad, because we have no unauthenticated content — the gain is essentially zero.
|
|
||||||
* Bad, because SSR-with-auth introduces non-trivial complexity (cookie-aware fetch, hydration with secured data).
|
|
||||||
|
|
||||||
### React + Next.js
|
|
||||||
|
|
||||||
* Good, because large ecosystem, fast iteration.
|
|
||||||
* Bad, because no architectural alignment with NestJS — different DI/state paradigms.
|
|
||||||
* Bad, because Next.js's defaults push toward server components and edge rendering, which conflict with our on-prem, all-authenticated profile.
|
|
||||||
|
|
||||||
### Vue + Nuxt / Svelte + SvelteKit
|
|
||||||
|
|
||||||
* Good, because lighter syntax, strong DX.
|
|
||||||
* Bad, because no DI alignment with NestJS.
|
|
||||||
* Bad, because smaller enterprise-grade community than Angular or React.
|
|
||||||
|
|
||||||
## More Information
|
|
||||||
|
|
||||||
* Angular zoneless change detection: https://angular.dev/guide/experimental/zoneless
|
|
||||||
* Angular Signals: https://angular.dev/guide/signals
|
|
||||||
* Vitest with Nx: https://nx.dev/nx-api/vite
|
|
||||||
* Related ADRs: [ADR-0005](0005-backend-stack-nestjs.md), ADR-0008 (auth flow, future), ADR-0014 (accessibility, future), ADR-0015 (performance budgets, future).
|
|
||||||
@@ -1,244 +0,0 @@
|
|||||||
---
|
|
||||||
status: accepted
|
|
||||||
date: 2026-04-30
|
|
||||||
decision-makers: R&D Lead
|
|
||||||
tags: [accessibility, frontend, process]
|
|
||||||
---
|
|
||||||
|
|
||||||
# Accessibility baseline — WCAG 2.2 AA + targeted AAA, Angular CDK + spartan-ng + Tailwind, APF panel testing
|
|
||||||
|
|
||||||
## Context and Problem Statement
|
|
||||||
|
|
||||||
The host organisation is **APF France Handicap**. Accessibility is the organisation's core mission, not a compliance checkbox. A meaningful proportion of the portal's users will be in situations of disability — visual, motor, cognitive, hearing — and will rely on assistive technologies (screen readers, switch controls, eye tracking, voice control, head pointers, mouth sticks). The portal's accessibility quality is therefore a **product attribute**, not a downstream concern.
|
|
||||||
|
|
||||||
The default WCAG 2.2 AA baseline applicable to enterprise portals would *under-serve* this user base. We need to fix:
|
|
||||||
- the conformance target (WCAG level + targeted enhancements);
|
|
||||||
- the implementation stack (component library, primitives, styling);
|
|
||||||
- the user-preferences surface (contrast, text size, motion, etc.) the portal must offer;
|
|
||||||
- the testing strategy, including manual testing with users from APF's internal network;
|
|
||||||
- the CI gates that prevent regression;
|
|
||||||
- the legal-disclosure surface (the EU Accessibility Act–required accessibility statement).
|
|
||||||
|
|
||||||
This ADR fixes the framework. Concrete component-by-component implementation lands as the scaffold and features arrive.
|
|
||||||
|
|
||||||
## Decision Drivers
|
|
||||||
|
|
||||||
* APF's mission elevates accessibility from "compliance" to "product".
|
|
||||||
* European Accessibility Act (EAA, in application since June 2025) makes WCAG 2.2 AA a legal floor in the EU; the portal will publish an accessibility statement and accept regression as a defect, not as a backlog item.
|
|
||||||
* RGAA 4.1 (French national standard, derived from WCAG) is the operational reference for accessibility audits in France; alignment is necessary to be auditable by French experts (including APF's own).
|
|
||||||
* The user base extends beyond screen-reader users: switch controls, eye tracking, voice control, cognitive load tolerance — all must be considered.
|
|
||||||
* The technical stack is locked on Angular ([ADR-0004](0004-frontend-stack-angular-csr-zoneless-signals.md)). Component-library choice must therefore be Angular-native or framework-agnostic.
|
|
||||||
* The dual-audience design ([ADR-0008](0008-identity-model-entra-workforce-dual-audience.md)) means a11y must be uniform across audiences — workforce and (future) customer users alike.
|
|
||||||
* Anti-bricolage: no hand-rolled a11y where a maintained library exists.
|
|
||||||
|
|
||||||
## Considered Options
|
|
||||||
|
|
||||||
### Conformance target
|
|
||||||
* WCAG 2.2 **AA** baseline (EU EAA legal floor).
|
|
||||||
* WCAG 2.2 **AA + targeted AAA** on criteria with high impact for APF's user base. (Chosen.)
|
|
||||||
* WCAG 2.2 **AAA** uniform.
|
|
||||||
|
|
||||||
### UI component stack
|
|
||||||
* Angular Material (Angular CDK + Material visual layer).
|
|
||||||
* **Angular CDK + spartan-ng + Tailwind.** (Chosen — see also `notes/argumentaire-stack-ui-spartan-cdk-tailwind.md` for the team-internal rationale.)
|
|
||||||
* Custom-on-Angular-CDK only (no spartan-ng).
|
|
||||||
* React-ecosystem libs (shadcn/ui, dice-ui, animate-ui).
|
|
||||||
|
|
||||||
### User-preferences scope
|
|
||||||
* None (rely on browser/OS prefs).
|
|
||||||
* `prefers-reduced-motion` only.
|
|
||||||
* **Full preferences panel** — contrast modes, text size, motion, spacing, simplified UI. (Chosen.)
|
|
||||||
|
|
||||||
### Testing strategy
|
|
||||||
* Automated only (axe-core + lint).
|
|
||||||
* Automated + external annual audit.
|
|
||||||
* **Automated + manual screen-reader/keyboard/switch testing + APF user panel cadence + annual internal audit.** (Chosen.)
|
|
||||||
|
|
||||||
### Documentation surface
|
|
||||||
* Internal docs only.
|
|
||||||
* **Public accessibility statement page (legal EAA requirement) + internal patterns library.** (Chosen.)
|
|
||||||
|
|
||||||
## Decision Outcome
|
|
||||||
|
|
||||||
### Conformance target
|
|
||||||
|
|
||||||
**WCAG 2.2 AA** as the universal baseline. **AAA** applied on the criteria that materially affect APF's user base:
|
|
||||||
|
|
||||||
| AAA criterion | What it adds vs AA | Why APF |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| **1.4.6 Contrast (Enhanced)** | 7:1 normal text / 4.5:1 large text (vs 4.5:1 / 3:1 at AA) | Low-vision users are a primary audience |
|
|
||||||
| **2.2.3 No Timing** | No time limits except where essential | Cognitive load and motor speed vary widely |
|
|
||||||
| **2.3.3 Animation from Interactions** | All interaction-driven animation can be disabled | Vestibular and cognitive disorders |
|
|
||||||
| **3.1.5 Reading Level** | Content readable at lower secondary level *or* a simpler alternative is provided | Cognitive accessibility |
|
|
||||||
| **1.4.8 Visual Presentation** | User control over text formatting (line spacing, paragraph spacing, line length) | Dyslexia, low vision |
|
|
||||||
| **2.4.9 Link Purpose (Link Only)** | Each link's purpose understandable from its text alone (no surrounding context) | Screen-reader navigation by link list |
|
|
||||||
| **3.3.5 Help** | Context-sensitive help available where needed | Cognitive accessibility |
|
|
||||||
|
|
||||||
The remaining AAA criteria (e.g. 1.2.6 Sign Language, 1.2.8 Media Alternative) are **not** uniformly required — they apply where the corresponding content type appears, and are addressed case by case. **RGAA 4.1** alignment is maintained as the audit reference for French experts (APF, CNIL audits, public-sector partners).
|
|
||||||
|
|
||||||
### User-preferences panel
|
|
||||||
|
|
||||||
The portal exposes a **first-class user-preferences panel** accessible from any page (keyboard-discoverable, screen-reader-announced, persisted in the user's session per [ADR-0010](0010-session-management-redis.md)):
|
|
||||||
|
|
||||||
| Preference | Options | Default |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| Contrast mode | Standard (AA-compliant) / Enhanced (AAA, 7:1) / Maximum (high-contrast OS-style, monochrome) | Standard |
|
|
||||||
| Text size | 100 % / 125 % / 150 % / 175 % / 200 % | 100 % |
|
|
||||||
| Motion | Full / Reduced / None | inherited from `prefers-reduced-motion` |
|
|
||||||
| Text spacing | Default / Generous (line-spacing 1.8, paragraph-spacing 2×, letter-spacing 0.12em) | Default |
|
|
||||||
| Cognitive simplification | Off / On (reduces secondary chrome, larger primary actions, easier-to-read summaries when authored) | Off |
|
|
||||||
| Reading focus | Off / Line-by-line | Off |
|
|
||||||
|
|
||||||
Preferences are saved per user; an unauthenticated default is read from browser preferences (`prefers-reduced-motion`, `prefers-contrast`, etc.). Changing a preference takes effect immediately, no page reload.
|
|
||||||
|
|
||||||
### Component stack
|
|
||||||
|
|
||||||
**Angular CDK + spartan-ng + TailwindCSS**. See [`notes/argumentaire-stack-ui-spartan-cdk-tailwind.md`](../notes/argumentaire-stack-ui-spartan-cdk-tailwind.md) for the full rationale produced for the dev team. Summary:
|
|
||||||
|
|
||||||
- **Angular CDK** (`@angular/cdk`) — primitives accessibility layer maintained by Google's Angular Material team. Equivalent role to Radix UI in React. Provides `Overlay`, `FocusTrap`, `FocusMonitor`, `LiveAnnouncer`, `A11yModule`, `ListKeyManager`, `Drag and Drop`, etc. Battle-tested since 2017.
|
|
||||||
- **spartan-ng** (https://www.spartan.ng/) — copy-paste component layer built on Angular CDK, styled with Tailwind. Components are checked into our repo (`libs/shared/ui/`) and modified freely. Equivalent role to shadcn/ui in React.
|
|
||||||
- **Tailwind CSS** — utility-first styling. Tokens for colour, spacing, contrast tiers live in `libs/shared/tokens/`. The contrast tiers map directly to the user preference modes above.
|
|
||||||
|
|
||||||
Angular Material is **rejected** because its visual design system (Material Design) is opinionated in ways that clash with both APF branding and the multi-tier contrast requirements. React-ecosystem libraries (shadcn/ui, dice-ui, animate-ui) are rejected as incompatible with the Angular stack ([ADR-0004](0004-frontend-stack-angular-csr-zoneless-signals.md)).
|
|
||||||
|
|
||||||
### Concrete patterns to enforce
|
|
||||||
|
|
||||||
| Concern | Rule |
|
|
||||||
| --- | --- |
|
|
||||||
| Sémantique HTML | `<header>`, `<main>`, `<nav>`, `<aside>`, `<footer>` landmarks. One `h1` per page. `<button>` for actions, `<a>` for navigation. **No** `<div onclick>` — flagged by ESLint and rejected at PR review |
|
|
||||||
| Skip-links | "Aller au contenu principal" link as the first focusable element on every page |
|
|
||||||
| Focus | Always visible (no `outline: none` without an at-least-equivalent replacement). Trapped in modals via Angular CDK `FocusTrap`. Restored after navigation/modal close |
|
|
||||||
| Forms | Each `<input>` has an associated `<label>`. Errors associated via `aria-describedby`. `autocomplete` attribute correct. Valid HTML5 `type` |
|
|
||||||
| Touch targets | Minimum **44 × 44 CSS pixels** (WCAG 2.5.5 AAA), default 48 × 48 in spartan-ng tokens for safety |
|
|
||||||
| Animations | Respect `prefers-reduced-motion` and the user-preferences `motion` setting. No autoplay video. No carousels with auto-rotation |
|
|
||||||
| Language | `lang` attribute on `<html>` set dynamically per active locale. Inline `lang` on foreign-language fragments |
|
|
||||||
| Images | All non-decorative images carry meaningful `alt`. Decorative images carry `alt=""` and `role="presentation"` |
|
|
||||||
| ARIA | Used **only** when native HTML semantics are insufficient. Misuse of ARIA (the most common a11y bug) is treated as a code-quality defect |
|
|
||||||
| Error messaging | Programmatic association (`aria-describedby`), live regions (`aria-live="polite"`) for server errors, never just colour to convey state |
|
|
||||||
|
|
||||||
### Tooling and CI gates
|
|
||||||
|
|
||||||
| Layer | Tool | Where it runs | Blocking? |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| Static lint on Angular templates | `@angular-eslint/template/*` rules (no-positive-tabindex, click-events-have-key-events, label-has-associated-control, elements-content, no-autofocus, alt-text, valid-aria, button-has-type, …) | local hook + CI (`pnpm ci:check`, [ADR-0015](0015-cicd-gitea-actions.md)) | **Yes** |
|
|
||||||
| Automated runtime a11y check | `@axe-core/playwright` integrated in e2e tests | CI (`pnpm ci:quality` — the `a11y` gate of [ADR-0015](0015-cicd-gitea-actions.md)) | **Yes** on `critical` or `serious` violations; warning on `moderate`/`minor` |
|
|
||||||
| Design-token contrast verification | Custom script that walks `libs/shared/tokens/` colour pairs and verifies WCAG AA + AAA contrast ratios | CI | **Yes** |
|
|
||||||
| Touch-target check | Layout test verifying interactive element minimum size | CI (Playwright) | **Yes** on min 44 × 44 |
|
|
||||||
| Motion respect check | E2E test that turns motion off and verifies no animation occurs | CI (Playwright) | **Yes** |
|
|
||||||
| Screen-reader manual testing | NVDA (Windows), VoiceOver (macOS, iOS), TalkBack (Android) | Manual, before merge of any new component or major refactor | **Yes** — checklist in PR template |
|
|
||||||
| Keyboard-only navigation | Manual | Manual, before merge of any UI change | **Yes** — checklist in PR template |
|
|
||||||
| Switch-control / eye-tracking | Manual, on flagship features | APF user panel cadence | **Yes** as gate for major releases |
|
|
||||||
| Cognitive walkthrough | Manual, with user representatives | APF user panel cadence | **Yes** as gate for major releases |
|
|
||||||
|
|
||||||
### Manual testing — APF user panel
|
|
||||||
|
|
||||||
APF France Handicap has, by design, an active network of users in situations of disability. This is a structural advantage that no consultant can replicate.
|
|
||||||
|
|
||||||
- **Cadence** : panel-tested before each major release. Composition: at least one user per primary disability category (visual, motor, cognitive, hearing) drawn from the APF network.
|
|
||||||
- **Scope** : critical user journeys (sign-in, navigation, integrated apps, user-preferences panel, error-recovery flows) plus any new flagship feature.
|
|
||||||
- **Format** : moderated session, observation-based, recorded with consent. Findings categorised (blocker / serious / moderate / minor) and triaged like security findings — blockers and serious must be fixed before release.
|
|
||||||
- **Authority** : a finding by a user-panel session is at least as authoritative as an axe-core report. Disagreements escalate to the R&D lead.
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
|
|
||||||
- **Accessibility statement page** at `/accessibility` (English) and `/accessibilite` (French) — required by EAA. Content: conformance level claimed, scope (which routes are covered), known issues with target resolution dates, contact email for accessibility complaints, date of last review, RGAA alignment level. Generated from a maintained source-of-truth file in the repo (e.g. `apps/portal-shell/src/accessibility-statement.md`) so the statement evolves with the code, not separately.
|
|
||||||
- **Internal patterns library** in `docs/accessibility/` (created when first patterns ship) covering: focus management, ARIA usage, form patterns, error messaging, multi-step flows, modals, notifications. Examples drawn from the codebase, kept in sync via review.
|
|
||||||
- **Component library docs** : every spartan-ng component checked into `libs/shared/ui/` carries a11y notes (keyboard model, ARIA roles emitted, screen-reader expectations).
|
|
||||||
|
|
||||||
### Consequences
|
|
||||||
|
|
||||||
* Good, because a11y is structural — encoded in the lint, the gates, the components, the tokens, the user-preferences panel — rather than relying on individual discipline.
|
|
||||||
* Good, because the chosen stack (CDK + spartan-ng + Tailwind) gives full design control without forfeiting Google-tier a11y primitives, and supports the multi-tier contrast/text-size/motion modes without contortion.
|
|
||||||
* Good, because the AA + targeted AAA approach concentrates effort where APF's user base benefits most, instead of spreading thin across every AAA criterion.
|
|
||||||
* Good, because access to APF's user network is a competitive advantage — the panel testing is more rigorous than what most enterprise projects can afford.
|
|
||||||
* Good, because the accessibility statement and the patterns library make the project's a11y posture *legible* — auditable by external experts, demonstrable to APF stakeholders.
|
|
||||||
* Bad, because the upfront cost is non-trivial — design tokens with multi-tier contrast, components with theming, user-preferences panel, e2e a11y tests. Estimated +25–35 % of UI delivery time vs. an a11y-as-afterthought approach. Acknowledged and accepted given APF's mission.
|
|
||||||
* Bad, because spartan-ng is younger than Angular Material — occasional rough edges expected. Mitigated by the copy-paste model: components live in our repo, we own them, fallback is custom-on-CDK without lib dependency.
|
|
||||||
* Bad, because the user-preferences panel must be wired through every component — components that don't honour the active contrast mode or the active text size break the contract. Enforced by review and a custom lint rule per component.
|
|
||||||
* Bad, because manual testing cadence (panel sessions, screen-reader, keyboard) is real ops effort. Mitigated by APF's internal network; expense kept inside the org.
|
|
||||||
|
|
||||||
### Confirmation
|
|
||||||
|
|
||||||
* `libs/shared/tokens` exposes design tokens for colour (across at least three contrast tiers), spacing, typography, motion. Token contrast ratios are verified by a CI script.
|
|
||||||
* `libs/shared/ui` hosts spartan-ng components, copied in and themed against the tokens. Each component carries an a11y note in its docs.
|
|
||||||
* Angular CDK is the only allowed source of `Overlay`, `FocusTrap`, `FocusMonitor`, `LiveAnnouncer`, `Drag and Drop`. No equivalent rolled by hand.
|
|
||||||
* `apps/portal-shell` ships a user-preferences panel persisting to the session (Redis); changes apply without reload; default reads from `prefers-*` browser media queries.
|
|
||||||
* ESLint configuration enables every `@angular-eslint/template/*` a11y rule. CI fails on any violation.
|
|
||||||
* `@axe-core/playwright` is integrated in the e2e suite. CI's `a11y` gate fails on any `critical` or `serious` violation, warns on `moderate` or `minor`.
|
|
||||||
* Design-token contrast verifier and touch-target verifier are part of `pnpm ci:quality`.
|
|
||||||
* PR template includes a manual-checklist section: keyboard navigation tested, screen reader tested (which one), focus order verified, error states verified.
|
|
||||||
* Accessibility statement page is published, referenced from the footer, and updated at every major release.
|
|
||||||
* APF user-panel session findings are tracked as issues with a label `a11y/panel-finding` and triaged with the same gravity as security issues.
|
|
||||||
|
|
||||||
## Pros and Cons of the Options
|
|
||||||
|
|
||||||
### Conformance target
|
|
||||||
|
|
||||||
#### AA + targeted AAA (chosen)
|
|
||||||
|
|
||||||
* Good, because matches the user base where it matters and stays achievable on a uniform basis.
|
|
||||||
* Good, because the AAA criteria selected are the ones with measurable user-impact in APF's primary populations.
|
|
||||||
* Bad, because the selection is judgement-based — open to debate at audit. Mitigated by documenting the rationale per criterion (this ADR).
|
|
||||||
|
|
||||||
#### AA only
|
|
||||||
|
|
||||||
* Good, because the EU EAA legal floor.
|
|
||||||
* Bad, because clearly insufficient given APF's mission. Bare AA on contrast (4.5:1) is uncomfortable for a low-vision user; AA on motion is permissive.
|
|
||||||
|
|
||||||
#### AAA uniform
|
|
||||||
|
|
||||||
* Good, because the highest WCAG level on every criterion.
|
|
||||||
* Bad, because some AAA criteria (1.2.6 Sign Language, 1.2.8 Media Alternative, 1.4.9 Images of Text) require content production or content forms we may not have. Pretending uniform AAA when not really delivered is worse than an honest AA + targeted AAA.
|
|
||||||
|
|
||||||
### UI component stack
|
|
||||||
|
|
||||||
#### Angular CDK + spartan-ng + Tailwind (chosen)
|
|
||||||
|
|
||||||
* Good, because Google-tier a11y primitives via CDK + copy-paste components via spartan-ng + utility CSS via Tailwind = full control, no design-system lock-in, no proprietary visuals to fight.
|
|
||||||
* Good, because aligns with the team-internal preference for the shadcn-style pattern (per dev consultation), without having to break the Angular alignment with NestJS.
|
|
||||||
* Bad, because spartan-ng is younger (2024). Risk mitigated by the copy-paste model — we own the code.
|
|
||||||
|
|
||||||
#### Angular Material
|
|
||||||
|
|
||||||
* Good, because most mature, Google-maintained.
|
|
||||||
* Bad, because the Material Design visual language is opinionated and clashes with multi-tier contrast modes and APF branding. Switching themes within Material is harder than switching themes when the components are owned in source.
|
|
||||||
|
|
||||||
#### Custom-on-Angular-CDK only
|
|
||||||
|
|
||||||
* Good, because zero dependency beyond Google.
|
|
||||||
* Bad, because every component (button, dialog, tabs, menu, etc.) must be built from CDK primitives — large upfront cost.
|
|
||||||
|
|
||||||
#### React-ecosystem libs
|
|
||||||
|
|
||||||
* Bad, because incompatible with Angular ([ADR-0004](0004-frontend-stack-angular-csr-zoneless-signals.md)).
|
|
||||||
|
|
||||||
### Testing strategy
|
|
||||||
|
|
||||||
#### Automated + manual + APF user panel + annual internal audit (chosen)
|
|
||||||
|
|
||||||
* Good, because covers the spectrum from regression detection to genuine usability.
|
|
||||||
* Good, because user panel access is structurally cheaper than what a consultancy would charge.
|
|
||||||
* Bad, because requires panel scheduling and triage — operational item.
|
|
||||||
|
|
||||||
#### Automated only
|
|
||||||
|
|
||||||
* Bad, because tools detect ~30 % of real accessibility defects. The remaining 70 % are interaction patterns invisible to static and runtime checks.
|
|
||||||
|
|
||||||
#### Automated + external annual audit
|
|
||||||
|
|
||||||
* Good, because external perspective.
|
|
||||||
* Bad, because expensive (5–15 k€/year for a real audit) and arguably less informed than APF's internal expertise. Reconsidered if APF wants a third-party signature for legal disclosure.
|
|
||||||
|
|
||||||
## More Information
|
|
||||||
|
|
||||||
* WCAG 2.2 specification: https://www.w3.org/TR/WCAG22/
|
|
||||||
* WCAG 2.2 quick reference (filterable by level): https://www.w3.org/WAI/WCAG22/quickref/
|
|
||||||
* European Accessibility Act (EAA): https://ec.europa.eu/social/main.jsp?catId=1202
|
|
||||||
* RGAA 4.1: https://accessibilite.numerique.gouv.fr/
|
|
||||||
* APF France Handicap: https://www.apf-francehandicap.org/
|
|
||||||
* Angular CDK A11y: https://material.angular.io/cdk/a11y/overview
|
|
||||||
* spartan-ng: https://www.spartan.ng/
|
|
||||||
* `@angular-eslint`: https://github.com/angular-eslint/angular-eslint
|
|
||||||
* `@axe-core/playwright`: https://github.com/dequelabs/axe-core-npm/tree/develop/packages/playwright
|
|
||||||
* Inclusive Components: https://inclusive-components.design/
|
|
||||||
* Related ADRs: [ADR-0004](0004-frontend-stack-angular-csr-zoneless-signals.md) (Angular stack), [ADR-0010](0010-session-management-redis.md) (preferences persistence), [ADR-0015](0015-cicd-gitea-actions.md) (CI gates `a11y` and `perf`), and the future ADRs for performance budgets, security baseline (ASVS L3 implications cross-cut a11y in some areas — e.g. step-up MFA must remain a11y-conformant), and on-prem infrastructure.
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
# Architectural Decision Records
|
|
||||||
|
|
||||||
This project records architecturally-significant decisions as **ADRs** in the [MADR 4.0.0](https://github.com/adr/madr) format. References: [adr.github.io](https://adr.github.io/).
|
|
||||||
|
|
||||||
## Why ADRs
|
|
||||||
|
|
||||||
ADRs capture the *why* behind a decision — context, drivers, options considered, trade-offs accepted — at the moment the decision is made. They make architecture reviewable, onboarding faster, and prevent the same debate from being re-litigated later.
|
|
||||||
|
|
||||||
## Conventions
|
|
||||||
|
|
||||||
- **Format:** MADR 4.0.0. Start from [template.md](template.md).
|
|
||||||
- **Filename:** `NNNN-kebab-case-title.md`, e.g. `0007-adopt-tailwind-for-design-tokens.md`.
|
|
||||||
- **Numbering:** globally sequential 4-digit prefix. Numbers never reset, never get reused — even when an ADR is superseded or deprecated.
|
|
||||||
- **Layout:** flat folder. ADRs are not nested into category subfolders; topical organization happens via tags.
|
|
||||||
- **Tags:** every ADR carries a `tags:` array in the MADR frontmatter, drawn from the [tag vocabulary](#tag-vocabulary) below. An ADR may carry several tags. Propose new tags (or renames) in the same PR that needs them; never invent ad-hoc tags inline.
|
|
||||||
- **Status lifecycle:** `proposed` → `accepted` → optionally `deprecated` or `superseded by [ADR-NNNN](NNNN-other.md)`. Update the YAML frontmatter; never delete an ADR.
|
|
||||||
- **Index maintenance:** every ADR addition or status change must update the [Index](#index) below in the same commit.
|
|
||||||
|
|
||||||
## When to write an ADR
|
|
||||||
|
|
||||||
Write one whenever a development decision is non-trivial: tool or library choice, framework pattern, security control, perf budget, a11y target, naming convention, deprecation, breaking change, or any choice that future contributors would benefit from understanding the *why* of.
|
|
||||||
|
|
||||||
## Tag vocabulary
|
|
||||||
|
|
||||||
The vocabulary below is the source of truth. It is intentionally coarse — propose extensions only when an existing tag genuinely doesn't fit, and avoid overly narrow tags.
|
|
||||||
|
|
||||||
| Tag | Scope |
|
|
||||||
| ---------------- | ----- |
|
|
||||||
| `frontend` | UI, Angular, components, design system, client-side state |
|
|
||||||
| `backend` | API, BFF, server-side services |
|
|
||||||
| `security` | AuthN, AuthZ, sessions, CSP, dependency scanning, secret management |
|
|
||||||
| `performance` | Perf budgets, caching, bundle size, Lighthouse |
|
|
||||||
| `accessibility` | WCAG, a11y testing, keyboard, ARIA, contrast |
|
|
||||||
| `infrastructure` | CI/CD, hosting, deployment, runtime |
|
|
||||||
| `observability` | Logs, metrics, traces, correlation IDs, monitoring |
|
|
||||||
| `data` | Persistence, schemas, migrations, data flow |
|
|
||||||
| `process` | Team conventions, workflows, repo policy |
|
|
||||||
|
|
||||||
> _Status: starter vocabulary, to be refined as ADRs accumulate. Update this table whenever a tag is added, renamed, or retired._
|
|
||||||
|
|
||||||
## Index
|
|
||||||
|
|
||||||
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 |
|
|
||||||
+1
-1
@@ -7,7 +7,7 @@ This is the entry point to all project documentation. It is maintained automatic
|
|||||||
- Documentation is written in **English**.
|
- Documentation is written in **English**.
|
||||||
- One topic per file. Group related files into a folder when there are three or more.
|
- One topic per file. Group related files into a folder when there are three or more.
|
||||||
- Cross-reference with relative links so they keep working in GitHub, IDEs, and exported sites.
|
- Cross-reference with relative links so they keep working in GitHub, IDEs, and exported sites.
|
||||||
- For architectural decisions, do **not** add them here — they belong in [../decisions/](../decisions/) as MADR 4.0.0 ADRs.
|
- For architectural decisions, do **not** add them here — they belong in [decisions/](decisions/) as MADR 4.0.0 ADRs.
|
||||||
|
|
||||||
## Sections
|
## Sections
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
---
|
||||||
|
status: accepted
|
||||||
|
date: 2026-04-29
|
||||||
|
decision-makers: R&D Lead
|
||||||
|
tags: [process]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Use ADRs to record architectural decisions
|
||||||
|
|
||||||
|
## Context and Problem Statement
|
||||||
|
|
||||||
|
Architecturally-significant decisions will be taken throughout the lifecycle of `apf-portal`. Without a written, durable trace of _why_ a decision was made — context, drivers, alternatives, trade-offs — that knowledge fades within months. New contributors re-litigate settled debates, the rationale of constraints (security, performance, accessibility) gets lost, and reversing a choice later becomes risky because nobody remembers what it was protecting against.
|
||||||
|
|
||||||
|
How do we record decisions in a way that is light enough to be sustained, durable enough to outlive contributor turnover, and integrated into the development workflow?
|
||||||
|
|
||||||
|
## Decision Drivers
|
||||||
|
|
||||||
|
- Preserve the _why_ of each decision so future contributors can understand or revisit it without re-deriving it.
|
||||||
|
- Make architecture reviewable — both at the time of a decision and during retrospective audits.
|
||||||
|
- Keep the format light enough that writing an ADR isn't a bureaucratic deterrent.
|
||||||
|
- Live alongside the code in version control, so decisions evolve with what they govern.
|
||||||
|
- Match the project's stated values: high technical bar, no bricolage, security/performance/accessibility as first-class concerns whose rationale must be traceable.
|
||||||
|
|
||||||
|
## Considered Options
|
||||||
|
|
||||||
|
- No formal records — rely on commit messages and tribal knowledge.
|
||||||
|
- External wiki / Confluence — decisions live outside the codebase.
|
||||||
|
- ADRs in markdown using the [MADR 4.0.0](https://github.com/adr/madr) format, in-repo.
|
||||||
|
- ADRs in another format (Nygard, Y-Statement, Cockburn).
|
||||||
|
|
||||||
|
## Decision Outcome
|
||||||
|
|
||||||
|
Chosen option: **ADRs in markdown using MADR 4.0.0**, stored under `docs/decisions/` so they sit with the rest of the project documentation.
|
||||||
|
|
||||||
|
> **Amended on 2026-04-30** — ADRs were originally located at `decisions/` at the repository root (this ADR was written that way). On 2026-04-30 the folder was relocated to `docs/decisions/` to consolidate documentation under `docs/`. The relocation preserves git history (`git mv`) and the flat-folder + globally-sequential-numbering + tags conventions are unchanged; only the path moved.
|
||||||
|
|
||||||
|
Conventions are defined in [README.md](README.md):
|
||||||
|
|
||||||
|
- flat folder layout (no nested category folders);
|
||||||
|
- globally sequential 4-digit numeric prefix (`NNNN-kebab-title.md`);
|
||||||
|
- categorization via the `tags:` field in the MADR frontmatter, drawn from a canonical vocabulary;
|
||||||
|
- status lifecycle: `proposed` → `accepted` → optionally `deprecated` / `superseded by ADR-NNNN`;
|
||||||
|
- index maintained in [README.md](README.md) alongside any ADR change.
|
||||||
|
|
||||||
|
### Consequences
|
||||||
|
|
||||||
|
- Good, because the _why_ of every architecturally-significant decision is captured at the moment it is made and travels with the code.
|
||||||
|
- Good, because ADRs are reviewed via the normal pull-request flow — architecture becomes an explicit team conversation.
|
||||||
|
- Good, because MADR provides enough structure (drivers, options, trade-offs) without being heavy.
|
||||||
|
- Good, because flat-folder + tags scales better than nested category folders as the count grows.
|
||||||
|
- Bad, because writing an ADR adds friction; this must be balanced against the cost of _not_ writing one.
|
||||||
|
- Bad, because the index is maintained manually until tooling or a CI check is added.
|
||||||
|
|
||||||
|
### Confirmation
|
||||||
|
|
||||||
|
- Every change introducing or transitioning the status of an ADR updates the index table in [README.md](README.md) in the same commit.
|
||||||
|
- PR review explicitly checks for ADR proposal whenever a non-trivial decision is being introduced.
|
||||||
|
- (Future) A CI lint job validates filename pattern, frontmatter shape, and tag vocabulary.
|
||||||
|
|
||||||
|
## More Information
|
||||||
|
|
||||||
|
- MADR 4.0.0 specification: https://github.com/adr/madr
|
||||||
|
- General ADR resources: https://adr.github.io/
|
||||||
|
- Template: [template.md](template.md)
|
||||||
|
- Tag vocabulary: see "Tag vocabulary" in [README.md](README.md).
|
||||||
+36
-35
@@ -15,25 +15,26 @@ How do we structure the repository so both apps coexist with shared libraries, b
|
|||||||
|
|
||||||
## Decision Drivers
|
## Decision Drivers
|
||||||
|
|
||||||
* Two cooperating runtimes (Angular + NestJS), both TypeScript, sharing types and contracts on the wire.
|
- Two cooperating runtimes (Angular + NestJS), both TypeScript, sharing types and contracts on the wire.
|
||||||
* Need for incremental, cached builds and tests on what changed (`affected`-style workflow).
|
- Need for incremental, cached builds and tests on what changed (`affected`-style workflow).
|
||||||
* Long-term enterprise track record — no exotic stack.
|
- Long-term enterprise track record — no exotic stack.
|
||||||
* Compatibility with `pnpm` (already mandated by project setup).
|
- Compatibility with `pnpm` (already mandated by project setup).
|
||||||
* Enforceable dependency boundaries between apps and libs.
|
- Enforceable dependency boundaries between apps and libs.
|
||||||
|
|
||||||
## Considered Options
|
## Considered Options
|
||||||
|
|
||||||
* **Polyrepos** — separate repository per runtime.
|
- **Polyrepos** — separate repository per runtime.
|
||||||
* **Nx with the `apps` preset** — generic multi-tech workspace, plugins added as needed (`@nx/angular`, `@nx/nest`).
|
- **Nx with the `apps` preset** — generic multi-tech workspace, plugins added as needed (`@nx/angular`, `@nx/nest`).
|
||||||
* **Nx with the `angular-monorepo` preset** — front-first preset, BFF grafted on later.
|
- **Nx with the `angular-monorepo` preset** — front-first preset, BFF grafted on later.
|
||||||
* **Turborepo + pnpm workspaces** — lighter alternative to Nx.
|
- **Turborepo + pnpm workspaces** — lighter alternative to Nx.
|
||||||
* **pnpm workspaces alone** — manual orchestration, no monorepo tool.
|
- **pnpm workspaces alone** — manual orchestration, no monorepo tool.
|
||||||
|
|
||||||
## Decision Outcome
|
## Decision Outcome
|
||||||
|
|
||||||
Chosen option: **Nx monorepo with the `apps` preset, managed by pnpm**.
|
Chosen option: **Nx monorepo with the `apps` preset, managed by pnpm**.
|
||||||
|
|
||||||
The workspace will host:
|
The workspace will host:
|
||||||
|
|
||||||
- `apps/portal-shell` — Angular SPA frontend (see [ADR-0004](0004-frontend-stack-angular-csr-zoneless-signals.md));
|
- `apps/portal-shell` — Angular SPA frontend (see [ADR-0004](0004-frontend-stack-angular-csr-zoneless-signals.md));
|
||||||
- `apps/portal-bff` — NestJS BFF backend (see [ADR-0005](0005-backend-stack-nestjs.md));
|
- `apps/portal-bff` — NestJS BFF backend (see [ADR-0005](0005-backend-stack-nestjs.md));
|
||||||
- `libs/shared/<scope>` and `libs/feature/<name>` for shared and feature code (see [ADR-0003](0003-workspace-and-app-naming-convention.md)).
|
- `libs/shared/<scope>` and `libs/feature/<name>` for shared and feature code (see [ADR-0003](0003-workspace-and-app-naming-convention.md)).
|
||||||
@@ -42,51 +43,51 @@ Plugins enabled at bootstrap: `@nx/angular`, `@nx/nest`, `@nx/eslint`, `@nx/vite
|
|||||||
|
|
||||||
### Consequences
|
### Consequences
|
||||||
|
|
||||||
* Good, because front and back share types, lint config, formatter, and test runner with no extra plumbing.
|
- Good, because front and back share types, lint config, formatter, and test runner with no extra plumbing.
|
||||||
* Good, because Nx's project graph + `affected` make CI fast and frugal as the codebase grows.
|
- Good, because Nx's project graph + `affected` make CI fast and frugal as the codebase grows.
|
||||||
* Good, because the `apps` preset stays neutral — neither runtime is privileged in workspace structure.
|
- Good, because the `apps` preset stays neutral — neither runtime is privileged in workspace structure.
|
||||||
* Good, because Nx is mainstream, used by major enterprises, and actively maintained by Nrwl.
|
- Good, because Nx is mainstream, used by major enterprises, and actively maintained by Nrwl.
|
||||||
* Bad, because Nx adds a non-trivial conceptual surface (executors, generators, project graph) — a one-time learning cost.
|
- Bad, because Nx adds a non-trivial conceptual surface (executors, generators, project graph) — a one-time learning cost.
|
||||||
* Bad, because monorepos require discipline to keep dependency boundaries clean — mitigated by Nx's `enforce-module-boundaries` lint rule.
|
- Bad, because monorepos require discipline to keep dependency boundaries clean — mitigated by Nx's `enforce-module-boundaries` lint rule.
|
||||||
|
|
||||||
### Confirmation
|
### Confirmation
|
||||||
|
|
||||||
* Workspace is bootstrapped with `pnpm dlx create-nx-workspace@latest apf-portal --preset=apps --pm=pnpm`.
|
- Workspace is bootstrapped with `pnpm dlx create-nx-workspace@latest apf-portal --preset=apps --pm=pnpm`.
|
||||||
* `nx.json` has `@nx/eslint/enforce-module-boundaries` enabled and configured against the project tags.
|
- `nx.json` has `@nx/eslint/enforce-module-boundaries` enabled and configured against the project tags.
|
||||||
* CI runs `pnpm nx affected -t lint test build` on every push.
|
- CI runs `pnpm nx affected -t lint test build` on every push.
|
||||||
|
|
||||||
## Pros and Cons of the Options
|
## Pros and Cons of the Options
|
||||||
|
|
||||||
### Polyrepos
|
### Polyrepos
|
||||||
|
|
||||||
* Good, because each runtime has independent versioning and CI scope.
|
- Good, because each runtime has independent versioning and CI scope.
|
||||||
* Bad, because sharing TypeScript types across repos requires a published package (overhead, version drift).
|
- Bad, because sharing TypeScript types across repos requires a published package (overhead, version drift).
|
||||||
* Bad, because cross-cutting changes (e.g. adjusting an auth claim shape) touch two repos and two PRs.
|
- Bad, because cross-cutting changes (e.g. adjusting an auth claim shape) touch two repos and two PRs.
|
||||||
|
|
||||||
### Nx with `apps` preset (chosen)
|
### Nx with `apps` preset (chosen)
|
||||||
|
|
||||||
* Good, because runtime-agnostic — Angular and Node coexist as peers.
|
- Good, because runtime-agnostic — Angular and Node coexist as peers.
|
||||||
* Good, because Nx provides build orchestration, caching, generators, and lint boundaries out of the box.
|
- Good, because Nx provides build orchestration, caching, generators, and lint boundaries out of the box.
|
||||||
|
|
||||||
### Nx with `angular-monorepo` preset
|
### Nx with `angular-monorepo` preset
|
||||||
|
|
||||||
* Good, because faster initial setup for a front-first project.
|
- Good, because faster initial setup for a front-first project.
|
||||||
* Bad, because the preset implies the project is "an Angular workspace" — the BFF feels grafted, the workspace `package.json` ends up biased toward Angular tooling.
|
- Bad, because the preset implies the project is "an Angular workspace" — the BFF feels grafted, the workspace `package.json` ends up biased toward Angular tooling.
|
||||||
|
|
||||||
### Turborepo
|
### Turborepo
|
||||||
|
|
||||||
* Good, because lighter, no codegen surface.
|
- Good, because lighter, no codegen surface.
|
||||||
* Neutral, because caching/affected is comparable.
|
- Neutral, because caching/affected is comparable.
|
||||||
* Bad, because no first-class Angular plugin; both runtimes would need manual integration.
|
- Bad, because no first-class Angular plugin; both runtimes would need manual integration.
|
||||||
* Bad, because smaller enterprise track record than Nx.
|
- Bad, because smaller enterprise track record than Nx.
|
||||||
|
|
||||||
### pnpm workspaces alone
|
### pnpm workspaces alone
|
||||||
|
|
||||||
* Good, because no extra tool to learn.
|
- Good, because no extra tool to learn.
|
||||||
* Bad, because no caching, no `affected`, no generators — every piece reinvented by hand (the bricolage we're forbidden).
|
- Bad, because no caching, no `affected`, no generators — every piece reinvented by hand (the bricolage we're forbidden).
|
||||||
|
|
||||||
## More Information
|
## More Information
|
||||||
|
|
||||||
* Nx documentation: https://nx.dev
|
- Nx documentation: https://nx.dev
|
||||||
* Nx `apps` preset: https://nx.dev/getting-started/intro
|
- Nx `apps` preset: https://nx.dev/getting-started/intro
|
||||||
* Related ADRs: [ADR-0003](0003-workspace-and-app-naming-convention.md), [ADR-0004](0004-frontend-stack-angular-csr-zoneless-signals.md), [ADR-0005](0005-backend-stack-nestjs.md), ADR-0017 (CI/CD, future).
|
- Related ADRs: [ADR-0003](0003-workspace-and-app-naming-convention.md), [ADR-0004](0004-frontend-stack-angular-csr-zoneless-signals.md), [ADR-0005](0005-backend-stack-nestjs.md), ADR-0017 (CI/CD, future).
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
---
|
||||||
|
status: accepted
|
||||||
|
date: 2026-04-29
|
||||||
|
decision-makers: R&D Lead
|
||||||
|
tags: [process]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Workspace and app naming convention
|
||||||
|
|
||||||
|
## Context and Problem Statement
|
||||||
|
|
||||||
|
Default Nx scaffolding offers placeholder names like `my-workspace` and `web`, which are unacceptable for a long-lived enterprise project. Naming has to survive two foreseeable changes: the product's marketing name may evolve (the working title `apf_portal` is provisional), and the project may sit alongside other R&D projects in the same Gitea organization, where collisions on generic names like `web` or `api` would cause confusion.
|
||||||
|
|
||||||
|
What naming convention do we adopt for the workspace, the apps, and future libraries so names are explicit, function-anchored, and stable against rebranding?
|
||||||
|
|
||||||
|
## Decision Drivers
|
||||||
|
|
||||||
|
- The product's marketing name is provisional — names anchored on it become stale at rebrand.
|
||||||
|
- Names should reflect _function_, not _brand_.
|
||||||
|
- Multiple R&D projects may share the org — generic names (`web`, `api`, `frontend`) cause friction.
|
||||||
|
- Compliance with npm package naming rules (lowercase, kebab-case).
|
||||||
|
- Readability inside Nx's project graph and CLI output.
|
||||||
|
|
||||||
|
## Considered Options
|
||||||
|
|
||||||
|
- **Defaults** — `my-workspace`, `web`. (Rejected up-front.)
|
||||||
|
- **Brand-anchored** — `apf-front`, `apf-back`. Fragile to further rebranding.
|
||||||
|
- **Function-prefixed** — `portal-shell`, `portal-bff`. (Chosen.)
|
||||||
|
- **Generic** — `shell`, `bff`. Risk of org-wide collision; reduced readability.
|
||||||
|
|
||||||
|
## Decision Outcome
|
||||||
|
|
||||||
|
Chosen option: **function-prefixed naming**.
|
||||||
|
|
||||||
|
| Scope | Name | Rationale |
|
||||||
|
| ----------------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Workspace / repo / npm package root | `apf-portal` | matches the Gitea repository name; the only place a brand-ish name lives, so a rebrand is a one-line change |
|
||||||
|
| Frontend app | `portal-shell` | "shell" describes the function — the Angular host that loads features |
|
||||||
|
| Backend app | `portal-bff` | explicit role: backend-for-frontend |
|
||||||
|
| Feature libraries | `feature-<name>` | e.g. `feature-auth`, `feature-billing` |
|
||||||
|
| Shared libraries | `shared-<scope>` | e.g. `shared-ui`, `shared-data-access`, `shared-util` |
|
||||||
|
|
||||||
|
The `portal-` prefix encodes the _function_ (a portal). The host organisation's brand can change without invalidating "portal" — the project was originally proposed as `adastra_portal` and renamed to `apf_portal` on 2026-04-30 once the host organisation (APF France Handicap) was confirmed; the apps `portal-shell` / `portal-bff` were unaffected by the rename, validating this convention. The brand-ish token `apf` is confined to the workspace root and a tiny number of metadata files (root `package.json`, repo URL).
|
||||||
|
|
||||||
|
### Consequences
|
||||||
|
|
||||||
|
- Good, because names survive a brand change with minimal churn.
|
||||||
|
- Good, because `portal-shell` / `portal-bff` are unambiguous in CLI output and in the Nx project graph.
|
||||||
|
- Good, because the prefix scales when more apps appear (e.g. `portal-admin`, `portal-jobs`).
|
||||||
|
- Bad, because the prefix adds verbosity (`pnpm nx serve portal-shell` vs `pnpm nx serve web`). Acceptable.
|
||||||
|
- Neutral, because if "portal" itself ceases to be accurate (e.g. the system pivots to something other than a portal), the prefix would also need updating — but that would be a deeper architectural pivot anyway.
|
||||||
|
|
||||||
|
### Confirmation
|
||||||
|
|
||||||
|
- Nx project names match the convention in `nx.json` and in each project's `project.json`.
|
||||||
|
- `@nx/eslint/enforce-module-boundaries` is configured with tags aligned to the convention (`scope:portal-shell`, `scope:portal-bff`, `type:feature`, `type:shared`).
|
||||||
|
- PR review rejects new project names that don't match.
|
||||||
|
|
||||||
|
## More Information
|
||||||
|
|
||||||
|
- Related ADRs: [ADR-0002](0002-adopt-nx-monorepo-apps-preset.md), and the "Project name" rule in [CLAUDE.md](../../CLAUDE.md).
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
---
|
||||||
|
status: accepted
|
||||||
|
date: 2026-04-29
|
||||||
|
decision-makers: R&D Lead
|
||||||
|
tags: [frontend]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Frontend stack — Angular (latest LTS), standalone, zoneless, Signals, CSR-only, Vitest
|
||||||
|
|
||||||
|
## Context and Problem Statement
|
||||||
|
|
||||||
|
The portal's frontend (`portal-shell`) is a single-page application that aggregates access to existing applications and progressively integrates re-developed features. It sits fully behind authentication: no public content. We need to fix the framework, the rendering mode, the change-detection model, and the test runner now, so subsequent ADRs (auth flow, observability, accessibility, performance budgets) can build on a stable foundation.
|
||||||
|
|
||||||
|
What frontend stack maximizes alignment with our backend (NestJS), gives us first-class accessibility and performance hooks, and is enterprise-stable for a long-lived project?
|
||||||
|
|
||||||
|
## Decision Drivers
|
||||||
|
|
||||||
|
- Architectural alignment with NestJS (DI, decorators, modules, RxJS) — minimizes cognitive distance for the team.
|
||||||
|
- Performance and accessibility as first-class concerns from day one.
|
||||||
|
- Long-term enterprise stability (no pre-1.0, no exotic stack).
|
||||||
|
- End-to-end type safety.
|
||||||
|
- No public surface — all content gated by authentication, which removes the usual SSR drivers (SEO, anonymous LCP).
|
||||||
|
|
||||||
|
## Considered Options
|
||||||
|
|
||||||
|
- **Angular (latest LTS) — standalone, zoneless, Signals, CSR, Vitest.** (Chosen.)
|
||||||
|
- Angular with SSR (`@angular/ssr`).
|
||||||
|
- React + Next.js (or Vite + React Router).
|
||||||
|
- Vue + Nuxt.
|
||||||
|
- Svelte + SvelteKit.
|
||||||
|
|
||||||
|
## Decision Outcome
|
||||||
|
|
||||||
|
Chosen option: **Angular at the latest LTS major**, with the following modern defaults:
|
||||||
|
|
||||||
|
- **Standalone APIs** — no `NgModule`s. Configuration is composed via providers; routing is functional.
|
||||||
|
- **Zoneless change detection** — no `Zone.js`. Change detection is driven by Signals and explicit `markForCheck` where needed.
|
||||||
|
- **Signals** — primary reactive primitive for component state. RxJS retained for async streams (HTTP, events).
|
||||||
|
- **Vitest** — unit and component test runner (Karma is deprecated upstream).
|
||||||
|
- **CSR only — no SSR** for v1. Reconsider only if a measured performance metric requires it.
|
||||||
|
- **SCSS** for styles. Design-token strategy to be defined in a future ADR.
|
||||||
|
- **Strict TypeScript** across the workspace (`strict: true`, `noUncheckedIndexedAccess: true`).
|
||||||
|
|
||||||
|
### Consequences
|
||||||
|
|
||||||
|
- Good, because the team works with a single mental model across front and back (DI, decorators, RxJS).
|
||||||
|
- Good, because zoneless + Signals removes Zone.js overhead and gives finer-grained, more predictable reactivity.
|
||||||
|
- Good, because Vitest is significantly faster than Karma and shares the Vite toolchain Nx already uses.
|
||||||
|
- Good, because CSR keeps the BFF free of SSR concerns (no auth-aware rendering, no double-fetch, no hydration debugging) — simpler operational surface.
|
||||||
|
- Bad, because Angular's release cadence is brisk; the project commits to staying on LTS rather than skipping versions.
|
||||||
|
- Bad, because zoneless and Signals are recent — though stable, the broader Angular ecosystem still includes Zone-dependent libraries that must be vetted before adoption.
|
||||||
|
- Bad, because CSR yields a minimal initial HTML payload; this must be paired with strong performance budgets (covered by ADR-0015, future).
|
||||||
|
|
||||||
|
### Confirmation
|
||||||
|
|
||||||
|
- `package.json` pins Angular to the current LTS major.
|
||||||
|
- `app.config.ts` registers `provideZonelessChangeDetection()` (or its current stable equivalent) and standalone routing.
|
||||||
|
- `tsconfig.base.json` sets `strict: true` and `noUncheckedIndexedAccess: true`.
|
||||||
|
- CI runs `pnpm nx test portal-shell` (Vitest).
|
||||||
|
- No `import 'zone.js'` anywhere in the codebase.
|
||||||
|
- `@angular/ssr` is not installed.
|
||||||
|
|
||||||
|
## Pros and Cons of the Options
|
||||||
|
|
||||||
|
### Angular CSR (chosen)
|
||||||
|
|
||||||
|
- Good, because alignment with NestJS minimizes context-switching for the team.
|
||||||
|
- Good, because mature, enterprise-supported (Google + community).
|
||||||
|
- Good, because Signals + zoneless make Angular's reactivity competitive with React/Vue.
|
||||||
|
- Bad, because verbosity is higher than minimalist frameworks.
|
||||||
|
|
||||||
|
### Angular SSR
|
||||||
|
|
||||||
|
- Good, because better TTFB and accessibility on first paint for unauthenticated content.
|
||||||
|
- Bad, because we have no unauthenticated content — the gain is essentially zero.
|
||||||
|
- Bad, because SSR-with-auth introduces non-trivial complexity (cookie-aware fetch, hydration with secured data).
|
||||||
|
|
||||||
|
### React + Next.js
|
||||||
|
|
||||||
|
- Good, because large ecosystem, fast iteration.
|
||||||
|
- Bad, because no architectural alignment with NestJS — different DI/state paradigms.
|
||||||
|
- Bad, because Next.js's defaults push toward server components and edge rendering, which conflict with our on-prem, all-authenticated profile.
|
||||||
|
|
||||||
|
### Vue + Nuxt / Svelte + SvelteKit
|
||||||
|
|
||||||
|
- Good, because lighter syntax, strong DX.
|
||||||
|
- Bad, because no DI alignment with NestJS.
|
||||||
|
- Bad, because smaller enterprise-grade community than Angular or React.
|
||||||
|
|
||||||
|
## More Information
|
||||||
|
|
||||||
|
- Angular zoneless change detection: https://angular.dev/guide/experimental/zoneless
|
||||||
|
- Angular Signals: https://angular.dev/guide/signals
|
||||||
|
- Vitest with Nx: https://nx.dev/nx-api/vite
|
||||||
|
- Related ADRs: [ADR-0005](0005-backend-stack-nestjs.md), ADR-0008 (auth flow, future), ADR-0014 (accessibility, future), ADR-0015 (performance budgets, future).
|
||||||
+41
-41
@@ -15,19 +15,19 @@ Which Node TypeScript framework gives us the right combination of structure, eco
|
|||||||
|
|
||||||
## Decision Drivers
|
## Decision Drivers
|
||||||
|
|
||||||
* Security is a first-class concern — the framework must impose structure rather than leave it to be hand-built.
|
- Security is a first-class concern — the framework must impose structure rather than leave it to be hand-built.
|
||||||
* Architectural alignment with the Angular frontend (DI, decorators, modules, RxJS).
|
- Architectural alignment with the Angular frontend (DI, decorators, modules, RxJS).
|
||||||
* First-class ecosystem for the things we will need: Passport / MSAL, Pino logging, OpenTelemetry, Prisma, Redis, validation, health checks.
|
- First-class ecosystem for the things we will need: Passport / MSAL, Pino logging, OpenTelemetry, Prisma, Redis, validation, health checks.
|
||||||
* Long-term enterprise viability (no pre-1.0, no single-maintainer projects).
|
- Long-term enterprise viability (no pre-1.0, no single-maintainer projects).
|
||||||
* Performance acceptable for a BFF (request mediation, not raw throughput benchmarks).
|
- Performance acceptable for a BFF (request mediation, not raw throughput benchmarks).
|
||||||
|
|
||||||
## Considered Options
|
## Considered Options
|
||||||
|
|
||||||
* **NestJS** (latest stable major). (Chosen.)
|
- **NestJS** (latest stable major). (Chosen.)
|
||||||
* **Express 5** — minimal, ubiquitous.
|
- **Express 5** — minimal, ubiquitous.
|
||||||
* **Fastify 5** — performance-oriented, plugin-based.
|
- **Fastify 5** — performance-oriented, plugin-based.
|
||||||
* **Hono** — modern, multi-runtime.
|
- **Hono** — modern, multi-runtime.
|
||||||
* **tRPC** — typed RPC over HTTP.
|
- **tRPC** — typed RPC over HTTP.
|
||||||
|
|
||||||
## Decision Outcome
|
## Decision Outcome
|
||||||
|
|
||||||
@@ -35,57 +35,57 @@ Chosen option: **NestJS at the latest stable major**, mounted on the **Express a
|
|||||||
|
|
||||||
### Consequences
|
### Consequences
|
||||||
|
|
||||||
* Good, because NestJS imposes a clear, opinionated architecture (modules, providers, controllers, pipes, guards, interceptors) — minimal room for bricolage.
|
- Good, because NestJS imposes a clear, opinionated architecture (modules, providers, controllers, pipes, guards, interceptors) — minimal room for bricolage.
|
||||||
* Good, because the team lives in the same paradigm as Angular (DI, decorators, RxJS).
|
- Good, because the team lives in the same paradigm as Angular (DI, decorators, RxJS).
|
||||||
* Good, because every supporting concern has a first-class NestJS integration: `@nestjs/passport` + `@azure/msal-node` for auth, `nestjs-pino` for logs, `nestjs-cls` for AsyncLocalStorage, `@nestjs/terminus` for health checks, `nestjs-prisma` for persistence.
|
- Good, because every supporting concern has a first-class NestJS integration: `@nestjs/passport` + `@azure/msal-node` for auth, `nestjs-pino` for logs, `nestjs-cls` for AsyncLocalStorage, `@nestjs/terminus` for health checks, `nestjs-prisma` for persistence.
|
||||||
* Good, because NestJS is at v11+ with a healthy enterprise track record (Adidas, Roche, Capgemini publicly), an active core team, and commercial support options.
|
- Good, because NestJS is at v11+ with a healthy enterprise track record (Adidas, Roche, Capgemini publicly), an active core team, and commercial support options.
|
||||||
* Good, because the adapter pattern lets us swap to Fastify later without touching application code.
|
- Good, because the adapter pattern lets us swap to Fastify later without touching application code.
|
||||||
* Bad, because NestJS has a non-trivial conceptual surface (DI hierarchy, lifecycle hooks, request-scoped providers) — one-time learning cost.
|
- Bad, because NestJS has a non-trivial conceptual surface (DI hierarchy, lifecycle hooks, request-scoped providers) — one-time learning cost.
|
||||||
* Bad, because the framework is heavier than Express or Fastify alone — acceptable for our profile.
|
- Bad, because the framework is heavier than Express or Fastify alone — acceptable for our profile.
|
||||||
|
|
||||||
### Confirmation
|
### Confirmation
|
||||||
|
|
||||||
* `apps/portal-bff/package.json` depends on `@nestjs/core`, `@nestjs/common`, `@nestjs/platform-express`.
|
- `apps/portal-bff/package.json` depends on `@nestjs/core`, `@nestjs/common`, `@nestjs/platform-express`.
|
||||||
* `apps/portal-bff/src/main.ts` bootstraps via `NestFactory.create`.
|
- `apps/portal-bff/src/main.ts` bootstraps via `NestFactory.create`.
|
||||||
* All HTTP entry points are `@Controller`-based; no raw Express routes outside framework escape hatches.
|
- All HTTP entry points are `@Controller`-based; no raw Express routes outside framework escape hatches.
|
||||||
* A global `ValidationPipe` is applied with `whitelist: true, forbidNonWhitelisted: true, transform: true`.
|
- A global `ValidationPipe` is applied with `whitelist: true, forbidNonWhitelisted: true, transform: true`.
|
||||||
|
|
||||||
## Pros and Cons of the Options
|
## Pros and Cons of the Options
|
||||||
|
|
||||||
### NestJS (chosen)
|
### NestJS (chosen)
|
||||||
|
|
||||||
* Good, because opinionated structure sets a clear bar across the team.
|
- Good, because opinionated structure sets a clear bar across the team.
|
||||||
* Good, because alignment with Angular reduces cognitive distance.
|
- Good, because alignment with Angular reduces cognitive distance.
|
||||||
* Good, because the ecosystem covers every BFF concern we have.
|
- Good, because the ecosystem covers every BFF concern we have.
|
||||||
* Bad, because verbose for tiny services — acceptable here, this is a real BFF.
|
- Bad, because verbose for tiny services — acceptable here, this is a real BFF.
|
||||||
|
|
||||||
### Express 5
|
### Express 5
|
||||||
|
|
||||||
* Good, because the lowest common denominator — every Node engineer knows it.
|
- Good, because the lowest common denominator — every Node engineer knows it.
|
||||||
* Bad, because minimalist by design — every cross-cutting concern (DI, validation, structured errors, lifecycle) is built by hand. For a security-sensitive BFF in an "anti-bricolage" project, this is a poor fit.
|
- Bad, because minimalist by design — every cross-cutting concern (DI, validation, structured errors, lifecycle) is built by hand. For a security-sensitive BFF in an "anti-bricolage" project, this is a poor fit.
|
||||||
* Bad, because Express-as-foundation in 2026 is the legacy default; the modern center of gravity has moved.
|
- Bad, because Express-as-foundation in 2026 is the legacy default; the modern center of gravity has moved.
|
||||||
|
|
||||||
### Fastify 5
|
### Fastify 5
|
||||||
|
|
||||||
* Good, because excellent raw performance and a clean plugin model.
|
- Good, because excellent raw performance and a clean plugin model.
|
||||||
* Good, because schema-driven validation is built in.
|
- Good, because schema-driven validation is built in.
|
||||||
* Bad, because architecture is left to the application — we'd reinvent NestJS poorly.
|
- Bad, because architecture is left to the application — we'd reinvent NestJS poorly.
|
||||||
* Note: NestJS can run on a Fastify adapter — we can have both if a perf profile demands it.
|
- Note: NestJS can run on a Fastify adapter — we can have both if a perf profile demands it.
|
||||||
|
|
||||||
### Hono
|
### Hono
|
||||||
|
|
||||||
* Good, because modern, minimal, multi-runtime (Node, Bun, Workers).
|
- Good, because modern, minimal, multi-runtime (Node, Bun, Workers).
|
||||||
* Bad, because too young (~2023) for our longevity bar — limited enterprise track record.
|
- Bad, because too young (~2023) for our longevity bar — limited enterprise track record.
|
||||||
* Status: keep in watch list; reconsider in 12–24 months.
|
- Status: keep in watch list; reconsider in 12–24 months.
|
||||||
|
|
||||||
### tRPC
|
### tRPC
|
||||||
|
|
||||||
* Good, because end-to-end type safety from server to client without code generation.
|
- Good, because end-to-end type safety from server to client without code generation.
|
||||||
* Bad, because it tightly couples frontend and backend builds — incompatible with a BFF that may also need to serve non-Angular consumers later (downstream API gateways, mobile, partner integrations).
|
- Bad, because it tightly couples frontend and backend builds — incompatible with a BFF that may also need to serve non-Angular consumers later (downstream API gateways, mobile, partner integrations).
|
||||||
* Bad, because not a general HTTP framework — orthogonal to the choice we're making.
|
- Bad, because not a general HTTP framework — orthogonal to the choice we're making.
|
||||||
|
|
||||||
## More Information
|
## More Information
|
||||||
|
|
||||||
* NestJS documentation: https://docs.nestjs.com
|
- NestJS documentation: https://docs.nestjs.com
|
||||||
* NestJS Fastify adapter: https://docs.nestjs.com/techniques/performance
|
- NestJS Fastify adapter: https://docs.nestjs.com/techniques/performance
|
||||||
* Related ADRs: [ADR-0002](0002-adopt-nx-monorepo-apps-preset.md), [ADR-0006](0006-persistence-postgresql-prisma.md), ADR-0008 (auth flow, future), ADR-0009 (sessions, future), ADR-0012 (observability, future).
|
- Related ADRs: [ADR-0002](0002-adopt-nx-monorepo-apps-preset.md), [ADR-0006](0006-persistence-postgresql-prisma.md), ADR-0008 (auth flow, future), ADR-0009 (sessions, future), ADR-0012 (observability, future).
|
||||||
+51
-51
@@ -15,28 +15,28 @@ Which database engine and which data-access layer do we adopt?
|
|||||||
|
|
||||||
## Decision Drivers
|
## Decision Drivers
|
||||||
|
|
||||||
* On-prem deployment (no managed-service shortcut).
|
- On-prem deployment (no managed-service shortcut).
|
||||||
* Multi-audience data with strict isolation between workforce and customer scopes (Row-Level Security desirable).
|
- Multi-audience data with strict isolation between workforce and customer scopes (Row-Level Security desirable).
|
||||||
* ACID transactions and a well-understood operational story (HA, backup, point-in-time recovery).
|
- ACID transactions and a well-understood operational story (HA, backup, point-in-time recovery).
|
||||||
* TypeScript-first developer experience for the BFF.
|
- TypeScript-first developer experience for the BFF.
|
||||||
* Long-term licensing safety — open source, no recent license-change risk (e.g. SSPL).
|
- Long-term licensing safety — open source, no recent license-change risk (e.g. SSPL).
|
||||||
|
|
||||||
## Considered Options
|
## Considered Options
|
||||||
|
|
||||||
### Database engine
|
### Database engine
|
||||||
|
|
||||||
* **PostgreSQL** (latest stable major). (Chosen.)
|
- **PostgreSQL** (latest stable major). (Chosen.)
|
||||||
* MariaDB / MySQL.
|
- MariaDB / MySQL.
|
||||||
* SQL Server (would imply Microsoft licensing terms).
|
- SQL Server (would imply Microsoft licensing terms).
|
||||||
* MongoDB / DocumentDB (rejected — not a fit for relational, audit-heavy business data; SSPL concerns).
|
- MongoDB / DocumentDB (rejected — not a fit for relational, audit-heavy business data; SSPL concerns).
|
||||||
|
|
||||||
### Data-access layer
|
### Data-access layer
|
||||||
|
|
||||||
* **Prisma** (latest stable major). (Chosen.)
|
- **Prisma** (latest stable major). (Chosen.)
|
||||||
* Drizzle.
|
- Drizzle.
|
||||||
* Kysely.
|
- Kysely.
|
||||||
* TypeORM.
|
- TypeORM.
|
||||||
* Plain `pg` driver + hand-written SQL.
|
- Plain `pg` driver + hand-written SQL.
|
||||||
|
|
||||||
## Decision Outcome
|
## Decision Outcome
|
||||||
|
|
||||||
@@ -48,72 +48,72 @@ Prisma is wired into NestJS via the `nestjs-prisma` integration, with a dedicate
|
|||||||
|
|
||||||
### Consequences
|
### Consequences
|
||||||
|
|
||||||
* Good, because PostgreSQL is the de facto enterprise open-source RDBMS — mature HA, replication, RLS, JSONB, full-text search.
|
- Good, because PostgreSQL is the de facto enterprise open-source RDBMS — mature HA, replication, RLS, JSONB, full-text search.
|
||||||
* Good, because RLS gives a strong substrate for the workforce/customer isolation called for by ADR-0007 (identity model, future).
|
- Good, because RLS gives a strong substrate for the workforce/customer isolation called for by ADR-0007 (identity model, future).
|
||||||
* Good, because Prisma's schema-first model is a single source of truth, with type generation and a readable migration log.
|
- Good, because Prisma's schema-first model is a single source of truth, with type generation and a readable migration log.
|
||||||
* Good, because Prisma's NestJS integration is well-trodden territory.
|
- Good, because Prisma's NestJS integration is well-trodden territory.
|
||||||
* Bad, because Prisma adds a generated client and a query engine binary — operational surface to ship and version.
|
- Bad, because Prisma adds a generated client and a query engine binary — operational surface to ship and version.
|
||||||
* Bad, because Prisma's escape hatch for advanced PostgreSQL features (advanced CTEs, window functions, RLS policies) is `$queryRaw` — usable, but a reminder that not everything goes through the ORM. We will not fight Prisma when raw SQL is the right tool.
|
- Bad, because Prisma's escape hatch for advanced PostgreSQL features (advanced CTEs, window functions, RLS policies) is `$queryRaw` — usable, but a reminder that not everything goes through the ORM. We will not fight Prisma when raw SQL is the right tool.
|
||||||
* Neutral, because Drizzle is a credible "à la pointe" alternative; we accept the more conservative choice for now and re-evaluate at the next major architectural review.
|
- Neutral, because Drizzle is a credible "à la pointe" alternative; we accept the more conservative choice for now and re-evaluate at the next major architectural review.
|
||||||
|
|
||||||
### Confirmation
|
### Confirmation
|
||||||
|
|
||||||
* `apps/portal-bff` depends on `prisma` and `@prisma/client`.
|
- `apps/portal-bff` depends on `prisma` and `@prisma/client`.
|
||||||
* A single `schema.prisma` lives at `apps/portal-bff/prisma/schema.prisma`.
|
- A single `schema.prisma` lives at `apps/portal-bff/prisma/schema.prisma`.
|
||||||
* `nestjs-prisma`'s `PrismaModule` is imported globally in the BFF.
|
- `nestjs-prisma`'s `PrismaModule` is imported globally in the BFF.
|
||||||
* CI runs `prisma validate` and `prisma migrate diff` against the staging schema.
|
- CI runs `prisma validate` and `prisma migrate diff` against the staging schema.
|
||||||
* Database engine version is pinned in deployment manifests.
|
- Database engine version is pinned in deployment manifests.
|
||||||
|
|
||||||
## Pros and Cons of the Options
|
## Pros and Cons of the Options
|
||||||
|
|
||||||
### PostgreSQL (chosen)
|
### PostgreSQL (chosen)
|
||||||
|
|
||||||
* Good, because mature, open source under the PostgreSQL License (permissive), broad on-prem operational tooling.
|
- Good, because mature, open source under the PostgreSQL License (permissive), broad on-prem operational tooling.
|
||||||
* Good, because RLS is first-class — directly serves multi-audience isolation.
|
- Good, because RLS is first-class — directly serves multi-audience isolation.
|
||||||
* Good, because JSONB + relational hybrid suits BFF data shapes.
|
- Good, because JSONB + relational hybrid suits BFF data shapes.
|
||||||
* Bad, because operational complexity (HA, backups, tuning) must be owned — true of any on-prem RDBMS.
|
- Bad, because operational complexity (HA, backups, tuning) must be owned — true of any on-prem RDBMS.
|
||||||
|
|
||||||
### MariaDB / MySQL
|
### MariaDB / MySQL
|
||||||
|
|
||||||
* Good, because operationally well-known.
|
- Good, because operationally well-known.
|
||||||
* Bad, because feature gap vs. PostgreSQL (RLS, JSON ergonomics, CTE maturity, generated columns).
|
- Bad, because feature gap vs. PostgreSQL (RLS, JSON ergonomics, CTE maturity, generated columns).
|
||||||
|
|
||||||
### MongoDB
|
### MongoDB
|
||||||
|
|
||||||
* Good, because the document model fits some BFF caches.
|
- Good, because the document model fits some BFF caches.
|
||||||
* Bad, because business data here is relational; forcing a document store would push joins into the application — bricolage.
|
- Bad, because business data here is relational; forcing a document store would push joins into the application — bricolage.
|
||||||
* Bad, because the SSPL license raises enterprise legal complications.
|
- Bad, because the SSPL license raises enterprise legal complications.
|
||||||
|
|
||||||
### Prisma (chosen)
|
### Prisma (chosen)
|
||||||
|
|
||||||
* Good, because schema-first declarative, strong DX, type generation, mainstream in 2026.
|
- Good, because schema-first declarative, strong DX, type generation, mainstream in 2026.
|
||||||
* Good, because a mature NestJS integration exists.
|
- Good, because a mature NestJS integration exists.
|
||||||
* Bad, because the query engine binary adds a deployment artifact.
|
- Bad, because the query engine binary adds a deployment artifact.
|
||||||
* Bad, because some PostgreSQL features require `$queryRaw`.
|
- Bad, because some PostgreSQL features require `$queryRaw`.
|
||||||
|
|
||||||
### Drizzle
|
### Drizzle
|
||||||
|
|
||||||
* Good, because lighter, closer to SQL, faster, type-safe.
|
- Good, because lighter, closer to SQL, faster, type-safe.
|
||||||
* Bad, because younger (2022) — smaller enterprise track record than Prisma. Kept on the watch list for re-evaluation.
|
- Bad, because younger (2022) — smaller enterprise track record than Prisma. Kept on the watch list for re-evaluation.
|
||||||
|
|
||||||
### Kysely
|
### Kysely
|
||||||
|
|
||||||
* Good, because elegant query-builder, no ORM magic, fully type-safe.
|
- Good, because elegant query-builder, no ORM magic, fully type-safe.
|
||||||
* Bad, because no schema/migrations of its own — must be paired with another tool, increasing surface area.
|
- Bad, because no schema/migrations of its own — must be paired with another tool, increasing surface area.
|
||||||
|
|
||||||
### TypeORM
|
### TypeORM
|
||||||
|
|
||||||
* Good, because long-standing default in NestJS history.
|
- Good, because long-standing default in NestJS history.
|
||||||
* Bad, because design issues (decorator hell, runtime metadata fragility, migration story) and slowing maintenance — community momentum has shifted to Prisma and Drizzle.
|
- Bad, because design issues (decorator hell, runtime metadata fragility, migration story) and slowing maintenance — community momentum has shifted to Prisma and Drizzle.
|
||||||
|
|
||||||
### Plain `pg` driver + hand-written SQL
|
### Plain `pg` driver + hand-written SQL
|
||||||
|
|
||||||
* Good, because zero abstraction; full control.
|
- Good, because zero abstraction; full control.
|
||||||
* Bad, because every cross-cutting concern (transactions, connection pooling, type mapping, migrations) is hand-built — the very bricolage we are forbidden.
|
- Bad, because every cross-cutting concern (transactions, connection pooling, type mapping, migrations) is hand-built — the very bricolage we are forbidden.
|
||||||
|
|
||||||
## More Information
|
## More Information
|
||||||
|
|
||||||
* PostgreSQL: https://www.postgresql.org/
|
- PostgreSQL: https://www.postgresql.org/
|
||||||
* Prisma: https://www.prisma.io/
|
- Prisma: https://www.prisma.io/
|
||||||
* `nestjs-prisma`: https://nestjs-prisma.dev/
|
- `nestjs-prisma`: https://nestjs-prisma.dev/
|
||||||
* Related ADRs: [ADR-0005](0005-backend-stack-nestjs.md), ADR-0007 (identity model + RLS use, future), ADR-0009 (sessions in Redis, future), future infrastructure ADRs (Postgres HA, backup).
|
- Related ADRs: [ADR-0005](0005-backend-stack-nestjs.md), ADR-0007 (identity model + RLS use, future), ADR-0009 (sessions in Redis, future), future infrastructure ADRs (Postgres HA, backup).
|
||||||
+43
-42
@@ -15,19 +15,19 @@ Which tooling do we adopt for git pre-commit checks and commit message validatio
|
|||||||
|
|
||||||
## Decision Drivers
|
## Decision Drivers
|
||||||
|
|
||||||
* Fast feedback: catch trivial issues locally; CI is defense in depth, not the first line.
|
- Fast feedback: catch trivial issues locally; CI is defense in depth, not the first line.
|
||||||
* Consistency of code style and commit history across contributors.
|
- Consistency of code style and commit history across contributors.
|
||||||
* Conventional commit history that machines can read (later automation: changelog, release notes, semver bumps).
|
- Conventional commit history that machines can read (later automation: changelog, release notes, semver bumps).
|
||||||
* Mainstream tooling — no exotic shell hooks, no single-maintainer projects.
|
- Mainstream tooling — no exotic shell hooks, no single-maintainer projects.
|
||||||
* Easy onboarding: no extra runtime to manage beyond Node/pnpm.
|
- Easy onboarding: no extra runtime to manage beyond Node/pnpm.
|
||||||
|
|
||||||
## Considered Options
|
## Considered Options
|
||||||
|
|
||||||
* **Husky + lint-staged + commitlint with Conventional Commits.** (Chosen.)
|
- **Husky + lint-staged + commitlint with Conventional Commits.** (Chosen.)
|
||||||
* No git hooks — rely on CI.
|
- No git hooks — rely on CI.
|
||||||
* `pre-commit` (Python framework).
|
- `pre-commit` (Python framework).
|
||||||
* `lefthook` (Go single-binary hook manager).
|
- `lefthook` (Go single-binary hook manager).
|
||||||
* Custom shell scripts in `.git/hooks/`.
|
- Custom shell scripts in `.git/hooks/`.
|
||||||
|
|
||||||
## Decision Outcome
|
## Decision Outcome
|
||||||
|
|
||||||
@@ -36,11 +36,12 @@ Chosen option: **Husky + lint-staged + commitlint, with [Conventional Commits](h
|
|||||||
Hooks installed under `.husky/`:
|
Hooks installed under `.husky/`:
|
||||||
|
|
||||||
| Hook | Action |
|
| Hook | Action |
|
||||||
| --- | --- |
|
| ------------ | ---------------------------------------------------------------------------------------------- |
|
||||||
| `pre-commit` | `pnpm exec lint-staged` — runs lint and format on staged files only |
|
| `pre-commit` | `pnpm exec lint-staged` — runs lint and format on staged files only |
|
||||||
| `commit-msg` | `pnpm exec commitlint --edit "$1"` — validates the commit message against Conventional Commits |
|
| `commit-msg` | `pnpm exec commitlint --edit "$1"` — validates the commit message against Conventional Commits |
|
||||||
|
|
||||||
Configuration:
|
Configuration:
|
||||||
|
|
||||||
- `package.json`: `"prepare": "husky"` script (Husky 9+ pattern), `lint-staged` config block.
|
- `package.json`: `"prepare": "husky"` script (Husky 9+ pattern), `lint-staged` config block.
|
||||||
- `commitlint.config.cjs`: `module.exports = { extends: ['@commitlint/config-conventional'] }`.
|
- `commitlint.config.cjs`: `module.exports = { extends: ['@commitlint/config-conventional'] }`.
|
||||||
|
|
||||||
@@ -50,57 +51,57 @@ CI re-runs the same checks (defense in depth — hooks can be bypassed with `--n
|
|||||||
|
|
||||||
### Consequences
|
### Consequences
|
||||||
|
|
||||||
* Good, because trivial issues are caught locally and don't pollute PR history.
|
- Good, because trivial issues are caught locally and don't pollute PR history.
|
||||||
* Good, because Conventional Commits gives us a machine-readable history — enables automated changelogs, semver inference, and release tooling without retroactive cleanup.
|
- Good, because Conventional Commits gives us a machine-readable history — enables automated changelogs, semver inference, and release tooling without retroactive cleanup.
|
||||||
* Good, because Husky / lint-staged / commitlint are the de facto standard in JS/TS projects — wide community, abundant documentation, low surprise factor.
|
- Good, because Husky / lint-staged / commitlint are the de facto standard in JS/TS projects — wide community, abundant documentation, low surprise factor.
|
||||||
* Good, because lint-staged keeps commit time low (only changed files are checked).
|
- Good, because lint-staged keeps commit time low (only changed files are checked).
|
||||||
* Bad, because hooks can be bypassed (`git commit --no-verify`); CI must remain authoritative.
|
- Bad, because hooks can be bypassed (`git commit --no-verify`); CI must remain authoritative.
|
||||||
* Bad, because Husky 9 changed its installation pattern (no `husky install`, just `husky`); contributors with stale instructions can be confused. Mitigated by the setup guide.
|
- Bad, because Husky 9 changed its installation pattern (no `husky install`, just `husky`); contributors with stale instructions can be confused. Mitigated by the setup guide.
|
||||||
* Bad, because Conventional Commits adds a small learning curve; mitigated by IDE plugins, `commitlint` error messages, and a one-page contributor cheatsheet (future doc).
|
- Bad, because Conventional Commits adds a small learning curve; mitigated by IDE plugins, `commitlint` error messages, and a one-page contributor cheatsheet (future doc).
|
||||||
|
|
||||||
### Confirmation
|
### Confirmation
|
||||||
|
|
||||||
* `package.json` declares `husky`, `lint-staged`, `@commitlint/cli`, `@commitlint/config-conventional` as `devDependencies`.
|
- `package.json` declares `husky`, `lint-staged`, `@commitlint/cli`, `@commitlint/config-conventional` as `devDependencies`.
|
||||||
* `package.json` has a `"prepare": "husky"` script.
|
- `package.json` has a `"prepare": "husky"` script.
|
||||||
* `.husky/pre-commit` runs `pnpm exec lint-staged` and is executable.
|
- `.husky/pre-commit` runs `pnpm exec lint-staged` and is executable.
|
||||||
* `.husky/commit-msg` runs `pnpm exec commitlint --edit "$1"` and is executable.
|
- `.husky/commit-msg` runs `pnpm exec commitlint --edit "$1"` and is executable.
|
||||||
* `commitlint.config.cjs` exists at the workspace root and extends `@commitlint/config-conventional`.
|
- `commitlint.config.cjs` exists at the workspace root and extends `@commitlint/config-conventional`.
|
||||||
* CI pipeline (future ADR) re-runs lint and format checks on every push, plus a Conventional Commits validation on the PR commit range.
|
- CI pipeline (future ADR) re-runs lint and format checks on every push, plus a Conventional Commits validation on the PR commit range.
|
||||||
|
|
||||||
## Pros and Cons of the Options
|
## Pros and Cons of the Options
|
||||||
|
|
||||||
### Husky + lint-staged + commitlint (chosen)
|
### Husky + lint-staged + commitlint (chosen)
|
||||||
|
|
||||||
* Good, because the trio is the de facto standard in the JS/TS ecosystem.
|
- Good, because the trio is the de facto standard in the JS/TS ecosystem.
|
||||||
* Good, because each tool is single-purpose and composable.
|
- Good, because each tool is single-purpose and composable.
|
||||||
* Good, because the configuration lives with the repo and is versioned with the code.
|
- Good, because the configuration lives with the repo and is versioned with the code.
|
||||||
* Bad, because three packages instead of one — slightly more dependency surface.
|
- Bad, because three packages instead of one — slightly more dependency surface.
|
||||||
|
|
||||||
### No git hooks
|
### No git hooks
|
||||||
|
|
||||||
* Good, because zero local setup.
|
- Good, because zero local setup.
|
||||||
* Bad, because every trivial issue costs a CI round-trip. Wasteful and noisy.
|
- Bad, because every trivial issue costs a CI round-trip. Wasteful and noisy.
|
||||||
|
|
||||||
### `pre-commit` (Python)
|
### `pre-commit` (Python)
|
||||||
|
|
||||||
* Good, because language-agnostic, used in mixed-language repos (Python + JS + Go).
|
- Good, because language-agnostic, used in mixed-language repos (Python + JS + Go).
|
||||||
* Bad, because adds a Python runtime dependency to a Node-only project — extra setup for contributors, especially under WSL where Python toolchains are not always uniform.
|
- Bad, because adds a Python runtime dependency to a Node-only project — extra setup for contributors, especially under WSL where Python toolchains are not always uniform.
|
||||||
|
|
||||||
### lefthook
|
### lefthook
|
||||||
|
|
||||||
* Good, because single Go binary, very fast, parallel hook execution, declarative YAML config.
|
- Good, because single Go binary, very fast, parallel hook execution, declarative YAML config.
|
||||||
* Bad, because smaller community than Husky in JS/TS land; less prior art and fewer answers when something breaks.
|
- Bad, because smaller community than Husky in JS/TS land; less prior art and fewer answers when something breaks.
|
||||||
* Status: kept on the watch list for re-evaluation if commit performance becomes a pain.
|
- Status: kept on the watch list for re-evaluation if commit performance becomes a pain.
|
||||||
|
|
||||||
### Custom shell scripts in `.git/hooks/`
|
### Custom shell scripts in `.git/hooks/`
|
||||||
|
|
||||||
* Good, because zero dependency.
|
- Good, because zero dependency.
|
||||||
* Bad, because hooks aren't versioned with the repo by default; a wrapper layer is needed anyway. Bricolage.
|
- Bad, because hooks aren't versioned with the repo by default; a wrapper layer is needed anyway. Bricolage.
|
||||||
|
|
||||||
## More Information
|
## More Information
|
||||||
|
|
||||||
* Husky: https://typicode.github.io/husky/
|
- Husky: https://typicode.github.io/husky/
|
||||||
* lint-staged: https://github.com/lint-staged/lint-staged
|
- lint-staged: https://github.com/lint-staged/lint-staged
|
||||||
* commitlint: https://commitlint.js.org/
|
- commitlint: https://commitlint.js.org/
|
||||||
* Conventional Commits: https://www.conventionalcommits.org/
|
- Conventional Commits: https://www.conventionalcommits.org/
|
||||||
* Related: future quality / CI ADR will re-run these checks as defense in depth.
|
- Related: future quality / CI ADR will re-run these checks as defense in depth.
|
||||||
+60
-59
@@ -14,18 +14,18 @@ The portal exposes no public content — every interaction requires authenticati
|
|||||||
- **Workforce.** Employees of the host organization, plus employees of partner organizations who must access the portal.
|
- **Workforce.** Employees of the host organization, plus employees of partner organizations who must access the portal.
|
||||||
- **Customers.** External end-users (clients) without a corporate IdP — covered by Microsoft Entra External ID (the CIAM product, formerly Azure AD B2C).
|
- **Customers.** External end-users (clients) without a corporate IdP — covered by Microsoft Entra External ID (the CIAM product, formerly Azure AD B2C).
|
||||||
|
|
||||||
Only workforce is needed for v1. Customers are a probable future need with no current users on the horizon. We must therefore decide three intertwined questions: which identity provider, which tenant model, and how much of the customer-side architecture to put in place *before* there are customers.
|
Only workforce is needed for v1. Customers are a probable future need with no current users on the horizon. We must therefore decide three intertwined questions: which identity provider, which tenant model, and how much of the customer-side architecture to put in place _before_ there are customers.
|
||||||
|
|
||||||
A separate complication: the host organization has no Microsoft 365 or Azure subscription yet. The production Entra tenant has to be provisioned, which is org-level IT work that must not block development.
|
A separate complication: the host organization has no Microsoft 365 or Azure subscription yet. The production Entra tenant has to be provisioned, which is org-level IT work that must not block development.
|
||||||
|
|
||||||
## Decision Drivers
|
## Decision Drivers
|
||||||
|
|
||||||
* Authentication is security-critical — the technical bar is "no bricolage", not "good enough".
|
- Authentication is security-critical — the technical bar is "no bricolage", not "good enough".
|
||||||
* Workforce identity belongs in a managed, enterprise-grade IdP — the org's chosen direction is Microsoft Entra.
|
- Workforce identity belongs in a managed, enterprise-grade IdP — the org's chosen direction is Microsoft Entra.
|
||||||
* Customer (CIAM) infrastructure has a real cost (separate tenant provisioning, branding, IDPs, self-service signup, conditional access policies). Don't pay it before there are customers.
|
- Customer (CIAM) infrastructure has a real cost (separate tenant provisioning, branding, IDPs, self-service signup, conditional access policies). Don't pay it before there are customers.
|
||||||
* Adding the customer audience later must not require a security-audit refactor of authorization, sessions, or data — the structural cost of being audience-aware is much lower up-front than retrofitted.
|
- Adding the customer audience later must not require a security-audit refactor of authorization, sessions, or data — the structural cost of being audience-aware is much lower up-front than retrofitted.
|
||||||
* Development must not be blocked on org-level provisioning of the production tenant.
|
- Development must not be blocked on org-level provisioning of the production tenant.
|
||||||
* Authorization must be claims-based and audience-aware from day one — the implicit assumption "authenticated == internal employee" is exactly the failure mode that turns into a privilege-escalation incident the day external users are introduced.
|
- Authorization must be claims-based and audience-aware from day one — the implicit assumption "authenticated == internal employee" is exactly the failure mode that turns into a privilege-escalation incident the day external users are introduced.
|
||||||
|
|
||||||
## Considered Options
|
## Considered Options
|
||||||
|
|
||||||
@@ -33,21 +33,21 @@ Three independent axes, decided together because they constrain each other:
|
|||||||
|
|
||||||
### Tenant model (production)
|
### Tenant model (production)
|
||||||
|
|
||||||
* **Single-tenant strict** — only employees of our own Entra tenant can sign in.
|
- **Single-tenant strict** — only employees of our own Entra tenant can sign in.
|
||||||
* **Multi-tenant with B2B invitation.** (Chosen.) Employees of partner organizations are explicitly invited as B2B guests in our tenant; they appear in our directory and are subject to our Conditional Access policies.
|
- **Multi-tenant with B2B invitation.** (Chosen.) Employees of partner organizations are explicitly invited as B2B guests in our tenant; they appear in our directory and are subject to our Conditional Access policies.
|
||||||
* **Multi-tenant open** — any user from any Entra tenant can sign in; tenant allowlisting handled in the application.
|
- **Multi-tenant open** — any user from any Entra tenant can sign in; tenant allowlisting handled in the application.
|
||||||
* **Workforce + Entra External ID from day one** — full dual-IdP architecture provisioned now.
|
- **Workforce + Entra External ID from day one** — full dual-IdP architecture provisioned now.
|
||||||
|
|
||||||
### Code and data architecture
|
### Code and data architecture
|
||||||
|
|
||||||
* **A. Audience-aware design from v1, workforce-only implementation.** (Chosen.) The session, claims, DB schemas, RLS policies, and authorization layer all carry an `audience` value. In v1 the only valid value is `'workforce'`; `'customer'` exists in the type system and code paths but is gated off (no External ID authority registered). When External ID is later activated, no schema migration or authz refactor is needed.
|
- **A. Audience-aware design from v1, workforce-only implementation.** (Chosen.) The session, claims, DB schemas, RLS policies, and authorization layer all carry an `audience` value. In v1 the only valid value is `'workforce'`; `'customer'` exists in the type system and code paths but is gated off (no External ID authority registered). When External ID is later activated, no schema migration or authz refactor is needed.
|
||||||
* **B. No audience-awareness — build for workforce only, refactor later.**
|
- **B. No audience-awareness — build for workforce only, refactor later.**
|
||||||
|
|
||||||
### Development tenant (while prod is being provisioned)
|
### Development tenant (while prod is being provisioned)
|
||||||
|
|
||||||
* Wait for the prod tenant — accept the dev block.
|
- Wait for the prod tenant — accept the dev block.
|
||||||
* **Microsoft 365 Developer Program tenant.** (Chosen.) Free, full-featured, renewable every 3 months.
|
- **Microsoft 365 Developer Program tenant.** (Chosen.) Free, full-featured, renewable every 3 months.
|
||||||
* Self-hosted IdP for dev (e.g. Keycloak) — explicitly rejected as bricolage and as a divergence from the prod code path.
|
- Self-hosted IdP for dev (e.g. Keycloak) — explicitly rejected as bricolage and as a divergence from the prod code path.
|
||||||
|
|
||||||
## Decision Outcome
|
## Decision Outcome
|
||||||
|
|
||||||
@@ -66,6 +66,7 @@ Three independent axes, decided together because they constrain each other:
|
|||||||
**Development environment:** a **Microsoft 365 Developer Program tenant** is provisioned and used for all non-prod environments until the prod tenant is delivered. The same multi-tenant + B2B configuration is mirrored. MSAL Node config is environment-driven — `ENTRA_TENANT_ID`, `ENTRA_CLIENT_ID`, `ENTRA_CLIENT_SECRET` (or certificate), and the tenant allowlist are all read from env, never hardcoded.
|
**Development environment:** a **Microsoft 365 Developer Program tenant** is provisioned and used for all non-prod environments until the prod tenant is delivered. The same multi-tenant + B2B configuration is mirrored. MSAL Node config is environment-driven — `ENTRA_TENANT_ID`, `ENTRA_CLIENT_ID`, `ENTRA_CLIENT_SECRET` (or certificate), and the tenant allowlist are all read from env, never hardcoded.
|
||||||
|
|
||||||
**Future External ID activation** is explicitly out of scope of v1 but in scope of the design. When it is activated:
|
**Future External ID activation** is explicitly out of scope of v1 but in scope of the design. When it is activated:
|
||||||
|
|
||||||
- A second tenant (Entra External ID) is provisioned — separate product, separate tenant, separate issuer.
|
- A second tenant (Entra External ID) is provisioned — separate product, separate tenant, separate issuer.
|
||||||
- A second authority is registered with MSAL Node.
|
- A second authority is registered with MSAL Node.
|
||||||
- The session sets `audience: 'customer'` for users authenticated via that authority.
|
- The session sets `audience: 'customer'` for users authenticated via that authority.
|
||||||
@@ -74,17 +75,17 @@ Three independent axes, decided together because they constrain each other:
|
|||||||
|
|
||||||
### Consequences
|
### Consequences
|
||||||
|
|
||||||
* Good, because workforce gets the full Entra ID enterprise feature set: Conditional Access, MFA, sign-in risk, B2B governance, audit logs.
|
- Good, because workforce gets the full Entra ID enterprise feature set: Conditional Access, MFA, sign-in risk, B2B governance, audit logs.
|
||||||
* Good, because B2B invitation gives controlled access to partner-org employees — they appear in our directory and are subject to our policies, not theirs.
|
- Good, because B2B invitation gives controlled access to partner-org employees — they appear in our directory and are subject to our policies, not theirs.
|
||||||
* Good, because deferring External ID saves multiple weeks of CIAM provisioning, branding, and policy work that would deliver no v1 value.
|
- Good, because deferring External ID saves multiple weeks of CIAM provisioning, branding, and policy work that would deliver no v1 value.
|
||||||
* Good, because the dual-audience design costs almost nothing up-front (a column per relevant table, a claim in the session, a discipline in authorization) and prevents an expensive retrofit + security audit later.
|
- Good, because the dual-audience design costs almost nothing up-front (a column per relevant table, a claim in the session, a discipline in authorization) and prevents an expensive retrofit + security audit later.
|
||||||
* Good, because the dev tenant strategy unblocks development while prod provisioning happens at IT pace.
|
- Good, because the dev tenant strategy unblocks development while prod provisioning happens at IT pace.
|
||||||
* Good, because future External ID activation is a switch-flip — provision the second tenant, register a second authority, ship — not a re-architecture.
|
- Good, because future External ID activation is a switch-flip — provision the second tenant, register a second authority, ship — not a re-architecture.
|
||||||
* Bad, because the organization needs to acquire **Entra ID P1 licensing** for production — a business decision with a real cost. Mitigation: documented up-front so it can be planned, not surprised.
|
- Bad, because the organization needs to acquire **Entra ID P1 licensing** for production — a business decision with a real cost. Mitigation: documented up-front so it can be planned, not surprised.
|
||||||
* Bad, because Microsoft 365 Developer Program tenants must be **renewed every ~3 months** (Microsoft's policy). Mitigation: a recurring task on the IT side; data in dev is non-sensitive by definition.
|
- Bad, because Microsoft 365 Developer Program tenants must be **renewed every ~3 months** (Microsoft's policy). Mitigation: a recurring task on the IT side; data in dev is non-sensitive by definition.
|
||||||
* Bad, because dual-audience discipline must be applied consistently — every new table, query, and authz check must respect it. Mitigation: helpers, code review, and a future CI lint rule that flags untagged tables.
|
- Bad, because dual-audience discipline must be applied consistently — every new table, query, and authz check must respect it. Mitigation: helpers, code review, and a future CI lint rule that flags untagged tables.
|
||||||
* Bad, because multi-tenant token validation requires maintaining a tenant allowlist — operational item, not a one-shot. Mitigation: list maintained in config (versioned), reviewed during partner onboarding.
|
- Bad, because multi-tenant token validation requires maintaining a tenant allowlist — operational item, not a one-shot. Mitigation: list maintained in config (versioned), reviewed during partner onboarding.
|
||||||
* Bad, because there is no formal v1 user for the `'customer'` code path — meaning it is not exercised in production traffic. Mitigation: dedicated automated tests with a synthetic customer principal so the path stays alive and correct.
|
- Bad, because there is no formal v1 user for the `'customer'` code path — meaning it is not exercised in production traffic. Mitigation: dedicated automated tests with a synthetic customer principal so the path stays alive and correct.
|
||||||
|
|
||||||
### Confirmation
|
### Confirmation
|
||||||
|
|
||||||
@@ -103,66 +104,66 @@ Three independent axes, decided together because they constrain each other:
|
|||||||
|
|
||||||
#### Single-tenant strict
|
#### Single-tenant strict
|
||||||
|
|
||||||
* Good, because simplest issuer validation (one accepted issuer).
|
- Good, because simplest issuer validation (one accepted issuer).
|
||||||
* Good, because users are all in our directory, fully under our policies.
|
- Good, because users are all in our directory, fully under our policies.
|
||||||
* Bad, because partner-org employees can't sign in unless we provision them as guest accounts (overhead) or assign them ours (security risk).
|
- Bad, because partner-org employees can't sign in unless we provision them as guest accounts (overhead) or assign them ours (security risk).
|
||||||
|
|
||||||
#### Multi-tenant with B2B invitation (chosen)
|
#### Multi-tenant with B2B invitation (chosen)
|
||||||
|
|
||||||
* Good, because partner-org employees use their own Entra credentials but are visible in our directory and subject to our Conditional Access.
|
- Good, because partner-org employees use their own Entra credentials but are visible in our directory and subject to our Conditional Access.
|
||||||
* Good, because B2B governance (access reviews, expiry) is built-in.
|
- Good, because B2B governance (access reviews, expiry) is built-in.
|
||||||
* Good, because Microsoft's recommended pattern for inter-org workforce access.
|
- Good, because Microsoft's recommended pattern for inter-org workforce access.
|
||||||
* Bad, because requires invitation flow per guest user — operational item.
|
- Bad, because requires invitation flow per guest user — operational item.
|
||||||
|
|
||||||
#### Multi-tenant open (no invitation)
|
#### Multi-tenant open (no invitation)
|
||||||
|
|
||||||
* Good, because lowest friction for any workforce user from any Entra tenant.
|
- Good, because lowest friction for any workforce user from any Entra tenant.
|
||||||
* Bad, because no control over who signs in unless an application-level allowlist is implemented and maintained.
|
- Bad, because no control over who signs in unless an application-level allowlist is implemented and maintained.
|
||||||
* Bad, because guests aren't in our directory — limited governance, no Conditional Access from our side.
|
- Bad, because guests aren't in our directory — limited governance, no Conditional Access from our side.
|
||||||
|
|
||||||
#### Workforce + External ID from day one
|
#### Workforce + External ID from day one
|
||||||
|
|
||||||
* Good, because the full dual-audience capability is live.
|
- Good, because the full dual-audience capability is live.
|
||||||
* Bad, because the org has neither M365/Azure nor any customer user identified — provisioning a CIAM tenant now has zero v1 yield and significant ongoing cost.
|
- Bad, because the org has neither M365/Azure nor any customer user identified — provisioning a CIAM tenant now has zero v1 yield and significant ongoing cost.
|
||||||
|
|
||||||
### Code and data architecture
|
### Code and data architecture
|
||||||
|
|
||||||
#### A. Audience-aware design from v1 (chosen)
|
#### A. Audience-aware design from v1 (chosen)
|
||||||
|
|
||||||
* Good, because the cost is small upfront — one column per shared table, one claim in the session, a discipline in authz.
|
- Good, because the cost is small upfront — one column per shared table, one claim in the session, a discipline in authz.
|
||||||
* Good, because adding External ID later is a configuration change, not a refactor.
|
- Good, because adding External ID later is a configuration change, not a refactor.
|
||||||
* Good, because authorization tests can already exercise both audiences.
|
- Good, because authorization tests can already exercise both audiences.
|
||||||
* Bad, because a discipline that must be respected on every new table — mitigated by lint and review.
|
- Bad, because a discipline that must be respected on every new table — mitigated by lint and review.
|
||||||
|
|
||||||
#### B. No audience-awareness — workforce only, refactor later
|
#### B. No audience-awareness — workforce only, refactor later
|
||||||
|
|
||||||
* Good, because the v1 codebase is slightly simpler.
|
- Good, because the v1 codebase is slightly simpler.
|
||||||
* Bad, because retrofitting audience-awareness after the fact requires schema migration on every relevant table, refactoring of every authorization check, and a full data-leak audit. The cost is asymmetrically larger than option A.
|
- Bad, because retrofitting audience-awareness after the fact requires schema migration on every relevant table, refactoring of every authorization check, and a full data-leak audit. The cost is asymmetrically larger than option A.
|
||||||
|
|
||||||
### Development tenant
|
### Development tenant
|
||||||
|
|
||||||
#### Wait for the prod tenant
|
#### Wait for the prod tenant
|
||||||
|
|
||||||
* Good, because the dev environment is identical to prod.
|
- Good, because the dev environment is identical to prod.
|
||||||
* Bad, because the development of every auth-touching feature is blocked until org-level provisioning is complete.
|
- Bad, because the development of every auth-touching feature is blocked until org-level provisioning is complete.
|
||||||
|
|
||||||
#### Microsoft 365 Developer Program tenant (chosen)
|
#### Microsoft 365 Developer Program tenant (chosen)
|
||||||
|
|
||||||
* Good, because free, full-featured, mirrors the prod configuration.
|
- Good, because free, full-featured, mirrors the prod configuration.
|
||||||
* Good, because completely decoupled from the prod tenant timeline.
|
- Good, because completely decoupled from the prod tenant timeline.
|
||||||
* Bad, because requires periodic renewal (every ~3 months).
|
- Bad, because requires periodic renewal (every ~3 months).
|
||||||
|
|
||||||
#### Self-hosted IdP (Keycloak or equivalent) for dev
|
#### Self-hosted IdP (Keycloak or equivalent) for dev
|
||||||
|
|
||||||
* Good, because fully under our control, no Microsoft account dependency.
|
- Good, because fully under our control, no Microsoft account dependency.
|
||||||
* Bad, because the dev code path differs from prod (different OIDC quirks, different claim shapes) — exactly the kind of bricolage that creates "works in dev, breaks in prod" surprises.
|
- Bad, because the dev code path differs from prod (different OIDC quirks, different claim shapes) — exactly the kind of bricolage that creates "works in dev, breaks in prod" surprises.
|
||||||
* Bad, because adds a moving part to local dev (a running IdP container) for no real gain.
|
- Bad, because adds a moving part to local dev (a running IdP container) for no real gain.
|
||||||
|
|
||||||
## More Information
|
## More Information
|
||||||
|
|
||||||
* Multi-tenant Entra apps: https://learn.microsoft.com/azure/active-directory/develop/howto-convert-app-to-be-multi-tenant
|
- Multi-tenant Entra apps: https://learn.microsoft.com/azure/active-directory/develop/howto-convert-app-to-be-multi-tenant
|
||||||
* Entra B2B collaboration: https://learn.microsoft.com/entra/external-id/what-is-b2b
|
- Entra B2B collaboration: https://learn.microsoft.com/entra/external-id/what-is-b2b
|
||||||
* Entra ID licensing tiers: https://learn.microsoft.com/entra/fundamentals/licensing
|
- Entra ID licensing tiers: https://learn.microsoft.com/entra/fundamentals/licensing
|
||||||
* Microsoft 365 Developer Program: https://developer.microsoft.com/microsoft-365/dev-program
|
- Microsoft 365 Developer Program: https://developer.microsoft.com/microsoft-365/dev-program
|
||||||
* PostgreSQL Row Level Security: https://www.postgresql.org/docs/current/ddl-rowsecurity.html
|
- PostgreSQL Row Level Security: https://www.postgresql.org/docs/current/ddl-rowsecurity.html
|
||||||
* Related ADRs: [ADR-0006](0006-persistence-postgresql-prisma.md) (Postgres + Prisma — RLS used by this ADR), and the future phase-2 ADRs for auth flow (OIDC Auth Code + PKCE via MSAL Node), session management (Redis), MFA enforcement (Conditional Access claims), downstream API access (On-Behalf-Of), and audit trail.
|
- Related ADRs: [ADR-0006](0006-persistence-postgresql-prisma.md) (Postgres + Prisma — RLS used by this ADR), and the future phase-2 ADRs for auth flow (OIDC Auth Code + PKCE via MSAL Node), session management (Redis), MFA enforcement (Conditional Access claims), downstream API access (On-Behalf-Of), and audit trail.
|
||||||
+82
-75
@@ -9,48 +9,54 @@ tags: [security, backend]
|
|||||||
|
|
||||||
## Context and Problem Statement
|
## Context and Problem Statement
|
||||||
|
|
||||||
[ADR-0008](0008-identity-model-entra-workforce-dual-audience.md) fixed the identity model: multi-tenant Entra ID for workforce, dual-audience design, M365 Developer tenant for non-prod. We now need to fix the *technical flow*: which OAuth/OIDC mechanism, which library, how tokens are obtained and held, how the session cookie is shaped, how CSRF is handled, how logout works.
|
[ADR-0008](0008-identity-model-entra-workforce-dual-audience.md) fixed the identity model: multi-tenant Entra ID for workforce, dual-audience design, M365 Developer tenant for non-prod. We now need to fix the _technical flow_: which OAuth/OIDC mechanism, which library, how tokens are obtained and held, how the session cookie is shaped, how CSRF is handled, how logout works.
|
||||||
|
|
||||||
The SPA must never hold tokens — that is the BFF security pattern, recommended by Microsoft and by the OAuth 2.0 BCP for browser-based apps (`draft-ietf-oauth-browser-based-apps`). The BFF holds the tokens server-side; the browser only carries an opaque session cookie.
|
The SPA must never hold tokens — that is the BFF security pattern, recommended by Microsoft and by the OAuth 2.0 BCP for browser-based apps (`draft-ietf-oauth-browser-based-apps`). The BFF holds the tokens server-side; the browser only carries an opaque session cookie.
|
||||||
|
|
||||||
## Decision Drivers
|
## Decision Drivers
|
||||||
|
|
||||||
* Conform to the current IETF best current practice for browser-based apps (BFF pattern).
|
- Conform to the current IETF best current practice for browser-based apps (BFF pattern).
|
||||||
* Conform to Microsoft's recommended path for multi-tenant Entra workforce apps.
|
- Conform to Microsoft's recommended path for multi-tenant Entra workforce apps.
|
||||||
* Tokens never leave the server — the SPA cannot leak them via XSS.
|
- Tokens never leave the server — the SPA cannot leak them via XSS.
|
||||||
* Support a future On-Behalf-Of flow when the BFF needs to call downstream Entra-protected APIs (covered by a later ADR).
|
- Support a future On-Behalf-Of flow when the BFF needs to call downstream Entra-protected APIs (covered by a later ADR).
|
||||||
* Defense in depth: hardened cookies, CSRF protection, refresh-token rotation.
|
- Defense in depth: hardened cookies, CSRF protection, refresh-token rotation.
|
||||||
* Consistent route shape so frontend, backend, and operators speak the same vocabulary.
|
- Consistent route shape so frontend, backend, and operators speak the same vocabulary.
|
||||||
|
|
||||||
## Considered Options
|
## Considered Options
|
||||||
|
|
||||||
### Library
|
### Library
|
||||||
* **`@azure/msal-node`** (`ConfidentialClientApplication`). (Chosen.)
|
|
||||||
* `openid-client` — generic OIDC client.
|
- **`@azure/msal-node`** (`ConfidentialClientApplication`). (Chosen.)
|
||||||
* `passport-azure-ad` — deprecated, in maintenance only.
|
- `openid-client` — generic OIDC client.
|
||||||
|
- `passport-azure-ad` — deprecated, in maintenance only.
|
||||||
|
|
||||||
### OAuth flow
|
### OAuth flow
|
||||||
* **Authorization Code Flow with PKCE.** (Chosen.)
|
|
||||||
* Implicit Flow — deprecated by IETF, rejected.
|
- **Authorization Code Flow with PKCE.** (Chosen.)
|
||||||
* Hybrid Flow — legacy, rejected.
|
- Implicit Flow — deprecated by IETF, rejected.
|
||||||
* Resource Owner Password Credentials — forbidden by Microsoft for production scenarios.
|
- Hybrid Flow — legacy, rejected.
|
||||||
|
- Resource Owner Password Credentials — forbidden by Microsoft for production scenarios.
|
||||||
|
|
||||||
### Token storage
|
### Token storage
|
||||||
* **Tokens held in the BFF session, never sent to the browser.** (Chosen — the BFF pattern itself.)
|
|
||||||
* Tokens in browser memory or localStorage / sessionStorage — XSS-exfiltrable, rejected.
|
- **Tokens held in the BFF session, never sent to the browser.** (Chosen — the BFF pattern itself.)
|
||||||
|
- Tokens in browser memory or localStorage / sessionStorage — XSS-exfiltrable, rejected.
|
||||||
|
|
||||||
### Session cookie shape
|
### Session cookie shape
|
||||||
* **`__Host-portal_session`** with `HttpOnly`, `Secure`, `SameSite=Lax`. (Chosen.)
|
|
||||||
* Same attributes without the `__Host-` prefix — weaker (subdomain bleed, downgrade attacks).
|
- **`__Host-portal_session`** with `HttpOnly`, `Secure`, `SameSite=Lax`. (Chosen.)
|
||||||
|
- Same attributes without the `__Host-` prefix — weaker (subdomain bleed, downgrade attacks).
|
||||||
|
|
||||||
### CSRF
|
### CSRF
|
||||||
* **Double-submit cookie pattern.** (Chosen.) `__Host-portal_csrf` cookie (readable by the SPA) + matching `X-CSRF-Token` header on every state-changing request.
|
|
||||||
* Synchronizer token (server-side store) — heavier, requires a dedicated CSRF token store.
|
- **Double-submit cookie pattern.** (Chosen.) `__Host-portal_csrf` cookie (readable by the SPA) + matching `X-CSRF-Token` header on every state-changing request.
|
||||||
* No CSRF protection — unacceptable.
|
- Synchronizer token (server-side store) — heavier, requires a dedicated CSRF token store.
|
||||||
|
- No CSRF protection — unacceptable.
|
||||||
|
|
||||||
### Logout
|
### Logout
|
||||||
* **RP-initiated logout.** (Chosen.) BFF invalidates the session, then redirects the browser to Entra's `end_session_endpoint`, which clears the Entra SSO session and redirects back.
|
|
||||||
* Local-only logout — the Entra SSO session stays live, the user can be silently re-authenticated without intent.
|
- **RP-initiated logout.** (Chosen.) BFF invalidates the session, then redirects the browser to Entra's `end_session_endpoint`, which clears the Entra SSO session and redirects back.
|
||||||
|
- Local-only logout — the Entra SSO session stays live, the user can be silently re-authenticated without intent.
|
||||||
|
|
||||||
## Decision Outcome
|
## Decision Outcome
|
||||||
|
|
||||||
@@ -61,6 +67,7 @@ The SPA must never hold tokens — that is the BFF security pattern, recommended
|
|||||||
**Tokens.** ID/access/refresh tokens are stored in the server-side session (storage backend covered by the next ADR). The browser only holds an opaque session identifier in `__Host-portal_session`.
|
**Tokens.** ID/access/refresh tokens are stored in the server-side session (storage backend covered by the next ADR). The browser only holds an opaque session identifier in `__Host-portal_session`.
|
||||||
|
|
||||||
**Token validation.** On callback, MSAL Node validates the `id_token` (signature against JWKS, `iss`, `aud`, `exp`, `nbf`). The BFF additionally enforces:
|
**Token validation.** On callback, MSAL Node validates the `id_token` (signature against JWKS, `iss`, `aud`, `exp`, `nbf`). The BFF additionally enforces:
|
||||||
|
|
||||||
- `iss` belongs to the tenant allowlist (workforce tenants accepted by ADR-0008);
|
- `iss` belongs to the tenant allowlist (workforce tenants accepted by ADR-0008);
|
||||||
- `aud` matches our app's `client_id`;
|
- `aud` matches our app's `client_id`;
|
||||||
- the audience claim is mapped to our `Audience` enum (workforce tokens → `audience: 'workforce'`; any other classification fails).
|
- the audience claim is mapped to our `Audience` enum (workforce tokens → `audience: 'workforce'`; any other classification fails).
|
||||||
@@ -70,7 +77,7 @@ The SPA must never hold tokens — that is the BFF security pattern, recommended
|
|||||||
**Cookies.**
|
**Cookies.**
|
||||||
|
|
||||||
| Cookie | Purpose | Attributes |
|
| Cookie | Purpose | Attributes |
|
||||||
| --- | --- | --- |
|
| ----------------------- | --------------------------- | -------------------------------------------------------------------------------------------- |
|
||||||
| `__Host-portal_session` | opaque session id | `HttpOnly`, `Secure`, `SameSite=Lax`, `Path=/` (forced by the `__Host-` prefix), no `Domain` |
|
| `__Host-portal_session` | opaque session id | `HttpOnly`, `Secure`, `SameSite=Lax`, `Path=/` (forced by the `__Host-` prefix), no `Domain` |
|
||||||
| `__Host-portal_csrf` | CSRF token (readable by JS) | `Secure`, `SameSite=Lax`, `Path=/`, no `HttpOnly` (the SPA must read it) |
|
| `__Host-portal_csrf` | CSRF token (readable by JS) | `Secure`, `SameSite=Lax`, `Path=/`, no `HttpOnly` (the SPA must read it) |
|
||||||
|
|
||||||
@@ -83,7 +90,7 @@ In **local development** (HTTP `localhost`), browsers reject the `__Host-` prefi
|
|||||||
**Routes.** All authentication endpoints live under the `/auth` prefix on the BFF:
|
**Routes.** All authentication endpoints live under the `/auth` prefix on the BFF:
|
||||||
|
|
||||||
| Method | Path | Purpose |
|
| Method | Path | Purpose |
|
||||||
| --- | --- | --- |
|
| ------ | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| `GET` | `/auth/login` | starts the flow; accepts an optional `returnTo` query parameter validated against an in-app path allowlist; redirects the browser to Entra's `authorize` endpoint with `state`, `nonce`, and PKCE parameters |
|
| `GET` | `/auth/login` | starts the flow; accepts an optional `returnTo` query parameter validated against an in-app path allowlist; redirects the browser to Entra's `authorize` endpoint with `state`, `nonce`, and PKCE parameters |
|
||||||
| `GET` | `/auth/callback` | receives `code` + `state` from Entra; verifies state, exchanges via MSAL Node, validates id_token, creates the session, redirects to `returnTo` (or `/`) |
|
| `GET` | `/auth/callback` | receives `code` + `state` from Entra; verifies state, exchanges via MSAL Node, validates id_token, creates the session, redirects to `returnTo` (or `/`) |
|
||||||
| `POST` | `/auth/logout` | requires `X-CSRF-Token`; invalidates the BFF session; returns 200 with the Entra `end_session_endpoint` URL in JSON; the SPA navigates the browser to that URL |
|
| `POST` | `/auth/logout` | requires `X-CSRF-Token`; invalidates the BFF session; returns 200 with the Entra `end_session_endpoint` URL in JSON; the SPA navigates the browser to that URL |
|
||||||
@@ -94,10 +101,10 @@ In **local development** (HTTP `localhost`), browsers reject the `__Host-` prefi
|
|||||||
**Configuration.** All Entra-specific values come from environment variables — no tenant ID, client ID, or secret in source. The BFF refuses to start if any required variable is missing.
|
**Configuration.** All Entra-specific values come from environment variables — no tenant ID, client ID, or secret in source. The BFF refuses to start if any required variable is missing.
|
||||||
|
|
||||||
| Variable | Purpose |
|
| Variable | Purpose |
|
||||||
| --- | --- |
|
| --------------------------------------------------- | ----------------------------------------------------------------------------------- |
|
||||||
| `ENTRA_TENANT_ID` | home tenant id of the BFF (the tenant where the app is registered as the home org) |
|
| `ENTRA_TENANT_ID` | home tenant id of the BFF (the tenant where the app is registered as the home org) |
|
||||||
| `ENTRA_CLIENT_ID` | app's client id |
|
| `ENTRA_CLIENT_ID` | app's client id |
|
||||||
| `ENTRA_CLIENT_SECRET` *or* `ENTRA_CLIENT_CERT_PATH` | confidential-client credential (certificate preferred for production) |
|
| `ENTRA_CLIENT_SECRET` _or_ `ENTRA_CLIENT_CERT_PATH` | confidential-client credential (certificate preferred for production) |
|
||||||
| `ENTRA_ACCEPTED_TENANT_IDS` | comma-separated allowlist for `iss` validation |
|
| `ENTRA_ACCEPTED_TENANT_IDS` | comma-separated allowlist for `iss` validation |
|
||||||
| `ENTRA_REDIRECT_URI` | absolute URL of the `/auth/callback` endpoint, registered in the app registration |
|
| `ENTRA_REDIRECT_URI` | absolute URL of the `/auth/callback` endpoint, registered in the app registration |
|
||||||
| `ENTRA_POST_LOGOUT_REDIRECT_URI` | absolute URL the user lands on after Entra logout |
|
| `ENTRA_POST_LOGOUT_REDIRECT_URI` | absolute URL the user lands on after Entra logout |
|
||||||
@@ -105,29 +112,29 @@ In **local development** (HTTP `localhost`), browsers reject the `__Host-` prefi
|
|||||||
|
|
||||||
### Consequences
|
### Consequences
|
||||||
|
|
||||||
* Good, because the SPA can never leak tokens — the worst an XSS can do is hijack the session cookie, and `HttpOnly` blocks even that.
|
- Good, because the SPA can never leak tokens — the worst an XSS can do is hijack the session cookie, and `HttpOnly` blocks even that.
|
||||||
* Good, because PKCE protects the code-exchange step against authorization-code interception even on the BFF side.
|
- Good, because PKCE protects the code-exchange step against authorization-code interception even on the BFF side.
|
||||||
* Good, because the route shape and configuration surface are simple, predictable, and entirely documented.
|
- Good, because the route shape and configuration surface are simple, predictable, and entirely documented.
|
||||||
* Good, because MSAL Node will let us add the On-Behalf-Of flow in the downstream-API ADR with minimal extra work.
|
- Good, because MSAL Node will let us add the On-Behalf-Of flow in the downstream-API ADR with minimal extra work.
|
||||||
* Good, because RP-initiated logout produces a clean cross-app state; users are not silently re-authenticated against their will.
|
- Good, because RP-initiated logout produces a clean cross-app state; users are not silently re-authenticated against their will.
|
||||||
* Good, because the dual-audience claim mapping is enforced at the validation step — workforce vs customer is a server-side decision, not a client-side hint.
|
- Good, because the dual-audience claim mapping is enforced at the validation step — workforce vs customer is a server-side decision, not a client-side hint.
|
||||||
* Bad, because the codebase becomes tightly coupled to MSAL Node and Entra. Switching IdP later means more than swapping a library — but the trade-off is accepted given identity is already locked to Entra.
|
- Bad, because the codebase becomes tightly coupled to MSAL Node and Entra. Switching IdP later means more than swapping a library — but the trade-off is accepted given identity is already locked to Entra.
|
||||||
* Bad, because `SameSite=Lax` (instead of `Strict`) is necessary for the callback to work; this is a known and accepted trade-off, mitigated by HTTPS, HSTS, the `__Host-` prefix, and CSRF.
|
- Bad, because `SameSite=Lax` (instead of `Strict`) is necessary for the callback to work; this is a known and accepted trade-off, mitigated by HTTPS, HSTS, the `__Host-` prefix, and CSRF.
|
||||||
* Bad, because every state-changing call from the SPA must carry `X-CSRF-Token` — small DX overhead, mitigated by a single Angular HTTP interceptor.
|
- Bad, because every state-changing call from the SPA must carry `X-CSRF-Token` — small DX overhead, mitigated by a single Angular HTTP interceptor.
|
||||||
* Bad, because mkcert is required in dev to keep cookie behaviour identical to prod — slightly higher onboarding cost; documented in the dev setup guide.
|
- Bad, because mkcert is required in dev to keep cookie behaviour identical to prod — slightly higher onboarding cost; documented in the dev setup guide.
|
||||||
* Bad, because the tenant allowlist (`ENTRA_ACCEPTED_TENANT_IDS`) must be maintained operationally as new B2B partners are onboarded — this is an operational item, not a one-shot.
|
- Bad, because the tenant allowlist (`ENTRA_ACCEPTED_TENANT_IDS`) must be maintained operationally as new B2B partners are onboarded — this is an operational item, not a one-shot.
|
||||||
|
|
||||||
### Confirmation
|
### Confirmation
|
||||||
|
|
||||||
* `apps/portal-bff/src/auth/auth.module.ts` provides a single `ConfidentialClientApplication` configured from env, with PKCE enabled.
|
- `apps/portal-bff/src/auth/auth.module.ts` provides a single `ConfidentialClientApplication` configured from env, with PKCE enabled.
|
||||||
* `apps/portal-bff/src/auth/auth.controller.ts` exposes the four routes above and no others.
|
- `apps/portal-bff/src/auth/auth.controller.ts` exposes the four routes above and no others.
|
||||||
* `AuthGuard` is registered globally; routes that must be public are explicitly marked with a `@Public()` decorator.
|
- `AuthGuard` is registered globally; routes that must be public are explicitly marked with a `@Public()` decorator.
|
||||||
* `__Host-portal_session` and `__Host-portal_csrf` are the only cookies set by the BFF (other than infrastructure cookies, none of which carry session data).
|
- `__Host-portal_session` and `__Host-portal_csrf` are the only cookies set by the BFF (other than infrastructure cookies, none of which carry session data).
|
||||||
* A NestJS interceptor enforces double-submit CSRF on every non-`GET` request.
|
- A NestJS interceptor enforces double-submit CSRF on every non-`GET` request.
|
||||||
* The Angular HTTP interceptor in `portal-shell` injects `X-CSRF-Token` on every outgoing request to the BFF.
|
- The Angular HTTP interceptor in `portal-shell` injects `X-CSRF-Token` on every outgoing request to the BFF.
|
||||||
* `iss` validation rejects any token whose issuer is not in the tenant allowlist; tested with a token from a non-allowlisted tenant.
|
- `iss` validation rejects any token whose issuer is not in the tenant allowlist; tested with a token from a non-allowlisted tenant.
|
||||||
* Refresh-token rotation is asserted via integration tests (one expired access token + a still-valid refresh token must succeed silently; an expired refresh token must produce 401 and trigger re-authentication).
|
- Refresh-token rotation is asserted via integration tests (one expired access token + a still-valid refresh token must succeed silently; an expired refresh token must produce 401 and trigger re-authentication).
|
||||||
* RP-initiated logout is asserted: `POST /auth/logout` invalidates the session, returns the `end_session_endpoint` URL, and a subsequent request without re-authentication is denied.
|
- RP-initiated logout is asserted: `POST /auth/logout` invalidates the session, returns the `end_session_endpoint` URL, and a subsequent request without re-authentication is denied.
|
||||||
|
|
||||||
## Pros and Cons of the Options
|
## Pros and Cons of the Options
|
||||||
|
|
||||||
@@ -135,81 +142,81 @@ In **local development** (HTTP `localhost`), browsers reject the `__Host-` prefi
|
|||||||
|
|
||||||
#### `@azure/msal-node` (chosen)
|
#### `@azure/msal-node` (chosen)
|
||||||
|
|
||||||
* Good, because Microsoft-official, actively maintained, aligned with Entra's quirks and roadmap (B2B, External ID later, OBO).
|
- Good, because Microsoft-official, actively maintained, aligned with Entra's quirks and roadmap (B2B, External ID later, OBO).
|
||||||
* Good, because handles refresh-token rotation, multi-tenant authority routing, and JWKS caching out of the box.
|
- Good, because handles refresh-token rotation, multi-tenant authority routing, and JWKS caching out of the box.
|
||||||
* Bad, because Microsoft-specific — couples the BFF to Entra at the protocol-handler layer.
|
- Bad, because Microsoft-specific — couples the BFF to Entra at the protocol-handler layer.
|
||||||
|
|
||||||
#### `openid-client`
|
#### `openid-client`
|
||||||
|
|
||||||
* Good, because a generic, RFC-faithful OIDC client; would work with any IdP.
|
- Good, because a generic, RFC-faithful OIDC client; would work with any IdP.
|
||||||
* Bad, because lacks Microsoft-specific helpers, especially the OBO flow we'll need in the downstream-API ADR.
|
- Bad, because lacks Microsoft-specific helpers, especially the OBO flow we'll need in the downstream-API ADR.
|
||||||
* Bad, because we'd be writing the Microsoft-specific glue ourselves — bricolage on a security-critical surface.
|
- Bad, because we'd be writing the Microsoft-specific glue ourselves — bricolage on a security-critical surface.
|
||||||
|
|
||||||
#### `passport-azure-ad`
|
#### `passport-azure-ad`
|
||||||
|
|
||||||
* Bad, because deprecated by Microsoft; no new features, only critical fixes. Rejected.
|
- Bad, because deprecated by Microsoft; no new features, only critical fixes. Rejected.
|
||||||
|
|
||||||
### OAuth flow
|
### OAuth flow
|
||||||
|
|
||||||
#### Authorization Code with PKCE (chosen)
|
#### Authorization Code with PKCE (chosen)
|
||||||
|
|
||||||
* Good, because IETF BCP for browser-based apps, recommended by Microsoft for confidential clients on the BFF.
|
- Good, because IETF BCP for browser-based apps, recommended by Microsoft for confidential clients on the BFF.
|
||||||
* Good, because PKCE adds a binding between the auth-request and the code-exchange that defeats code-interception attacks.
|
- Good, because PKCE adds a binding between the auth-request and the code-exchange that defeats code-interception attacks.
|
||||||
|
|
||||||
#### Implicit Flow
|
#### Implicit Flow
|
||||||
|
|
||||||
* Bad, because deprecated; tokens were returned in the URL fragment, exposed to browser history and the SPA. Rejected.
|
- Bad, because deprecated; tokens were returned in the URL fragment, exposed to browser history and the SPA. Rejected.
|
||||||
|
|
||||||
#### Hybrid Flow
|
#### Hybrid Flow
|
||||||
|
|
||||||
* Bad, because legacy; combines Code and Implicit. Rejected.
|
- Bad, because legacy; combines Code and Implicit. Rejected.
|
||||||
|
|
||||||
#### Resource Owner Password Credentials
|
#### Resource Owner Password Credentials
|
||||||
|
|
||||||
* Bad, because the BFF would handle the user's credentials directly — forbidden by Microsoft for production. Rejected.
|
- Bad, because the BFF would handle the user's credentials directly — forbidden by Microsoft for production. Rejected.
|
||||||
|
|
||||||
### Cookie shape
|
### Cookie shape
|
||||||
|
|
||||||
#### `__Host-` prefix (chosen)
|
#### `__Host-` prefix (chosen)
|
||||||
|
|
||||||
* Good, because browsers enforce `Secure`, `Path=/`, and absence of `Domain` — no subdomain bleed, no downgrade.
|
- Good, because browsers enforce `Secure`, `Path=/`, and absence of `Domain` — no subdomain bleed, no downgrade.
|
||||||
* Bad, because requires HTTPS in dev (mkcert).
|
- Bad, because requires HTTPS in dev (mkcert).
|
||||||
|
|
||||||
#### Without the prefix
|
#### Without the prefix
|
||||||
|
|
||||||
* Bad, because subdomain cookies and HTTP-downgrade scenarios become attack surface.
|
- Bad, because subdomain cookies and HTTP-downgrade scenarios become attack surface.
|
||||||
|
|
||||||
### CSRF
|
### CSRF
|
||||||
|
|
||||||
#### Double-submit cookie (chosen)
|
#### Double-submit cookie (chosen)
|
||||||
|
|
||||||
* Good, because stateless server-side — no token store to maintain.
|
- Good, because stateless server-side — no token store to maintain.
|
||||||
* Good, because the cookie is `SameSite=Lax`, so it isn't sent on cross-site requests; combined with the header check, an attacker on another origin cannot synthesise a valid request.
|
- Good, because the cookie is `SameSite=Lax`, so it isn't sent on cross-site requests; combined with the header check, an attacker on another origin cannot synthesise a valid request.
|
||||||
|
|
||||||
#### Synchronizer token
|
#### Synchronizer token
|
||||||
|
|
||||||
* Good, because slightly stronger isolation (the token is server-only).
|
- Good, because slightly stronger isolation (the token is server-only).
|
||||||
* Bad, because requires a server-side store and synchronisation — heavier for marginal gain in our threat model.
|
- Bad, because requires a server-side store and synchronisation — heavier for marginal gain in our threat model.
|
||||||
|
|
||||||
#### No protection
|
#### No protection
|
||||||
|
|
||||||
* Bad, because cookies are sent on cross-site form submissions; state-changing endpoints would be trivially exploitable. Rejected.
|
- Bad, because cookies are sent on cross-site form submissions; state-changing endpoints would be trivially exploitable. Rejected.
|
||||||
|
|
||||||
### Logout
|
### Logout
|
||||||
|
|
||||||
#### RP-initiated (chosen)
|
#### RP-initiated (chosen)
|
||||||
|
|
||||||
* Good, because the user's Entra SSO state is cleared too — explicit, no silent re-auth.
|
- Good, because the user's Entra SSO state is cleared too — explicit, no silent re-auth.
|
||||||
|
|
||||||
#### Local-only
|
#### Local-only
|
||||||
|
|
||||||
* Bad, because the user remains signed in to Entra; "log out" feels reversible to the attacker who got their hands on the session for a moment, not to the user.
|
- Bad, because the user remains signed in to Entra; "log out" feels reversible to the attacker who got their hands on the session for a moment, not to the user.
|
||||||
|
|
||||||
## More Information
|
## More Information
|
||||||
|
|
||||||
* IETF "OAuth 2.0 for Browser-Based Apps" BCP draft: https://datatracker.ietf.org/doc/draft-ietf-oauth-browser-based-apps/
|
- IETF "OAuth 2.0 for Browser-Based Apps" BCP draft: https://datatracker.ietf.org/doc/draft-ietf-oauth-browser-based-apps/
|
||||||
* Microsoft BFF guidance: https://learn.microsoft.com/azure/architecture/patterns/backends-for-frontends
|
- Microsoft BFF guidance: https://learn.microsoft.com/azure/architecture/patterns/backends-for-frontends
|
||||||
* MSAL Node overview: https://learn.microsoft.com/azure/active-directory/develop/msal-node-overview
|
- MSAL Node overview: https://learn.microsoft.com/azure/active-directory/develop/msal-node-overview
|
||||||
* Cookie `__Host-` prefix: https://developer.mozilla.org/docs/Web/HTTP/Cookies#cookie_prefixes
|
- Cookie `__Host-` prefix: https://developer.mozilla.org/docs/Web/HTTP/Cookies#cookie_prefixes
|
||||||
* mkcert: https://github.com/FiloSottile/mkcert
|
- mkcert: https://github.com/FiloSottile/mkcert
|
||||||
* Related ADRs: [ADR-0005](0005-backend-stack-nestjs.md) (NestJS), [ADR-0008](0008-identity-model-entra-workforce-dual-audience.md) (identity model), and the future ADRs for session storage (Redis), MFA enforcement, downstream On-Behalf-Of flow, and audit trail.
|
- Related ADRs: [ADR-0005](0005-backend-stack-nestjs.md) (NestJS), [ADR-0008](0008-identity-model-entra-workforce-dual-audience.md) (identity model), and the future ADRs for session storage (Redis), MFA enforcement, downstream On-Behalf-Of flow, and audit trail.
|
||||||
+101
-90
@@ -12,50 +12,57 @@ tags: [security, backend, infrastructure]
|
|||||||
[ADR-0009](0009-auth-flow-oidc-pkce-msal-node.md) fixed the authentication flow: the BFF holds id/access/refresh tokens server-side and the browser only carries an opaque `__Host-portal_session` cookie. This ADR pins where the session lives, in what shape, with which TTL policy, and with which security posture.
|
[ADR-0009](0009-auth-flow-oidc-pkce-msal-node.md) fixed the authentication flow: the BFF holds id/access/refresh tokens server-side and the browser only carries an opaque `__Host-portal_session` cookie. This ADR pins where the session lives, in what shape, with which TTL policy, and with which security posture.
|
||||||
|
|
||||||
Two non-negotiable constraints frame the choices:
|
Two non-negotiable constraints frame the choices:
|
||||||
|
|
||||||
- the BFF must be able to **revoke a session immediately** (logout, security incident, admin action);
|
- the BFF must be able to **revoke a session immediately** (logout, security incident, admin action);
|
||||||
- the session payload contains tokens that, if exfiltrated from Redis, would let an attacker impersonate the user — so storage must protect against snapshot or memory-dump leaks, not only against network-level theft.
|
- the session payload contains tokens that, if exfiltrated from Redis, would let an attacker impersonate the user — so storage must protect against snapshot or memory-dump leaks, not only against network-level theft.
|
||||||
|
|
||||||
## Decision Drivers
|
## Decision Drivers
|
||||||
|
|
||||||
* Immediate, server-side revocation (logout, force-logout-everywhere).
|
- Immediate, server-side revocation (logout, force-logout-everywhere).
|
||||||
* Defense in depth at the storage layer — Redis security is necessary but not sufficient.
|
- Defense in depth at the storage layer — Redis security is necessary but not sufficient.
|
||||||
* Production HA with a self-hosted Redis (per the on-prem constraint of the project).
|
- Production HA with a self-hosted Redis (per the on-prem constraint of the project).
|
||||||
* Battle-tested integration in NestJS, no bricolage on a security-critical path.
|
- Battle-tested integration in NestJS, no bricolage on a security-critical path.
|
||||||
* TTL policy aligned with enterprise norms (idle + absolute), without harming UX.
|
- TTL policy aligned with enterprise norms (idle + absolute), without harming UX.
|
||||||
* Listing and revoking active sessions for a given user must be possible without a second persistence layer.
|
- Listing and revoking active sessions for a given user must be possible without a second persistence layer.
|
||||||
* Configuration is env-driven; no host or secret in source.
|
- Configuration is env-driven; no host or secret in source.
|
||||||
|
|
||||||
## Considered Options
|
## Considered Options
|
||||||
|
|
||||||
### Storage backend
|
### Storage backend
|
||||||
* **Redis (self-hosted).** (Chosen.)
|
|
||||||
* PostgreSQL session table.
|
- **Redis (self-hosted).** (Chosen.)
|
||||||
* In-memory (single-node only).
|
- PostgreSQL session table.
|
||||||
* Cookie-session (signed payload in the cookie itself).
|
- In-memory (single-node only).
|
||||||
|
- Cookie-session (signed payload in the cookie itself).
|
||||||
|
|
||||||
### NestJS integration
|
### NestJS integration
|
||||||
* **`express-session` + `connect-redis` middleware** mounted under the Express adapter. (Chosen.)
|
|
||||||
* A custom Redis-backed session service.
|
- **`express-session` + `connect-redis` middleware** mounted under the Express adapter. (Chosen.)
|
||||||
* A NestJS-specific wrapper (`nestjs-session`, etc.).
|
- A custom Redis-backed session service.
|
||||||
|
- A NestJS-specific wrapper (`nestjs-session`, etc.).
|
||||||
|
|
||||||
### Session ID format
|
### Session ID format
|
||||||
* **Opaque crypto-random ≥ 256 bits.** (Chosen.)
|
|
||||||
* JWT-as-session-id.
|
- **Opaque crypto-random ≥ 256 bits.** (Chosen.)
|
||||||
|
- JWT-as-session-id.
|
||||||
|
|
||||||
### Encryption at rest
|
### Encryption at rest
|
||||||
* **AES-256-GCM applied by the BFF before tokens are stored.** (Chosen.)
|
|
||||||
* No application-level encryption (rely on Redis ACL + TLS only).
|
- **AES-256-GCM applied by the BFF before tokens are stored.** (Chosen.)
|
||||||
|
- No application-level encryption (rely on Redis ACL + TLS only).
|
||||||
|
|
||||||
### TTL policy
|
### TTL policy
|
||||||
* **Idle (sliding) 30 min + absolute 12 h.** (Chosen.)
|
|
||||||
* Shorter idle (e.g. 15 min) — friction.
|
- **Idle (sliding) 30 min + absolute 12 h.** (Chosen.)
|
||||||
* Longer absolute (e.g. 24 h) — risk window.
|
- Shorter idle (e.g. 15 min) — friction.
|
||||||
* No idle expiry.
|
- Longer absolute (e.g. 24 h) — risk window.
|
||||||
|
- No idle expiry.
|
||||||
|
|
||||||
### Production topology
|
### Production topology
|
||||||
* **Redis Sentinel HA (3+ nodes).** (Chosen.) Operational details deferred to a phase-3 infrastructure ADR.
|
|
||||||
* Redis Cluster.
|
- **Redis Sentinel HA (3+ nodes).** (Chosen.) Operational details deferred to a phase-3 infrastructure ADR.
|
||||||
* Single node (dev only).
|
- Redis Cluster.
|
||||||
|
- Single node (dev only).
|
||||||
|
|
||||||
## Decision Outcome
|
## Decision Outcome
|
||||||
|
|
||||||
@@ -69,7 +76,8 @@ Two non-negotiable constraints frame the choices:
|
|||||||
type SessionPayload = {
|
type SessionPayload = {
|
||||||
userId: string;
|
userId: string;
|
||||||
audience: 'workforce' | 'customer';
|
audience: 'workforce' | 'customer';
|
||||||
claims: { // curated subset of the id_token, never the full token
|
claims: {
|
||||||
|
// curated subset of the id_token, never the full token
|
||||||
sub: string;
|
sub: string;
|
||||||
oid: string;
|
oid: string;
|
||||||
tid: string; // home tenant of the user
|
tid: string; // home tenant of the user
|
||||||
@@ -91,11 +99,12 @@ type SessionPayload = {
|
|||||||
**TTL policy.**
|
**TTL policy.**
|
||||||
|
|
||||||
| Field | Default | Source of truth |
|
| Field | Default | Source of truth |
|
||||||
| --- | --- | --- |
|
| ---------------------- | ------- | ---------------------------------- |
|
||||||
| Idle (sliding) timeout | 30 min | `SESSION_IDLE_TIMEOUT_SECONDS` |
|
| Idle (sliding) timeout | 30 min | `SESSION_IDLE_TIMEOUT_SECONDS` |
|
||||||
| Absolute timeout | 12 h | `SESSION_ABSOLUTE_TIMEOUT_SECONDS` |
|
| Absolute timeout | 12 h | `SESSION_ABSOLUTE_TIMEOUT_SECONDS` |
|
||||||
|
|
||||||
Mechanics:
|
Mechanics:
|
||||||
|
|
||||||
- The Redis key carries an `EXPIRE` matching the idle timeout; every authenticated request refreshes it via `connect-redis`'s `touch`.
|
- The Redis key carries an `EXPIRE` matching the idle timeout; every authenticated request refreshes it via `connect-redis`'s `touch`.
|
||||||
- `absoluteExpiresAt` is recorded at session creation and **checked on every request**. If exceeded, the BFF deletes the session key and returns 401.
|
- `absoluteExpiresAt` is recorded at session creation and **checked on every request**. If exceeded, the BFF deletes the session key and returns 401.
|
||||||
- The two checks are independent: a session ends at whichever timeout fires first.
|
- The two checks are independent: a session ends at whichever timeout fires first.
|
||||||
@@ -105,6 +114,7 @@ Defaults are policy decisions, not technical limits — they can be tuned per en
|
|||||||
**Token refresh.** Access tokens (Entra-issued, ~1 h lifetime) are refreshed via `acquireTokenSilent` (MSAL Node) when an authenticated handler observes the access token is within ~5 min of expiry. The refresh token (Entra workforce, ~90-day sliding) is stored in the encrypted blob alongside the access token. Refresh-token rotation is enabled (cf. ADR-0009).
|
**Token refresh.** Access tokens (Entra-issued, ~1 h lifetime) are refreshed via `acquireTokenSilent` (MSAL Node) when an authenticated handler observes the access token is within ~5 min of expiry. The refresh token (Entra workforce, ~90-day sliding) is stored in the encrypted blob alongside the access token. Refresh-token rotation is enabled (cf. ADR-0009).
|
||||||
|
|
||||||
**Revocation.**
|
**Revocation.**
|
||||||
|
|
||||||
- `POST /auth/logout` deletes the session key immediately (`DEL session:{id}`).
|
- `POST /auth/logout` deletes the session key immediately (`DEL session:{id}`).
|
||||||
- An admin "log out user X everywhere" operation lists keys via a secondary index `user_sessions:{userId}` (a Redis set of session ids maintained on session create/destroy) and `DEL`-s them.
|
- An admin "log out user X everywhere" operation lists keys via a secondary index `user_sessions:{userId}` (a Redis set of session ids maintained on session create/destroy) and `DEL`-s them.
|
||||||
- An admin "log out everyone" operation is intentionally not provided as a one-shot endpoint — it would be implemented as a runbook, not a feature, to avoid creating an obvious abuse vector.
|
- An admin "log out everyone" operation is intentionally not provided as a one-shot endpoint — it would be implemented as a runbook, not a feature, to avoid creating an obvious abuse vector.
|
||||||
@@ -112,14 +122,15 @@ Defaults are policy decisions, not technical limits — they can be tuned per en
|
|||||||
**Active-sessions listing.** The optional `user_sessions:{userId}` index supports listing of active sessions per user (for an admin dashboard or a user-side "my active sessions" view). No PostgreSQL mirror — historical trace lives in the audit log (future ADR).
|
**Active-sessions listing.** The optional `user_sessions:{userId}` index supports listing of active sessions per user (for an admin dashboard or a user-side "my active sessions" view). No PostgreSQL mirror — historical trace lives in the audit log (future ADR).
|
||||||
|
|
||||||
**Topology.**
|
**Topology.**
|
||||||
|
|
||||||
- **Production:** Redis Sentinel with at least 3 nodes (1 master, 2 replicas, 3 sentinel processes for quorum), TLS in transit, ACL-restricted credentials, AOF persistence at least every second. Hosting and operational specifics (k8s operator, backup strategy, monitoring) are deferred to a phase-3 infrastructure ADR.
|
- **Production:** Redis Sentinel with at least 3 nodes (1 master, 2 replicas, 3 sentinel processes for quorum), TLS in transit, ACL-restricted credentials, AOF persistence at least every second. Hosting and operational specifics (k8s operator, backup strategy, monitoring) are deferred to a phase-3 infrastructure ADR.
|
||||||
- **Development:** a single Redis instance (Docker container or bare process), no TLS, no persistence required. Same connection-string interface (`REDIS_URL` or Sentinel-style `REDIS_SENTINEL_HOSTS` / `REDIS_SENTINEL_NAME`) so code paths are identical.
|
- **Development:** a single Redis instance (Docker container or bare process), no TLS, no persistence required. Same connection-string interface (`REDIS_URL` or Sentinel-style `REDIS_SENTINEL_HOSTS` / `REDIS_SENTINEL_NAME`) so code paths are identical.
|
||||||
|
|
||||||
**Configuration (env-driven).**
|
**Configuration (env-driven).**
|
||||||
|
|
||||||
| Variable | Purpose |
|
| Variable | Purpose |
|
||||||
| --- | --- |
|
| --------------------------------------------------------------- | ------------------------------------------------------- |
|
||||||
| `REDIS_URL` *or* `REDIS_SENTINEL_HOSTS` + `REDIS_SENTINEL_NAME` | connection target |
|
| `REDIS_URL` _or_ `REDIS_SENTINEL_HOSTS` + `REDIS_SENTINEL_NAME` | connection target |
|
||||||
| `REDIS_PASSWORD` | client-side ACL credential |
|
| `REDIS_PASSWORD` | client-side ACL credential |
|
||||||
| `REDIS_TLS` | `'true'`/`'false'` — required `true` in prod |
|
| `REDIS_TLS` | `'true'`/`'false'` — required `true` in prod |
|
||||||
| `SESSION_SECRET` | cookie-signing HMAC secret (also used by ADR-0009) |
|
| `SESSION_SECRET` | cookie-signing HMAC secret (also used by ADR-0009) |
|
||||||
@@ -131,29 +142,29 @@ The BFF refuses to start if any required variable is missing or malformed (e.g.
|
|||||||
|
|
||||||
### Consequences
|
### Consequences
|
||||||
|
|
||||||
* Good, because server-side state means a session can be invalidated by a single Redis command, instantly, with no race window.
|
- Good, because server-side state means a session can be invalidated by a single Redis command, instantly, with no race window.
|
||||||
* Good, because AES-GCM at rest defends against Redis snapshot, RDB dump, or memory-inspection scenarios — exfiltrated ciphertext is useless without the key.
|
- Good, because AES-GCM at rest defends against Redis snapshot, RDB dump, or memory-inspection scenarios — exfiltrated ciphertext is useless without the key.
|
||||||
* Good, because the TTL policy (30 min idle / 12 h absolute) is consistent with enterprise standards and well within Entra's token lifetimes.
|
- Good, because the TTL policy (30 min idle / 12 h absolute) is consistent with enterprise standards and well within Entra's token lifetimes.
|
||||||
* Good, because Sentinel gives a clean failover story for self-hosted prod — no SaaS dependency.
|
- Good, because Sentinel gives a clean failover story for self-hosted prod — no SaaS dependency.
|
||||||
* Good, because `connect-redis` is mature and used by thousands of Node services; no bricolage on a security-critical path.
|
- Good, because `connect-redis` is mature and used by thousands of Node services; no bricolage on a security-critical path.
|
||||||
* Good, because the `user_sessions:{userId}` secondary index covers "list / revoke per user" without a Postgres mirror.
|
- Good, because the `user_sessions:{userId}` secondary index covers "list / revoke per user" without a Postgres mirror.
|
||||||
* Bad, because we now manage two long-lived secrets (`SESSION_SECRET`, `SESSION_ENCRYPTION_KEY`) — both must be backed up, rotated, and distributed safely. The rotation procedure is a real operational item (future ADR).
|
- Bad, because we now manage two long-lived secrets (`SESSION_SECRET`, `SESSION_ENCRYPTION_KEY`) — both must be backed up, rotated, and distributed safely. The rotation procedure is a real operational item (future ADR).
|
||||||
* Bad, because Sentinel deployment, monitoring, and persistence are non-trivial — flagged for the phase-3 infrastructure ADR; this is real ops work, not a footnote.
|
- Bad, because Sentinel deployment, monitoring, and persistence are non-trivial — flagged for the phase-3 infrastructure ADR; this is real ops work, not a footnote.
|
||||||
* Bad, because 30-min idle is short for tasks like writing a long form. Mitigation: the SPA can fire a lightweight heartbeat to `/auth/me` (already an existing route) on user activity to keep the session warm; this remains a UX detail, not an architectural one.
|
- Bad, because 30-min idle is short for tasks like writing a long form. Mitigation: the SPA can fire a lightweight heartbeat to `/auth/me` (already an existing route) on user activity to keep the session warm; this remains a UX detail, not an architectural one.
|
||||||
* Bad, because the secondary `user_sessions:{userId}` index adds two extra Redis writes per session lifecycle event and must be kept consistent — mitigated by treating it as best-effort (orphan entries are tolerated; expired session ids are cleaned during the next list/revoke operation).
|
- Bad, because the secondary `user_sessions:{userId}` index adds two extra Redis writes per session lifecycle event and must be kept consistent — mitigated by treating it as best-effort (orphan entries are tolerated; expired session ids are cleaned during the next list/revoke operation).
|
||||||
* Neutral, because no Postgres mirror means "active sessions" exists only in Redis; if Redis is wiped, all users are logged out. That is the intended behaviour — sessions are ephemeral by design.
|
- Neutral, because no Postgres mirror means "active sessions" exists only in Redis; if Redis is wiped, all users are logged out. That is the intended behaviour — sessions are ephemeral by design.
|
||||||
|
|
||||||
### Confirmation
|
### Confirmation
|
||||||
|
|
||||||
* `apps/portal-bff/src/session/session.module.ts` configures `express-session` + `connect-redis` against the env-provided Redis target.
|
- `apps/portal-bff/src/session/session.module.ts` configures `express-session` + `connect-redis` against the env-provided Redis target.
|
||||||
* The session store is wrapped in a `RedisSessionStore` that applies AES-256-GCM encryption to the `tokens` field on `set`/`touch` and decryption on `get`. The wrapper rejects (and logs as an audit event) any record whose authentication tag fails to verify — this catches both tampering and a wrong key.
|
- The session store is wrapped in a `RedisSessionStore` that applies AES-256-GCM encryption to the `tokens` field on `set`/`touch` and decryption on `get`. The wrapper rejects (and logs as an audit event) any record whose authentication tag fails to verify — this catches both tampering and a wrong key.
|
||||||
* The session id is generated via `crypto.randomBytes(32).toString('base64url')`.
|
- The session id is generated via `crypto.randomBytes(32).toString('base64url')`.
|
||||||
* Cookie name `__Host-portal_session` (per ADR-0009); cookie attributes asserted by integration tests.
|
- Cookie name `__Host-portal_session` (per ADR-0009); cookie attributes asserted by integration tests.
|
||||||
* `absoluteExpiresAt` is checked in a global NestJS interceptor before any controller logic; expiry triggers `DEL` and 401.
|
- `absoluteExpiresAt` is checked in a global NestJS interceptor before any controller logic; expiry triggers `DEL` and 401.
|
||||||
* `user_sessions:{userId}` membership is maintained on session create / destroy; "log out everywhere" is exposed as a controller method on an admin module (future).
|
- `user_sessions:{userId}` membership is maintained on session create / destroy; "log out everywhere" is exposed as a controller method on an admin module (future).
|
||||||
* Redis client is `ioredis`; in prod, configured via Sentinel with TLS; in dev, against `REDIS_URL`.
|
- Redis client is `ioredis`; in prod, configured via Sentinel with TLS; in dev, against `REDIS_URL`.
|
||||||
* Integration tests cover: login → session created; subsequent request → idle TTL refreshed; 30 min idle → 401; 12 h elapsed → 401 even under activity; logout → key deleted; tampered token blob → reject + audit; admin force-logout → all sessions for the target user deleted.
|
- Integration tests cover: login → session created; subsequent request → idle TTL refreshed; 30 min idle → 401; 12 h elapsed → 401 even under activity; logout → key deleted; tampered token blob → reject + audit; admin force-logout → all sessions for the target user deleted.
|
||||||
* `helmet` and the BFF startup checks reject missing or malformed `SESSION_SECRET`, `SESSION_ENCRYPTION_KEY`.
|
- `helmet` and the BFF startup checks reject missing or malformed `SESSION_SECRET`, `SESSION_ENCRYPTION_KEY`.
|
||||||
|
|
||||||
## Pros and Cons of the Options
|
## Pros and Cons of the Options
|
||||||
|
|
||||||
@@ -161,102 +172,102 @@ The BFF refuses to start if any required variable is missing or malformed (e.g.
|
|||||||
|
|
||||||
#### Redis self-hosted (chosen)
|
#### Redis self-hosted (chosen)
|
||||||
|
|
||||||
* Good, because in-memory fast, native TTL, sub-ms latency on session reads on the hot path.
|
- Good, because in-memory fast, native TTL, sub-ms latency on session reads on the hot path.
|
||||||
* Good, because immediate revocation, atomic operations, mature ecosystem.
|
- Good, because immediate revocation, atomic operations, mature ecosystem.
|
||||||
* Good, because aligned with the on-prem constraint without a SaaS dependency.
|
- Good, because aligned with the on-prem constraint without a SaaS dependency.
|
||||||
* Bad, because operating a Sentinel cluster is non-trivial — flagged for the phase-3 infra ADR.
|
- Bad, because operating a Sentinel cluster is non-trivial — flagged for the phase-3 infra ADR.
|
||||||
|
|
||||||
#### PostgreSQL session table
|
#### PostgreSQL session table
|
||||||
|
|
||||||
* Good, because we already need PostgreSQL ([ADR-0006](0006-persistence-postgresql-prisma.md)) — no new component.
|
- Good, because we already need PostgreSQL ([ADR-0006](0006-persistence-postgresql-prisma.md)) — no new component.
|
||||||
* Bad, because every authenticated request would hit a relational DB for a hot, ephemeral key-value lookup. Slower, more contention, less natural TTL.
|
- Bad, because every authenticated request would hit a relational DB for a hot, ephemeral key-value lookup. Slower, more contention, less natural TTL.
|
||||||
* Bad, because mixing transient session state with durable business data conflates concerns.
|
- Bad, because mixing transient session state with durable business data conflates concerns.
|
||||||
|
|
||||||
#### In-memory
|
#### In-memory
|
||||||
|
|
||||||
* Bad, because no HA, sessions lost at every restart, scale-out impossible.
|
- Bad, because no HA, sessions lost at every restart, scale-out impossible.
|
||||||
|
|
||||||
#### Cookie-session (signed payload in cookie)
|
#### Cookie-session (signed payload in cookie)
|
||||||
|
|
||||||
* Good, because stateless server.
|
- Good, because stateless server.
|
||||||
* Bad, because revocation is impossible without an extra blacklist; the session keeps "working" until the cookie naturally expires. Unacceptable for a security-sensitive portal.
|
- Bad, because revocation is impossible without an extra blacklist; the session keeps "working" until the cookie naturally expires. Unacceptable for a security-sensitive portal.
|
||||||
* Bad, because the cookie carrying tokens (even encrypted) is sent on every request — header bloat and a more attractive target.
|
- Bad, because the cookie carrying tokens (even encrypted) is sent on every request — header bloat and a more attractive target.
|
||||||
|
|
||||||
### NestJS integration
|
### NestJS integration
|
||||||
|
|
||||||
#### `express-session` + `connect-redis` (chosen)
|
#### `express-session` + `connect-redis` (chosen)
|
||||||
|
|
||||||
* Good, because the de facto standard pair in Node, mature, well-understood.
|
- Good, because the de facto standard pair in Node, mature, well-understood.
|
||||||
* Good, because runs natively under the Express adapter NestJS already uses.
|
- Good, because runs natively under the Express adapter NestJS already uses.
|
||||||
* Bad, because two libraries to keep up to date — manageable.
|
- Bad, because two libraries to keep up to date — manageable.
|
||||||
|
|
||||||
#### Custom Redis service
|
#### Custom Redis service
|
||||||
|
|
||||||
* Bad, because reinvents `connect-redis` poorly. Bricolage on a security path.
|
- Bad, because reinvents `connect-redis` poorly. Bricolage on a security path.
|
||||||
|
|
||||||
#### NestJS-specific wrapper
|
#### NestJS-specific wrapper
|
||||||
|
|
||||||
* Neutral, because adds nothing meaningful over the `express-session` + `connect-redis` baseline.
|
- Neutral, because adds nothing meaningful over the `express-session` + `connect-redis` baseline.
|
||||||
|
|
||||||
### Session ID format
|
### Session ID format
|
||||||
|
|
||||||
#### Opaque random (chosen)
|
#### Opaque random (chosen)
|
||||||
|
|
||||||
* Good, because zero information leaked client-side; revocation is just a `DEL`.
|
- Good, because zero information leaked client-side; revocation is just a `DEL`.
|
||||||
* Good, because compatible with `__Host-portal_session` and the BFF pattern.
|
- Good, because compatible with `__Host-portal_session` and the BFF pattern.
|
||||||
|
|
||||||
#### JWT-as-session-id
|
#### JWT-as-session-id
|
||||||
|
|
||||||
* Bad, because brittle (signature, claims surface), revocation requires a blacklist, and exposes structure that opaque IDs hide.
|
- Bad, because brittle (signature, claims surface), revocation requires a blacklist, and exposes structure that opaque IDs hide.
|
||||||
|
|
||||||
### Encryption at rest
|
### Encryption at rest
|
||||||
|
|
||||||
#### AES-256-GCM (chosen)
|
#### AES-256-GCM (chosen)
|
||||||
|
|
||||||
* Good, because authenticated encryption — tamper detection comes for free.
|
- Good, because authenticated encryption — tamper detection comes for free.
|
||||||
* Good, because defends against Redis exfiltration scenarios that go beyond the network/ACL boundary.
|
- Good, because defends against Redis exfiltration scenarios that go beyond the network/ACL boundary.
|
||||||
* Bad, because adds a second secret to manage and rotate.
|
- Bad, because adds a second secret to manage and rotate.
|
||||||
|
|
||||||
#### No application-level encryption
|
#### No application-level encryption
|
||||||
|
|
||||||
* Bad, because relies entirely on Redis being uncompromisable. Anyone who reads a memory dump or RDB snapshot gets working tokens.
|
- Bad, because relies entirely on Redis being uncompromisable. Anyone who reads a memory dump or RDB snapshot gets working tokens.
|
||||||
|
|
||||||
### TTL policy
|
### TTL policy
|
||||||
|
|
||||||
#### 30 min idle / 12 h absolute (chosen)
|
#### 30 min idle / 12 h absolute (chosen)
|
||||||
|
|
||||||
* Good, because aligned with enterprise practice and well within Entra refresh-token lifetimes.
|
- Good, because aligned with enterprise practice and well within Entra refresh-token lifetimes.
|
||||||
* Good, because limits the window of damage from a leaked session id.
|
- Good, because limits the window of damage from a leaked session id.
|
||||||
* Bad, because mildly UX-disruptive on long inactive periods (mitigated by heartbeat).
|
- Bad, because mildly UX-disruptive on long inactive periods (mitigated by heartbeat).
|
||||||
|
|
||||||
#### 15 min idle / 8 h absolute
|
#### 15 min idle / 8 h absolute
|
||||||
|
|
||||||
* Good, because tighter security.
|
- Good, because tighter security.
|
||||||
* Bad, because more user friction; would require explicit UX consideration.
|
- Bad, because more user friction; would require explicit UX consideration.
|
||||||
|
|
||||||
#### 60 min idle / 24 h absolute
|
#### 60 min idle / 24 h absolute
|
||||||
|
|
||||||
* Good, because more user-friendly.
|
- Good, because more user-friendly.
|
||||||
* Bad, because doubles the risk window after credential loss / session theft.
|
- Bad, because doubles the risk window after credential loss / session theft.
|
||||||
|
|
||||||
### Production topology
|
### Production topology
|
||||||
|
|
||||||
#### Redis Sentinel HA (chosen)
|
#### Redis Sentinel HA (chosen)
|
||||||
|
|
||||||
* Good, because automatic failover, replica reads possible, simple to reason about.
|
- Good, because automatic failover, replica reads possible, simple to reason about.
|
||||||
* Good, because matches the operational profile of an on-prem deployment.
|
- Good, because matches the operational profile of an on-prem deployment.
|
||||||
|
|
||||||
#### Redis Cluster
|
#### Redis Cluster
|
||||||
|
|
||||||
* Good, because scales beyond a single master.
|
- Good, because scales beyond a single master.
|
||||||
* Bad, because more complex; we don't need cross-shard scaling for sessions at expected volume.
|
- Bad, because more complex; we don't need cross-shard scaling for sessions at expected volume.
|
||||||
|
|
||||||
## More Information
|
## More Information
|
||||||
|
|
||||||
* OWASP Session Management Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html
|
- OWASP Session Management Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html
|
||||||
* `express-session`: https://github.com/expressjs/session
|
- `express-session`: https://github.com/expressjs/session
|
||||||
* `connect-redis`: https://github.com/tj/connect-redis
|
- `connect-redis`: https://github.com/tj/connect-redis
|
||||||
* `ioredis`: https://github.com/redis/ioredis
|
- `ioredis`: https://github.com/redis/ioredis
|
||||||
* Redis Sentinel: https://redis.io/docs/management/sentinel/
|
- Redis Sentinel: https://redis.io/docs/management/sentinel/
|
||||||
* AES-GCM (Node `crypto.createCipheriv`): https://nodejs.org/api/crypto.html#class-cipheriv
|
- AES-GCM (Node `crypto.createCipheriv`): https://nodejs.org/api/crypto.html#class-cipheriv
|
||||||
* Related ADRs: [ADR-0005](0005-backend-stack-nestjs.md) (NestJS on Express adapter), [ADR-0008](0008-identity-model-entra-workforce-dual-audience.md) (identity model), [ADR-0009](0009-auth-flow-oidc-pkce-msal-node.md) (auth flow), and the future ADRs for MFA enforcement, audit trail, and on-prem infrastructure (Sentinel deployment specifics, secret rotation procedure).
|
- Related ADRs: [ADR-0005](0005-backend-stack-nestjs.md) (NestJS on Express adapter), [ADR-0008](0008-identity-model-entra-workforce-dual-audience.md) (identity model), [ADR-0009](0009-auth-flow-oidc-pkce-msal-node.md) (auth flow), and the future ADRs for MFA enforcement, audit trail, and on-prem infrastructure (Sentinel deployment specifics, secret rotation procedure).
|
||||||
+63
-60
@@ -9,47 +9,50 @@ tags: [security]
|
|||||||
|
|
||||||
## Context and Problem Statement
|
## Context and Problem Statement
|
||||||
|
|
||||||
Multi-factor authentication is mandatory for the portal's workforce audience. The question is *where* MFA logic lives, *how* the BFF gains assurance that an authenticated session has actually been multi-factored, and *how* the application is prepared for future step-up MFA on sensitive operations — without writing any of it in v1.
|
Multi-factor authentication is mandatory for the portal's workforce audience. The question is _where_ MFA logic lives, _how_ the BFF gains assurance that an authenticated session has actually been multi-factored, and _how_ the application is prepared for future step-up MFA on sensitive operations — without writing any of it in v1.
|
||||||
|
|
||||||
Identity is Microsoft Entra ID ([ADR-0008](0008-identity-model-entra-workforce-dual-audience.md)) and the auth flow is OIDC Authorization Code + PKCE via MSAL Node ([ADR-0009](0009-auth-flow-oidc-pkce-msal-node.md)). The v1 portal has no admin UI and no defined "sensitive" operations beyond authentication itself, so step-up MFA is a design concern, not a v1 implementation concern.
|
Identity is Microsoft Entra ID ([ADR-0008](0008-identity-model-entra-workforce-dual-audience.md)) and the auth flow is OIDC Authorization Code + PKCE via MSAL Node ([ADR-0009](0009-auth-flow-oidc-pkce-msal-node.md)). The v1 portal has no admin UI and no defined "sensitive" operations beyond authentication itself, so step-up MFA is a design concern, not a v1 implementation concern.
|
||||||
|
|
||||||
## Decision Drivers
|
## Decision Drivers
|
||||||
|
|
||||||
* MFA is a non-negotiable security baseline for workforce.
|
- MFA is a non-negotiable security baseline for workforce.
|
||||||
* Application code must not implement MFA mechanics — that belongs in the IdP. "Anti-bricolage" applies particularly here.
|
- Application code must not implement MFA mechanics — that belongs in the IdP. "Anti-bricolage" applies particularly here.
|
||||||
* The mechanism chosen at sign-in must be auditable in the session (was MFA actually performed?).
|
- The mechanism chosen at sign-in must be auditable in the session (was MFA actually performed?).
|
||||||
* The architecture must support future step-up MFA (a sensitive operation requires *fresh* MFA, beyond what was done at sign-in) without retrofitting authorization.
|
- The architecture must support future step-up MFA (a sensitive operation requires _fresh_ MFA, beyond what was done at sign-in) without retrofitting authorization.
|
||||||
* Configuration responsibility must be clearly separated: the IdP (org IT) owns the policy; the BFF owns the verification.
|
- Configuration responsibility must be clearly separated: the IdP (org IT) owns the policy; the BFF owns the verification.
|
||||||
|
|
||||||
## Considered Options
|
## Considered Options
|
||||||
|
|
||||||
### Where MFA is enforced
|
### Where MFA is enforced
|
||||||
* **Microsoft Entra Conditional Access policy at the tenant level.** (Chosen.)
|
|
||||||
* MFA logic in the BFF (e.g., a second-factor prompt after password).
|
- **Microsoft Entra Conditional Access policy at the tenant level.** (Chosen.)
|
||||||
|
- MFA logic in the BFF (e.g., a second-factor prompt after password).
|
||||||
|
|
||||||
### How the BFF validates that MFA happened
|
### How the BFF validates that MFA happened
|
||||||
* **Sanity-check the `amr` claim of the id_token at session creation.** (Chosen.)
|
|
||||||
* Require an explicit Authentication Context Class Reference (ACR) value via `acr_values`/`claims` request parameter.
|
- **Sanity-check the `amr` claim of the id_token at session creation.** (Chosen.)
|
||||||
* Trust Entra unconditionally without verification.
|
- Require an explicit Authentication Context Class Reference (ACR) value via `acr_values`/`claims` request parameter.
|
||||||
|
- Trust Entra unconditionally without verification.
|
||||||
|
|
||||||
### Step-up MFA in v1
|
### Step-up MFA in v1
|
||||||
* **Designed-in via a `@RequireMfa()` decorator and a claims-challenge mechanism, but not applied to any v1 route.** (Chosen.)
|
|
||||||
* Built and exercised in v1 — premature, no consumer.
|
- **Designed-in via a `@RequireMfa()` decorator and a claims-challenge mechanism, but not applied to any v1 route.** (Chosen.)
|
||||||
* Deferred entirely (no decorator, no hook) — costly to retrofit.
|
- Built and exercised in v1 — premature, no consumer.
|
||||||
|
- Deferred entirely (no decorator, no hook) — costly to retrofit.
|
||||||
|
|
||||||
## Decision Outcome
|
## Decision Outcome
|
||||||
|
|
||||||
**MFA enforcement** lives in **Microsoft Entra Conditional Access**. The org's IT/identity contact configures a Conditional Access policy at the tenant level that requires MFA for any sign-in to the BFF's app registration, for both the prod tenant and the M365 Developer tenant used for non-prod. Recommended settings (org-side, not in our code):
|
**MFA enforcement** lives in **Microsoft Entra Conditional Access**. The org's IT/identity contact configures a Conditional Access policy at the tenant level that requires MFA for any sign-in to the BFF's app registration, for both the prod tenant and the M365 Developer tenant used for non-prod. Recommended settings (org-side, not in our code):
|
||||||
|
|
||||||
- *Assignment.* Cloud apps → the BFF app. Users → All users (or "All workforce", depending on how the org structures groups). Excluded: break-glass accounts, by Microsoft's own recommendation.
|
- _Assignment._ Cloud apps → the BFF app. Users → All users (or "All workforce", depending on how the org structures groups). Excluded: break-glass accounts, by Microsoft's own recommendation.
|
||||||
- *Conditions* (recommended, not strictly required): device compliance, location, sign-in risk (Entra ID P2 for risk-based — opt-in).
|
- _Conditions_ (recommended, not strictly required): device compliance, location, sign-in risk (Entra ID P2 for risk-based — opt-in).
|
||||||
- *Grant.* Require multi-factor authentication. Prefer phishing-resistant methods (FIDO2 / passkeys / Windows Hello for Business). Fall back to authenticator-app push or TOTP. SMS/voice deprecated, to be avoided.
|
- _Grant._ Require multi-factor authentication. Prefer phishing-resistant methods (FIDO2 / passkeys / Windows Hello for Business). Fall back to authenticator-app push or TOTP. SMS/voice deprecated, to be avoided.
|
||||||
|
|
||||||
This policy is **out of scope for application code**. Our app does not implement MFA — it consumes the MFA outcome.
|
This policy is **out of scope for application code**. Our app does not implement MFA — it consumes the MFA outcome.
|
||||||
|
|
||||||
**BFF verification.** At session creation (after the OIDC callback exchange and id_token validation), the BFF performs a sanity-check on the `amr` claim of the id_token. The session is rejected with a 401 and an explicit error if the claim is absent, empty, or contains only password-class values without an MFA-class indicator. The expected normal value emitted by Entra after a CA-enforced MFA sign-in is `["pwd","mfa"]` (or equivalent depending on the factor).
|
**BFF verification.** At session creation (after the OIDC callback exchange and id_token validation), the BFF performs a sanity-check on the `amr` claim of the id_token. The session is rejected with a 401 and an explicit error if the claim is absent, empty, or contains only password-class values without an MFA-class indicator. The expected normal value emitted by Entra after a CA-enforced MFA sign-in is `["pwd","mfa"]` (or equivalent depending on the factor).
|
||||||
|
|
||||||
This is *defense in depth*, not the primary control: the primary control is the CA policy. The sanity-check exists so that a misconfigured or disabled CA policy does not silently regress the security posture without anyone noticing — the BFF will simply refuse to grant sessions if MFA assurance is missing.
|
This is _defense in depth_, not the primary control: the primary control is the CA policy. The sanity-check exists so that a misconfigured or disabled CA policy does not silently regress the security posture without anyone noticing — the BFF will simply refuse to grant sessions if MFA assurance is missing.
|
||||||
|
|
||||||
A small validation table maintained in the BFF (in source, reviewed in PRs) lists the `amr` values accepted as multi-factor:
|
A small validation table maintained in the BFF (in source, reviewed in PRs) lists the `amr` values accepted as multi-factor:
|
||||||
|
|
||||||
@@ -64,35 +67,35 @@ This list is reviewed against Microsoft's documentation as part of the security
|
|||||||
**Step-up MFA — designed-in, dormant.** A `@RequireMfa()` controller-method decorator and an associated guard are implemented in v1, but no v1 route uses them. The guard, when active, will:
|
**Step-up MFA — designed-in, dormant.** A `@RequireMfa()` controller-method decorator and an associated guard are implemented in v1, but no v1 route uses them. The guard, when active, will:
|
||||||
|
|
||||||
1. Inspect the session for a recent MFA assertion (a `mfaVerifiedAt` timestamp added to the session payload, set to `lastSeenAt` of the auth callback).
|
1. Inspect the session for a recent MFA assertion (a `mfaVerifiedAt` timestamp added to the session payload, set to `lastSeenAt` of the auth callback).
|
||||||
2. If the timestamp is older than a configurable freshness window (`MFA_FRESHNESS_SECONDS`, default 600 = 10 min), respond with 401 + a `WWW-Authenticate` header carrying a *claims challenge* (an opaque blob produced by MSAL Node).
|
2. If the timestamp is older than a configurable freshness window (`MFA_FRESHNESS_SECONDS`, default 600 = 10 min), respond with 401 + a `WWW-Authenticate` header carrying a _claims challenge_ (an opaque blob produced by MSAL Node).
|
||||||
3. The Angular SPA HTTP interceptor catches the 401 + claims challenge, redirects the browser to `/auth/login?claims=<challenge>`, which forwards the challenge to Entra. Entra re-prompts MFA, returns a fresher `amr`/`mfaVerifiedAt`, and the original request is retried.
|
3. The Angular SPA HTTP interceptor catches the 401 + claims challenge, redirects the browser to `/auth/login?claims=<challenge>`, which forwards the challenge to Entra. Entra re-prompts MFA, returns a fresher `amr`/`mfaVerifiedAt`, and the original request is retried.
|
||||||
|
|
||||||
Authentication Context Classes (`acr_values`) are *not* used in v1. The freshness-timestamp approach is sufficient for the baseline. ACR-based step-up (e.g., "this action requires CA policy `c2`") will be considered later, gated behind a follow-up ADR if specific operations demand it.
|
Authentication Context Classes (`acr_values`) are _not_ used in v1. The freshness-timestamp approach is sufficient for the baseline. ACR-based step-up (e.g., "this action requires CA policy `c2`") will be considered later, gated behind a follow-up ADR if specific operations demand it.
|
||||||
|
|
||||||
**Service accounts / app-only tokens.** Out of scope. The BFF authenticates end-users via OIDC; service-to-service tokens (if needed for downstream APIs in a future ADR) follow a different flow — On-Behalf-Of or app-only — and have their own MFA story (i.e., none, because no human is involved).
|
**Service accounts / app-only tokens.** Out of scope. The BFF authenticates end-users via OIDC; service-to-service tokens (if needed for downstream APIs in a future ADR) follow a different flow — On-Behalf-Of or app-only — and have their own MFA story (i.e., none, because no human is involved).
|
||||||
|
|
||||||
### Consequences
|
### Consequences
|
||||||
|
|
||||||
* Good, because MFA mechanics live where they belong (the IdP). The application is not in the business of implementing OTP delivery, push notifications, or hardware-key challenges.
|
- Good, because MFA mechanics live where they belong (the IdP). The application is not in the business of implementing OTP delivery, push notifications, or hardware-key challenges.
|
||||||
* Good, because the IdP configuration can evolve independently — adding device compliance, sign-in risk, location-based rules, or moving to phishing-resistant-only — without code changes.
|
- Good, because the IdP configuration can evolve independently — adding device compliance, sign-in risk, location-based rules, or moving to phishing-resistant-only — without code changes.
|
||||||
* Good, because the `amr` sanity-check in the BFF catches a category of misconfiguration that would otherwise silently regress security.
|
- Good, because the `amr` sanity-check in the BFF catches a category of misconfiguration that would otherwise silently regress security.
|
||||||
* Good, because step-up MFA is designed-in via a single decorator. When a sensitive operation appears in a future feature, marking it requires only `@RequireMfa()` — no architectural change.
|
- Good, because step-up MFA is designed-in via a single decorator. When a sensitive operation appears in a future feature, marking it requires only `@RequireMfa()` — no architectural change.
|
||||||
* Good, because separating freshness-based step-up (default) from ACR-based step-up (advanced) keeps v1 simple while leaving a clean upgrade path.
|
- Good, because separating freshness-based step-up (default) from ACR-based step-up (advanced) keeps v1 simple while leaving a clean upgrade path.
|
||||||
* Bad, because the `amr` value list is implicitly tied to Entra's emitted values; if Microsoft introduces a new factor with a new `amr` token, the BFF rejects sessions until the list is updated. Mitigated by a documented review cadence.
|
- Bad, because the `amr` value list is implicitly tied to Entra's emitted values; if Microsoft introduces a new factor with a new `amr` token, the BFF rejects sessions until the list is updated. Mitigated by a documented review cadence.
|
||||||
* Bad, because step-up MFA needs SPA cooperation (the HTTP interceptor that handles 401 + claims challenge); if a future SPA feature bypasses the interceptor, step-up will silently degrade to plain 401. Mitigated by integration tests on the interceptor.
|
- Bad, because step-up MFA needs SPA cooperation (the HTTP interceptor that handles 401 + claims challenge); if a future SPA feature bypasses the interceptor, step-up will silently degrade to plain 401. Mitigated by integration tests on the interceptor.
|
||||||
* Bad, because Conditional Access policies — especially advanced ones — require Entra ID P1 (and P2 for risk-based). Already flagged in [ADR-0008](0008-identity-model-entra-workforce-dual-audience.md), restated here.
|
- Bad, because Conditional Access policies — especially advanced ones — require Entra ID P1 (and P2 for risk-based). Already flagged in [ADR-0008](0008-identity-model-entra-workforce-dual-audience.md), restated here.
|
||||||
* Neutral, because v1 does not exercise the step-up path. Hooks must be kept alive by automated tests, not by being used in production traffic.
|
- Neutral, because v1 does not exercise the step-up path. Hooks must be kept alive by automated tests, not by being used in production traffic.
|
||||||
|
|
||||||
### Confirmation
|
### Confirmation
|
||||||
|
|
||||||
* `apps/portal-bff/src/auth/mfa.ts` exports the `MFA_AMR_VALUES` constant and a `wasMultiFactor(claims): boolean` helper.
|
- `apps/portal-bff/src/auth/mfa.ts` exports the `MFA_AMR_VALUES` constant and a `wasMultiFactor(claims): boolean` helper.
|
||||||
* The OIDC callback path rejects sessions for which `wasMultiFactor(idTokenClaims)` is false, with a structured 401 (logged as an audit event in the future audit-log ADR).
|
- The OIDC callback path rejects sessions for which `wasMultiFactor(idTokenClaims)` is false, with a structured 401 (logged as an audit event in the future audit-log ADR).
|
||||||
* Each session payload carries `mfaVerifiedAt: number` (epoch ms), set at the callback and refreshed on any subsequent step-up auth completion.
|
- Each session payload carries `mfaVerifiedAt: number` (epoch ms), set at the callback and refreshed on any subsequent step-up auth completion.
|
||||||
* `apps/portal-bff/src/auth/require-mfa.guard.ts` provides the `RequireMfaGuard` and the `@RequireMfa()` decorator (on top of the global `AuthGuard` from ADR-0009).
|
- `apps/portal-bff/src/auth/require-mfa.guard.ts` provides the `RequireMfaGuard` and the `@RequireMfa()` decorator (on top of the global `AuthGuard` from ADR-0009).
|
||||||
* `MFA_FRESHNESS_SECONDS` is read from env, defaulting to 600. The BFF refuses to start with a value below 60 (to catch misconfiguration).
|
- `MFA_FRESHNESS_SECONDS` is read from env, defaulting to 600. The BFF refuses to start with a value below 60 (to catch misconfiguration).
|
||||||
* The Angular HTTP interceptor in `portal-shell` handles `401` + `WWW-Authenticate: Bearer error="insufficient_user_authentication"` (or the claims-challenge equivalent) and redirects to `/auth/login` with the challenge propagated.
|
- The Angular HTTP interceptor in `portal-shell` handles `401` + `WWW-Authenticate: Bearer error="insufficient_user_authentication"` (or the claims-challenge equivalent) and redirects to `/auth/login` with the challenge propagated.
|
||||||
* Integration tests cover: a sign-in without MFA assertion is rejected; a sign-in with valid MFA assertion succeeds; a route protected by `@RequireMfa()` accepts a fresh session, rejects with claims challenge after `MFA_FRESHNESS_SECONDS`, and accepts again after re-auth.
|
- Integration tests cover: a sign-in without MFA assertion is rejected; a sign-in with valid MFA assertion succeeds; a route protected by `@RequireMfa()` accepts a fresh session, rejects with claims challenge after `MFA_FRESHNESS_SECONDS`, and accepts again after re-auth.
|
||||||
* The org-side Conditional Access policy is documented in an operational runbook (out of repo, owned by IT) and reviewed during onboarding of each tenant (prod + dev).
|
- The org-side Conditional Access policy is documented in an operational runbook (out of repo, owned by IT) and reviewed during onboarding of each tenant (prod + dev).
|
||||||
|
|
||||||
## Pros and Cons of the Options
|
## Pros and Cons of the Options
|
||||||
|
|
||||||
@@ -100,54 +103,54 @@ Authentication Context Classes (`acr_values`) are *not* used in v1. The freshnes
|
|||||||
|
|
||||||
#### Conditional Access (chosen)
|
#### Conditional Access (chosen)
|
||||||
|
|
||||||
* Good, because it is the Microsoft-native, audited, IdP-managed mechanism — exactly the kind of "recognised, battle-tested" choice the project values.
|
- Good, because it is the Microsoft-native, audited, IdP-managed mechanism — exactly the kind of "recognised, battle-tested" choice the project values.
|
||||||
* Good, because changes to MFA policy are a tenant-level config, not a deployment.
|
- Good, because changes to MFA policy are a tenant-level config, not a deployment.
|
||||||
* Good, because the IdP catalogue of factors evolves without us — passkeys, FIDO2, certificate-based, etc.
|
- Good, because the IdP catalogue of factors evolves without us — passkeys, FIDO2, certificate-based, etc.
|
||||||
* Bad, because it requires Entra ID P1 minimum at the tenant level (cost flagged in ADR-0008).
|
- Bad, because it requires Entra ID P1 minimum at the tenant level (cost flagged in ADR-0008).
|
||||||
|
|
||||||
#### MFA logic in the BFF
|
#### MFA logic in the BFF
|
||||||
|
|
||||||
* Bad, because it would re-implement OTP/push/key flows the IdP already provides — exactly the bricolage we are forbidden.
|
- Bad, because it would re-implement OTP/push/key flows the IdP already provides — exactly the bricolage we are forbidden.
|
||||||
* Bad, because secret material (TOTP shared secrets, etc.) ends up in our Postgres, expanding the attack surface.
|
- Bad, because secret material (TOTP shared secrets, etc.) ends up in our Postgres, expanding the attack surface.
|
||||||
|
|
||||||
### How the BFF validates MFA
|
### How the BFF validates MFA
|
||||||
|
|
||||||
#### `amr` sanity-check (chosen)
|
#### `amr` sanity-check (chosen)
|
||||||
|
|
||||||
* Good, because cheap, immediate, no extra round trip.
|
- Good, because cheap, immediate, no extra round trip.
|
||||||
* Good, because catches a real failure mode (CA policy disabled, scope misconfigured).
|
- Good, because catches a real failure mode (CA policy disabled, scope misconfigured).
|
||||||
* Bad, because dependent on Entra's `amr` emission values — list maintenance required.
|
- Bad, because dependent on Entra's `amr` emission values — list maintenance required.
|
||||||
|
|
||||||
#### Required ACR via `claims` parameter
|
#### Required ACR via `claims` parameter
|
||||||
|
|
||||||
* Good, because explicit and machine-verifiable per request.
|
- Good, because explicit and machine-verifiable per request.
|
||||||
* Bad, because requires Authentication Context Classes to be configured tenant-side and exchanged on every request — heavy for a baseline "MFA at sign-in" need. Re-evaluate when fine-grained step-up arrives.
|
- Bad, because requires Authentication Context Classes to be configured tenant-side and exchanged on every request — heavy for a baseline "MFA at sign-in" need. Re-evaluate when fine-grained step-up arrives.
|
||||||
|
|
||||||
#### No verification
|
#### No verification
|
||||||
|
|
||||||
* Bad, because a CA policy disabled by mistake would silently regress security with no audit signal until an incident.
|
- Bad, because a CA policy disabled by mistake would silently regress security with no audit signal until an incident.
|
||||||
|
|
||||||
### Step-up MFA in v1
|
### Step-up MFA in v1
|
||||||
|
|
||||||
#### Designed-in via decorator, dormant (chosen)
|
#### Designed-in via decorator, dormant (chosen)
|
||||||
|
|
||||||
* Good, because the cost is minimal: one guard, one decorator, one timestamp in the session.
|
- Good, because the cost is minimal: one guard, one decorator, one timestamp in the session.
|
||||||
* Good, because step-up is a one-line annotation on any future sensitive route.
|
- Good, because step-up is a one-line annotation on any future sensitive route.
|
||||||
* Bad, because a dormant code path drifts unless covered by tests — addressed in the Confirmation section.
|
- Bad, because a dormant code path drifts unless covered by tests — addressed in the Confirmation section.
|
||||||
|
|
||||||
#### Built and exercised in v1
|
#### Built and exercised in v1
|
||||||
|
|
||||||
* Bad, because no v1 route is sensitive enough to require it. Building a feature with no consumer is bricolage by another name.
|
- Bad, because no v1 route is sensitive enough to require it. Building a feature with no consumer is bricolage by another name.
|
||||||
|
|
||||||
#### Deferred entirely
|
#### Deferred entirely
|
||||||
|
|
||||||
* Bad, because retrofitting step-up after the fact means refactoring guards, sessions, and the SPA interceptor. Cost asymmetric vs. the dormant-hook option.
|
- Bad, because retrofitting step-up after the fact means refactoring guards, sessions, and the SPA interceptor. Cost asymmetric vs. the dormant-hook option.
|
||||||
|
|
||||||
## More Information
|
## More Information
|
||||||
|
|
||||||
* Microsoft Entra Conditional Access overview: https://learn.microsoft.com/entra/identity/conditional-access/overview
|
- Microsoft Entra Conditional Access overview: https://learn.microsoft.com/entra/identity/conditional-access/overview
|
||||||
* `amr` claim values (OpenID Connect): https://www.iana.org/assignments/authentication-method-reference-values/
|
- `amr` claim values (OpenID Connect): https://www.iana.org/assignments/authentication-method-reference-values/
|
||||||
* Microsoft `amr` documentation: https://learn.microsoft.com/azure/active-directory/develop/access-tokens
|
- Microsoft `amr` documentation: https://learn.microsoft.com/azure/active-directory/develop/access-tokens
|
||||||
* Authentication Context Classes (ACR) and claims challenges: https://learn.microsoft.com/entra/identity-platform/v2-conditional-access-dev-guide
|
- Authentication Context Classes (ACR) and claims challenges: https://learn.microsoft.com/entra/identity-platform/v2-conditional-access-dev-guide
|
||||||
* Phishing-resistant MFA (passkeys / FIDO2): https://learn.microsoft.com/entra/identity/authentication/concept-authentication-passwordless
|
- Phishing-resistant MFA (passkeys / FIDO2): https://learn.microsoft.com/entra/identity/authentication/concept-authentication-passwordless
|
||||||
* Related ADRs: [ADR-0008](0008-identity-model-entra-workforce-dual-audience.md) (identity model — P1 licensing flagged), [ADR-0009](0009-auth-flow-oidc-pkce-msal-node.md) (auth flow), [ADR-0010](0010-session-management-redis.md) (session — `mfaVerifiedAt` lives here), and the future ADRs for downstream APIs (On-Behalf-Of), audit trail (rejected-MFA logged events), and the operational runbook for tenant Conditional Access policies.
|
- Related ADRs: [ADR-0008](0008-identity-model-entra-workforce-dual-audience.md) (identity model — P1 licensing flagged), [ADR-0009](0009-auth-flow-oidc-pkce-msal-node.md) (auth flow), [ADR-0010](0010-session-management-redis.md) (session — `mfaVerifiedAt` lives here), and the future ADRs for downstream APIs (On-Behalf-Of), audit trail (rejected-MFA logged events), and the operational runbook for tenant Conditional Access policies.
|
||||||
+115
-107
@@ -11,63 +11,71 @@ tags: [observability, backend, frontend]
|
|||||||
|
|
||||||
The portal has stated security, performance, and accessibility as first-class concerns. None of the three can be operated, audited, or improved without observability built in from day one. We need a fixed answer to:
|
The portal has stated security, performance, and accessibility as first-class concerns. None of the three can be operated, audited, or improved without observability built in from day one. We need a fixed answer to:
|
||||||
|
|
||||||
- *How* application events are recorded (logs, traces);
|
- _How_ application events are recorded (logs, traces);
|
||||||
- *Which* identifier ties together what happens in the SPA, the BFF, the DB, the cache, and any downstream API call for a single user action;
|
- _Which_ identifier ties together what happens in the SPA, the BFF, the DB, the cache, and any downstream API call for a single user action;
|
||||||
- *Where* the signals are emitted, and how they reach a backend;
|
- _Where_ the signals are emitted, and how they reach a backend;
|
||||||
- *What* must never appear in a log line (PII, credentials, tokens);
|
- _What_ must never appear in a log line (PII, credentials, tokens);
|
||||||
- *How* the architecture stays vendor-neutral so the chosen on-prem backend (Grafana stack, ELK, or otherwise — phase 3) can be selected without rewriting the application.
|
- _How_ the architecture stays vendor-neutral so the chosen on-prem backend (Grafana stack, ELK, or otherwise — phase 3) can be selected without rewriting the application.
|
||||||
|
|
||||||
Two signals are in scope here: **logs** and **traces**. Metrics are deliberately out of scope; if metrics become necessary later, they fit naturally into the same OpenTelemetry pipeline and will get their own ADR.
|
Two signals are in scope here: **logs** and **traces**. Metrics are deliberately out of scope; if metrics become necessary later, they fit naturally into the same OpenTelemetry pipeline and will get their own ADR.
|
||||||
|
|
||||||
## Decision Drivers
|
## Decision Drivers
|
||||||
|
|
||||||
* A single correlation identifier across all signals — front-end click, BFF request, DB query, downstream API call, log line — debuggable in seconds, not in spelunking.
|
- A single correlation identifier across all signals — front-end click, BFF request, DB query, downstream API call, log line — debuggable in seconds, not in spelunking.
|
||||||
* Vendor-neutral standards (OpenTelemetry, W3C Trace Context) so the on-prem backend can be chosen later (phase 3) without coupling the application to it.
|
- Vendor-neutral standards (OpenTelemetry, W3C Trace Context) so the on-prem backend can be chosen later (phase 3) without coupling the application to it.
|
||||||
* Mature, enterprise-grade libraries — anti-bricolage applies particularly here.
|
- Mature, enterprise-grade libraries — anti-bricolage applies particularly here.
|
||||||
* 12-factor: the application emits to stdout / OTLP; shipping is an operational concern, not an application concern.
|
- 12-factor: the application emits to stdout / OTLP; shipping is an operational concern, not an application concern.
|
||||||
* Privacy: PII (and credentials, tokens) must never reach a log line. Defense in depth — even if a log is exfiltrated, it must contain the minimum needed for support.
|
- Privacy: PII (and credentials, tokens) must never reach a log line. Defense in depth — even if a log is exfiltrated, it must contain the minimum needed for support.
|
||||||
* The SPA must participate in the trace so the first segment (user click → first request) is not lost.
|
- The SPA must participate in the trace so the first segment (user click → first request) is not lost.
|
||||||
|
|
||||||
## Considered Options
|
## Considered Options
|
||||||
|
|
||||||
### Logging library
|
### Logging library
|
||||||
* **Pino + `nestjs-pino`.** (Chosen.)
|
|
||||||
* Winston.
|
- **Pino + `nestjs-pino`.** (Chosen.)
|
||||||
* Bunyan.
|
- Winston.
|
||||||
* Plain `console.log` / NestJS default `Logger` only.
|
- Bunyan.
|
||||||
|
- Plain `console.log` / NestJS default `Logger` only.
|
||||||
|
|
||||||
### Tracing instrumentation
|
### Tracing instrumentation
|
||||||
* **OpenTelemetry SDK + `auto-instrumentations-node`.** (Chosen.)
|
|
||||||
* Vendor SDKs (Datadog, New Relic).
|
- **OpenTelemetry SDK + `auto-instrumentations-node`.** (Chosen.)
|
||||||
* OpenTracing.
|
- Vendor SDKs (Datadog, New Relic).
|
||||||
* Custom hand-rolled tracer.
|
- OpenTracing.
|
||||||
|
- Custom hand-rolled tracer.
|
||||||
|
|
||||||
### Trace propagation header
|
### Trace propagation header
|
||||||
* **W3C Trace Context (`traceparent`, `tracestate`).** (Chosen.)
|
|
||||||
* Custom `X-Correlation-ID`.
|
- **W3C Trace Context (`traceparent`, `tracestate`).** (Chosen.)
|
||||||
* Vendor formats (B3, Datadog, Jaeger).
|
- Custom `X-Correlation-ID`.
|
||||||
|
- Vendor formats (B3, Datadog, Jaeger).
|
||||||
|
|
||||||
### Request-scoped context (correlation propagation in code)
|
### Request-scoped context (correlation propagation in code)
|
||||||
* **`nestjs-cls` over Node `AsyncLocalStorage`.** (Chosen.)
|
|
||||||
* Manual passing through every call signature.
|
- **`nestjs-cls` over Node `AsyncLocalStorage`.** (Chosen.)
|
||||||
* Direct `AsyncLocalStorage` without the NestJS wrapper.
|
- Manual passing through every call signature.
|
||||||
|
- Direct `AsyncLocalStorage` without the NestJS wrapper.
|
||||||
|
|
||||||
### Sampling
|
### Sampling
|
||||||
* **Always-on (100 %) at the application; tail sampling deferred to the collector / phase-3 ADR.** (Chosen for v1.)
|
|
||||||
* Probabilistic head sampling (e.g. 10 %).
|
- **Always-on (100 %) at the application; tail sampling deferred to the collector / phase-3 ADR.** (Chosen for v1.)
|
||||||
|
- Probabilistic head sampling (e.g. 10 %).
|
||||||
|
|
||||||
### Emission and shipping
|
### Emission and shipping
|
||||||
* **stdout (logs) + OTLP/HTTP (traces) → local OpenTelemetry Collector → on-prem backend (phase 3).** (Chosen.)
|
|
||||||
* Direct shipping from the app to the backend.
|
- **stdout (logs) + OTLP/HTTP (traces) → local OpenTelemetry Collector → on-prem backend (phase 3).** (Chosen.)
|
||||||
|
- Direct shipping from the app to the backend.
|
||||||
|
|
||||||
### Sensitive data handling in logs
|
### Sensitive data handling in logs
|
||||||
* **Pino `redact` with an explicit allowlist of paths to strip.** (Chosen.)
|
|
||||||
* Hand-rolled scrubbing per log call.
|
- **Pino `redact` with an explicit allowlist of paths to strip.** (Chosen.)
|
||||||
* No redaction.
|
- Hand-rolled scrubbing per log call.
|
||||||
|
- No redaction.
|
||||||
|
|
||||||
### Frontend tracing
|
### Frontend tracing
|
||||||
* **OpenTelemetry-Web in the Angular SPA, propagating `traceparent` on outbound HTTP.** (Chosen.)
|
|
||||||
* Front-end out of the trace.
|
- **OpenTelemetry-Web in the Angular SPA, propagating `traceparent` on outbound HTTP.** (Chosen.)
|
||||||
|
- Front-end out of the trace.
|
||||||
|
|
||||||
## Decision Outcome
|
## Decision Outcome
|
||||||
|
|
||||||
@@ -97,12 +105,12 @@ The contextual fields (`trace_id`, `span_id`, `session_id`, `user_id_hash`, `aud
|
|||||||
|
|
||||||
**Tracing.** OpenTelemetry SDK for Node (`@opentelemetry/sdk-node`) is started before NestJS bootstrap. `@opentelemetry/auto-instrumentations-node` is enabled with the modules we use: HTTP, Express, NestJS core, `pg`, `ioredis`, and Prisma (via Prisma's built-in OTel hooks). Custom spans can be created inline via `@opentelemetry/api`'s `tracer.startActiveSpan` for non-trivial business flows.
|
**Tracing.** OpenTelemetry SDK for Node (`@opentelemetry/sdk-node`) is started before NestJS bootstrap. `@opentelemetry/auto-instrumentations-node` is enabled with the modules we use: HTTP, Express, NestJS core, `pg`, `ioredis`, and Prisma (via Prisma's built-in OTel hooks). Custom spans can be created inline via `@opentelemetry/api`'s `tracer.startActiveSpan` for non-trivial business flows.
|
||||||
|
|
||||||
**Propagation.** **W3C Trace Context** is the only propagation format. The `traceparent` (and optionally `tracestate`) header carries the trace identity end to end. The `trace_id` extracted from `traceparent` *is* the correlation identifier — there is no separate `X-Correlation-ID`. If a request arrives without a `traceparent`, the SDK starts a new trace and the BFF emits the resulting `traceparent` in the response so the SPA can pick it up. Downstream HTTP calls automatically carry `traceparent` thanks to the HTTP auto-instrumentation.
|
**Propagation.** **W3C Trace Context** is the only propagation format. The `traceparent` (and optionally `tracestate`) header carries the trace identity end to end. The `trace_id` extracted from `traceparent` _is_ the correlation identifier — there is no separate `X-Correlation-ID`. If a request arrives without a `traceparent`, the SDK starts a new trace and the BFF emits the resulting `traceparent` in the response so the SPA can pick it up. Downstream HTTP calls automatically carry `traceparent` thanks to the HTTP auto-instrumentation.
|
||||||
|
|
||||||
**Request-scoped context.** `nestjs-cls` is registered globally and exposes the per-request store. At the entry of every request, an interceptor populates:
|
**Request-scoped context.** `nestjs-cls` is registered globally and exposes the per-request store. At the entry of every request, an interceptor populates:
|
||||||
|
|
||||||
| Key | Source |
|
| Key | Source |
|
||||||
| --- | --- |
|
| -------------- | ------------------------------------------------------------------------------------------------ |
|
||||||
| `trace_id` | OTel current span context |
|
| `trace_id` | OTel current span context |
|
||||||
| `span_id` | OTel current span context |
|
| `span_id` | OTel current span context |
|
||||||
| `session_id` | session lookup (ADR-0010) |
|
| `session_id` | session lookup (ADR-0010) |
|
||||||
@@ -113,11 +121,11 @@ The Pino formatter and the audit-log writer (future ADR) read from CLS — neith
|
|||||||
|
|
||||||
**`user_id_hash` salting.** A per-environment salt (`LOG_USER_ID_SALT`) is required and read from env. The hash is `sha256(userId || ":" || salt)`, base64-url-encoded. The salt is rotated as part of secret-rotation procedures (future ops ADR). Without the salt, the same `userId` always produces the same hash, which is enumerable for known userId spaces; the salt makes per-environment hashes incomparable.
|
**`user_id_hash` salting.** A per-environment salt (`LOG_USER_ID_SALT`) is required and read from env. The hash is `sha256(userId || ":" || salt)`, base64-url-encoded. The salt is rotated as part of secret-rotation procedures (future ops ADR). Without the salt, the same `userId` always produces the same hash, which is enumerable for known userId spaces; the salt makes per-environment hashes incomparable.
|
||||||
|
|
||||||
**Sampling.** v1 emits **100 % of traces** at the application. Volumes are low and end-to-end debuggability is the priority. Tail sampling (drop the boring traces, keep the slow / failed ones) is performed at the **OpenTelemetry Collector**, configured as part of the phase-3 infrastructure ADR. The application *does not* know nor care about sampling policy — moving the policy is a collector-config change.
|
**Sampling.** v1 emits **100 % of traces** at the application. Volumes are low and end-to-end debuggability is the priority. Tail sampling (drop the boring traces, keep the slow / failed ones) is performed at the **OpenTelemetry Collector**, configured as part of the phase-3 infrastructure ADR. The application _does not_ know nor care about sampling policy — moving the policy is a collector-config change.
|
||||||
|
|
||||||
**Emission and shipping.** Logs go to **stdout** as line-delimited JSON. Traces are exported via **OTLP/HTTP** to a local **OpenTelemetry Collector** instance running alongside the BFF (sidecar in container orchestration, local process in dev). The collector is the single point that knows the on-prem backend's address and credentials; the application stays decoupled. Backend choice and collector deployment topology are deferred to the phase-3 infrastructure ADR.
|
**Emission and shipping.** Logs go to **stdout** as line-delimited JSON. Traces are exported via **OTLP/HTTP** to a local **OpenTelemetry Collector** instance running alongside the BFF (sidecar in container orchestration, local process in dev). The collector is the single point that knows the on-prem backend's address and credentials; the application stays decoupled. Backend choice and collector deployment topology are deferred to the phase-3 infrastructure ADR.
|
||||||
|
|
||||||
**Redaction.** Pino's `redact` option strips a fixed list of paths from every log line *before* it is serialised:
|
**Redaction.** Pino's `redact` option strips a fixed list of paths from every log line _before_ it is serialised:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
redact: {
|
redact: {
|
||||||
@@ -146,12 +154,12 @@ The list lives in `apps/portal-bff/src/observability/log.redact.ts` and is revie
|
|||||||
|
|
||||||
The SPA does **not** ship its own logs to a backend in v1. Browser-side error reporting (uncaught exceptions, Promise rejections) is a separate concern and will get its own ADR if and when it becomes necessary; in the interim, errors are visible in the browser console for development and surface as 500-equivalents in the BFF logs once the failed request reaches the server.
|
The SPA does **not** ship its own logs to a backend in v1. Browser-side error reporting (uncaught exceptions, Promise rejections) is a separate concern and will get its own ADR if and when it becomes necessary; in the interim, errors are visible in the browser console for development and surface as 500-equivalents in the BFF logs once the failed request reaches the server.
|
||||||
|
|
||||||
**Audit logs are not application logs.** Anything that is a security-relevant audit event (sign-in, sign-out, MFA challenge, authorization deny, admin action — when one exists) is emitted via a *separate* writer and a *separate* sink, defined by the audit-trail ADR (next). The two streams may share the trace identifier and the CLS context, but they do not share the same retention, the same access controls, or the same volume profile.
|
**Audit logs are not application logs.** Anything that is a security-relevant audit event (sign-in, sign-out, MFA challenge, authorization deny, admin action — when one exists) is emitted via a _separate_ writer and a _separate_ sink, defined by the audit-trail ADR (next). The two streams may share the trace identifier and the CLS context, but they do not share the same retention, the same access controls, or the same volume profile.
|
||||||
|
|
||||||
**Configuration (env-driven).**
|
**Configuration (env-driven).**
|
||||||
|
|
||||||
| Variable | Purpose |
|
| Variable | Purpose |
|
||||||
| --- | --- |
|
| ----------------------------- | ------------------------------------------------------------------------------------- |
|
||||||
| `LOG_LEVEL` | `trace`/`debug`/`info`/`warn`/`error`/`fatal`; default `info` in prod, `debug` in dev |
|
| `LOG_LEVEL` | `trace`/`debug`/`info`/`warn`/`error`/`fatal`; default `info` in prod, `debug` in dev |
|
||||||
| `LOG_USER_ID_SALT` | per-env salt for `user_id_hash` |
|
| `LOG_USER_ID_SALT` | per-env salt for `user_id_hash` |
|
||||||
| `OTEL_SERVICE_NAME` | e.g. `portal-bff`, `portal-shell` |
|
| `OTEL_SERVICE_NAME` | e.g. `portal-bff`, `portal-shell` |
|
||||||
@@ -165,33 +173,33 @@ The BFF refuses to start if `LOG_USER_ID_SALT` or `OTEL_SERVICE_NAME` is missing
|
|||||||
|
|
||||||
### Consequences
|
### Consequences
|
||||||
|
|
||||||
* Good, because every signal carries the same `trace_id`, navigable across the SPA, the BFF, the DB, the cache, and downstream APIs once they exist. End-to-end debugging is one click in any conformant backend.
|
- Good, because every signal carries the same `trace_id`, navigable across the SPA, the BFF, the DB, the cache, and downstream APIs once they exist. End-to-end debugging is one click in any conformant backend.
|
||||||
* Good, because OpenTelemetry + W3C are vendor-neutral; the on-prem backend (Grafana / ELK / other) is chosen later without code changes.
|
- Good, because OpenTelemetry + W3C are vendor-neutral; the on-prem backend (Grafana / ELK / other) is chosen later without code changes.
|
||||||
* Good, because Pino is the fastest established Node logger; structured JSON output is directly ingestible by every backend.
|
- Good, because Pino is the fastest established Node logger; structured JSON output is directly ingestible by every backend.
|
||||||
* Good, because `nestjs-cls` removes context-threading boilerplate — the developer writes business logic, the platform handles correlation.
|
- Good, because `nestjs-cls` removes context-threading boilerplate — the developer writes business logic, the platform handles correlation.
|
||||||
* Good, because PII redaction and per-environment salting on `user_id_hash` minimise leakage if a log is exfiltrated; the redaction list is reviewable in PRs.
|
- Good, because PII redaction and per-environment salting on `user_id_hash` minimise leakage if a log is exfiltrated; the redaction list is reviewable in PRs.
|
||||||
* Good, because 100 % sampling at the application keeps the architecture simple in v1; sampling policy moves to the collector when volumes justify it.
|
- Good, because 100 % sampling at the application keeps the architecture simple in v1; sampling policy moves to the collector when volumes justify it.
|
||||||
* Bad, because the OTel Node SDK has historically had churn in API surface; the decision to use auto-instrumentations rather than hand-instrument is a calculated bet on the upstream stabilisation. Mitigated by pinning OTel package versions and lifting them deliberately.
|
- Bad, because the OTel Node SDK has historically had churn in API surface; the decision to use auto-instrumentations rather than hand-instrument is a calculated bet on the upstream stabilisation. Mitigated by pinning OTel package versions and lifting them deliberately.
|
||||||
* Bad, because emitting 100 % traces in prod at scale will eventually cost storage; the deferral to collector-side tail sampling is the proper answer, not "drop everything at the source".
|
- Bad, because emitting 100 % traces in prod at scale will eventually cost storage; the deferral to collector-side tail sampling is the proper answer, not "drop everything at the source".
|
||||||
* Bad, because the redaction list is a per-line config — adding a new sensitive shape (e.g. a future PII field) requires a PR. There is no fully automatic "redact anything that looks PII-like". This is an accepted trade-off: explicit beats clever.
|
- Bad, because the redaction list is a per-line config — adding a new sensitive shape (e.g. a future PII field) requires a PR. There is no fully automatic "redact anything that looks PII-like". This is an accepted trade-off: explicit beats clever.
|
||||||
* Bad, because the SPA's OTel-Web bundle adds a small payload (~30 KB gzip). Acceptable; we already prioritise observability over aesthetic minimalism.
|
- Bad, because the SPA's OTel-Web bundle adds a small payload (~30 KB gzip). Acceptable; we already prioritise observability over aesthetic minimalism.
|
||||||
* Bad, because no front-end log shipping in v1 means we are blind to client-only errors that don't cause a server-side request. Acceptable for v1; revisit if support volume justifies it.
|
- Bad, because no front-end log shipping in v1 means we are blind to client-only errors that don't cause a server-side request. Acceptable for v1; revisit if support volume justifies it.
|
||||||
* Neutral, because audit logs being a separate stream is enforced by writer separation, not by namespacing inside the same Pino logger — this is intentional (different retention, different access).
|
- Neutral, because audit logs being a separate stream is enforced by writer separation, not by namespacing inside the same Pino logger — this is intentional (different retention, different access).
|
||||||
|
|
||||||
### Confirmation
|
### Confirmation
|
||||||
|
|
||||||
* `apps/portal-bff/src/observability/observability.module.ts` registers `nestjs-pino`, `nestjs-cls`, the OTel SDK initialiser, and the redact-list test fixture.
|
- `apps/portal-bff/src/observability/observability.module.ts` registers `nestjs-pino`, `nestjs-cls`, the OTel SDK initialiser, and the redact-list test fixture.
|
||||||
* `apps/portal-bff/src/main.ts` calls `tracingSetup()` *before* `NestFactory.create(AppModule)` — OTel must be initialised before any auto-instrumented module is loaded.
|
- `apps/portal-bff/src/main.ts` calls `tracingSetup()` _before_ `NestFactory.create(AppModule)` — OTel must be initialised before any auto-instrumented module is loaded.
|
||||||
* The Pino transport in dev uses `pino-pretty` (human-readable); prod emits raw JSON.
|
- The Pino transport in dev uses `pino-pretty` (human-readable); prod emits raw JSON.
|
||||||
* `apps/portal-shell/src/observability/tracing.ts` initialises `@opentelemetry/sdk-trace-web` with the OTLP/HTTP exporter pointing to the BFF's `/v1/traces` ingress (or directly to the collector if exposed).
|
- `apps/portal-shell/src/observability/tracing.ts` initialises `@opentelemetry/sdk-trace-web` with the OTLP/HTTP exporter pointing to the BFF's `/v1/traces` ingress (or directly to the collector if exposed).
|
||||||
* Every controller receives `req` and `res` and the http auto-instrumentation produces a span; non-trivial service methods open custom spans via `tracer.startActiveSpan('domain.<verb>', ...)` and propagate the CLS context within them.
|
- Every controller receives `req` and `res` and the http auto-instrumentation produces a span; non-trivial service methods open custom spans via `tracer.startActiveSpan('domain.<verb>', ...)` and propagate the CLS context within them.
|
||||||
* Integration tests:
|
- Integration tests:
|
||||||
- a single user-action request emits one trace with correct parent-child structure (SPA → BFF → DB);
|
- a single user-action request emits one trace with correct parent-child structure (SPA → BFF → DB);
|
||||||
- every log line for that request carries the same `trace_id`;
|
- every log line for that request carries the same `trace_id`;
|
||||||
- the redact list strips its targets from a captured stream;
|
- the redact list strips its targets from a captured stream;
|
||||||
- a missing `LOG_USER_ID_SALT` prevents BFF startup.
|
- a missing `LOG_USER_ID_SALT` prevents BFF startup.
|
||||||
* The CLS-bound `user_id_hash` is identical for two consecutive requests of the same user, and different across environments (different salt).
|
- The CLS-bound `user_id_hash` is identical for two consecutive requests of the same user, and different across environments (different salt).
|
||||||
* The OTel Collector configuration ships in the deployment manifest (covered by the future infrastructure ADR), not in source — the application remains backend-agnostic.
|
- The OTel Collector configuration ships in the deployment manifest (covered by the future infrastructure ADR), not in source — the application remains backend-agnostic.
|
||||||
|
|
||||||
## Pros and Cons of the Options
|
## Pros and Cons of the Options
|
||||||
|
|
||||||
@@ -199,133 +207,133 @@ The BFF refuses to start if `LOG_USER_ID_SALT` or `OTEL_SERVICE_NAME` is missing
|
|||||||
|
|
||||||
#### Pino + `nestjs-pino` (chosen)
|
#### Pino + `nestjs-pino` (chosen)
|
||||||
|
|
||||||
* Good, because the fastest established Node logger; JSON structured output by default; first-class integration with NestJS (HTTP request logging, async context).
|
- Good, because the fastest established Node logger; JSON structured output by default; first-class integration with NestJS (HTTP request logging, async context).
|
||||||
* Good, because rich `redact` configuration with path globbing — exactly what we need for PII handling.
|
- Good, because rich `redact` configuration with path globbing — exactly what we need for PII handling.
|
||||||
* Bad, because `nestjs-pino` is third-party (well-maintained, but not Nest-team-owned). Acceptable.
|
- Bad, because `nestjs-pino` is third-party (well-maintained, but not Nest-team-owned). Acceptable.
|
||||||
|
|
||||||
#### Winston
|
#### Winston
|
||||||
|
|
||||||
* Good, because long-standing, large user base.
|
- Good, because long-standing, large user base.
|
||||||
* Bad, because slower than Pino in JSON mode; ergonomics around async context less polished. Less aligned with our perf and DX goals.
|
- Bad, because slower than Pino in JSON mode; ergonomics around async context less polished. Less aligned with our perf and DX goals.
|
||||||
|
|
||||||
#### Bunyan
|
#### Bunyan
|
||||||
|
|
||||||
* Bad, because slowing maintenance, smaller community in 2026.
|
- Bad, because slowing maintenance, smaller community in 2026.
|
||||||
|
|
||||||
#### `console.log` / NestJS default Logger only
|
#### `console.log` / NestJS default Logger only
|
||||||
|
|
||||||
* Bad, because no structured output, no redaction, no async context, no levels by config. Bricolage.
|
- Bad, because no structured output, no redaction, no async context, no levels by config. Bricolage.
|
||||||
|
|
||||||
### Tracing instrumentation
|
### Tracing instrumentation
|
||||||
|
|
||||||
#### OpenTelemetry (chosen)
|
#### OpenTelemetry (chosen)
|
||||||
|
|
||||||
* Good, because the CNCF graduated standard — supported by every observability vendor and every major language.
|
- Good, because the CNCF graduated standard — supported by every observability vendor and every major language.
|
||||||
* Good, because auto-instrumentation removes the bulk of glue work for HTTP, DB, Redis, Prisma.
|
- Good, because auto-instrumentation removes the bulk of glue work for HTTP, DB, Redis, Prisma.
|
||||||
* Bad, because the Node SDK has had API churn historically — managed by version pinning.
|
- Bad, because the Node SDK has had API churn historically — managed by version pinning.
|
||||||
|
|
||||||
#### Vendor SDK (Datadog, New Relic, etc.)
|
#### Vendor SDK (Datadog, New Relic, etc.)
|
||||||
|
|
||||||
* Good, because typically richer auto-instrumentation than OTel for that vendor.
|
- Good, because typically richer auto-instrumentation than OTel for that vendor.
|
||||||
* Bad, because vendor lock-in — switching backend later means rewriting instrumentation. Rejected.
|
- Bad, because vendor lock-in — switching backend later means rewriting instrumentation. Rejected.
|
||||||
|
|
||||||
#### OpenTracing
|
#### OpenTracing
|
||||||
|
|
||||||
* Bad, because deprecated; succeeded by OTel. Rejected.
|
- Bad, because deprecated; succeeded by OTel. Rejected.
|
||||||
|
|
||||||
#### Custom
|
#### Custom
|
||||||
|
|
||||||
* Bad, because bricolage on a critical surface.
|
- Bad, because bricolage on a critical surface.
|
||||||
|
|
||||||
### Propagation header
|
### Propagation header
|
||||||
|
|
||||||
#### W3C Trace Context (chosen)
|
#### W3C Trace Context (chosen)
|
||||||
|
|
||||||
* Good, because the IETF/W3C standard adopted across the industry.
|
- Good, because the IETF/W3C standard adopted across the industry.
|
||||||
* Good, because covered by every OTel SDK and by intermediate proxies (Envoy, NGINX, AWS ALB, etc.).
|
- Good, because covered by every OTel SDK and by intermediate proxies (Envoy, NGINX, AWS ALB, etc.).
|
||||||
|
|
||||||
#### Custom `X-Correlation-ID`
|
#### Custom `X-Correlation-ID`
|
||||||
|
|
||||||
* Bad, because reinvents what already exists; loses the parent-child relation that `traceparent` carries; doesn't cooperate with auto-instrumentation.
|
- Bad, because reinvents what already exists; loses the parent-child relation that `traceparent` carries; doesn't cooperate with auto-instrumentation.
|
||||||
|
|
||||||
#### Vendor formats (B3, Datadog, Jaeger)
|
#### Vendor formats (B3, Datadog, Jaeger)
|
||||||
|
|
||||||
* Bad, because vendor-specific. OTel can be configured to emit them as a fallback if a specific intermediate requires them, but they are not the default.
|
- Bad, because vendor-specific. OTel can be configured to emit them as a fallback if a specific intermediate requires them, but they are not the default.
|
||||||
|
|
||||||
### Request-scoped context
|
### Request-scoped context
|
||||||
|
|
||||||
#### `nestjs-cls` (chosen)
|
#### `nestjs-cls` (chosen)
|
||||||
|
|
||||||
* Good, because purpose-built for NestJS, integrates cleanly with interceptors, guards, and the logger.
|
- Good, because purpose-built for NestJS, integrates cleanly with interceptors, guards, and the logger.
|
||||||
* Good, because minimal boilerplate at the call sites.
|
- Good, because minimal boilerplate at the call sites.
|
||||||
|
|
||||||
#### Manual passing
|
#### Manual passing
|
||||||
|
|
||||||
* Bad, because every service signature acquires a context parameter — relentless ceremony, easy to forget.
|
- Bad, because every service signature acquires a context parameter — relentless ceremony, easy to forget.
|
||||||
|
|
||||||
#### `AsyncLocalStorage` directly
|
#### `AsyncLocalStorage` directly
|
||||||
|
|
||||||
* Good, because no extra dependency.
|
- Good, because no extra dependency.
|
||||||
* Bad, because we'd reinvent the request-bind plumbing already provided by `nestjs-cls`.
|
- Bad, because we'd reinvent the request-bind plumbing already provided by `nestjs-cls`.
|
||||||
|
|
||||||
### Sampling
|
### Sampling
|
||||||
|
|
||||||
#### Always-on (chosen, v1)
|
#### Always-on (chosen, v1)
|
||||||
|
|
||||||
* Good, because end-to-end debuggability with no surprises.
|
- Good, because end-to-end debuggability with no surprises.
|
||||||
* Bad, because storage cost grows linearly with volume.
|
- Bad, because storage cost grows linearly with volume.
|
||||||
|
|
||||||
#### Probabilistic head sampling
|
#### Probabilistic head sampling
|
||||||
|
|
||||||
* Good, because cheap.
|
- Good, because cheap.
|
||||||
* Bad, because we lose traces of rare events — exactly the ones we need for incident analysis. Tail sampling at the collector is strictly better.
|
- Bad, because we lose traces of rare events — exactly the ones we need for incident analysis. Tail sampling at the collector is strictly better.
|
||||||
|
|
||||||
### Emission and shipping
|
### Emission and shipping
|
||||||
|
|
||||||
#### stdout + collector (chosen)
|
#### stdout + collector (chosen)
|
||||||
|
|
||||||
* Good, because 12-factor; the application is decoupled from the backend; rotating backends is an ops change, not a deploy.
|
- Good, because 12-factor; the application is decoupled from the backend; rotating backends is an ops change, not a deploy.
|
||||||
* Good, because the collector handles batching, retries, sampling, and protocol translation.
|
- Good, because the collector handles batching, retries, sampling, and protocol translation.
|
||||||
|
|
||||||
#### Direct shipping
|
#### Direct shipping
|
||||||
|
|
||||||
* Bad, because backend coupled to the application; outage of the backend impacts the app's emission path; multi-tenant credentials end up in the app config.
|
- Bad, because backend coupled to the application; outage of the backend impacts the app's emission path; multi-tenant credentials end up in the app config.
|
||||||
|
|
||||||
### Sensitive data handling
|
### Sensitive data handling
|
||||||
|
|
||||||
#### Pino `redact` allowlist (chosen)
|
#### Pino `redact` allowlist (chosen)
|
||||||
|
|
||||||
* Good, because explicit, reviewable, testable.
|
- Good, because explicit, reviewable, testable.
|
||||||
* Bad, because a new sensitive shape requires a PR — accepted.
|
- Bad, because a new sensitive shape requires a PR — accepted.
|
||||||
|
|
||||||
#### Hand-rolled scrubbing
|
#### Hand-rolled scrubbing
|
||||||
|
|
||||||
* Bad, because per-call boilerplate; easy to forget; inconsistent.
|
- Bad, because per-call boilerplate; easy to forget; inconsistent.
|
||||||
|
|
||||||
#### No redaction
|
#### No redaction
|
||||||
|
|
||||||
* Bad, because a single accidental log of an `Authorization` header turns into a security incident.
|
- Bad, because a single accidental log of an `Authorization` header turns into a security incident.
|
||||||
|
|
||||||
### Frontend tracing
|
### Frontend tracing
|
||||||
|
|
||||||
#### OTel-Web in the SPA (chosen)
|
#### OTel-Web in the SPA (chosen)
|
||||||
|
|
||||||
* Good, because the trace starts where the user clicks — first segment of every story is captured.
|
- Good, because the trace starts where the user clicks — first segment of every story is captured.
|
||||||
* Good, because the same `trace_id` shown to the user is the one used by support — no translation.
|
- Good, because the same `trace_id` shown to the user is the one used by support — no translation.
|
||||||
* Bad, because adds a small bundle to the SPA; quantifiable, accepted.
|
- Bad, because adds a small bundle to the SPA; quantifiable, accepted.
|
||||||
|
|
||||||
#### Front out of the trace
|
#### Front out of the trace
|
||||||
|
|
||||||
* Bad, because every trace begins one hop too late, and "front was slow" becomes uninvestigable.
|
- Bad, because every trace begins one hop too late, and "front was slow" becomes uninvestigable.
|
||||||
|
|
||||||
## More Information
|
## More Information
|
||||||
|
|
||||||
* OpenTelemetry: https://opentelemetry.io/
|
- OpenTelemetry: https://opentelemetry.io/
|
||||||
* OpenTelemetry for Node.js: https://opentelemetry.io/docs/languages/js/
|
- OpenTelemetry for Node.js: https://opentelemetry.io/docs/languages/js/
|
||||||
* OpenTelemetry for Web: https://opentelemetry.io/docs/languages/js/getting-started/browser/
|
- OpenTelemetry for Web: https://opentelemetry.io/docs/languages/js/getting-started/browser/
|
||||||
* W3C Trace Context: https://www.w3.org/TR/trace-context/
|
- W3C Trace Context: https://www.w3.org/TR/trace-context/
|
||||||
* Pino: https://github.com/pinojs/pino
|
- Pino: https://github.com/pinojs/pino
|
||||||
* `nestjs-pino`: https://github.com/iamolegga/nestjs-pino
|
- `nestjs-pino`: https://github.com/iamolegga/nestjs-pino
|
||||||
* `nestjs-cls`: https://github.com/Papooch/nestjs-cls
|
- `nestjs-cls`: https://github.com/Papooch/nestjs-cls
|
||||||
* OWASP Logging Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html
|
- OWASP Logging Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html
|
||||||
* Related ADRs: [ADR-0005](0005-backend-stack-nestjs.md) (NestJS), [ADR-0006](0006-persistence-postgresql-prisma.md) (Postgres + Prisma — auto-instrumented), [ADR-0010](0010-session-management-redis.md) (Redis — auto-instrumented), and the future ADRs for audit trail (separate stream, same `trace_id`) and on-prem infrastructure (collector deployment, backend choice, retention, tail sampling).
|
- Related ADRs: [ADR-0005](0005-backend-stack-nestjs.md) (NestJS), [ADR-0006](0006-persistence-postgresql-prisma.md) (Postgres + Prisma — auto-instrumented), [ADR-0010](0010-session-management-redis.md) (Redis — auto-instrumented), and the future ADRs for audit trail (separate stream, same `trace_id`) and on-prem infrastructure (collector deployment, backend choice, retention, tail sampling).
|
||||||
+88
-83
@@ -11,46 +11,51 @@ tags: [security, observability, data]
|
|||||||
|
|
||||||
[ADR-0012](0012-observability-pino-opentelemetry.md) fixed the application observability stack: structured logs and traces, all carrying the same `trace_id`, optimised for debugging and operations. Application logs are noisy, broadly accessible, and have a relatively short retention window — appropriate for ops, inappropriate for security forensics or compliance evidence.
|
[ADR-0012](0012-observability-pino-opentelemetry.md) fixed the application observability stack: structured logs and traces, all carrying the same `trace_id`, optimised for debugging and operations. Application logs are noisy, broadly accessible, and have a relatively short retention window — appropriate for ops, inappropriate for security forensics or compliance evidence.
|
||||||
|
|
||||||
Audit logs serve a different purpose: they record *security-relevant* events authoritatively, must survive years, must not be alterable by anyone (including operators), and must be queryable by auditors with *different* access controls than developers. Mixing the two streams is the recurring failure mode that turns an audit log into noise — or worse, lets a developer accidentally `UPDATE` a row.
|
Audit logs serve a different purpose: they record _security-relevant_ events authoritatively, must survive years, must not be alterable by anyone (including operators), and must be queryable by auditors with _different_ access controls than developers. Mixing the two streams is the recurring failure mode that turns an audit log into noise — or worse, lets a developer accidentally `UPDATE` a row.
|
||||||
|
|
||||||
We need a fixed answer to: which events are recorded, where they are stored, who can read them, who can write them, who can never delete or modify them, how long they are kept, and how they relate to the application observability stream.
|
We need a fixed answer to: which events are recorded, where they are stored, who can read them, who can write them, who can never delete or modify them, how long they are kept, and how they relate to the application observability stream.
|
||||||
|
|
||||||
## Decision Drivers
|
## Decision Drivers
|
||||||
|
|
||||||
* Compliance posture: an audit log that can be tampered with is no audit log.
|
- Compliance posture: an audit log that can be tampered with is no audit log.
|
||||||
* Strict separation of concern from application logs — different sink, different access, different retention, different volume profile.
|
- Strict separation of concern from application logs — different sink, different access, different retention, different volume profile.
|
||||||
* Forensic queryability: an investigator must be able to reconstruct the timeline of any user's interactions with the system.
|
- Forensic queryability: an investigator must be able to reconstruct the timeline of any user's interactions with the system.
|
||||||
* Cross-reference with the app observability stream by `trace_id` so an audit event in isolation can be enriched with the surrounding app log.
|
- Cross-reference with the app observability stream by `trace_id` so an audit event in isolation can be enriched with the surrounding app log.
|
||||||
* Avoid premature complexity: full cryptographic chaining and WORM storage are not required to start, but the path to them must remain open.
|
- Avoid premature complexity: full cryptographic chaining and WORM storage are not required to start, but the path to them must remain open.
|
||||||
* Performance: per-event INSERT must not be in the critical latency path of unrelated operations.
|
- Performance: per-event INSERT must not be in the critical latency path of unrelated operations.
|
||||||
|
|
||||||
## Considered Options
|
## Considered Options
|
||||||
|
|
||||||
### Sink
|
### Sink
|
||||||
* **Dedicated `audit` schema in the same PostgreSQL instance as business data.** (Chosen.)
|
|
||||||
* Dedicated PostgreSQL instance for audit only.
|
- **Dedicated `audit` schema in the same PostgreSQL instance as business data.** (Chosen.)
|
||||||
* Same Pino logger as app logs, with a different category.
|
- Dedicated PostgreSQL instance for audit only.
|
||||||
* Push to a SIEM (Splunk, Wazuh, Graylog, etc.).
|
- Same Pino logger as app logs, with a different category.
|
||||||
* Append-only file with rotation.
|
- Push to a SIEM (Splunk, Wazuh, Graylog, etc.).
|
||||||
|
- Append-only file with rotation.
|
||||||
|
|
||||||
### Writer
|
### Writer
|
||||||
* **Direct Prisma INSERT, in the same transaction as the business action when applicable.** (Chosen.)
|
|
||||||
* Background queue (Redis Streams, RabbitMQ, etc.) decoupled from the request path.
|
- **Direct Prisma INSERT, in the same transaction as the business action when applicable.** (Chosen.)
|
||||||
* Pino transport extension.
|
- Background queue (Redis Streams, RabbitMQ, etc.) decoupled from the request path.
|
||||||
|
- Pino transport extension.
|
||||||
|
|
||||||
### Tamper evidence
|
### Tamper evidence
|
||||||
* **Append-only at the database role level (REVOKE UPDATE / DELETE / TRUNCATE on the writer and on every other role except a dedicated archiver).** (Chosen for v1.)
|
|
||||||
* Cryptographic chaining (each row carries a hash linking it to the previous row) — deferred unless compliance demands.
|
- **Append-only at the database role level (REVOKE UPDATE / DELETE / TRUNCATE on the writer and on every other role except a dedicated archiver).** (Chosen for v1.)
|
||||||
* WORM-capable storage at the infrastructure layer — deferred to future infrastructure ADR if needed.
|
- Cryptographic chaining (each row carries a hash linking it to the previous row) — deferred unless compliance demands.
|
||||||
|
- WORM-capable storage at the infrastructure layer — deferred to future infrastructure ADR if needed.
|
||||||
|
|
||||||
### Failure semantics
|
### Failure semantics
|
||||||
* **Audit failures are blocking — if the audit INSERT fails for an in-scope event, the operation fails.** (Chosen.)
|
|
||||||
* Best-effort — audit failures are logged but the operation succeeds.
|
- **Audit failures are blocking — if the audit INSERT fails for an in-scope event, the operation fails.** (Chosen.)
|
||||||
|
- Best-effort — audit failures are logged but the operation succeeds.
|
||||||
|
|
||||||
### Retention
|
### Retention
|
||||||
* **365 days default, override via env per environment.** (Chosen.)
|
|
||||||
* Indefinite (until a cleanup is requested manually).
|
- **365 days default, override via env per environment.** (Chosen.)
|
||||||
* Aligned with a specific regulation (HDS, PCI, ISO 27001 — to be revisited if and when an applicable framework is identified).
|
- Indefinite (until a cleanup is requested manually).
|
||||||
|
- Aligned with a specific regulation (HDS, PCI, ISO 27001 — to be revisited if and when an applicable framework is identified).
|
||||||
|
|
||||||
## Decision Outcome
|
## Decision Outcome
|
||||||
|
|
||||||
@@ -93,7 +98,7 @@ enum AuditOutcome {
|
|||||||
**Events emitted in v1.**
|
**Events emitted in v1.**
|
||||||
|
|
||||||
| `event_type` | When | `outcome` |
|
| `event_type` | When | `outcome` |
|
||||||
| --- | --- | --- |
|
| ------------------------------ | -------------------------------------------------------------------------------------------------------------------------- | --------- |
|
||||||
| `auth.sign_in` | OIDC callback creates a session | `success` |
|
| `auth.sign_in` | OIDC callback creates a session | `success` |
|
||||||
| `auth.sign_in.failed` | OIDC callback rejects (state mismatch, signature, `iss` not allowlisted, MFA assertion missing, audience misclassified, …) | `failure` |
|
| `auth.sign_in.failed` | OIDC callback rejects (state mismatch, signature, `iss` not allowlisted, MFA assertion missing, audience misclassified, …) | `failure` |
|
||||||
| `auth.sign_out` | `POST /auth/logout` invalidates a session | `success` |
|
| `auth.sign_out` | `POST /auth/logout` invalidates a session | `success` |
|
||||||
@@ -103,14 +108,14 @@ enum AuditOutcome {
|
|||||||
| `auth.mfa.assertion.failed` | the BFF rejects a session for missing or weak `amr` (ADR-0011) | `failure` |
|
| `auth.mfa.assertion.failed` | the BFF rejects a session for missing or weak `amr` (ADR-0011) | `failure` |
|
||||||
| `authz.deny` | a guard rejects an authenticated request because the user's `audience`/claims don't authorise the action | `failure` |
|
| `authz.deny` | a guard rejects an authenticated request because the user's `audience`/claims don't authorise the action | `failure` |
|
||||||
|
|
||||||
The `details` JSONB field carries event-specific information (e.g. expected vs received `iss`, denied route, claim names involved). Sensitive material (full tokens, claims that should never leave the BFF) is *never* placed in `details` — the same redaction posture as app logs applies, enforced by typed event payloads at the writer's boundary.
|
The `details` JSONB field carries event-specific information (e.g. expected vs received `iss`, denied route, claim names involved). Sensitive material (full tokens, claims that should never leave the BFF) is _never_ placed in `details` — the same redaction posture as app logs applies, enforced by typed event payloads at the writer's boundary.
|
||||||
|
|
||||||
Hooks for **admin actions** and **sensitive data access** are designed-in: the writer accepts those event families today, but no v1 caller emits them. They are exercised by unit tests so the path stays alive.
|
Hooks for **admin actions** and **sensitive data access** are designed-in: the writer accepts those event families today, but no v1 caller emits them. They are exercised by unit tests so the path stays alive.
|
||||||
|
|
||||||
**Decoupling and cross-reference.** Audit events do not flow through the Pino app logger. They flow through `AuditService` directly to Postgres. Each event carries the `trace_id` from the request-scoped CLS (ADR-0012); an investigator joining audit and app log streams on `trace_id` can reconstruct the full timeline of a request. The two streams have:
|
**Decoupling and cross-reference.** Audit events do not flow through the Pino app logger. They flow through `AuditService` directly to Postgres. Each event carries the `trace_id` from the request-scoped CLS (ADR-0012); an investigator joining audit and app log streams on `trace_id` can reconstruct the full timeline of a request. The two streams have:
|
||||||
|
|
||||||
| Aspect | App logs | Audit log |
|
| Aspect | App logs | Audit log |
|
||||||
| --- | --- | --- |
|
| ---------------- | ---------------------------------------------------------- | ---------------------------------------------- |
|
||||||
| Sink | stdout → collector → on-prem backend | PostgreSQL `audit.events` |
|
| Sink | stdout → collector → on-prem backend | PostgreSQL `audit.events` |
|
||||||
| Volume | high (every request, every internal step) | low (security-relevant events only) |
|
| Volume | high (every request, every internal step) | low (security-relevant events only) |
|
||||||
| Retention | short (operational, weeks–months) | long (≥ 365 days, env-driven) |
|
| Retention | short (operational, weeks–months) | long (≥ 365 days, env-driven) |
|
||||||
@@ -124,12 +129,12 @@ Hooks for **admin actions** and **sensitive data access** are designed-in: the w
|
|||||||
|
|
||||||
> **Compliance note.** The 365-day default is engineering-prudent, not legally derived. Specific regulations applicable to the host organisation (RGPD/GDPR retention principles, sectoral rules — health, finance — if any) may demand longer or shorter windows, or full archival before deletion. The retention default is a starting point. The legal review is owed by the organisation; this ADR does not pretend to settle it.
|
> **Compliance note.** The 365-day default is engineering-prudent, not legally derived. Specific regulations applicable to the host organisation (RGPD/GDPR retention principles, sectoral rules — health, finance — if any) may demand longer or shorter windows, or full archival before deletion. The retention default is a starting point. The legal review is owed by the organisation; this ADR does not pretend to settle it.
|
||||||
|
|
||||||
**GDPR / right-of-erasure** interactions: audit events are typically retained under a "legal obligation" or "legitimate interest" basis even after a user's right-of-erasure request. The userId itself is already pseudonymised (`actor_id_hash`); the join key to user identity exists in the live DB only and disappears with account deletion, leaving the audit row scientifically pseudonymous. Whether this is legally sufficient is the legal team's call. The current implementation is compatible with both "keep" and "anonymise further" policies (we can null `actor_id_hash` for archived users without violating append-only because *zeroing* counts as a sanctioned operation under a yet-to-define `audit_redactor` role — designed-in, not implemented in v1).
|
**GDPR / right-of-erasure** interactions: audit events are typically retained under a "legal obligation" or "legitimate interest" basis even after a user's right-of-erasure request. The userId itself is already pseudonymised (`actor_id_hash`); the join key to user identity exists in the live DB only and disappears with account deletion, leaving the audit row scientifically pseudonymous. Whether this is legally sufficient is the legal team's call. The current implementation is compatible with both "keep" and "anonymise further" policies (we can null `actor_id_hash` for archived users without violating append-only because _zeroing_ counts as a sanctioned operation under a yet-to-define `audit_redactor` role — designed-in, not implemented in v1).
|
||||||
|
|
||||||
**Configuration (env-driven).**
|
**Configuration (env-driven).**
|
||||||
|
|
||||||
| Variable | Purpose |
|
| Variable | Purpose |
|
||||||
| --- | --- |
|
| ----------------------------- | ------------------------------------------------------------------------------------------------------- |
|
||||||
| `AUDIT_DATABASE_URL` | Postgres connection for the BFF as `audit_writer` (separate creds, different from the business DB role) |
|
| `AUDIT_DATABASE_URL` | Postgres connection for the BFF as `audit_writer` (separate creds, different from the business DB role) |
|
||||||
| `AUDIT_RETENTION_DAYS` | default `365`; the BFF refuses to start with a value below `30` |
|
| `AUDIT_RETENTION_DAYS` | default `365`; the BFF refuses to start with a value below `30` |
|
||||||
| `AUDIT_ARCHIVER_DATABASE_URL` | connection for the archiver job as `audit_archiver` (used by the scheduled task only) |
|
| `AUDIT_ARCHIVER_DATABASE_URL` | connection for the archiver job as `audit_archiver` (used by the scheduled task only) |
|
||||||
@@ -137,28 +142,28 @@ Hooks for **admin actions** and **sensitive data access** are designed-in: the w
|
|||||||
|
|
||||||
### Consequences
|
### Consequences
|
||||||
|
|
||||||
* Good, because audit log integrity rests on Postgres role grants — operationally enforced by the database, not by application discipline.
|
- Good, because audit log integrity rests on Postgres role grants — operationally enforced by the database, not by application discipline.
|
||||||
* Good, because the `trace_id` makes audit events instantly cross-referenceable with the rich app log, without coupling the two streams.
|
- Good, because the `trace_id` makes audit events instantly cross-referenceable with the rich app log, without coupling the two streams.
|
||||||
* Good, because the dedicated schema and roles let a future SOC team be granted `audit_reader` without giving them anything else.
|
- Good, because the dedicated schema and roles let a future SOC team be granted `audit_reader` without giving them anything else.
|
||||||
* Good, because keeping the audit DB on the same instance avoids the operational doubling of running two Postgres clusters; the schema/role separation gives most of the isolation benefits for a fraction of the cost.
|
- Good, because keeping the audit DB on the same instance avoids the operational doubling of running two Postgres clusters; the schema/role separation gives most of the isolation benefits for a fraction of the cost.
|
||||||
* Good, because the retention default is conservative and overridable, so adapting to a stricter regulatory regime is a config change, not a refactor.
|
- Good, because the retention default is conservative and overridable, so adapting to a stricter regulatory regime is a config change, not a refactor.
|
||||||
* Good, because hooks for admin actions and sensitive data access are in place but inert — adding such an event family in a future module is one writer call.
|
- Good, because hooks for admin actions and sensitive data access are in place but inert — adding such an event family in a future module is one writer call.
|
||||||
* Bad, because blocking on audit writes means the audit DB is part of the trust path; an audit DB incident degrades the application. Mitigated by HA in prod, made explicit here so it is owned, not surprising.
|
- Bad, because blocking on audit writes means the audit DB is part of the trust path; an audit DB incident degrades the application. Mitigated by HA in prod, made explicit here so it is owned, not surprising.
|
||||||
* Bad, because true tamper evidence in the cryptographic sense (chained hashes, signed entries) is not in v1. A privileged Postgres operator with both credentials and intent could in principle bypass the role grants. Mitigated by access discipline; full chaining deferred unless compliance requires it.
|
- Bad, because true tamper evidence in the cryptographic sense (chained hashes, signed entries) is not in v1. A privileged Postgres operator with both credentials and intent could in principle bypass the role grants. Mitigated by access discipline; full chaining deferred unless compliance requires it.
|
||||||
* Bad, because retention purges via `pg_cron` or a Nest task are themselves an operational item — they must be monitored, alerted on failure, and audited (via a meta-event).
|
- Bad, because retention purges via `pg_cron` or a Nest task are themselves an operational item — they must be monitored, alerted on failure, and audited (via a meta-event).
|
||||||
* Bad, because GDPR right-of-erasure interactions are *acknowledged* but not *resolved* here — the legal review is owed; the architecture allows either policy.
|
- Bad, because GDPR right-of-erasure interactions are _acknowledged_ but not _resolved_ here — the legal review is owed; the architecture allows either policy.
|
||||||
* Neutral, because a dedicated audit Postgres instance can be substituted for the schema without code change (just `AUDIT_DATABASE_URL` pointed elsewhere). The trade-off can be revisited.
|
- Neutral, because a dedicated audit Postgres instance can be substituted for the schema without code change (just `AUDIT_DATABASE_URL` pointed elsewhere). The trade-off can be revisited.
|
||||||
|
|
||||||
### Confirmation
|
### Confirmation
|
||||||
|
|
||||||
* `apps/portal-bff/prisma/schema.prisma` enables `multiSchema` and declares the `audit` schema; the `AuditEvent` model and `AuditOutcome` enum live in it.
|
- `apps/portal-bff/prisma/schema.prisma` enables `multiSchema` and declares the `audit` schema; the `AuditEvent` model and `AuditOutcome` enum live in it.
|
||||||
* Three Postgres roles exist: `audit_writer` (INSERT only on `audit.events`), `audit_reader` (SELECT only), `audit_archiver` (DELETE only, on rows older than the configured retention). The schema migration includes the explicit `REVOKE UPDATE, DELETE, TRUNCATE FROM PUBLIC` on `audit.events` and the targeted grants.
|
- Three Postgres roles exist: `audit_writer` (INSERT only on `audit.events`), `audit_reader` (SELECT only), `audit_archiver` (DELETE only, on rows older than the configured retention). The schema migration includes the explicit `REVOKE UPDATE, DELETE, TRUNCATE FROM PUBLIC` on `audit.events` and the targeted grants.
|
||||||
* `apps/portal-bff/src/audit/audit.module.ts` provides an `AuditService` with one typed method per event family (`signIn`, `signInFailed`, `signOut`, `sessionExpired`, `sessionRevoked`, `tokenValidationFailed`, `mfaAssertionFailed`, `authzDeny`, plus dormant `adminAction` and `sensitiveDataAccess`).
|
- `apps/portal-bff/src/audit/audit.module.ts` provides an `AuditService` with one typed method per event family (`signIn`, `signInFailed`, `signOut`, `sessionExpired`, `sessionRevoked`, `tokenValidationFailed`, `mfaAssertionFailed`, `authzDeny`, plus dormant `adminAction` and `sensitiveDataAccess`).
|
||||||
* `AuditService` connects via `AUDIT_DATABASE_URL` as `audit_writer`; a startup probe asserts that the role can `INSERT` and cannot `UPDATE` (a deliberate failing UPDATE during boot is rejected, the BFF starts; if it succeeds, the BFF refuses to start).
|
- `AuditService` connects via `AUDIT_DATABASE_URL` as `audit_writer`; a startup probe asserts that the role can `INSERT` and cannot `UPDATE` (a deliberate failing UPDATE during boot is rejected, the BFF starts; if it succeeds, the BFF refuses to start).
|
||||||
* The auth controller, token-validation interceptor, MFA guard, sessions module, and authorization guards each call `AuditService` on the relevant outcomes. Tests assert one audit row per event.
|
- The auth controller, token-validation interceptor, MFA guard, sessions module, and authorization guards each call `AuditService` on the relevant outcomes. Tests assert one audit row per event.
|
||||||
* The retention purge runs daily; failure raises an alert and emits `audit.retention.purge` with `outcome = failure`.
|
- The retention purge runs daily; failure raises an alert and emits `audit.retention.purge` with `outcome = failure`.
|
||||||
* The same `LOG_USER_ID_SALT` is used by app logs and by audit; an integration test asserts that the same user produces the same `actor_id_hash` in both streams.
|
- The same `LOG_USER_ID_SALT` is used by app logs and by audit; an integration test asserts that the same user produces the same `actor_id_hash` in both streams.
|
||||||
* `AUDIT_RETENTION_DAYS < 30` and missing required env vars prevent BFF startup.
|
- `AUDIT_RETENTION_DAYS < 30` and missing required env vars prevent BFF startup.
|
||||||
|
|
||||||
## Pros and Cons of the Options
|
## Pros and Cons of the Options
|
||||||
|
|
||||||
@@ -166,95 +171,95 @@ Hooks for **admin actions** and **sensitive data access** are designed-in: the w
|
|||||||
|
|
||||||
#### Dedicated `audit` schema in the same Postgres (chosen)
|
#### Dedicated `audit` schema in the same Postgres (chosen)
|
||||||
|
|
||||||
* Good, because shared infrastructure with the business DB (HA, backup, monitoring) yet logically isolated by schema and role.
|
- Good, because shared infrastructure with the business DB (HA, backup, monitoring) yet logically isolated by schema and role.
|
||||||
* Good, because transactional INSERT alongside a business action is trivial.
|
- Good, because transactional INSERT alongside a business action is trivial.
|
||||||
* Bad, because not a hard physical isolation; a Postgres-level compromise reaches both schemas. Mitigated by role separation.
|
- Bad, because not a hard physical isolation; a Postgres-level compromise reaches both schemas. Mitigated by role separation.
|
||||||
|
|
||||||
#### Dedicated Postgres instance for audit only
|
#### Dedicated Postgres instance for audit only
|
||||||
|
|
||||||
* Good, because hard physical isolation, separate backups, separate access keys.
|
- Good, because hard physical isolation, separate backups, separate access keys.
|
||||||
* Bad, because doubled operational surface (HA cluster, backup, monitoring, restoration drills) for a v1 yield that does not justify it.
|
- Bad, because doubled operational surface (HA cluster, backup, monitoring, restoration drills) for a v1 yield that does not justify it.
|
||||||
|
|
||||||
#### Same Pino logger with a different category
|
#### Same Pino logger with a different category
|
||||||
|
|
||||||
* Bad, because the audit stream inherits the access controls of the app log stream — no separation. Bricolage.
|
- Bad, because the audit stream inherits the access controls of the app log stream — no separation. Bricolage.
|
||||||
|
|
||||||
#### Push to a SIEM
|
#### Push to a SIEM
|
||||||
|
|
||||||
* Good, because aligned with how mature SOC operations consume audit data.
|
- Good, because aligned with how mature SOC operations consume audit data.
|
||||||
* Bad, because we do not have a SIEM yet; a future SIEM can ingest from the audit schema (via CDC or scheduled export) without touching application code.
|
- Bad, because we do not have a SIEM yet; a future SIEM can ingest from the audit schema (via CDC or scheduled export) without touching application code.
|
||||||
|
|
||||||
#### Append-only file with rotation
|
#### Append-only file with rotation
|
||||||
|
|
||||||
* Bad, because no concurrent-safe writes, no queryability, fragile ops, hard to enforce immutability across rotations.
|
- Bad, because no concurrent-safe writes, no queryability, fragile ops, hard to enforce immutability across rotations.
|
||||||
|
|
||||||
### Writer
|
### Writer
|
||||||
|
|
||||||
#### Direct Prisma INSERT, transactional with the business action (chosen)
|
#### Direct Prisma INSERT, transactional with the business action (chosen)
|
||||||
|
|
||||||
* Good, because either both succeed or both fail — no orphan business rows missing their audit trail.
|
- Good, because either both succeed or both fail — no orphan business rows missing their audit trail.
|
||||||
* Good, because no extra moving part to operate.
|
- Good, because no extra moving part to operate.
|
||||||
* Bad, because the audit DB is in the critical path of the request. Already discussed under failure semantics.
|
- Bad, because the audit DB is in the critical path of the request. Already discussed under failure semantics.
|
||||||
|
|
||||||
#### Background queue
|
#### Background queue
|
||||||
|
|
||||||
* Good, because the request path is decoupled from the audit DB.
|
- Good, because the request path is decoupled from the audit DB.
|
||||||
* Bad, because an event sitting in a queue when the queue dies is a missing audit event. Loud failure modes are harder to design and audit.
|
- Bad, because an event sitting in a queue when the queue dies is a missing audit event. Loud failure modes are harder to design and audit.
|
||||||
* Bad, because adds a queue (Redis Streams, RabbitMQ) to the v1 surface — out of proportion.
|
- Bad, because adds a queue (Redis Streams, RabbitMQ) to the v1 surface — out of proportion.
|
||||||
|
|
||||||
#### Pino transport extension
|
#### Pino transport extension
|
||||||
|
|
||||||
* Bad, because piggybacks audit on the app-log path — exactly the coupling we are trying to prevent.
|
- Bad, because piggybacks audit on the app-log path — exactly the coupling we are trying to prevent.
|
||||||
|
|
||||||
### Tamper evidence
|
### Tamper evidence
|
||||||
|
|
||||||
#### Append-only at the role level (chosen)
|
#### Append-only at the role level (chosen)
|
||||||
|
|
||||||
* Good, because enforced by Postgres, not by application discipline.
|
- Good, because enforced by Postgres, not by application discipline.
|
||||||
* Bad, because a privileged DB operator can in principle override grants. The credentials and the intent must coincide — this is the standard enterprise trust model.
|
- Bad, because a privileged DB operator can in principle override grants. The credentials and the intent must coincide — this is the standard enterprise trust model.
|
||||||
|
|
||||||
#### Cryptographic chaining
|
#### Cryptographic chaining
|
||||||
|
|
||||||
* Good, because tamper-evident even against the DB operator.
|
- Good, because tamper-evident even against the DB operator.
|
||||||
* Bad, because adds complexity to inserts (hashing the previous row), to schema migrations, and to range queries. Re-evaluated when a compliance regime mandates it.
|
- Bad, because adds complexity to inserts (hashing the previous row), to schema migrations, and to range queries. Re-evaluated when a compliance regime mandates it.
|
||||||
|
|
||||||
#### WORM storage
|
#### WORM storage
|
||||||
|
|
||||||
* Good, because true immutability at the storage layer.
|
- Good, because true immutability at the storage layer.
|
||||||
* Bad, because infrastructure-level decision deferred to the on-prem ADR.
|
- Bad, because infrastructure-level decision deferred to the on-prem ADR.
|
||||||
|
|
||||||
### Failure semantics
|
### Failure semantics
|
||||||
|
|
||||||
#### Blocking (chosen)
|
#### Blocking (chosen)
|
||||||
|
|
||||||
* Good, because security-positive: no audit means no action.
|
- Good, because security-positive: no audit means no action.
|
||||||
* Bad, because audit DB is part of the request trust path.
|
- Bad, because audit DB is part of the request trust path.
|
||||||
|
|
||||||
#### Best-effort
|
#### Best-effort
|
||||||
|
|
||||||
* Good, because the application keeps running through audit-DB incidents.
|
- Good, because the application keeps running through audit-DB incidents.
|
||||||
* Bad, because a sufficiently long incident creates audit gaps that compliance frameworks treat as evidence of negligence.
|
- Bad, because a sufficiently long incident creates audit gaps that compliance frameworks treat as evidence of negligence.
|
||||||
|
|
||||||
### Retention
|
### Retention
|
||||||
|
|
||||||
#### 365 days, env-overridable (chosen)
|
#### 365 days, env-overridable (chosen)
|
||||||
|
|
||||||
* Good, because conservative starting point; legally adjustable later.
|
- Good, because conservative starting point; legally adjustable later.
|
||||||
* Bad, because the engineering default is not the legal default; the legal review remains owed.
|
- Bad, because the engineering default is not the legal default; the legal review remains owed.
|
||||||
|
|
||||||
#### Indefinite
|
#### Indefinite
|
||||||
|
|
||||||
* Bad, because GDPR principles favour proportionality; unbounded retention is hard to defend without a specific basis.
|
- Bad, because GDPR principles favour proportionality; unbounded retention is hard to defend without a specific basis.
|
||||||
|
|
||||||
#### Tied to a specific regulation
|
#### Tied to a specific regulation
|
||||||
|
|
||||||
* Bad, because we don't know yet which regulation applies. To be revisited when the org clarifies.
|
- Bad, because we don't know yet which regulation applies. To be revisited when the org clarifies.
|
||||||
|
|
||||||
## More Information
|
## More Information
|
||||||
|
|
||||||
* OWASP Logging Cheat Sheet — Audit logging section: https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html
|
- OWASP Logging Cheat Sheet — Audit logging section: https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html
|
||||||
* PostgreSQL roles, GRANT, REVOKE: https://www.postgresql.org/docs/current/sql-grant.html
|
- PostgreSQL roles, GRANT, REVOKE: https://www.postgresql.org/docs/current/sql-grant.html
|
||||||
* PostgreSQL multiSchema with Prisma: https://www.prisma.io/docs/orm/prisma-schema/data-model/multi-schema
|
- PostgreSQL multiSchema with Prisma: https://www.prisma.io/docs/orm/prisma-schema/data-model/multi-schema
|
||||||
* `pg_cron`: https://github.com/citusdata/pg_cron
|
- `pg_cron`: https://github.com/citusdata/pg_cron
|
||||||
* GDPR/RGPD on retention and pseudonymisation: https://gdpr-info.eu/art-5-gdpr/
|
- GDPR/RGPD on retention and pseudonymisation: https://gdpr-info.eu/art-5-gdpr/
|
||||||
* Related ADRs: [ADR-0006](0006-persistence-postgresql-prisma.md) (Postgres + Prisma), [ADR-0008](0008-identity-model-entra-workforce-dual-audience.md) (audience), [ADR-0009](0009-auth-flow-oidc-pkce-msal-node.md) (auth flow events), [ADR-0010](0010-session-management-redis.md) (session lifecycle events), [ADR-0011](0011-mfa-enforcement-entra-conditional-access.md) (MFA events), [ADR-0012](0012-observability-pino-opentelemetry.md) (`trace_id`, `LOG_USER_ID_SALT` shared); future: infrastructure ADR (HA Postgres, backup, possibly WORM), legal review of retention, optional SIEM integration ADR, optional cryptographic-chaining ADR if compliance demands it.
|
- Related ADRs: [ADR-0006](0006-persistence-postgresql-prisma.md) (Postgres + Prisma), [ADR-0008](0008-identity-model-entra-workforce-dual-audience.md) (audience), [ADR-0009](0009-auth-flow-oidc-pkce-msal-node.md) (auth flow events), [ADR-0010](0010-session-management-redis.md) (session lifecycle events), [ADR-0011](0011-mfa-enforcement-entra-conditional-access.md) (MFA events), [ADR-0012](0012-observability-pino-opentelemetry.md) (`trace_id`, `LOG_USER_ID_SALT` shared); future: infrastructure ADR (HA Postgres, backup, possibly WORM), legal review of retention, optional SIEM integration ADR, optional cryptographic-chaining ADR if compliance demands it.
|
||||||
+83
-74
@@ -15,38 +15,43 @@ The concrete list of downstream services is not known yet (the project lead defe
|
|||||||
|
|
||||||
## Decision Drivers
|
## Decision Drivers
|
||||||
|
|
||||||
* No token ever leaves the BFF except through the legitimate downstream-call path.
|
- 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.
|
- 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.
|
- 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)).
|
- 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.
|
- 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.
|
- 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".
|
- 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
|
## Considered Options
|
||||||
|
|
||||||
### Authentication strategy
|
### 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.)
|
- **OBO (On-Behalf-Of) for Entra-protected downstreams.** (Chosen as default.)
|
||||||
* Token relay (the BFF passes the user's access token unchanged).
|
- **Service credential + signed user-assertion header for non-Entra downstreams.** (Chosen as fallback.)
|
||||||
* Per-user credential mapping (the BFF stores each user's credentials for each legacy downstream).
|
- 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
|
### HTTP client
|
||||||
* **`@nestjs/axios`.** (Chosen.)
|
|
||||||
* `got`, `undici`, native `fetch`.
|
- **`@nestjs/axios`.** (Chosen.)
|
||||||
|
- `got`, `undici`, native `fetch`.
|
||||||
|
|
||||||
### Resilience
|
### Resilience
|
||||||
* **`cockatiel`** — TypeScript-native composable policies (timeout, retry, circuit breaker, bulkhead). (Chosen.)
|
|
||||||
* `p-retry` / `opossum` / hand-rolled.
|
- **`cockatiel`** — TypeScript-native composable policies (timeout, retry, circuit breaker, bulkhead). (Chosen.)
|
||||||
|
- `p-retry` / `opossum` / hand-rolled.
|
||||||
|
|
||||||
### Token cache (for OBO)
|
### Token cache (for OBO)
|
||||||
* **Redis with AES-256-GCM, dedicated key.** (Chosen.)
|
|
||||||
* In-memory only.
|
- **Redis with AES-256-GCM, dedicated key.** (Chosen.)
|
||||||
* No cache.
|
- In-memory only.
|
||||||
|
- No cache.
|
||||||
|
|
||||||
### Service registration
|
### Service registration
|
||||||
* **Module-level typed config.** (Chosen.)
|
|
||||||
* Decorator-based registration.
|
- **Module-level typed config.** (Chosen.)
|
||||||
|
- Decorator-based registration.
|
||||||
|
|
||||||
## Decision Outcome
|
## Decision Outcome
|
||||||
|
|
||||||
@@ -58,7 +63,11 @@ export type DownstreamApiConfig = {
|
|||||||
baseUrl: string; // env-driven
|
baseUrl: string; // env-driven
|
||||||
auth:
|
auth:
|
||||||
| { strategy: 'obo'; resource: string; scopes: string[] }
|
| { strategy: 'obo'; resource: string; scopes: string[] }
|
||||||
| { strategy: 'service'; credential: ServiceCredential; userAssertion: SignedAssertionConfig | null }
|
| {
|
||||||
|
strategy: 'service';
|
||||||
|
credential: ServiceCredential;
|
||||||
|
userAssertion: SignedAssertionConfig | null;
|
||||||
|
}
|
||||||
| { strategy: 'none' };
|
| { strategy: 'none' };
|
||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
retry: { attempts: number; baseDelayMs: number; maxDelayMs: number; jitter: boolean };
|
retry: { attempts: number; baseDelayMs: number; maxDelayMs: number; jitter: boolean };
|
||||||
@@ -76,7 +85,7 @@ On cache miss or imminent expiry, the BFF calls Entra's OBO endpoint and re-cach
|
|||||||
|
|
||||||
### Service strategy (non-Entra downstreams)
|
### 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:
|
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
|
```jsonc
|
||||||
{
|
{
|
||||||
@@ -114,7 +123,7 @@ Per-user credential mapping is **rejected for v1**. If a downstream cannot be se
|
|||||||
|
|
||||||
### Audience pre-check
|
### 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.
|
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
|
### Observability
|
||||||
|
|
||||||
@@ -126,12 +135,12 @@ Downstream errors are translated to BFF errors at the client boundary — **neve
|
|||||||
|
|
||||||
### Per-integration ADRs
|
### 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*.
|
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)
|
### Configuration (env-driven)
|
||||||
|
|
||||||
| Variable | Purpose |
|
| Variable | Purpose |
|
||||||
| --- | --- |
|
| ---------------------------------------------- | ----------------------------------------------------------------------------------- |
|
||||||
| `OBO_CACHE_ENCRYPTION_KEY` | 32-byte base64 AES-GCM key for the OBO token cache; refused at startup if malformed |
|
| `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_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` |
|
| `BFF_JWKS_KID` | key id published in `/.well-known/jwks.json` |
|
||||||
@@ -140,27 +149,27 @@ A downstream that requires a non-default auth strategy, a custom retry policy, a
|
|||||||
|
|
||||||
### Consequences
|
### 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 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 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 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 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 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.
|
- 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 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 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 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.
|
- 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
|
### Confirmation
|
||||||
|
|
||||||
* `apps/portal-bff/src/downstream/downstream.module.ts` exposes `DownstreamApiClientFactory`.
|
- `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/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.
|
- `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.
|
- `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.
|
- 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.
|
- 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.
|
- 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.)
|
- 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
|
## Pros and Cons of the Options
|
||||||
|
|
||||||
@@ -168,81 +177,81 @@ A downstream that requires a non-default auth strategy, a custom retry policy, a
|
|||||||
|
|
||||||
#### OBO for Entra (chosen default)
|
#### 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 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.
|
- Good, because preserves the user's audience, claims, and tenancy in the downstream token.
|
||||||
* Bad, because requires Entra-protected downstreams.
|
- Bad, because requires Entra-protected downstreams.
|
||||||
|
|
||||||
#### Service credential + signed user-assertion (chosen for non-Entra)
|
#### 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 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.
|
- 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).
|
- Bad, because requires per-integration trust setup (publishing JWKS, having the downstream verify).
|
||||||
|
|
||||||
#### Token relay
|
#### Token relay
|
||||||
|
|
||||||
* Good, because zero exchange.
|
- Good, because zero exchange.
|
||||||
* Bad, because only works if the downstream and the BFF share the exact same audience/resource. Non-portable.
|
- 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.
|
- Bad, because a leaky downstream that logs Authorization headers leaks the user's BFF-scoped token.
|
||||||
|
|
||||||
#### Per-user credential mapping
|
#### Per-user credential mapping
|
||||||
|
|
||||||
* Bad, because operationally toxic — storing user passwords or tokens for legacy systems, violating user secrecy. Rejected for v1.
|
- Bad, because operationally toxic — storing user passwords or tokens for legacy systems, violating user secrecy. Rejected for v1.
|
||||||
|
|
||||||
### HTTP client
|
### HTTP client
|
||||||
|
|
||||||
#### `@nestjs/axios` (chosen)
|
#### `@nestjs/axios` (chosen)
|
||||||
|
|
||||||
* Good, because mainstream, integrates with NestJS DI, easy to wrap with interceptors and OTel.
|
- Good, because mainstream, integrates with NestJS DI, easy to wrap with interceptors and OTel.
|
||||||
|
|
||||||
#### `got` / `undici` / native `fetch`
|
#### `got` / `undici` / native `fetch`
|
||||||
|
|
||||||
* Good, because lighter or more modern.
|
- Good, because lighter or more modern.
|
||||||
* Bad, because no NestJS-blessed wrapper; we'd reinvent the surface for marginal gain.
|
- Bad, because no NestJS-blessed wrapper; we'd reinvent the surface for marginal gain.
|
||||||
|
|
||||||
### Resilience
|
### Resilience
|
||||||
|
|
||||||
#### `cockatiel` (chosen)
|
#### `cockatiel` (chosen)
|
||||||
|
|
||||||
* Good, because TypeScript-native, composable policies, well-maintained.
|
- Good, because TypeScript-native, composable policies, well-maintained.
|
||||||
* Good, because exposes timeout, retry, circuit breaker, bulkhead, fallback as first-class concepts.
|
- Good, because exposes timeout, retry, circuit breaker, bulkhead, fallback as first-class concepts.
|
||||||
|
|
||||||
#### `p-retry` / `opossum`
|
#### `p-retry` / `opossum`
|
||||||
|
|
||||||
* Good, because simpler.
|
- Good, because simpler.
|
||||||
* Bad, because narrower (one concern each) — composing them into a coherent policy stack reproduces what `cockatiel` already gives.
|
- Bad, because narrower (one concern each) — composing them into a coherent policy stack reproduces what `cockatiel` already gives.
|
||||||
|
|
||||||
### Token cache
|
### Token cache
|
||||||
|
|
||||||
#### Redis encrypted (chosen)
|
#### Redis encrypted (chosen)
|
||||||
|
|
||||||
* Good, because shared across BFF replicas, survives restart, encrypted at rest.
|
- 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.
|
- Bad, because adds a Redis round-trip per OBO acquisition. Negligible vs. an Entra round-trip.
|
||||||
|
|
||||||
#### In-memory only
|
#### In-memory only
|
||||||
|
|
||||||
* Bad, because per-replica caches duplicate work and break cache hit-rate under scale-out.
|
- Bad, because per-replica caches duplicate work and break cache hit-rate under scale-out.
|
||||||
|
|
||||||
#### No cache
|
#### No cache
|
||||||
|
|
||||||
* Bad, because hits Entra rate limits and adds 100–300 ms per downstream call.
|
- Bad, because hits Entra rate limits and adds 100–300 ms per downstream call.
|
||||||
|
|
||||||
### Service registration
|
### Service registration
|
||||||
|
|
||||||
#### Typed config (chosen)
|
#### Typed config (chosen)
|
||||||
|
|
||||||
* Good, because explicit, reviewable, IDE-discoverable.
|
- Good, because explicit, reviewable, IDE-discoverable.
|
||||||
* Bad, because every new service requires a config commit. Acceptable.
|
- Bad, because every new service requires a config commit. Acceptable.
|
||||||
|
|
||||||
#### Decorator-based
|
#### Decorator-based
|
||||||
|
|
||||||
* Good, because feels Nest-native.
|
- Good, because feels Nest-native.
|
||||||
* Bad, because hides registration in source — harder to grep, harder to audit. Defer.
|
- Bad, because hides registration in source — harder to grep, harder to audit. Defer.
|
||||||
|
|
||||||
## More Information
|
## More Information
|
||||||
|
|
||||||
* Microsoft On-Behalf-Of flow: https://learn.microsoft.com/entra/identity-platform/v2-oauth2-on-behalf-of-flow
|
- 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
|
- MSAL Node `acquireTokenOnBehalfOf`: https://learn.microsoft.com/javascript/api/@azure/msal-node/confidentialclientapplication
|
||||||
* `@nestjs/axios`: https://docs.nestjs.com/techniques/http-module
|
- `@nestjs/axios`: https://docs.nestjs.com/techniques/http-module
|
||||||
* `cockatiel`: https://github.com/connor4312/cockatiel
|
- `cockatiel`: https://github.com/connor4312/cockatiel
|
||||||
* RFC 7517 (JWKS): https://datatracker.ietf.org/doc/html/rfc7517
|
- 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).
|
- 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).
|
||||||
@@ -14,55 +14,63 @@ The repository currently lives on Gitea (`gitea@git.unespace.com:julien/apf_port
|
|||||||
We also need to fix the branch model, the merge strategy, the required gates, the protection rules on `main`, and the location of the orchestration logic — all of these have first-order effects on the Nx scaffold (`package.json` scripts, lint configs, branch tags, conventional-commits validation surface).
|
We also need to fix the branch model, the merge strategy, the required gates, the protection rules on `main`, and the location of the orchestration logic — all of these have first-order effects on the Nx scaffold (`package.json` scripts, lint configs, branch tags, conventional-commits validation surface).
|
||||||
|
|
||||||
This ADR is split into two levels of decision:
|
This ADR is split into two levels of decision:
|
||||||
|
|
||||||
- **Level 1 — vendor-neutral.** Survives the future GitLab migration unchanged.
|
- **Level 1 — vendor-neutral.** Survives the future GitLab migration unchanged.
|
||||||
- **Level 2 — Gitea Actions implementation.** Will be rewritten when GitLab is adopted; a future ADR will supersede the level-2 sections without affecting level-1.
|
- **Level 2 — Gitea Actions implementation.** Will be rewritten when GitLab is adopted; a future ADR will supersede the level-2 sections without affecting level-1.
|
||||||
|
|
||||||
## Decision Drivers
|
## Decision Drivers
|
||||||
|
|
||||||
* Pipeline portability across CI vendors (Gitea now, GitLab later, possibly other platforms in the future).
|
- Pipeline portability across CI vendors (Gitea now, GitLab later, possibly other platforms in the future).
|
||||||
* All gates blocking — no "warnings ignored". Either we fail or we adjust the threshold via ADR.
|
- All gates blocking — no "warnings ignored". Either we fail or we adjust the threshold via ADR.
|
||||||
* Trunk-based development for fast feedback and continuous integration discipline.
|
- Trunk-based development for fast feedback and continuous integration discipline.
|
||||||
* Compatibility with the Nx monorepo's `affected` model and remote cache.
|
- Compatibility with the Nx monorepo's `affected` model and remote cache.
|
||||||
* Self-hosted runners (on-prem context — see ADR-0008's hosting constraint).
|
- Self-hosted runners (on-prem context — see ADR-0008's hosting constraint).
|
||||||
* Mature, mainstream tooling — anti-bricolage applies particularly here.
|
- Mature, mainstream tooling — anti-bricolage applies particularly here.
|
||||||
* Local enforcement (Husky + lint-staged + commitlint, ADR-0007) plus CI defense in depth — the same checks run twice, on purpose.
|
- Local enforcement (Husky + lint-staged + commitlint, ADR-0007) plus CI defense in depth — the same checks run twice, on purpose.
|
||||||
|
|
||||||
## Considered Options
|
## Considered Options
|
||||||
|
|
||||||
### Branch / merge strategy (level 1)
|
### Branch / merge strategy (level 1)
|
||||||
* **Trunk-based + squash-merge.** (Chosen.)
|
|
||||||
* Trunk-based + rebase-merge.
|
- **Trunk-based + squash-merge.** (Chosen.)
|
||||||
* Trunk-based + merge commit.
|
- Trunk-based + rebase-merge.
|
||||||
* GitFlow.
|
- Trunk-based + merge commit.
|
||||||
|
- GitFlow.
|
||||||
|
|
||||||
### Required reviewer count on PRs to `main` (level 1)
|
### Required reviewer count on PRs to `main` (level 1)
|
||||||
* **0 in v1, ≥1 once a second active contributor exists.** (Chosen.)
|
|
||||||
* 1 always (blocks if solo).
|
- **0 in v1, ≥1 once a second active contributor exists.** (Chosen.)
|
||||||
* 2 (heavy for a small team).
|
- 1 always (blocks if solo).
|
||||||
|
- 2 (heavy for a small team).
|
||||||
|
|
||||||
### Signed commits (level 1)
|
### Signed commits (level 1)
|
||||||
* **Recommended but not required in v1; reconsidered at the GitLab migration ADR.** (Chosen.)
|
|
||||||
* Required in v1.
|
- **Recommended but not required in v1; reconsidered at the GitLab migration ADR.** (Chosen.)
|
||||||
* Never.
|
- Required in v1.
|
||||||
|
- Never.
|
||||||
|
|
||||||
### Conventional Commits validation (level 1)
|
### Conventional Commits validation (level 1)
|
||||||
* **Local `commit-msg` hook (ADR-0007) + CI defense-in-depth on the PR commit range.** (Chosen.)
|
|
||||||
* Local hook only.
|
- **Local `commit-msg` hook (ADR-0007) + CI defense-in-depth on the PR commit range.** (Chosen.)
|
||||||
* CI only.
|
- Local hook only.
|
||||||
|
- CI only.
|
||||||
|
|
||||||
### Pipeline orchestration logic location (level 1)
|
### Pipeline orchestration logic location (level 1)
|
||||||
* **Thin YAML — logic lives in `package.json` scripts and Nx targets, the workflow file orchestrates.** (Chosen.)
|
|
||||||
* Logic in YAML, scripts called step by step.
|
- **Thin YAML — logic lives in `package.json` scripts and Nx targets, the workflow file orchestrates.** (Chosen.)
|
||||||
|
- Logic in YAML, scripts called step by step.
|
||||||
|
|
||||||
### CI engine (level 2 — Gitea-specific)
|
### CI engine (level 2 — Gitea-specific)
|
||||||
* **Gitea Actions** (built-in since Gitea 1.19, GitHub Actions-compatible YAML). (Chosen.)
|
|
||||||
* Drone CI alongside Gitea.
|
- **Gitea Actions** (built-in since Gitea 1.19, GitHub Actions-compatible YAML). (Chosen.)
|
||||||
* Concourse / Tekton / Buildkite / etc.
|
- Drone CI alongside Gitea.
|
||||||
|
- Concourse / Tekton / Buildkite / etc.
|
||||||
|
|
||||||
### Runner topology (level 2)
|
### Runner topology (level 2)
|
||||||
* **≥ 3 self-hosted `act_runner` instances on-prem.** (Chosen.)
|
|
||||||
* Single runner.
|
- **≥ 3 self-hosted `act_runner` instances on-prem.** (Chosen.)
|
||||||
* Cloud-hosted (rejected — on-prem constraint).
|
- Single runner.
|
||||||
|
- Cloud-hosted (rejected — on-prem constraint).
|
||||||
|
|
||||||
## Decision Outcome
|
## Decision Outcome
|
||||||
|
|
||||||
@@ -73,6 +81,7 @@ This ADR is split into two levels of decision:
|
|||||||
**Merge strategy.** Squash-merge only. The squash subject is the PR title and must be a valid Conventional Commits message; the squash body inherits the PR body. This produces a clean linear history on `main` where each commit corresponds 1:1 to a PR. Rebase-merge and merge-commit are disabled at the platform level.
|
**Merge strategy.** Squash-merge only. The squash subject is the PR title and must be a valid Conventional Commits message; the squash body inherits the PR body. This produces a clean linear history on `main` where each commit corresponds 1:1 to a PR. Rebase-merge and merge-commit are disabled at the platform level.
|
||||||
|
|
||||||
**Branch protection on `main`:**
|
**Branch protection on `main`:**
|
||||||
|
|
||||||
- direct push: forbidden (no exceptions, including the project lead);
|
- direct push: forbidden (no exceptions, including the project lead);
|
||||||
- force push: forbidden;
|
- force push: forbidden;
|
||||||
- linear history: required (consistent with squash-merge);
|
- linear history: required (consistent with squash-merge);
|
||||||
@@ -84,7 +93,7 @@ This ADR is split into two levels of decision:
|
|||||||
**Required CI gates** (every gate is blocking; failing any blocks the merge):
|
**Required CI gates** (every gate is blocking; failing any blocks the merge):
|
||||||
|
|
||||||
| Gate | What it runs | Tooling |
|
| Gate | What it runs | Tooling |
|
||||||
| --- | --- | --- |
|
| ------------- | -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
|
||||||
| `format` | Prettier check, no auto-fix | `prettier --check` via `pnpm nx format:check` |
|
| `format` | Prettier check, no auto-fix | `prettier --check` via `pnpm nx format:check` |
|
||||||
| `lint` | ESLint across affected projects, including `@nx/enforce-module-boundaries` | `pnpm nx affected -t lint` |
|
| `lint` | ESLint across affected projects, including `@nx/enforce-module-boundaries` | `pnpm nx affected -t lint` |
|
||||||
| `type-check` | TypeScript strict, no emit | `pnpm nx affected -t type-check` |
|
| `type-check` | TypeScript strict, no emit | `pnpm nx affected -t type-check` |
|
||||||
@@ -100,7 +109,7 @@ This ADR is split into two levels of decision:
|
|||||||
|
|
||||||
**Signed commits.** Recommended but not required in v1. Setup overhead for contributors (GPG or SSH signing key) is non-trivial relative to the marginal value with a single contributor and a well-controlled host. The decision is revisited as part of the GitLab migration ADR (GitLab has stronger built-in tooling for centralised signing policies than current Gitea).
|
**Signed commits.** Recommended but not required in v1. Setup overhead for contributors (GPG or SSH signing key) is non-trivial relative to the marginal value with a single contributor and a well-controlled host. The decision is revisited as part of the GitLab migration ADR (GitLab has stronger built-in tooling for centralised signing policies than current Gitea).
|
||||||
|
|
||||||
**Logic location — the "thin YAML" pattern.** All non-trivial CI logic lives in `package.json` scripts and Nx targets, *not* in the workflow YAML. The YAML's role is restricted to: checkout, runtime setup, cache restoration, and calling a single high-level script per job. Concretely:
|
**Logic location — the "thin YAML" pattern.** All non-trivial CI logic lives in `package.json` scripts and Nx targets, _not_ in the workflow YAML. The YAML's role is restricted to: checkout, runtime setup, cache restoration, and calling a single high-level script per job. Concretely:
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
// package.json (excerpt — lands with the scaffold)
|
// package.json (excerpt — lands with the scaffold)
|
||||||
@@ -114,12 +123,14 @@ This ADR is split into two levels of decision:
|
|||||||
The migration to GitLab then becomes a rewrite of the YAML wrappers (a few dozen lines) and not a re-derivation of the gates — these scripts are platform-agnostic, runnable locally, and serve as the source of truth.
|
The migration to GitLab then becomes a rewrite of the YAML wrappers (a few dozen lines) and not a re-derivation of the gates — these scripts are platform-agnostic, runnable locally, and serve as the source of truth.
|
||||||
|
|
||||||
**Caching.** Two cache surfaces, both portable:
|
**Caching.** Two cache surfaces, both portable:
|
||||||
|
|
||||||
- the `pnpm` store, keyed on `pnpm-lock.yaml`;
|
- the `pnpm` store, keyed on `pnpm-lock.yaml`;
|
||||||
- the Nx local cache (`.nx/cache`), keyed on the project graph.
|
- the Nx local cache (`.nx/cache`), keyed on the project graph.
|
||||||
|
|
||||||
The level-2 implementation wires both via the CI vendor's cache action; the level-1 contract is "these two paths must be cached".
|
The level-2 implementation wires both via the CI vendor's cache action; the level-1 contract is "these two paths must be cached".
|
||||||
|
|
||||||
**Secrets policy.**
|
**Secrets policy.**
|
||||||
|
|
||||||
- Secrets live exclusively in CI vendor variables (Gitea → GitLab later). Naming convention: `SCOPE_PURPOSE` (e.g. `BFF_DATABASE_URL`, `OBO_CACHE_ENCRYPTION_KEY`).
|
- Secrets live exclusively in CI vendor variables (Gitea → GitLab later). Naming convention: `SCOPE_PURPOSE` (e.g. `BFF_DATABASE_URL`, `OBO_CACHE_ENCRYPTION_KEY`).
|
||||||
- No secret ever in source. `gitleaks` enforces.
|
- No secret ever in source. `gitleaks` enforces.
|
||||||
- Rotation procedures, key vault, and operator runbooks belong in the future operations / secret-management ADR.
|
- Rotation procedures, key vault, and operator runbooks belong in the future operations / secret-management ADR.
|
||||||
@@ -133,6 +144,7 @@ The level-2 implementation wires both via the CI vendor's cache action; the leve
|
|||||||
**Runners.** Three self-hosted `act_runner` instances on internal infrastructure. The first runner is deployed to validate the pipeline; the second and third are added before the project hits any non-trivial PR volume. Runners are labelled `self-hosted`, `on-prem`, plus capacity labels (`size:default`) for future job differentiation. Runner image baseline: a Debian image (aligned with the WSL development environment) pinned by SHA and rebuilt on a cadence by a security-scheduled job.
|
**Runners.** Three self-hosted `act_runner` instances on internal infrastructure. The first runner is deployed to validate the pipeline; the second and third are added before the project hits any non-trivial PR volume. Runners are labelled `self-hosted`, `on-prem`, plus capacity labels (`size:default`) for future job differentiation. Runner image baseline: a Debian image (aligned with the WSL development environment) pinned by SHA and rebuilt on a cadence by a security-scheduled job.
|
||||||
|
|
||||||
**Workflow file structure.**
|
**Workflow file structure.**
|
||||||
|
|
||||||
- `.gitea/workflows/ci.yml` — runs on `pull_request` and `push` to `main`. Hosts the `check`, `scan`, and `commits` jobs.
|
- `.gitea/workflows/ci.yml` — runs on `pull_request` and `push` to `main`. Hosts the `check`, `scan`, and `commits` jobs.
|
||||||
- `.gitea/workflows/release.yml` — runs on `push` of a `vX.Y.Z` tag. Builds release artefacts. Empty stub in v1; gains content when the on-prem deploy ADR lands.
|
- `.gitea/workflows/release.yml` — runs on `push` of a `vX.Y.Z` tag. Builds release artefacts. Empty stub in v1; gains content when the on-prem deploy ADR lands.
|
||||||
- `.gitea/workflows/security-scheduled.yml` — runs weekly via `schedule:` cron. Re-runs Trivy and gitleaks on the full tree (not just affected), and triggers Renovate (configuration covered by the security baseline ADR).
|
- `.gitea/workflows/security-scheduled.yml` — runs weekly via `schedule:` cron. Re-runs Trivy and gitleaks on the full tree (not just affected), and triggers Renovate (configuration covered by the security baseline ADR).
|
||||||
@@ -187,6 +199,7 @@ jobs:
|
|||||||
This is a sketch, not the final file — the final lands with the scaffold and any iteration on it is not by itself an ADR-worthy change.
|
This is a sketch, not the final file — the final lands with the scaffold and any iteration on it is not by itself an ADR-worthy change.
|
||||||
|
|
||||||
**Branch protection (Gitea-side configuration):** `Settings → Branches → Add rule` on `main`:
|
**Branch protection (Gitea-side configuration):** `Settings → Branches → Add rule` on `main`:
|
||||||
|
|
||||||
- Disable force push: yes;
|
- Disable force push: yes;
|
||||||
- Disable direct push: yes (only PR merge);
|
- Disable direct push: yes (only PR merge);
|
||||||
- Required status checks: `check`, `scan`, `commits`, plus future `a11y`, `perf`;
|
- Required status checks: `check`, `scan`, `commits`, plus future `a11y`, `perf`;
|
||||||
@@ -198,7 +211,7 @@ This is a sketch, not the final file — the final lands with the scaffold and a
|
|||||||
|
|
||||||
When the GitLab migration happens (6–18-month horizon), a new ADR will be written that **supersedes only the level-2 sections** of this ADR. The level-1 decisions stand unchanged. Concretely the migration touches:
|
When the GitLab migration happens (6–18-month horizon), a new ADR will be written that **supersedes only the level-2 sections** of this ADR. The level-1 decisions stand unchanged. Concretely the migration touches:
|
||||||
|
|
||||||
- `.gitea/workflows/*.yml` → `.gitlab-ci.yml` (rewrite — the *gates* are the same, the *DSL* is different). Estimate: 1–2 days.
|
- `.gitea/workflows/*.yml` → `.gitlab-ci.yml` (rewrite — the _gates_ are the same, the _DSL_ is different). Estimate: 1–2 days.
|
||||||
- Self-hosted `act_runner` → GitLab Runner. Estimate: 0.5–1 day.
|
- Self-hosted `act_runner` → GitLab Runner. Estimate: 0.5–1 day.
|
||||||
- Gitea branch protection rules → GitLab merge request approval rules. Same concepts, different UI. Estimate: a few hours.
|
- Gitea branch protection rules → GitLab merge request approval rules. Same concepts, different UI. Estimate: a few hours.
|
||||||
- Secrets re-creation in GitLab CI/CD variables. Same naming convention, copy values. Estimate: a few hours.
|
- Secrets re-creation in GitLab CI/CD variables. Same naming convention, copy values. Estimate: a few hours.
|
||||||
@@ -207,27 +220,27 @@ The `package.json` scripts (`ci:check`, `ci:scan`, `ci:commits`), the Nx workspa
|
|||||||
|
|
||||||
### Consequences
|
### Consequences
|
||||||
|
|
||||||
* Good, because the gates are written once and run twice (locally via hooks, in CI as defense). Drift is impossible without breaking both layers.
|
- Good, because the gates are written once and run twice (locally via hooks, in CI as defense). Drift is impossible without breaking both layers.
|
||||||
* Good, because thin YAML keeps CI reproducible locally — anyone can run `pnpm ci:check` to mirror what the runner does.
|
- Good, because thin YAML keeps CI reproducible locally — anyone can run `pnpm ci:check` to mirror what the runner does.
|
||||||
* Good, because Gitea Actions' GHA-compatible syntax doubles as a hedge: the same YAML can land on GitHub Actions if the org's plans change again.
|
- Good, because Gitea Actions' GHA-compatible syntax doubles as a hedge: the same YAML can land on GitHub Actions if the org's plans change again.
|
||||||
* Good, because squash-merge produces a Conventional-Commits-only history on `main` — clean changelog generation, predictable release notes, smooth semver inference.
|
- Good, because squash-merge produces a Conventional-Commits-only history on `main` — clean changelog generation, predictable release notes, smooth semver inference.
|
||||||
* Good, because branch protection is enforced at the platform level, not at convention level.
|
- Good, because branch protection is enforced at the platform level, not at convention level.
|
||||||
* Good, because the migration weight is bounded and transparent: ~3–5 days of dev/ops work, no code rewrite.
|
- Good, because the migration weight is bounded and transparent: ~3–5 days of dev/ops work, no code rewrite.
|
||||||
* Bad, because the level-2 sections will need to be rewritten at the GitLab migration. This is the explicit trade-off; it is accepted because the alternatives (going GitLab now without the existing tooling, or staying CI-less until GitLab) cost more.
|
- Bad, because the level-2 sections will need to be rewritten at the GitLab migration. This is the explicit trade-off; it is accepted because the alternatives (going GitLab now without the existing tooling, or staying CI-less until GitLab) cost more.
|
||||||
* Bad, because trunk-based with required-reviewers=0 in v1 leaves the project lead as the sole gatekeeper. Mitigated by mandating green CI (which the lead cannot bypass without rewriting branch protection — a deliberate, audit-visible action).
|
- Bad, because trunk-based with required-reviewers=0 in v1 leaves the project lead as the sole gatekeeper. Mitigated by mandating green CI (which the lead cannot bypass without rewriting branch protection — a deliberate, audit-visible action).
|
||||||
* Bad, because `act_runner` is younger than GitLab Runner; expect occasional rough edges, especially around large action ecosystems. Mitigated by pinning third-party actions by SHA and cadence-rebuilding the runner image.
|
- Bad, because `act_runner` is younger than GitLab Runner; expect occasional rough edges, especially around large action ecosystems. Mitigated by pinning third-party actions by SHA and cadence-rebuilding the runner image.
|
||||||
* Bad, because deferring signed commits to v2 means the v1 history won't carry attribution-grade signatures. Reasonable for a small team; revisit at GitLab migration.
|
- Bad, because deferring signed commits to v2 means the v1 history won't carry attribution-grade signatures. Reasonable for a small team; revisit at GitLab migration.
|
||||||
|
|
||||||
### Confirmation
|
### Confirmation
|
||||||
|
|
||||||
* `.gitea/workflows/ci.yml`, `release.yml`, `security-scheduled.yml` exist with the structure above. The `release.yml` may be a stub until the deploy ADR lands.
|
- `.gitea/workflows/ci.yml`, `release.yml`, `security-scheduled.yml` exist with the structure above. The `release.yml` may be a stub until the deploy ADR lands.
|
||||||
* `package.json` exposes `ci:check`, `ci:scan`, `ci:commits` scripts. Each is runnable locally and produces the same exit code as the CI job.
|
- `package.json` exposes `ci:check`, `ci:scan`, `ci:commits` scripts. Each is runnable locally and produces the same exit code as the CI job.
|
||||||
* Gitea branch protection on `main` has the rules above; configuration is documented in `docs/operations/branch-protection.md` (created with the scaffold).
|
- Gitea branch protection on `main` has the rules above; configuration is documented in `docs/operations/branch-protection.md` (created with the scaffold).
|
||||||
* `Trivy`, `gitleaks`, and `commitlint` are in `devDependencies` (or available as actions pinned by SHA — both acceptable).
|
- `Trivy`, `gitleaks`, and `commitlint` are in `devDependencies` (or available as actions pinned by SHA — both acceptable).
|
||||||
* At least three `act_runner` instances are registered to the org; their bootstrap and update procedure live in an operations doc.
|
- At least three `act_runner` instances are registered to the org; their bootstrap and update procedure live in an operations doc.
|
||||||
* CI exits non-zero on any gate failure; no gate is `continue-on-error: true`.
|
- CI exits non-zero on any gate failure; no gate is `continue-on-error: true`.
|
||||||
* Runner images are pinned by SHA in the workflows. The `security-scheduled.yml` job rebuilds the runner image weekly and reports to the security audit feed.
|
- Runner images are pinned by SHA in the workflows. The `security-scheduled.yml` job rebuilds the runner image weekly and reports to the security audit feed.
|
||||||
* A future migration ADR (GitLab) explicitly references this ADR, supersedes only level 2, and inherits level 1.
|
- A future migration ADR (GitLab) explicitly references this ADR, supersedes only level 2, and inherits level 1.
|
||||||
|
|
||||||
## Pros and Cons of the Options
|
## Pros and Cons of the Options
|
||||||
|
|
||||||
@@ -235,78 +248,78 @@ The `package.json` scripts (`ci:check`, `ci:scan`, `ci:commits`), the Nx workspa
|
|||||||
|
|
||||||
#### Trunk-based + squash-merge (chosen)
|
#### Trunk-based + squash-merge (chosen)
|
||||||
|
|
||||||
* Good, because clean linear history on `main`, one squash commit per PR.
|
- Good, because clean linear history on `main`, one squash commit per PR.
|
||||||
* Good, because Conventional Commits + squash-merge yields a directly machine-readable changelog.
|
- Good, because Conventional Commits + squash-merge yields a directly machine-readable changelog.
|
||||||
* Good, because feature branches stay short-lived — pressure against long-lived branches becomes structural, not cultural.
|
- Good, because feature branches stay short-lived — pressure against long-lived branches becomes structural, not cultural.
|
||||||
* Bad, because contributors lose granular commit history on the merged branch (the squash collapses it). Mitigated: the PR retains the full history for review purposes.
|
- Bad, because contributors lose granular commit history on the merged branch (the squash collapses it). Mitigated: the PR retains the full history for review purposes.
|
||||||
|
|
||||||
#### Trunk-based + rebase-merge
|
#### Trunk-based + rebase-merge
|
||||||
|
|
||||||
* Good, because preserves individual commits without merge bubbles.
|
- Good, because preserves individual commits without merge bubbles.
|
||||||
* Bad, because contributors must groom every commit to be CI-clean (each commit must compile and pass tests if we want a clean bisect history). High discipline cost; squash-merge gets most of the benefit at lower cost.
|
- Bad, because contributors must groom every commit to be CI-clean (each commit must compile and pass tests if we want a clean bisect history). High discipline cost; squash-merge gets most of the benefit at lower cost.
|
||||||
|
|
||||||
#### Trunk-based + merge commit
|
#### Trunk-based + merge commit
|
||||||
|
|
||||||
* Good, because preserves the full history, including the branch topology.
|
- Good, because preserves the full history, including the branch topology.
|
||||||
* Bad, because produces messy merge bubbles on `main`; conflicts with the "linear history" branch protection.
|
- Bad, because produces messy merge bubbles on `main`; conflicts with the "linear history" branch protection.
|
||||||
|
|
||||||
#### GitFlow
|
#### GitFlow
|
||||||
|
|
||||||
* Good, because release branches isolate stabilisation.
|
- Good, because release branches isolate stabilisation.
|
||||||
* Bad, because heavy for a continuously deployable monorepo; redundant with semver tags on a trunk-based main; introduces the "long-lived `develop` branch" anti-pattern.
|
- Bad, because heavy for a continuously deployable monorepo; redundant with semver tags on a trunk-based main; introduces the "long-lived `develop` branch" anti-pattern.
|
||||||
|
|
||||||
### CI engine (level 2)
|
### CI engine (level 2)
|
||||||
|
|
||||||
#### Gitea Actions (chosen)
|
#### Gitea Actions (chosen)
|
||||||
|
|
||||||
* Good, because built-in to Gitea, no extra deployment.
|
- Good, because built-in to Gitea, no extra deployment.
|
||||||
* Good, because GitHub Actions syntax means transferable skills and a partial portability hedge.
|
- Good, because GitHub Actions syntax means transferable skills and a partial portability hedge.
|
||||||
* Good, because actively developed by the Gitea team.
|
- Good, because actively developed by the Gitea team.
|
||||||
* Bad, because younger ecosystem than GitLab CI or GitHub Actions proper — expect occasional rough edges.
|
- Bad, because younger ecosystem than GitLab CI or GitHub Actions proper — expect occasional rough edges.
|
||||||
|
|
||||||
#### Drone CI
|
#### Drone CI
|
||||||
|
|
||||||
* Good, because mature, lean, opinionated.
|
- Good, because mature, lean, opinionated.
|
||||||
* Bad, because separate deployment and operational surface; YAML is Drone-specific (less portable than GHA-compatible Gitea Actions).
|
- Bad, because separate deployment and operational surface; YAML is Drone-specific (less portable than GHA-compatible Gitea Actions).
|
||||||
|
|
||||||
#### Concourse / Tekton / Buildkite
|
#### Concourse / Tekton / Buildkite
|
||||||
|
|
||||||
* Good, because powerful for complex pipelines.
|
- Good, because powerful for complex pipelines.
|
||||||
* Bad, because over-engineered for the v1 scope, and orthogonal to the Gitea/GitLab decision axis.
|
- Bad, because over-engineered for the v1 scope, and orthogonal to the Gitea/GitLab decision axis.
|
||||||
|
|
||||||
### Required reviewer count
|
### Required reviewer count
|
||||||
|
|
||||||
#### 0 in v1, raise later (chosen)
|
#### 0 in v1, raise later (chosen)
|
||||||
|
|
||||||
* Good, because doesn't block solo development.
|
- Good, because doesn't block solo development.
|
||||||
* Good, because the green-CI requirement still prevents the project lead from merging broken code without effort.
|
- Good, because the green-CI requirement still prevents the project lead from merging broken code without effort.
|
||||||
* Bad, because relies on the project lead's discipline (and CI's correctness) for code quality.
|
- Bad, because relies on the project lead's discipline (and CI's correctness) for code quality.
|
||||||
|
|
||||||
#### 1 always
|
#### 1 always
|
||||||
|
|
||||||
* Good, because rigour.
|
- Good, because rigour.
|
||||||
* Bad, because blocks if there's only one contributor — would force the project lead to merge their own PRs by overriding protection, which is the opposite of the intended behaviour.
|
- Bad, because blocks if there's only one contributor — would force the project lead to merge their own PRs by overriding protection, which is the opposite of the intended behaviour.
|
||||||
|
|
||||||
### Signed commits
|
### Signed commits
|
||||||
|
|
||||||
#### Optional in v1, revisited at GitLab migration (chosen)
|
#### Optional in v1, revisited at GitLab migration (chosen)
|
||||||
|
|
||||||
* Good, because no setup overhead for contributors during a phase where the priority is shipping the structural ADRs and scaffolding.
|
- Good, because no setup overhead for contributors during a phase where the priority is shipping the structural ADRs and scaffolding.
|
||||||
* Bad, because v1 history carries no attribution-grade signatures. Acceptable: the host (Gitea) records the user identity on each commit.
|
- Bad, because v1 history carries no attribution-grade signatures. Acceptable: the host (Gitea) records the user identity on each commit.
|
||||||
|
|
||||||
#### Required in v1
|
#### Required in v1
|
||||||
|
|
||||||
* Good, because rigorous attribution from day one.
|
- Good, because rigorous attribution from day one.
|
||||||
* Bad, because every contributor must set up GPG or SSH signing — high friction for early stages.
|
- Bad, because every contributor must set up GPG or SSH signing — high friction for early stages.
|
||||||
|
|
||||||
## More Information
|
## More Information
|
||||||
|
|
||||||
* Gitea Actions: https://docs.gitea.com/usage/actions/overview
|
- Gitea Actions: https://docs.gitea.com/usage/actions/overview
|
||||||
* `act_runner`: https://gitea.com/gitea/act_runner
|
- `act_runner`: https://gitea.com/gitea/act_runner
|
||||||
* GitHub Actions reference (compatible with Gitea Actions): https://docs.github.com/actions
|
- GitHub Actions reference (compatible with Gitea Actions): https://docs.github.com/actions
|
||||||
* Conventional Commits: https://www.conventionalcommits.org/
|
- Conventional Commits: https://www.conventionalcommits.org/
|
||||||
* commitlint: https://commitlint.js.org/
|
- commitlint: https://commitlint.js.org/
|
||||||
* Trivy: https://github.com/aquasecurity/trivy
|
- Trivy: https://github.com/aquasecurity/trivy
|
||||||
* gitleaks: https://github.com/gitleaks/gitleaks
|
- gitleaks: https://github.com/gitleaks/gitleaks
|
||||||
* `nrwl/nx-set-shas`: https://github.com/nrwl/nx-set-shas
|
- `nrwl/nx-set-shas`: https://github.com/nrwl/nx-set-shas
|
||||||
* Related ADRs: [ADR-0002](0002-adopt-nx-monorepo-apps-preset.md) (Nx workspace + `affected`), [ADR-0007](0007-pre-commit-hooks-and-conventional-commits.md) (local hooks + commitlint config), and the future ADRs for security baseline (Trivy / gitleaks / Renovate config), accessibility baseline (`a11y` gate), performance budgets (`perf` gate), on-prem infrastructure stack (deploy pipeline, runners hosting), and GitLab migration (level-2 supersession).
|
- Related ADRs: [ADR-0002](0002-adopt-nx-monorepo-apps-preset.md) (Nx workspace + `affected`), [ADR-0007](0007-pre-commit-hooks-and-conventional-commits.md) (local hooks + commitlint config), and the future ADRs for security baseline (Trivy / gitleaks / Renovate config), accessibility baseline (`a11y` gate), performance budgets (`perf` gate), on-prem infrastructure stack (deploy pipeline, runners hosting), and GitLab migration (level-2 supersession).
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
---
|
||||||
|
status: accepted
|
||||||
|
date: 2026-04-30
|
||||||
|
decision-makers: R&D Lead
|
||||||
|
tags: [accessibility, frontend, process]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Accessibility baseline — WCAG 2.2 AA + targeted AAA, Angular CDK + spartan-ng + Tailwind, APF panel testing
|
||||||
|
|
||||||
|
## Context and Problem Statement
|
||||||
|
|
||||||
|
The host organisation is **APF France Handicap**. Accessibility is the organisation's core mission, not a compliance checkbox. A meaningful proportion of the portal's users will be in situations of disability — visual, motor, cognitive, hearing — and will rely on assistive technologies (screen readers, switch controls, eye tracking, voice control, head pointers, mouth sticks). The portal's accessibility quality is therefore a **product attribute**, not a downstream concern.
|
||||||
|
|
||||||
|
The default WCAG 2.2 AA baseline applicable to enterprise portals would _under-serve_ this user base. We need to fix:
|
||||||
|
|
||||||
|
- the conformance target (WCAG level + targeted enhancements);
|
||||||
|
- the implementation stack (component library, primitives, styling);
|
||||||
|
- the user-preferences surface (contrast, text size, motion, etc.) the portal must offer;
|
||||||
|
- the testing strategy, including manual testing with users from APF's internal network;
|
||||||
|
- the CI gates that prevent regression;
|
||||||
|
- the legal-disclosure surface (the EU Accessibility Act–required accessibility statement).
|
||||||
|
|
||||||
|
This ADR fixes the framework. Concrete component-by-component implementation lands as the scaffold and features arrive.
|
||||||
|
|
||||||
|
## Decision Drivers
|
||||||
|
|
||||||
|
- APF's mission elevates accessibility from "compliance" to "product".
|
||||||
|
- European Accessibility Act (EAA, in application since June 2025) makes WCAG 2.2 AA a legal floor in the EU; the portal will publish an accessibility statement and accept regression as a defect, not as a backlog item.
|
||||||
|
- RGAA 4.1 (French national standard, derived from WCAG) is the operational reference for accessibility audits in France; alignment is necessary to be auditable by French experts (including APF's own).
|
||||||
|
- The user base extends beyond screen-reader users: switch controls, eye tracking, voice control, cognitive load tolerance — all must be considered.
|
||||||
|
- The technical stack is locked on Angular ([ADR-0004](0004-frontend-stack-angular-csr-zoneless-signals.md)). Component-library choice must therefore be Angular-native or framework-agnostic.
|
||||||
|
- The dual-audience design ([ADR-0008](0008-identity-model-entra-workforce-dual-audience.md)) means a11y must be uniform across audiences — workforce and (future) customer users alike.
|
||||||
|
- Anti-bricolage: no hand-rolled a11y where a maintained library exists.
|
||||||
|
|
||||||
|
## Considered Options
|
||||||
|
|
||||||
|
### Conformance target
|
||||||
|
|
||||||
|
- WCAG 2.2 **AA** baseline (EU EAA legal floor).
|
||||||
|
- WCAG 2.2 **AA + targeted AAA** on criteria with high impact for APF's user base. (Chosen.)
|
||||||
|
- WCAG 2.2 **AAA** uniform.
|
||||||
|
|
||||||
|
### UI component stack
|
||||||
|
|
||||||
|
- Angular Material (Angular CDK + Material visual layer).
|
||||||
|
- **Angular CDK + spartan-ng + Tailwind.** (Chosen — see also `notes/argumentaire-stack-ui-spartan-cdk-tailwind.md` for the team-internal rationale.)
|
||||||
|
- Custom-on-Angular-CDK only (no spartan-ng).
|
||||||
|
- React-ecosystem libs (shadcn/ui, dice-ui, animate-ui).
|
||||||
|
|
||||||
|
### User-preferences scope
|
||||||
|
|
||||||
|
- None (rely on browser/OS prefs).
|
||||||
|
- `prefers-reduced-motion` only.
|
||||||
|
- **Full preferences panel** — contrast modes, text size, motion, spacing, simplified UI. (Chosen.)
|
||||||
|
|
||||||
|
### Testing strategy
|
||||||
|
|
||||||
|
- Automated only (axe-core + lint).
|
||||||
|
- Automated + external annual audit.
|
||||||
|
- **Automated + manual screen-reader/keyboard/switch testing + APF user panel cadence + annual internal audit.** (Chosen.)
|
||||||
|
|
||||||
|
### Documentation surface
|
||||||
|
|
||||||
|
- Internal docs only.
|
||||||
|
- **Public accessibility statement page (legal EAA requirement) + internal patterns library.** (Chosen.)
|
||||||
|
|
||||||
|
## Decision Outcome
|
||||||
|
|
||||||
|
### Conformance target
|
||||||
|
|
||||||
|
**WCAG 2.2 AA** as the universal baseline. **AAA** applied on the criteria that materially affect APF's user base:
|
||||||
|
|
||||||
|
| AAA criterion | What it adds vs AA | Why APF |
|
||||||
|
| ------------------------------------- | -------------------------------------------------------------------------------- | ------------------------------------------ |
|
||||||
|
| **1.4.6 Contrast (Enhanced)** | 7:1 normal text / 4.5:1 large text (vs 4.5:1 / 3:1 at AA) | Low-vision users are a primary audience |
|
||||||
|
| **2.2.3 No Timing** | No time limits except where essential | Cognitive load and motor speed vary widely |
|
||||||
|
| **2.3.3 Animation from Interactions** | All interaction-driven animation can be disabled | Vestibular and cognitive disorders |
|
||||||
|
| **3.1.5 Reading Level** | Content readable at lower secondary level _or_ a simpler alternative is provided | Cognitive accessibility |
|
||||||
|
| **1.4.8 Visual Presentation** | User control over text formatting (line spacing, paragraph spacing, line length) | Dyslexia, low vision |
|
||||||
|
| **2.4.9 Link Purpose (Link Only)** | Each link's purpose understandable from its text alone (no surrounding context) | Screen-reader navigation by link list |
|
||||||
|
| **3.3.5 Help** | Context-sensitive help available where needed | Cognitive accessibility |
|
||||||
|
|
||||||
|
The remaining AAA criteria (e.g. 1.2.6 Sign Language, 1.2.8 Media Alternative) are **not** uniformly required — they apply where the corresponding content type appears, and are addressed case by case. **RGAA 4.1** alignment is maintained as the audit reference for French experts (APF, CNIL audits, public-sector partners).
|
||||||
|
|
||||||
|
### User-preferences panel
|
||||||
|
|
||||||
|
The portal exposes a **first-class user-preferences panel** accessible from any page (keyboard-discoverable, screen-reader-announced, persisted in the user's session per [ADR-0010](0010-session-management-redis.md)):
|
||||||
|
|
||||||
|
| Preference | Options | Default |
|
||||||
|
| ------------------------ | --------------------------------------------------------------------------------------------------- | --------------------------------------- |
|
||||||
|
| Contrast mode | Standard (AA-compliant) / Enhanced (AAA, 7:1) / Maximum (high-contrast OS-style, monochrome) | Standard |
|
||||||
|
| Text size | 100 % / 125 % / 150 % / 175 % / 200 % | 100 % |
|
||||||
|
| Motion | Full / Reduced / None | inherited from `prefers-reduced-motion` |
|
||||||
|
| Text spacing | Default / Generous (line-spacing 1.8, paragraph-spacing 2×, letter-spacing 0.12em) | Default |
|
||||||
|
| Cognitive simplification | Off / On (reduces secondary chrome, larger primary actions, easier-to-read summaries when authored) | Off |
|
||||||
|
| Reading focus | Off / Line-by-line | Off |
|
||||||
|
|
||||||
|
Preferences are saved per user; an unauthenticated default is read from browser preferences (`prefers-reduced-motion`, `prefers-contrast`, etc.). Changing a preference takes effect immediately, no page reload.
|
||||||
|
|
||||||
|
### Component stack
|
||||||
|
|
||||||
|
**Angular CDK + spartan-ng + TailwindCSS**. See [`notes/argumentaire-stack-ui-spartan-cdk-tailwind.md`](../notes/argumentaire-stack-ui-spartan-cdk-tailwind.md) for the full rationale produced for the dev team. Summary:
|
||||||
|
|
||||||
|
- **Angular CDK** (`@angular/cdk`) — primitives accessibility layer maintained by Google's Angular Material team. Equivalent role to Radix UI in React. Provides `Overlay`, `FocusTrap`, `FocusMonitor`, `LiveAnnouncer`, `A11yModule`, `ListKeyManager`, `Drag and Drop`, etc. Battle-tested since 2017.
|
||||||
|
- **spartan-ng** (https://www.spartan.ng/) — copy-paste component layer built on Angular CDK, styled with Tailwind. Components are checked into our repo (`libs/shared/ui/`) and modified freely. Equivalent role to shadcn/ui in React.
|
||||||
|
- **Tailwind CSS** — utility-first styling. Tokens for colour, spacing, contrast tiers live in `libs/shared/tokens/`. The contrast tiers map directly to the user preference modes above.
|
||||||
|
|
||||||
|
Angular Material is **rejected** because its visual design system (Material Design) is opinionated in ways that clash with both APF branding and the multi-tier contrast requirements. React-ecosystem libraries (shadcn/ui, dice-ui, animate-ui) are rejected as incompatible with the Angular stack ([ADR-0004](0004-frontend-stack-angular-csr-zoneless-signals.md)).
|
||||||
|
|
||||||
|
### Concrete patterns to enforce
|
||||||
|
|
||||||
|
| Concern | Rule |
|
||||||
|
| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Sémantique HTML | `<header>`, `<main>`, `<nav>`, `<aside>`, `<footer>` landmarks. One `h1` per page. `<button>` for actions, `<a>` for navigation. **No** `<div onclick>` — flagged by ESLint and rejected at PR review |
|
||||||
|
| Skip-links | "Aller au contenu principal" link as the first focusable element on every page |
|
||||||
|
| Focus | Always visible (no `outline: none` without an at-least-equivalent replacement). Trapped in modals via Angular CDK `FocusTrap`. Restored after navigation/modal close |
|
||||||
|
| Forms | Each `<input>` has an associated `<label>`. Errors associated via `aria-describedby`. `autocomplete` attribute correct. Valid HTML5 `type` |
|
||||||
|
| Touch targets | Minimum **44 × 44 CSS pixels** (WCAG 2.5.5 AAA), default 48 × 48 in spartan-ng tokens for safety |
|
||||||
|
| Animations | Respect `prefers-reduced-motion` and the user-preferences `motion` setting. No autoplay video. No carousels with auto-rotation |
|
||||||
|
| Language | `lang` attribute on `<html>` set dynamically per active locale. Inline `lang` on foreign-language fragments |
|
||||||
|
| Images | All non-decorative images carry meaningful `alt`. Decorative images carry `alt=""` and `role="presentation"` |
|
||||||
|
| ARIA | Used **only** when native HTML semantics are insufficient. Misuse of ARIA (the most common a11y bug) is treated as a code-quality defect |
|
||||||
|
| Error messaging | Programmatic association (`aria-describedby`), live regions (`aria-live="polite"`) for server errors, never just colour to convey state |
|
||||||
|
|
||||||
|
### Tooling and CI gates
|
||||||
|
|
||||||
|
| Layer | Tool | Where it runs | Blocking? |
|
||||||
|
| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
|
||||||
|
| Static lint on Angular templates | `@angular-eslint/template/*` rules (no-positive-tabindex, click-events-have-key-events, label-has-associated-control, elements-content, no-autofocus, alt-text, valid-aria, button-has-type, …) | local hook + CI (`pnpm ci:check`, [ADR-0015](0015-cicd-gitea-actions.md)) | **Yes** |
|
||||||
|
| Automated runtime a11y check | `@axe-core/playwright` integrated in e2e tests | CI (`pnpm ci:quality` — the `a11y` gate of [ADR-0015](0015-cicd-gitea-actions.md)) | **Yes** on `critical` or `serious` violations; warning on `moderate`/`minor` |
|
||||||
|
| Design-token contrast verification | Custom script that walks `libs/shared/tokens/` colour pairs and verifies WCAG AA + AAA contrast ratios | CI | **Yes** |
|
||||||
|
| Touch-target check | Layout test verifying interactive element minimum size | CI (Playwright) | **Yes** on min 44 × 44 |
|
||||||
|
| Motion respect check | E2E test that turns motion off and verifies no animation occurs | CI (Playwright) | **Yes** |
|
||||||
|
| Screen-reader manual testing | NVDA (Windows), VoiceOver (macOS, iOS), TalkBack (Android) | Manual, before merge of any new component or major refactor | **Yes** — checklist in PR template |
|
||||||
|
| Keyboard-only navigation | Manual | Manual, before merge of any UI change | **Yes** — checklist in PR template |
|
||||||
|
| Switch-control / eye-tracking | Manual, on flagship features | APF user panel cadence | **Yes** as gate for major releases |
|
||||||
|
| Cognitive walkthrough | Manual, with user representatives | APF user panel cadence | **Yes** as gate for major releases |
|
||||||
|
|
||||||
|
### Manual testing — APF user panel
|
||||||
|
|
||||||
|
APF France Handicap has, by design, an active network of users in situations of disability. This is a structural advantage that no consultant can replicate.
|
||||||
|
|
||||||
|
- **Cadence** : panel-tested before each major release. Composition: at least one user per primary disability category (visual, motor, cognitive, hearing) drawn from the APF network.
|
||||||
|
- **Scope** : critical user journeys (sign-in, navigation, integrated apps, user-preferences panel, error-recovery flows) plus any new flagship feature.
|
||||||
|
- **Format** : moderated session, observation-based, recorded with consent. Findings categorised (blocker / serious / moderate / minor) and triaged like security findings — blockers and serious must be fixed before release.
|
||||||
|
- **Authority** : a finding by a user-panel session is at least as authoritative as an axe-core report. Disagreements escalate to the R&D lead.
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **Accessibility statement page** at `/accessibility` (English) and `/accessibilite` (French) — required by EAA. Content: conformance level claimed, scope (which routes are covered), known issues with target resolution dates, contact email for accessibility complaints, date of last review, RGAA alignment level. Generated from a maintained source-of-truth file in the repo (e.g. `apps/portal-shell/src/accessibility-statement.md`) so the statement evolves with the code, not separately.
|
||||||
|
- **Internal patterns library** in `docs/accessibility/` (created when first patterns ship) covering: focus management, ARIA usage, form patterns, error messaging, multi-step flows, modals, notifications. Examples drawn from the codebase, kept in sync via review.
|
||||||
|
- **Component library docs** : every spartan-ng component checked into `libs/shared/ui/` carries a11y notes (keyboard model, ARIA roles emitted, screen-reader expectations).
|
||||||
|
|
||||||
|
### Consequences
|
||||||
|
|
||||||
|
- Good, because a11y is structural — encoded in the lint, the gates, the components, the tokens, the user-preferences panel — rather than relying on individual discipline.
|
||||||
|
- Good, because the chosen stack (CDK + spartan-ng + Tailwind) gives full design control without forfeiting Google-tier a11y primitives, and supports the multi-tier contrast/text-size/motion modes without contortion.
|
||||||
|
- Good, because the AA + targeted AAA approach concentrates effort where APF's user base benefits most, instead of spreading thin across every AAA criterion.
|
||||||
|
- Good, because access to APF's user network is a competitive advantage — the panel testing is more rigorous than what most enterprise projects can afford.
|
||||||
|
- Good, because the accessibility statement and the patterns library make the project's a11y posture _legible_ — auditable by external experts, demonstrable to APF stakeholders.
|
||||||
|
- Bad, because the upfront cost is non-trivial — design tokens with multi-tier contrast, components with theming, user-preferences panel, e2e a11y tests. Estimated +25–35 % of UI delivery time vs. an a11y-as-afterthought approach. Acknowledged and accepted given APF's mission.
|
||||||
|
- Bad, because spartan-ng is younger than Angular Material — occasional rough edges expected. Mitigated by the copy-paste model: components live in our repo, we own them, fallback is custom-on-CDK without lib dependency.
|
||||||
|
- Bad, because the user-preferences panel must be wired through every component — components that don't honour the active contrast mode or the active text size break the contract. Enforced by review and a custom lint rule per component.
|
||||||
|
- Bad, because manual testing cadence (panel sessions, screen-reader, keyboard) is real ops effort. Mitigated by APF's internal network; expense kept inside the org.
|
||||||
|
|
||||||
|
### Confirmation
|
||||||
|
|
||||||
|
- `libs/shared/tokens` exposes design tokens for colour (across at least three contrast tiers), spacing, typography, motion. Token contrast ratios are verified by a CI script.
|
||||||
|
- `libs/shared/ui` hosts spartan-ng components, copied in and themed against the tokens. Each component carries an a11y note in its docs.
|
||||||
|
- Angular CDK is the only allowed source of `Overlay`, `FocusTrap`, `FocusMonitor`, `LiveAnnouncer`, `Drag and Drop`. No equivalent rolled by hand.
|
||||||
|
- `apps/portal-shell` ships a user-preferences panel persisting to the session (Redis); changes apply without reload; default reads from `prefers-*` browser media queries.
|
||||||
|
- ESLint configuration enables every `@angular-eslint/template/*` a11y rule. CI fails on any violation.
|
||||||
|
- `@axe-core/playwright` is integrated in the e2e suite. CI's `a11y` gate fails on any `critical` or `serious` violation, warns on `moderate` or `minor`.
|
||||||
|
- Design-token contrast verifier and touch-target verifier are part of `pnpm ci:quality`.
|
||||||
|
- PR template includes a manual-checklist section: keyboard navigation tested, screen reader tested (which one), focus order verified, error states verified.
|
||||||
|
- Accessibility statement page is published, referenced from the footer, and updated at every major release.
|
||||||
|
- APF user-panel session findings are tracked as issues with a label `a11y/panel-finding` and triaged with the same gravity as security issues.
|
||||||
|
|
||||||
|
## Pros and Cons of the Options
|
||||||
|
|
||||||
|
### Conformance target
|
||||||
|
|
||||||
|
#### AA + targeted AAA (chosen)
|
||||||
|
|
||||||
|
- Good, because matches the user base where it matters and stays achievable on a uniform basis.
|
||||||
|
- Good, because the AAA criteria selected are the ones with measurable user-impact in APF's primary populations.
|
||||||
|
- Bad, because the selection is judgement-based — open to debate at audit. Mitigated by documenting the rationale per criterion (this ADR).
|
||||||
|
|
||||||
|
#### AA only
|
||||||
|
|
||||||
|
- Good, because the EU EAA legal floor.
|
||||||
|
- Bad, because clearly insufficient given APF's mission. Bare AA on contrast (4.5:1) is uncomfortable for a low-vision user; AA on motion is permissive.
|
||||||
|
|
||||||
|
#### AAA uniform
|
||||||
|
|
||||||
|
- Good, because the highest WCAG level on every criterion.
|
||||||
|
- Bad, because some AAA criteria (1.2.6 Sign Language, 1.2.8 Media Alternative, 1.4.9 Images of Text) require content production or content forms we may not have. Pretending uniform AAA when not really delivered is worse than an honest AA + targeted AAA.
|
||||||
|
|
||||||
|
### UI component stack
|
||||||
|
|
||||||
|
#### Angular CDK + spartan-ng + Tailwind (chosen)
|
||||||
|
|
||||||
|
- Good, because Google-tier a11y primitives via CDK + copy-paste components via spartan-ng + utility CSS via Tailwind = full control, no design-system lock-in, no proprietary visuals to fight.
|
||||||
|
- Good, because aligns with the team-internal preference for the shadcn-style pattern (per dev consultation), without having to break the Angular alignment with NestJS.
|
||||||
|
- Bad, because spartan-ng is younger (2024). Risk mitigated by the copy-paste model — we own the code.
|
||||||
|
|
||||||
|
#### Angular Material
|
||||||
|
|
||||||
|
- Good, because most mature, Google-maintained.
|
||||||
|
- Bad, because the Material Design visual language is opinionated and clashes with multi-tier contrast modes and APF branding. Switching themes within Material is harder than switching themes when the components are owned in source.
|
||||||
|
|
||||||
|
#### Custom-on-Angular-CDK only
|
||||||
|
|
||||||
|
- Good, because zero dependency beyond Google.
|
||||||
|
- Bad, because every component (button, dialog, tabs, menu, etc.) must be built from CDK primitives — large upfront cost.
|
||||||
|
|
||||||
|
#### React-ecosystem libs
|
||||||
|
|
||||||
|
- Bad, because incompatible with Angular ([ADR-0004](0004-frontend-stack-angular-csr-zoneless-signals.md)).
|
||||||
|
|
||||||
|
### Testing strategy
|
||||||
|
|
||||||
|
#### Automated + manual + APF user panel + annual internal audit (chosen)
|
||||||
|
|
||||||
|
- Good, because covers the spectrum from regression detection to genuine usability.
|
||||||
|
- Good, because user panel access is structurally cheaper than what a consultancy would charge.
|
||||||
|
- Bad, because requires panel scheduling and triage — operational item.
|
||||||
|
|
||||||
|
#### Automated only
|
||||||
|
|
||||||
|
- Bad, because tools detect ~30 % of real accessibility defects. The remaining 70 % are interaction patterns invisible to static and runtime checks.
|
||||||
|
|
||||||
|
#### Automated + external annual audit
|
||||||
|
|
||||||
|
- Good, because external perspective.
|
||||||
|
- Bad, because expensive (5–15 k€/year for a real audit) and arguably less informed than APF's internal expertise. Reconsidered if APF wants a third-party signature for legal disclosure.
|
||||||
|
|
||||||
|
## More Information
|
||||||
|
|
||||||
|
- WCAG 2.2 specification: https://www.w3.org/TR/WCAG22/
|
||||||
|
- WCAG 2.2 quick reference (filterable by level): https://www.w3.org/WAI/WCAG22/quickref/
|
||||||
|
- European Accessibility Act (EAA): https://ec.europa.eu/social/main.jsp?catId=1202
|
||||||
|
- RGAA 4.1: https://accessibilite.numerique.gouv.fr/
|
||||||
|
- APF France Handicap: https://www.apf-francehandicap.org/
|
||||||
|
- Angular CDK A11y: https://material.angular.io/cdk/a11y/overview
|
||||||
|
- spartan-ng: https://www.spartan.ng/
|
||||||
|
- `@angular-eslint`: https://github.com/angular-eslint/angular-eslint
|
||||||
|
- `@axe-core/playwright`: https://github.com/dequelabs/axe-core-npm/tree/develop/packages/playwright
|
||||||
|
- Inclusive Components: https://inclusive-components.design/
|
||||||
|
- Related ADRs: [ADR-0004](0004-frontend-stack-angular-csr-zoneless-signals.md) (Angular stack), [ADR-0010](0010-session-management-redis.md) (preferences persistence), [ADR-0015](0015-cicd-gitea-actions.md) (CI gates `a11y` and `perf`), and the future ADRs for performance budgets, security baseline (ASVS L3 implications cross-cut a11y in some areas — e.g. step-up MFA must remain a11y-conformant), and on-prem infrastructure.
|
||||||
+77
-66
@@ -12,6 +12,7 @@ tags: [performance, frontend, backend, process]
|
|||||||
The portal is a CSR Angular SPA ([ADR-0004](0004-frontend-stack-angular-csr-zoneless-signals.md)) — without SSR, perceived performance depends entirely on the JS payload, the rendering path, and the BFF response latency. The host organisation context ([ADR-0016](0016-accessibility-baseline-wcag-aa-targeted-aaa.md)) elevates performance further: users on assistive technologies (screen readers, switch controls, eye tracking) are particularly affected by slow or jumpy interfaces; cognitive-disability tolerance for delayed feedback is reduced.
|
The portal is a CSR Angular SPA ([ADR-0004](0004-frontend-stack-angular-csr-zoneless-signals.md)) — without SSR, perceived performance depends entirely on the JS payload, the rendering path, and the BFF response latency. The host organisation context ([ADR-0016](0016-accessibility-baseline-wcag-aa-targeted-aaa.md)) elevates performance further: users on assistive technologies (screen readers, switch controls, eye tracking) are particularly affected by slow or jumpy interfaces; cognitive-disability tolerance for delayed feedback is reduced.
|
||||||
|
|
||||||
We need to fix:
|
We need to fix:
|
||||||
|
|
||||||
- which metrics we track;
|
- which metrics we track;
|
||||||
- which thresholds bound them;
|
- which thresholds bound them;
|
||||||
- which tooling enforces them in CI and in production;
|
- which tooling enforces them in CI and in production;
|
||||||
@@ -22,40 +23,46 @@ This ADR fixes the framework. Concrete optimisation work happens at feature deli
|
|||||||
|
|
||||||
## Decision Drivers
|
## Decision Drivers
|
||||||
|
|
||||||
* CSR-only ([ADR-0004](0004-frontend-stack-angular-csr-zoneless-signals.md)) — perceived perf is entirely client-side load + execution.
|
- CSR-only ([ADR-0004](0004-frontend-stack-angular-csr-zoneless-signals.md)) — perceived perf is entirely client-side load + execution.
|
||||||
* a11y context ([ADR-0016](0016-accessibility-baseline-wcag-aa-targeted-aaa.md)) — perf failure modes hurt the APF user base disproportionately.
|
- a11y context ([ADR-0016](0016-accessibility-baseline-wcag-aa-targeted-aaa.md)) — perf failure modes hurt the APF user base disproportionately.
|
||||||
* Industry-standard tooling, anti-bricolage.
|
- Industry-standard tooling, anti-bricolage.
|
||||||
* Same observability stack as [ADR-0012](0012-observability-pino-opentelemetry.md) — no parallel telemetry path.
|
- Same observability stack as [ADR-0012](0012-observability-pino-opentelemetry.md) — no parallel telemetry path.
|
||||||
* Cohérence with [ADR-0015](0015-cicd-gitea-actions.md) — the `perf` gate slot was reserved for this ADR.
|
- Cohérence with [ADR-0015](0015-cicd-gitea-actions.md) — the `perf` gate slot was reserved for this ADR.
|
||||||
|
|
||||||
## Considered Options
|
## Considered Options
|
||||||
|
|
||||||
### Metrics scope
|
### Metrics scope
|
||||||
* **Google Core Web Vitals (LCP, INP, CLS) + supplementary (TBT, TTFB) + bundle size.** (Chosen.)
|
|
||||||
* Custom metrics only (rejected — premature, not benchmarkable across the industry).
|
- **Google Core Web Vitals (LCP, INP, CLS) + supplementary (TBT, TTFB) + bundle size.** (Chosen.)
|
||||||
* Lighthouse Performance score only (insufficient on its own — score-as-only-metric is gameable).
|
- Custom metrics only (rejected — premature, not benchmarkable across the industry).
|
||||||
|
- Lighthouse Performance score only (insufficient on its own — score-as-only-metric is gameable).
|
||||||
|
|
||||||
### Front-end tooling
|
### Front-end tooling
|
||||||
* **Lighthouse CI (`@lhci/cli`)** for full audit + score, plus Angular `budgets` for bundle size enforcement at build. (Chosen.)
|
|
||||||
* WebPageTest API.
|
- **Lighthouse CI (`@lhci/cli`)** for full audit + score, plus Angular `budgets` for bundle size enforcement at build. (Chosen.)
|
||||||
* Calibre, Speedlify (à la pointe but less mature for CI integration).
|
- WebPageTest API.
|
||||||
|
- Calibre, Speedlify (à la pointe but less mature for CI integration).
|
||||||
|
|
||||||
### Threshold values
|
### Threshold values
|
||||||
* **Google "Good" Core Web Vitals thresholds + Lighthouse Performance ≥ 90.** (Chosen.)
|
|
||||||
* Stricter (e.g. LCP ≤ 2 s, score ≥ 95) — rejected as too flaky in CI runners.
|
- **Google "Good" Core Web Vitals thresholds + Lighthouse Performance ≥ 90.** (Chosen.)
|
||||||
* Looser (matches "Needs Improvement") — rejected as insufficient.
|
- Stricter (e.g. LCP ≤ 2 s, score ≥ 95) — rejected as too flaky in CI runners.
|
||||||
|
- Looser (matches "Needs Improvement") — rejected as insufficient.
|
||||||
|
|
||||||
### Bundle budgets
|
### Bundle budgets
|
||||||
* **Angular `budgets` in `project.json`, type `error`, blocking the build on overshoot.** (Chosen.)
|
|
||||||
* No budget (rejected — invariably leads to silent bloat).
|
- **Angular `budgets` in `project.json`, type `error`, blocking the build on overshoot.** (Chosen.)
|
||||||
|
- No budget (rejected — invariably leads to silent bloat).
|
||||||
|
|
||||||
### Back-end SLOs
|
### Back-end SLOs
|
||||||
* **Per-endpoint-family p95/p99 budgets, observed via OTel spans (already in place by [ADR-0012](0012-observability-pino-opentelemetry.md)), enforced at review and in scheduled reports.** (Chosen.)
|
|
||||||
* No back-end perf budget.
|
- **Per-endpoint-family p95/p99 budgets, observed via OTel spans (already in place by [ADR-0012](0012-observability-pino-opentelemetry.md)), enforced at review and in scheduled reports.** (Chosen.)
|
||||||
|
- No back-end perf budget.
|
||||||
|
|
||||||
### Real-user monitoring (RUM)
|
### Real-user monitoring (RUM)
|
||||||
* **None in v1** — rely on OTel server-side spans + Lighthouse CI in CI + scheduled prod Lighthouse runs. (Chosen.)
|
|
||||||
* Front-side RUM SDK (Sentry, Datadog Browser, custom OTel-Web RUM).
|
- **None in v1** — rely on OTel server-side spans + Lighthouse CI in CI + scheduled prod Lighthouse runs. (Chosen.)
|
||||||
|
- Front-side RUM SDK (Sentry, Datadog Browser, custom OTel-Web RUM).
|
||||||
|
|
||||||
## Decision Outcome
|
## Decision Outcome
|
||||||
|
|
||||||
@@ -64,7 +71,7 @@ This ADR fixes the framework. Concrete optimisation work happens at feature deli
|
|||||||
Core Web Vitals — Google "Good" thresholds, measured by Lighthouse CI:
|
Core Web Vitals — Google "Good" thresholds, measured by Lighthouse CI:
|
||||||
|
|
||||||
| Metric | Threshold | Source |
|
| Metric | Threshold | Source |
|
||||||
| --- | --- | --- |
|
| ----------------------------------------------- | --------------------------- | ---------------------------------------------------- |
|
||||||
| **LCP** (Largest Contentful Paint) | ≤ **2.5 s** | https://web.dev/lcp/ |
|
| **LCP** (Largest Contentful Paint) | ≤ **2.5 s** | https://web.dev/lcp/ |
|
||||||
| **INP** (Interaction to Next Paint) | ≤ **200 ms** | https://web.dev/inp/ — replaces FID since March 2024 |
|
| **INP** (Interaction to Next Paint) | ≤ **200 ms** | https://web.dev/inp/ — replaces FID since March 2024 |
|
||||||
| **CLS** (Cumulative Layout Shift) | ≤ **0.1** | https://web.dev/cls/ |
|
| **CLS** (Cumulative Layout Shift) | ≤ **0.1** | https://web.dev/cls/ |
|
||||||
@@ -75,7 +82,7 @@ Core Web Vitals — Google "Good" thresholds, measured by Lighthouse CI:
|
|||||||
Bundle budgets (`apps/portal-shell/project.json`, Angular `budgets` array, `type: "error"` — blocking at `nx build`):
|
Bundle budgets (`apps/portal-shell/project.json`, Angular `budgets` array, `type: "error"` — blocking at `nx build`):
|
||||||
|
|
||||||
| Bundle | Budget |
|
| Bundle | Budget |
|
||||||
| --- | --- |
|
| --------------------- | ------------ |
|
||||||
| Initial bundle (gzip) | ≤ **300 KB** |
|
| Initial bundle (gzip) | ≤ **300 KB** |
|
||||||
| Any lazy chunk (gzip) | ≤ **100 KB** |
|
| Any lazy chunk (gzip) | ≤ **100 KB** |
|
||||||
| Per-component CSS | ≤ **6 KB** |
|
| Per-component CSS | ≤ **6 KB** |
|
||||||
@@ -97,7 +104,7 @@ Each route is scored against the thresholds above. **Failure on any threshold is
|
|||||||
### Where Lighthouse CI runs
|
### Where Lighthouse CI runs
|
||||||
|
|
||||||
| Environment | Cadence | Purpose | Blocking? |
|
| Environment | Cadence | Purpose | Blocking? |
|
||||||
| --- | --- | --- | --- |
|
| ------------------------------------- | ---------------------------------------------------------------------------------------------------- | ---------------------------------------------- | ----------------------------------------------------- |
|
||||||
| **CI on every PR** | per push | Catch regressions before merge | **Yes** |
|
| **CI on every PR** | per push | Catch regressions before merge | **Yes** |
|
||||||
| **CI scheduled (weekly)** on prod env | cron in `security-scheduled.yml` from [ADR-0015](0015-cicd-gitea-actions.md), extended to cover perf | Detect drift / regressions in real environment | Reports as alerts; doesn't block, but triggers triage |
|
| **CI scheduled (weekly)** on prod env | cron in `security-scheduled.yml` from [ADR-0015](0015-cicd-gitea-actions.md), extended to cover perf | Detect drift / regressions in real environment | Reports as alerts; doesn't block, but triggers triage |
|
||||||
| **Local dev** | manual via `pnpm nx run portal-shell:lighthouse` | Developer-side feedback | Non-blocking |
|
| **Local dev** | manual via `pnpm nx run portal-shell:lighthouse` | Developer-side feedback | Non-blocking |
|
||||||
@@ -105,6 +112,7 @@ Each route is scored against the thresholds above. **Failure on any threshold is
|
|||||||
### Variability mitigation
|
### Variability mitigation
|
||||||
|
|
||||||
Lighthouse scores fluctuate ±2–5 points run-to-run on the same code due to runner variance. The CI configuration:
|
Lighthouse scores fluctuate ±2–5 points run-to-run on the same code due to runner variance. The CI configuration:
|
||||||
|
|
||||||
- runs **3 iterations** per route and uses the **median** (configurable via `lighthouserc.js`);
|
- runs **3 iterations** per route and uses the **median** (configurable via `lighthouserc.js`);
|
||||||
- uses a fixed runtime profile (CPU throttling, network throttling matching "Slow 4G" baseline) so scores are comparable across runs and across machines;
|
- uses a fixed runtime profile (CPU throttling, network throttling matching "Slow 4G" baseline) so scores are comparable across runs and across machines;
|
||||||
- pins the Lighthouse version per release of the runner image (avoids score shifts from Lighthouse updates).
|
- pins the Lighthouse version per release of the runner image (avoids score shifts from Lighthouse updates).
|
||||||
@@ -118,7 +126,7 @@ Lighthouse scores fluctuate ±2–5 points run-to-run on the same code due to ru
|
|||||||
Per-endpoint-family p95 / p99 SLOs, observed via the OpenTelemetry span data already produced by [ADR-0012](0012-observability-pino-opentelemetry.md). Initial budgets:
|
Per-endpoint-family p95 / p99 SLOs, observed via the OpenTelemetry span data already produced by [ADR-0012](0012-observability-pino-opentelemetry.md). Initial budgets:
|
||||||
|
|
||||||
| Endpoint family | p95 | p99 |
|
| Endpoint family | p95 | p99 |
|
||||||
| --- | --- | --- |
|
| -------------------------------------- | ---------------------------------------- | ----------------------------------------- |
|
||||||
| `GET /auth/me` | 50 ms | 150 ms |
|
| `GET /auth/me` | 50 ms | 150 ms |
|
||||||
| `GET /auth/login` (redirect) | 80 ms | 200 ms |
|
| `GET /auth/login` (redirect) | 80 ms | 200 ms |
|
||||||
| `GET /auth/callback` (token exchange) | 600 ms | 1500 ms — bound by Entra |
|
| `GET /auth/callback` (token exchange) | 600 ms | 1500 ms — bound by Entra |
|
||||||
@@ -128,6 +136,7 @@ Per-endpoint-family p95 / p99 SLOs, observed via the OpenTelemetry span data alr
|
|||||||
| Downstream-API-orchestrating endpoints | bound by downstream + 50 ms BFF overhead | bound by downstream + 200 ms BFF overhead |
|
| Downstream-API-orchestrating endpoints | bound by downstream + 50 ms BFF overhead | bound by downstream + 200 ms BFF overhead |
|
||||||
|
|
||||||
These budgets are **not** enforced as hard CI gates — load profile in CI is unrepresentative. They are enforced as:
|
These budgets are **not** enforced as hard CI gates — load profile in CI is unrepresentative. They are enforced as:
|
||||||
|
|
||||||
- **alerting thresholds** in the production observability backend (chosen in the future infrastructure ADR);
|
- **alerting thresholds** in the production observability backend (chosen in the future infrastructure ADR);
|
||||||
- **review-time signals** on PRs that touch hot paths (the OTel data from staging informs the reviewer);
|
- **review-time signals** on PRs that touch hot paths (the OTel data from staging informs the reviewer);
|
||||||
- **quarterly perf review** — tightened or loosened with ADR amendment.
|
- **quarterly perf review** — tightened or loosened with ADR amendment.
|
||||||
@@ -139,6 +148,7 @@ A regression on any of the front-end gates blocks the merge. A regression on a b
|
|||||||
### a11y / perf trade-off
|
### a11y / perf trade-off
|
||||||
|
|
||||||
Performance optimisation must not hurt accessibility. Some specific patterns to avoid:
|
Performance optimisation must not hurt accessibility. Some specific patterns to avoid:
|
||||||
|
|
||||||
- skipping FOUC prevention (briefly unstyled content disorients users with cognitive disabilities);
|
- skipping FOUC prevention (briefly unstyled content disorients users with cognitive disabilities);
|
||||||
- removing focus-visible polyfills "for bundle size";
|
- removing focus-visible polyfills "for bundle size";
|
||||||
- aggressive lazy-loading that delays critical interactive elements;
|
- aggressive lazy-loading that delays critical interactive elements;
|
||||||
@@ -157,6 +167,7 @@ When the perf budget and the a11y bar conflict, **a11y wins** — the perf budge
|
|||||||
```
|
```
|
||||||
|
|
||||||
`lighthouserc.js` (illustrative, lands with the scaffold) declares:
|
`lighthouserc.js` (illustrative, lands with the scaffold) declares:
|
||||||
|
|
||||||
- the URLs to test (the critical-routes list);
|
- the URLs to test (the critical-routes list);
|
||||||
- 3 iterations, median report;
|
- 3 iterations, median report;
|
||||||
- assertions matching the thresholds in this ADR;
|
- assertions matching the thresholds in this ADR;
|
||||||
@@ -170,25 +181,25 @@ No browser-side RUM SDK in v1. The OTel browser tracing from [ADR-0012](0012-obs
|
|||||||
|
|
||||||
### Consequences
|
### Consequences
|
||||||
|
|
||||||
* Good, because performance discipline is structural — every PR is measured against the same bar, on every route that matters.
|
- Good, because performance discipline is structural — every PR is measured against the same bar, on every route that matters.
|
||||||
* Good, because Core Web Vitals + Lighthouse CI is the industry-standard combo; results are comparable to public benchmarks and easy to explain to non-developers.
|
- Good, because Core Web Vitals + Lighthouse CI is the industry-standard combo; results are comparable to public benchmarks and easy to explain to non-developers.
|
||||||
* Good, because Angular `budgets` blocks bundle bloat at build time, before it ever reaches a user.
|
- Good, because Angular `budgets` blocks bundle bloat at build time, before it ever reaches a user.
|
||||||
* Good, because the BFF SLOs reuse the OTel pipeline already shipped by [ADR-0012](0012-observability-pino-opentelemetry.md) — no parallel telemetry.
|
- Good, because the BFF SLOs reuse the OTel pipeline already shipped by [ADR-0012](0012-observability-pino-opentelemetry.md) — no parallel telemetry.
|
||||||
* Good, because the explicit a11y/perf trade-off rule prevents "performance regressions" from being introduced under the guise of optimisation.
|
- Good, because the explicit a11y/perf trade-off rule prevents "performance regressions" from being introduced under the guise of optimisation.
|
||||||
* Bad, because Lighthouse score variability (±2–5 points) is real; CI must mitigate it with median-of-3 runs and pinned tooling. Otherwise gates flake.
|
- Bad, because Lighthouse score variability (±2–5 points) is real; CI must mitigate it with median-of-3 runs and pinned tooling. Otherwise gates flake.
|
||||||
* Bad, because tightening budgets at quarterly review requires discipline — quarterly meetings to actually happen, data to be reviewed, ADRs to be amended. Mitigated by making it a recurring calendar item.
|
- Bad, because tightening budgets at quarterly review requires discipline — quarterly meetings to actually happen, data to be reviewed, ADRs to be amended. Mitigated by making it a recurring calendar item.
|
||||||
* Bad, because the back-end SLOs are advisory in CI (not enforced) — the only enforcement is in production alerting, which means a hot-path regression can ship and only fire later. Acknowledged: enforcing perf in CI requires a representative load profile we don't have.
|
- Bad, because the back-end SLOs are advisory in CI (not enforced) — the only enforcement is in production alerting, which means a hot-path regression can ship and only fire later. Acknowledged: enforcing perf in CI requires a representative load profile we don't have.
|
||||||
|
|
||||||
### Confirmation
|
### Confirmation
|
||||||
|
|
||||||
* `lighthouserc.js` exists at the repo root with the critical-routes list, the assertions matching the thresholds above, and a 3-iteration median configuration.
|
- `lighthouserc.js` exists at the repo root with the critical-routes list, the assertions matching the thresholds above, and a 3-iteration median configuration.
|
||||||
* `apps/portal-shell/project.json` declares `budgets` of type `"error"` with the values above.
|
- `apps/portal-shell/project.json` declares `budgets` of type `"error"` with the values above.
|
||||||
* `package.json` exposes `ci:perf`, runnable locally with the same exit code as CI.
|
- `package.json` exposes `ci:perf`, runnable locally with the same exit code as CI.
|
||||||
* CI's `perf` gate from [ADR-0015](0015-cicd-gitea-actions.md) calls `pnpm ci:perf` and is blocking.
|
- CI's `perf` gate from [ADR-0015](0015-cicd-gitea-actions.md) calls `pnpm ci:perf` and is blocking.
|
||||||
* `apps/portal-shell` exposes a Nx target `analyze` invoking `source-map-explorer` against the production build's source maps.
|
- `apps/portal-shell` exposes a Nx target `analyze` invoking `source-map-explorer` against the production build's source maps.
|
||||||
* The BFF p95/p99 budgets are documented in `apps/portal-bff/README.md` and translate into alert rules in the production observability backend (configured per future infrastructure ADR).
|
- The BFF p95/p99 budgets are documented in `apps/portal-bff/README.md` and translate into alert rules in the production observability backend (configured per future infrastructure ADR).
|
||||||
* The `security-scheduled.yml` workflow includes a weekly Lighthouse CI run against the prod URL set, with reports uploaded as build artefacts and an alert on any threshold breach.
|
- The `security-scheduled.yml` workflow includes a weekly Lighthouse CI run against the prod URL set, with reports uploaded as build artefacts and an alert on any threshold breach.
|
||||||
* Quarterly performance review is on the team calendar; the agenda includes (a) review of the past quarter's reports, (b) decision to tighten budgets if achievable, (c) ADR amendment if budgets are adjusted.
|
- Quarterly performance review is on the team calendar; the agenda includes (a) review of the past quarter's reports, (b) decision to tighten budgets if achievable, (c) ADR amendment if budgets are adjusted.
|
||||||
|
|
||||||
## Pros and Cons of the Options
|
## Pros and Cons of the Options
|
||||||
|
|
||||||
@@ -196,65 +207,65 @@ No browser-side RUM SDK in v1. The OTel browser tracing from [ADR-0012](0012-obs
|
|||||||
|
|
||||||
#### Google Core Web Vitals + supplementary + bundle size (chosen)
|
#### Google Core Web Vitals + supplementary + bundle size (chosen)
|
||||||
|
|
||||||
* Good, because aligned with the industry, comparable to public benchmarks, well-documented.
|
- Good, because aligned with the industry, comparable to public benchmarks, well-documented.
|
||||||
* Good, because INP captures real user interaction latency (replaces FID since March 2024).
|
- Good, because INP captures real user interaction latency (replaces FID since March 2024).
|
||||||
* Good, because bundle size is the leading indicator that catches issues before they manifest as bad CWV.
|
- Good, because bundle size is the leading indicator that catches issues before they manifest as bad CWV.
|
||||||
|
|
||||||
#### Custom metrics only
|
#### Custom metrics only
|
||||||
|
|
||||||
* Bad, because un-benchmarkable, easy to be wrong about. Premature.
|
- Bad, because un-benchmarkable, easy to be wrong about. Premature.
|
||||||
|
|
||||||
#### Lighthouse score only
|
#### Lighthouse score only
|
||||||
|
|
||||||
* Bad, because the score is a weighted aggregate; gameable, hides which metric is failing. Use it as a top-line confirmation, not as the only metric.
|
- Bad, because the score is a weighted aggregate; gameable, hides which metric is failing. Use it as a top-line confirmation, not as the only metric.
|
||||||
|
|
||||||
### Front-end tooling
|
### Front-end tooling
|
||||||
|
|
||||||
#### Lighthouse CI (chosen)
|
#### Lighthouse CI (chosen)
|
||||||
|
|
||||||
* Good, because mature, free, the standard for automated CWV in CI.
|
- Good, because mature, free, the standard for automated CWV in CI.
|
||||||
* Good, because integrates trivially with any CI runner; uploads HTML reports as build artefacts for triage.
|
- Good, because integrates trivially with any CI runner; uploads HTML reports as build artefacts for triage.
|
||||||
* Bad, because score variability needs mitigation (median of 3, pinned version) — covered.
|
- Bad, because score variability needs mitigation (median of 3, pinned version) — covered.
|
||||||
|
|
||||||
#### WebPageTest API
|
#### WebPageTest API
|
||||||
|
|
||||||
* Good, because more granular network/profile control.
|
- Good, because more granular network/profile control.
|
||||||
* Bad, because requires a paid account or self-hosted instance; less plug-and-play in CI.
|
- Bad, because requires a paid account or self-hosted instance; less plug-and-play in CI.
|
||||||
|
|
||||||
#### Calibre / Speedlify
|
#### Calibre / Speedlify
|
||||||
|
|
||||||
* Good, because modern, well-presented dashboards.
|
- Good, because modern, well-presented dashboards.
|
||||||
* Bad, because younger / less established than Lighthouse CI; reconsidered later if Lighthouse CI proves insufficient.
|
- Bad, because younger / less established than Lighthouse CI; reconsidered later if Lighthouse CI proves insufficient.
|
||||||
|
|
||||||
### Threshold values
|
### Threshold values
|
||||||
|
|
||||||
#### Google "Good" + Lighthouse ≥ 90 (chosen)
|
#### Google "Good" + Lighthouse ≥ 90 (chosen)
|
||||||
|
|
||||||
* Good, because the de facto industry baseline; exceeding it is a clear positive signal.
|
- Good, because the de facto industry baseline; exceeding it is a clear positive signal.
|
||||||
* Bad, because not the strictest possible — a brand whose mission is accessibility might warrant tighter values. Mitigated by quarterly tightening.
|
- Bad, because not the strictest possible — a brand whose mission is accessibility might warrant tighter values. Mitigated by quarterly tightening.
|
||||||
|
|
||||||
#### Stricter (LCP ≤ 2 s, score ≥ 95)
|
#### Stricter (LCP ≤ 2 s, score ≥ 95)
|
||||||
|
|
||||||
* Good, because reflects an a11y-first mission.
|
- Good, because reflects an a11y-first mission.
|
||||||
* Bad, because flaky in CI runners — false negatives cost more than the marginal user benefit, especially given runner variance.
|
- Bad, because flaky in CI runners — false negatives cost more than the marginal user benefit, especially given runner variance.
|
||||||
|
|
||||||
### RUM
|
### RUM
|
||||||
|
|
||||||
#### None in v1 (chosen)
|
#### None in v1 (chosen)
|
||||||
|
|
||||||
* Good, because zero v1 ops surface; no privacy disclosure to make about browser-side data collection.
|
- Good, because zero v1 ops surface; no privacy disclosure to make about browser-side data collection.
|
||||||
* Bad, because real-user perf data is genuinely useful. Acceptable to defer because Lighthouse CI scheduled prod runs cover the gap.
|
- Bad, because real-user perf data is genuinely useful. Acceptable to defer because Lighthouse CI scheduled prod runs cover the gap.
|
||||||
|
|
||||||
#### RUM SDK (Sentry, Datadog Browser)
|
#### RUM SDK (Sentry, Datadog Browser)
|
||||||
|
|
||||||
* Good, because real data, real users, real distribution.
|
- Good, because real data, real users, real distribution.
|
||||||
* Bad, because adds a runtime SDK to the SPA, sends data to a third party (or our own collector with cost), and requires a privacy-disclosure update to the accessibility/legal pages.
|
- Bad, because adds a runtime SDK to the SPA, sends data to a third party (or our own collector with cost), and requires a privacy-disclosure update to the accessibility/legal pages.
|
||||||
|
|
||||||
## More Information
|
## More Information
|
||||||
|
|
||||||
* Web Vitals overview: https://web.dev/vitals/
|
- Web Vitals overview: https://web.dev/vitals/
|
||||||
* Core Web Vitals (Google): https://web.dev/articles/vitals
|
- Core Web Vitals (Google): https://web.dev/articles/vitals
|
||||||
* Lighthouse CI: https://github.com/GoogleChrome/lighthouse-ci
|
- Lighthouse CI: https://github.com/GoogleChrome/lighthouse-ci
|
||||||
* Angular budgets: https://angular.dev/tools/cli/build#configuring-size-budgets
|
- Angular budgets: https://angular.dev/tools/cli/build#configuring-size-budgets
|
||||||
* `source-map-explorer`: https://github.com/danvk/source-map-explorer
|
- `source-map-explorer`: https://github.com/danvk/source-map-explorer
|
||||||
* Related ADRs: [ADR-0004](0004-frontend-stack-angular-csr-zoneless-signals.md) (Angular stack — CSR perf profile), [ADR-0012](0012-observability-pino-opentelemetry.md) (OTel — basis for BFF SLOs), [ADR-0015](0015-cicd-gitea-actions.md) (CI gates — `perf` slot now wired), [ADR-0016](0016-accessibility-baseline-wcag-aa-targeted-aaa.md) (a11y / perf trade-off rule).
|
- Related ADRs: [ADR-0004](0004-frontend-stack-angular-csr-zoneless-signals.md) (Angular stack — CSR perf profile), [ADR-0012](0012-observability-pino-opentelemetry.md) (OTel — basis for BFF SLOs), [ADR-0015](0015-cicd-gitea-actions.md) (CI gates — `perf` slot now wired), [ADR-0016](0016-accessibility-baseline-wcag-aa-targeted-aaa.md) (a11y / perf trade-off rule).
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# Architectural Decision Records
|
||||||
|
|
||||||
|
This project records architecturally-significant decisions as **ADRs** in the [MADR 4.0.0](https://github.com/adr/madr) format. References: [adr.github.io](https://adr.github.io/).
|
||||||
|
|
||||||
|
## Why ADRs
|
||||||
|
|
||||||
|
ADRs capture the _why_ behind a decision — context, drivers, options considered, trade-offs accepted — at the moment the decision is made. They make architecture reviewable, onboarding faster, and prevent the same debate from being re-litigated later.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- **Format:** MADR 4.0.0. Start from [template.md](template.md).
|
||||||
|
- **Filename:** `NNNN-kebab-case-title.md`, e.g. `0007-adopt-tailwind-for-design-tokens.md`.
|
||||||
|
- **Numbering:** globally sequential 4-digit prefix. Numbers never reset, never get reused — even when an ADR is superseded or deprecated.
|
||||||
|
- **Layout:** flat folder. ADRs are not nested into category subfolders; topical organization happens via tags.
|
||||||
|
- **Tags:** every ADR carries a `tags:` array in the MADR frontmatter, drawn from the [tag vocabulary](#tag-vocabulary) below. An ADR may carry several tags. Propose new tags (or renames) in the same PR that needs them; never invent ad-hoc tags inline.
|
||||||
|
- **Status lifecycle:** `proposed` → `accepted` → optionally `deprecated` or `superseded by [ADR-NNNN](NNNN-other.md)`. Update the YAML frontmatter; never delete an ADR.
|
||||||
|
- **Index maintenance:** every ADR addition or status change must update the [Index](#index) below in the same commit.
|
||||||
|
|
||||||
|
## When to write an ADR
|
||||||
|
|
||||||
|
Write one whenever a development decision is non-trivial: tool or library choice, framework pattern, security control, perf budget, a11y target, naming convention, deprecation, breaking change, or any choice that future contributors would benefit from understanding the _why_ of.
|
||||||
|
|
||||||
|
## Tag vocabulary
|
||||||
|
|
||||||
|
The vocabulary below is the source of truth. It is intentionally coarse — propose extensions only when an existing tag genuinely doesn't fit, and avoid overly narrow tags.
|
||||||
|
|
||||||
|
| Tag | Scope |
|
||||||
|
| ---------------- | ------------------------------------------------------------------- |
|
||||||
|
| `frontend` | UI, Angular, components, design system, client-side state |
|
||||||
|
| `backend` | API, BFF, server-side services |
|
||||||
|
| `security` | AuthN, AuthZ, sessions, CSP, dependency scanning, secret management |
|
||||||
|
| `performance` | Perf budgets, caching, bundle size, Lighthouse |
|
||||||
|
| `accessibility` | WCAG, a11y testing, keyboard, ARIA, contrast |
|
||||||
|
| `infrastructure` | CI/CD, hosting, deployment, runtime |
|
||||||
|
| `observability` | Logs, metrics, traces, correlation IDs, monitoring |
|
||||||
|
| `data` | Persistence, schemas, migrations, data flow |
|
||||||
|
| `process` | Team conventions, workflows, repo policy |
|
||||||
|
|
||||||
|
> _Status: starter vocabulary, to be refined as ADRs accumulate. Update this table whenever a tag is added, renamed, or retired._
|
||||||
|
|
||||||
|
## Index
|
||||||
|
|
||||||
|
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 |
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
---
|
---
|
||||||
# `tags` is REQUIRED for this project — pick from the vocabulary in decisions/README.md.
|
# `tags` is REQUIRED for this project — pick from the vocabulary in docs/decisions/README.md.
|
||||||
# The other fields are standard MADR 4.0.0 metadata; remove any that don't apply.
|
# The other fields are standard MADR 4.0.0 metadata; remove any that don't apply.
|
||||||
status: "{proposed | rejected | accepted | deprecated | … | superseded by [ADR-NNNN](NNNN-example.md)}"
|
status: '{proposed | rejected | accepted | deprecated | … | superseded by [ADR-NNNN](NNNN-example.md)}'
|
||||||
date: {YYYY-MM-DD when the decision was last updated}
|
date: { YYYY-MM-DD when the decision was last updated }
|
||||||
decision-makers: {list everyone involved in the decision}
|
decision-makers: { list everyone involved in the decision }
|
||||||
consulted: {list everyone whose opinions are sought (typically subject-matter experts); two-way communication}
|
consulted:
|
||||||
informed: {list everyone who is kept up-to-date on progress; one-way communication}
|
{
|
||||||
|
list everyone whose opinions are sought (typically subject-matter experts); two-way communication,
|
||||||
|
}
|
||||||
|
informed: { list everyone who is kept up-to-date on progress; one-way communication }
|
||||||
tags: [<tag1>, <tag2>]
|
tags: [<tag1>, <tag2>]
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -16,57 +19,62 @@ tags: [<tag1>, <tag2>]
|
|||||||
{Describe the context and problem statement, e.g., in free form using two to three sentences or in the form of an illustrative story. You may want to articulate the problem in form of a question and add links to collaboration boards or issue management systems.}
|
{Describe the context and problem statement, e.g., in free form using two to three sentences or in the form of an illustrative story. You may want to articulate the problem in form of a question and add links to collaboration boards or issue management systems.}
|
||||||
|
|
||||||
<!-- This is an optional element. Feel free to remove. -->
|
<!-- This is an optional element. Feel free to remove. -->
|
||||||
|
|
||||||
## Decision Drivers
|
## Decision Drivers
|
||||||
|
|
||||||
* {decision driver 1, e.g., a force, facing concern, …}
|
- {decision driver 1, e.g., a force, facing concern, …}
|
||||||
* {decision driver 2, e.g., a force, facing concern, …}
|
- {decision driver 2, e.g., a force, facing concern, …}
|
||||||
* …
|
- …
|
||||||
|
|
||||||
## Considered Options
|
## Considered Options
|
||||||
|
|
||||||
* {title of option 1}
|
- {title of option 1}
|
||||||
* {title of option 2}
|
- {title of option 2}
|
||||||
* {title of option 3}
|
- {title of option 3}
|
||||||
* …
|
- …
|
||||||
|
|
||||||
## Decision Outcome
|
## Decision Outcome
|
||||||
|
|
||||||
Chosen option: "{title of option 1}", because {justification, e.g., only option which meets k.o. criterion / resolves force / comes out best (see below)}.
|
Chosen option: "{title of option 1}", because {justification, e.g., only option which meets k.o. criterion / resolves force / comes out best (see below)}.
|
||||||
|
|
||||||
<!-- This is an optional element. Feel free to remove. -->
|
<!-- This is an optional element. Feel free to remove. -->
|
||||||
|
|
||||||
### Consequences
|
### Consequences
|
||||||
|
|
||||||
* Good, because {positive consequence, e.g., improvement of one or more desired qualities, …}
|
- Good, because {positive consequence, e.g., improvement of one or more desired qualities, …}
|
||||||
* Bad, because {negative consequence, e.g., compromising one or more desired qualities, …}
|
- Bad, because {negative consequence, e.g., compromising one or more desired qualities, …}
|
||||||
* …
|
- …
|
||||||
|
|
||||||
<!-- This is an optional element. Feel free to remove. -->
|
<!-- This is an optional element. Feel free to remove. -->
|
||||||
|
|
||||||
### Confirmation
|
### Confirmation
|
||||||
|
|
||||||
{Describe how the implementation of / compliance with the ADR is confirmed. E.g., by a review, an ArchUnit test, a Lighthouse CI threshold, an axe-core assertion. Although classified as optional, this element is included in most ADRs.}
|
{Describe how the implementation of / compliance with the ADR is confirmed. E.g., by a review, an ArchUnit test, a Lighthouse CI threshold, an axe-core assertion. Although classified as optional, this element is included in most ADRs.}
|
||||||
|
|
||||||
<!-- This is an optional element. Feel free to remove. -->
|
<!-- This is an optional element. Feel free to remove. -->
|
||||||
|
|
||||||
## Pros and Cons of the Options
|
## Pros and Cons of the Options
|
||||||
|
|
||||||
### {title of option 1}
|
### {title of option 1}
|
||||||
|
|
||||||
{example | description | pointer to more information | …}
|
{example | description | pointer to more information | …}
|
||||||
|
|
||||||
* Good, because {argument a}
|
- Good, because {argument a}
|
||||||
* Good, because {argument b}
|
- Good, because {argument b}
|
||||||
* Neutral, because {argument c}
|
- Neutral, because {argument c}
|
||||||
* Bad, because {argument d}
|
- Bad, because {argument d}
|
||||||
|
|
||||||
### {title of option 2}
|
### {title of option 2}
|
||||||
|
|
||||||
{example | description | pointer to more information | …}
|
{example | description | pointer to more information | …}
|
||||||
|
|
||||||
* Good, because {argument a}
|
- Good, because {argument a}
|
||||||
* Good, because {argument b}
|
- Good, because {argument b}
|
||||||
* Neutral, because {argument c}
|
- Neutral, because {argument c}
|
||||||
* Bad, because {argument d}
|
- Bad, because {argument d}
|
||||||
|
|
||||||
<!-- This is an optional element. Feel free to remove. -->
|
<!-- This is an optional element. Feel free to remove. -->
|
||||||
|
|
||||||
## More Information
|
## More Information
|
||||||
|
|
||||||
{Additional evidence/confidence for the decision; team agreement; when and how the decision should be realized; consequences for other decisions, projects, or teams; links to related ADRs.}
|
{Additional evidence/confidence for the decision; team agreement; when and how the decision should be realized; consequences for other decisions, projects, or teams; links to related ADRs.}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# 🏗️ Bootstrap the Nx monorepo for apf-portal
|
# 🏗️ Bootstrap the Nx monorepo for apf-portal
|
||||||
|
|
||||||
> **Status.** Aligned with phase-1 ADRs ([0002](../../decisions/0002-adopt-nx-monorepo-apps-preset.md), [0003](../../decisions/0003-workspace-and-app-naming-convention.md), [0004](../../decisions/0004-frontend-stack-angular-csr-zoneless-signals.md), [0005](../../decisions/0005-backend-stack-nestjs.md), [0006](../../decisions/0006-persistence-postgresql-prisma.md)). Replaces the original placeholder-based draft.
|
> **Status.** Aligned with phase-1 ADRs ([0002](../decisions/0002-adopt-nx-monorepo-apps-preset.md), [0003](../decisions/0003-workspace-and-app-naming-convention.md), [0004](../decisions/0004-frontend-stack-angular-csr-zoneless-signals.md), [0005](../decisions/0005-backend-stack-nestjs.md), [0006](../decisions/0006-persistence-postgresql-prisma.md)). Replaces the original placeholder-based draft.
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ Out of scope here (covered by later phases): authentication (Entra ID), sessions
|
|||||||
|
|
||||||
## 1) Initialize the Nx workspace inside the existing repository
|
## 1) Initialize the Nx workspace inside the existing repository
|
||||||
|
|
||||||
The repository already carries documentation, ADRs, `.vscode/`, and `.gitignore`. `create-nx-workspace` always generates into a *new* directory, so we generate next to the repo and copy the workspace files in.
|
The repository already carries documentation, ADRs, `.vscode/`, and `.gitignore`. `create-nx-workspace` always generates into a _new_ directory, so we generate next to the repo and copy the workspace files in.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Generate a clean Nx workspace next to the repo
|
# 1. Generate a clean Nx workspace next to the repo
|
||||||
@@ -47,7 +47,7 @@ cp -v package.json pnpm-workspace.yaml nx.json tsconfig.base.json \
|
|||||||
cd ~ && rm -rf ~/dev/apf-portal-bootstrap
|
cd ~ && rm -rf ~/dev/apf-portal-bootstrap
|
||||||
```
|
```
|
||||||
|
|
||||||
The `apps` preset is generic — it does not privilege a runtime ([ADR-0002](../../decisions/0002-adopt-nx-monorepo-apps-preset.md)). Plugins are added in step 2.
|
The `apps` preset is generic — it does not privilege a runtime ([ADR-0002](../decisions/0002-adopt-nx-monorepo-apps-preset.md)). Plugins are added in step 2.
|
||||||
|
|
||||||
Verify:
|
Verify:
|
||||||
|
|
||||||
@@ -92,10 +92,7 @@ import { provideRouter } from '@angular/router';
|
|||||||
import { routes } from './app.routes';
|
import { routes } from './app.routes';
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [provideZonelessChangeDetection(), provideRouter(routes)],
|
||||||
provideZonelessChangeDetection(),
|
|
||||||
provideRouter(routes),
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -122,8 +119,8 @@ In `tsconfig.base.json`:
|
|||||||
"noUncheckedIndexedAccess": true,
|
"noUncheckedIndexedAccess": true,
|
||||||
"exactOptionalPropertyTypes": true,
|
"exactOptionalPropertyTypes": true,
|
||||||
"noImplicitOverride": true,
|
"noImplicitOverride": true,
|
||||||
"noPropertyAccessFromIndexSignature": true
|
"noPropertyAccessFromIndexSignature": true,
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -189,9 +186,7 @@ import { Module } from '@nestjs/common';
|
|||||||
import { PrismaModule } from 'nestjs-prisma';
|
import { PrismaModule } from 'nestjs-prisma';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [PrismaModule.forRoot({ isGlobal: true })],
|
||||||
PrismaModule.forRoot({ isGlobal: true }),
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
```
|
```
|
||||||
@@ -208,7 +203,8 @@ DATABASE_URL="postgresql://portal:portal@localhost:5432/portal_dev?schema=public
|
|||||||
|
|
||||||
## 6) Generate the first libraries
|
## 6) Generate the first libraries
|
||||||
|
|
||||||
Convention ([ADR-0003](../../decisions/0003-workspace-and-app-naming-convention.md)):
|
Convention ([ADR-0003](../decisions/0003-workspace-and-app-naming-convention.md)):
|
||||||
|
|
||||||
- `libs/feature/<name>` for vertical features;
|
- `libs/feature/<name>` for vertical features;
|
||||||
- `libs/shared/<scope>` for cross-cutting concerns.
|
- `libs/shared/<scope>` for cross-cutting concerns.
|
||||||
|
|
||||||
@@ -262,7 +258,7 @@ This prevents `portal-shell` from importing `portal-bff` code (and vice versa),
|
|||||||
|
|
||||||
## 8) Prettier, git hooks, and Conventional Commits
|
## 8) Prettier, git hooks, and Conventional Commits
|
||||||
|
|
||||||
See [ADR-0007](../../decisions/0007-pre-commit-hooks-and-conventional-commits.md) for the rationale (Husky + lint-staged + commitlint with Conventional Commits).
|
See [ADR-0007](../decisions/0007-pre-commit-hooks-and-conventional-commits.md) for the rationale (Husky + lint-staged + commitlint with Conventional Commits).
|
||||||
|
|
||||||
`.prettierrc` (project-wide):
|
`.prettierrc` (project-wide):
|
||||||
|
|
||||||
@@ -344,10 +340,10 @@ pnpm nx g @nx/nest:resource <name> --project=portal-bff
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## What this guide does *not* cover (yet)
|
## What this guide does _not_ cover (yet)
|
||||||
|
|
||||||
| Concern | Phase |
|
| Concern | Phase |
|
||||||
| --- | --- |
|
| ------------------------------------------------------------------------------------------ | ----- |
|
||||||
| Authentication (Entra ID workforce + External ID) | 2 |
|
| Authentication (Entra ID workforce + External ID) | 2 |
|
||||||
| Sessions / cache (Redis self-hosted) | 2 |
|
| Sessions / cache (Redis self-hosted) | 2 |
|
||||||
| Downstream API access (existing apps) | 2 |
|
| Downstream API access (existing apps) | 2 |
|
||||||
|
|||||||
Reference in New Issue
Block a user