From 312791b74e7665d2fdc18713616ed2ee5f8675b4 Mon Sep 17 00:00:00 2001 From: Julien Gautier Date: Thu, 30 Apr 2026 20:55:21 +0200 Subject: [PATCH 1/2] docs: add docs/architecture.md with C4 contexts, Nx boundaries, and CI/CD pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-cutting visual reference for the architecture, written in Mermaid (text in markdown, rendered natively by Gitea / GitHub / IDE viewers, diffable in PR). Four diagrams that summarise decisions spread across multiple ADRs: 1. C4 level 1 - System Context. The portal as a black box with its workforce/customer/IT/RSSI actors and Entra ID + downstream APIs as external systems. Customer audience and Entra External ID are shown dashed (future scope per ADR-0008's dual-audience design). 2. C4 level 2 - Containers. Browser-side portal-shell, on-prem BFF, Postgres (public + audit schemas), Redis, local OTel Collector, plus external Entra ID and downstream APIs. Annotates the wire protocols (HTTPS + __Host- cookies, OIDC, OBO/signed assertion, OTLP, ioredis with AES-GCM at rest, traceparent end-to-end). 3. Nx module boundaries. The Project graph rendered with the depConstraints from ADR-0003 (scope axis: portal-shell / portal-bff / shared, plus type axis: app / feature / shared). Forbidden directions called out below the diagram. 4. CI/CD pipeline. Local hooks → push → PR → 5 parallel CI jobs (check / scan / commits / perf / a11y) → branch protection → squash-merge → tag → release. Includes the weekly scheduled security-scheduled.yml workflow (full-tree scan + prod Lighthouse). Convention adopted at the top of architecture.md: cross-cutting diagrams live here; single-ADR diagrams live inline in the ADR itself (sequence flows, ERDs, lifecycle diagrams, etc.). The 'To be added' section at the bottom maps each future diagram to where it will land and what triggers its addition. docs/README.md index updated with a new 'Architecture' section linking to architecture.md, and the previous empty 'Architecture' placeholder removed (the placeholder was a tick-the-box section that violated the doc convention 'documentation when genuinely useful, not just to tick a box'). --- docs/README.md | 8 +- docs/architecture.md | 196 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 200 insertions(+), 4 deletions(-) create mode 100644 docs/architecture.md diff --git a/docs/README.md b/docs/README.md index ac96a0a..5d70a6d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,6 +15,10 @@ This is the entry point to all project documentation. It is maintained automatic - [development.md](development.md) — repo layout, prerequisites, initial setup, daily commands, conventional commit cycle. Day-to-day reference for working on the project. +### Architecture + +- [architecture.md](architecture.md) — cross-cutting Mermaid diagrams: C4 system context, C4 containers, Nx module boundaries, CI/CD pipeline. Single-decision diagrams (auth sequence, ERD, etc.) live inline in their ADR. + ### Onboarding & environment Setup guides for new contributors: @@ -23,10 +27,6 @@ Setup guides for new contributors: - [setup/02-dev-web-stack.md](setup/02-dev-web-stack.md) — Node via nvm, pnpm via corepack, Docker - [setup/03-angular-nx-monorepo.md](setup/03-angular-nx-monorepo.md) — Angular + Nx monorepo bootstrap -### Architecture - -_Empty — to be populated as the project grows._ - ### Operations & runbooks _Empty — to be populated when we deploy._ diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..5cf3ecd --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,196 @@ +# Architecture diagrams + +Visual cross-cuts of `apf_portal`'s architecture. Each diagram summarises decisions that are spread across several ADRs and exists to help a contributor (or auditor, RSSI, IT contact) build a mental model fast. Decisions themselves live in [decisions/](decisions/); diagrams reflect the current accepted state. + +Diagrams are written in [Mermaid](https://mermaid.js.org/) — text in markdown, rendered natively by Gitea, GitHub, and most IDE markdown viewers. Updating a diagram is an ordinary text edit reviewable in PR. + +When a diagram is the _content_ of a single decision (e.g. a sequence diagram that captures a flow described by one ADR), it lives **inside that ADR**, not here. This file holds the _cross-cutting_ views. + +--- + +## 1. System context (C4 level 1) + +The portal as a black box, with the actors that interact with it and the external systems it depends on. + +Sources: [ADR-0008](decisions/0008-identity-model-entra-workforce-dual-audience.md) (identity model), [ADR-0014](decisions/0014-downstream-api-access-obo-pattern.md) (downstream APIs), [ADR-0013](decisions/0013-audit-trail-separated-postgres-append-only.md) (audit access). + +```mermaid +flowchart TB + workforce([Workforce user
employee]) + customer([Customer user
external
future]) + it([IT / Identity admin]) + rssi([RSSI / SOC analyst]) + + portal[apf_portal
web portal centralising
access to existing apps] + + entra[(Microsoft Entra ID
workforce tenant
+ M365 Developer dev tenant)] + extid[(Microsoft Entra External ID
customer tenant
future)] + downstream[(Downstream APIs
existing applications
integrated by the portal)] + + workforce -->|browser HTTPS| portal + customer -.->|browser HTTPS
future| portal + portal -->|OIDC Auth Code + PKCE| entra + portal -.->|OIDC Auth Code + PKCE
future| extid + portal -->|OBO or signed assertion| downstream + it -->|configures
tenant + Conditional Access
+ B2B invitations| entra + rssi -->|reads audit events
via audit_reader role| portal + + classDef future stroke-dasharray: 5 5,opacity:0.7 + class customer,extid future +``` + +Dashed = future scope (not v1). The customer audience is _designed for_ but not implemented (per the dual-audience design in ADR-0008); it is shown to make the future expansion legible. + +--- + +## 2. Containers (C4 level 2) + +The runtime artefacts and their conversations. One step deeper than system context: what is actually deployed. + +Sources: [ADR-0002](decisions/0002-adopt-nx-monorepo-apps-preset.md) (Nx layout), [ADR-0004](decisions/0004-frontend-stack-angular-csr-zoneless-signals.md) (Angular SPA), [ADR-0005](decisions/0005-backend-stack-nestjs.md) (NestJS BFF), [ADR-0006](decisions/0006-persistence-postgresql-prisma.md) (Postgres + Prisma), [ADR-0009](decisions/0009-auth-flow-oidc-pkce-msal-node.md) (auth flow), [ADR-0010](decisions/0010-session-management-redis.md) (sessions in Redis), [ADR-0012](decisions/0012-observability-pino-opentelemetry.md) (OTel collector), [ADR-0014](decisions/0014-downstream-api-access-obo-pattern.md) (downstream calls). + +```mermaid +flowchart TB + user([Workforce user]) + + subgraph Browser["Browser"] + spa["portal-shell
Angular 21 SPA
zoneless / Signals / Tailwind 4"] + end + + subgraph OnPrem["On-prem deployment"] + bff["portal-bff
NestJS 11 / Node 24
Express adapter"] + pg[("Postgres 17
schemas: public + audit
RLS for dual-audience")] + redis[("Redis
sessions + OBO token cache
AES-256-GCM at rest")] + otel["OTel Collector
local sidecar
(OTLP → backend, future)"] + end + + entra[(Microsoft Entra ID)] + downstream[(Downstream APIs)] + + user -->|HTTPS| spa + spa -->|"HTTPS
__Host-portal_session
X-CSRF-Token
traceparent"| bff + spa -. "OTLP / HTTP
(browser spans)" .-> otel + bff -->|"OIDC Auth Code + PKCE
via @azure/msal-node"| entra + bff -->|Prisma 7| pg + bff -->|"ioredis
(opaque session id
→ encrypted blob)"| redis + bff -->|"OBO token (Entra-protected)
or signed X-User-Assertion JWT
+ traceparent"| downstream + bff -. "OTLP / HTTP
(server spans + Pino logs)" .-> otel +``` + +Notes embedded in the diagram: + +- The SPA carries no token. Only the opaque session id cookie (`__Host-portal_session`) plus the CSRF cookie (`__Host-portal_csrf`, read by JS for double-submit). +- `traceparent` (W3C) propagates from the browser through the BFF to the downstream APIs — same `trace_id` end-to-end. +- The OTel Collector is the only piece coupled to the eventual on-prem observability backend. Choice of backend (Grafana Loki + Tempo, ELK, …) is deferred to the on-prem infrastructure ADR. + +--- + +## 3. Nx module boundaries + +Which projects are allowed to depend on which. Encoded in `eslint.config.mjs` via `@nx/enforce-module-boundaries` with the `depConstraints` from [ADR-0003](decisions/0003-workspace-and-app-naming-convention.md). + +Each project carries two tags: + +- **`scope:`** — `portal-shell`, `portal-bff`, or `shared` (for libs consumable by both apps). +- **`type:`** — `app`, `feature`, or `shared`. + +A dependency is allowed only if both axes permit it. + +```mermaid +flowchart LR + subgraph Apps["apps · type:app"] + shell["portal-shell
scope:portal-shell"] + bff["portal-bff
scope:portal-bff"] + end + + subgraph FeatureLibs["libs · type:feature"] + fauth["feature-auth
scope:portal-shell"] + end + + subgraph SharedShellLibs["libs · type:shared · scope:portal-shell"] + sui["shared-ui
Angular components
(spartan-style on CDK)"] + end + + subgraph SharedAnyLibs["libs · type:shared · scope:shared"] + stokens["shared-tokens
design tokens (a11y)"] + sutil["shared-util
cross-runtime TS helpers"] + end + + shell --> fauth + shell --> sui + shell --> stokens + shell --> sutil + + bff --> stokens + bff --> sutil + + fauth --> sui + fauth --> stokens + fauth --> sutil + + sui --> stokens + + classDef forbidden stroke:#c00,stroke-dasharray: 4 4 +``` + +Forbidden by the depConstraints (and lint-enforced) — examples: + +- `portal-bff` ⟶ `shared-ui` (back imports Angular code: scope clash). +- `portal-bff` ⟶ `feature-auth` (back imports a frontend feature). +- `shared-tokens` ⟶ `shared-ui` (a `type:shared` lib cannot reach a `type:shared` lib in a narrower scope, nor a feature lib). +- Any lib ⟶ any app. + +--- + +## 4. CI/CD pipeline + +How a change moves from a developer's keyboard to `main`. Reflects [ADR-0007](decisions/0007-pre-commit-hooks-and-conventional-commits.md) (local hooks), [ADR-0015](decisions/0015-cicd-gitea-actions.md) (Gitea Actions, trunk-based + squash-merge), [ADR-0016](decisions/0016-accessibility-baseline-wcag-aa-targeted-aaa.md) (a11y gate), [ADR-0017](decisions/0017-performance-budgets-lighthouse-ci.md) (perf gate). + +```mermaid +flowchart TB + edit[Edit on feat/*, fix/*, chore/*, docs/* branch] + pre[".husky/pre-commit
lint-staged → prettier on staged"] + msg[".husky/commit-msg
commitlint → Conventional Commits"] + push[git push origin feat/*] + pr[Open PR → main] + + subgraph PRJobs["CI on PR · .gitea/workflows/ci.yml · all blocking"] + direction LR + j1["check
nx affected -t
format / lint / test / build"] + j2["scan
pnpm audit + Trivy + gitleaks"] + j3["commits
commitlint on PR range"] + j4["perf
Lighthouse CI
CWV thresholds (ADR-0017)"] + j5["a11y
axe-core via Playwright
(placeholder until first screens)"] + end + + protection{"Branch protection on main
· all 5 jobs green
· ≥0 reviewers (v1, →≥1 with 2nd contributor)
· linear history
· no direct push, no force push"} + squash[Squash-merge
subject = Conventional Commits
(becomes the commit on main)] + tag[Tag vX.Y.Z] + release[".gitea/workflows/release.yml
(stub today, populated with on-prem deploy ADR)"] + + edit --> pre --> msg --> push --> pr + pr --> PRJobs --> protection -->|all green| squash --> tag --> release + + cron["Weekly cron · Mon 04:00 UTC"] + sched[".gitea/workflows/security-scheduled.yml
full-tree Trivy + gitleaks
+ Lighthouse on prod (when LHCI_PROD_URL set)"] + cron --> sched +``` + +Note the parallelism: the five PR jobs run **in parallel**. The diagram shows the gate as one square because branch protection requires _all five_ before squash-merge is allowed. + +--- + +## To be added + +As features land, the following diagrams will be added — either here, or inline in the ADR they belong to (per the convention stated at the top: _cross-cutting → here, single-decision → in the ADR_). + +| Diagram | Where it will land | Triggered by | +| ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------- | ----------------------------------------------------- | +| **OIDC Auth Code + PKCE sequence** | inline in [ADR-0009](decisions/0009-auth-flow-oidc-pkce-msal-node.md) | already useful — added at the same time as this file | +| **Trace context propagation** (SPA → BFF → DB → downstream) | here | first observability instrumentation lands | +| **Dual-audience flow** (token validation, claim → enum, RLS filtering) | here or split between ADRs 0008/0009/0013 | first authz code that touches the audience | +| **Step-up MFA flow** (claims challenge round-trip) | inline in [ADR-0011](decisions/0011-mfa-enforcement-entra-conditional-access.md) | first `@RequireMfa()` route | +| **Database ERD** | inline in [ADR-0006](decisions/0006-persistence-postgresql-prisma.md) or a dedicated `data.md` | first business model in `schema.prisma` | +| **Audit event lifecycle** (writer → store → archiver → reader) | inline in [ADR-0013](decisions/0013-audit-trail-separated-postgres-append-only.md) | audit module shipped | +| **Downstream call lifecycle** (audience pre-check → strategy → cache → resilience → trace) | inline in [ADR-0014](decisions/0014-downstream-api-access-obo-pattern.md) | first downstream integration | +| **Trust boundaries** (security review view) | here | when the RSSI requests a security architecture review | From 4b8d0789b1723c8eb7ca0f5b702898fff121264a Mon Sep 17 00:00:00 2001 From: Julien Gautier Date: Thu, 30 Apr 2026 21:02:08 +0200 Subject: [PATCH 2/2] docs: add inline OIDC sequence diagram to ADR-0009 Render the OAuth 2.0 Authorization Code + PKCE flow as a Mermaid sequence diagram at the top of the Decision Outcome section. Five participants (user, SPA, BFF, Entra, Redis), thirteen autonumbered steps covering the authorize redirect, the user-side authentication (with the Conditional Access MFA enforcement annotated), the callback, the state verification, the token exchange, the id_token validation pipeline (signature, iss against the tenant allowlist, aud, exp/nbf, amr sanity-check, audience-claim mapping), the encrypted session write to Redis, and the cookie set. Inline rather than in docs/architecture.md per the convention stated at the top of architecture.md: cross-cutting diagrams live in the architecture file, single-decision diagrams live inside the ADR they visualise. ADR-0009 IS the auth flow decision; the sequence diagram belongs here. Cross-references the related ADRs (0010 sessions, 0011 MFA, 0008 audience model) so the diagram reads as the integration point of the security stack rather than as an isolated picture. --- .../0009-auth-flow-oidc-pkce-msal-node.md | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/docs/decisions/0009-auth-flow-oidc-pkce-msal-node.md b/docs/decisions/0009-auth-flow-oidc-pkce-msal-node.md index d0b2fb6..147fbe1 100644 --- a/docs/decisions/0009-auth-flow-oidc-pkce-msal-node.md +++ b/docs/decisions/0009-auth-flow-oidc-pkce-msal-node.md @@ -60,6 +60,37 @@ The SPA must never hold tokens — that is the BFF security pattern, recommended ## Decision Outcome +The end-to-end flow at a glance: + +```mermaid +sequenceDiagram + autonumber + actor U as User (browser) + participant SPA as portal-shell + participant BFF as portal-bff + participant E as Microsoft Entra ID + participant R as Redis (session store) + + U->>SPA: clicks "Sign in" + SPA->>BFF: GET /auth/login?returnTo=… + BFF->>BFF: generate state + nonce
+ code_verifier + code_challenge + BFF-->>U: 302 → Entra /authorize
(client_id, code_challenge, state, nonce, scope) + U->>E: authenticate
(Conditional Access enforces MFA) + E-->>U: 302 → BFF /auth/callback?code&state + U->>BFF: GET /auth/callback?code&state + + BFF->>BFF: verify state matches
(CSRF defence on the OIDC roundtrip) + BFF->>E: POST /token
(code + code_verifier + client_secret/cert) + E-->>BFF: id_token + access_token + refresh_token + + BFF->>BFF: validate id_token
(sig, iss in allowlist, aud, exp/nbf)
+ sanity-check amr (ADR-0011)
+ map audience claim → Audience enum + BFF->>R: SET session:{opaque_id}
payload incl. tokens encrypted AES-256-GCM (ADR-0010) + BFF-->>U: 302 → returnTo
+ Set-Cookie __Host-portal_session
+ Set-Cookie __Host-portal_csrf + U->>SPA: render destination (cookies attached on subsequent calls) +``` + +The numbered steps line up with the prose below: library / flow / tokens / token validation / refresh / cookies / CSRF / routes / auth layer. + **Library.** `@azure/msal-node`, instance of `ConfidentialClientApplication`, configured with the multi-tenant authority and the tenant allowlist from ADR-0008. PKCE is used despite the confidential-client setup, per IETF current BCP. **Flow.** OAuth 2.0 Authorization Code Flow with PKCE, executed entirely on the BFF. The SPA never sees `code`, `code_verifier`, or any token.