Merge pull request 'docs: add docs/architecture.md with C4 contexts, Nx boundaries, and CI/CD pipeline' (#1) from docs/architecture-diagrams into main
CI / check (push) Failing after 5s
CI / commits (push) Has been skipped
CI / scan (push) Failing after 6s
CI / a11y (push) Failing after 2s
CI / perf (push) Failing after 3s

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-05-01 00:45:23 +02:00
3 changed files with 231 additions and 4 deletions
+4 -4
View File
@@ -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. - [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 ### Onboarding & environment
Setup guides for new contributors: 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/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 - [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 ### Operations & runbooks
_Empty — to be populated when we deploy._ _Empty — to be populated when we deploy._
+196
View File
@@ -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<br/>employee])
customer([Customer user<br/>external<br/>future])
it([IT / Identity admin])
rssi([RSSI / SOC analyst])
portal[apf_portal<br/>web portal centralising<br/>access to existing apps]
entra[(Microsoft Entra ID<br/>workforce tenant<br/>+ M365 Developer dev tenant)]
extid[(Microsoft Entra External ID<br/>customer tenant<br/>future)]
downstream[(Downstream APIs<br/>existing applications<br/>integrated by the portal)]
workforce -->|browser HTTPS| portal
customer -.->|browser HTTPS<br/>future| portal
portal -->|OIDC Auth Code + PKCE| entra
portal -.->|OIDC Auth Code + PKCE<br/>future| extid
portal -->|OBO or signed assertion| downstream
it -->|configures<br/>tenant + Conditional Access<br/>+ B2B invitations| entra
rssi -->|reads audit events<br/>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<br/>Angular 21 SPA<br/>zoneless / Signals / Tailwind 4"]
end
subgraph OnPrem["On-prem deployment"]
bff["portal-bff<br/>NestJS 11 / Node 24<br/>Express adapter"]
pg[("Postgres 17<br/>schemas: public + audit<br/>RLS for dual-audience")]
redis[("Redis<br/>sessions + OBO token cache<br/>AES-256-GCM at rest")]
otel["OTel Collector<br/>local sidecar<br/>(OTLP → backend, future)"]
end
entra[(Microsoft Entra ID)]
downstream[(Downstream APIs)]
user -->|HTTPS| spa
spa -->|"HTTPS<br/>__Host-portal_session<br/>X-CSRF-Token<br/>traceparent"| bff
spa -. "OTLP / HTTP<br/>(browser spans)" .-> otel
bff -->|"OIDC Auth Code + PKCE<br/>via @azure/msal-node"| entra
bff -->|Prisma 7| pg
bff -->|"ioredis<br/>(opaque session id<br/>→ encrypted blob)"| redis
bff -->|"OBO token (Entra-protected)<br/>or signed X-User-Assertion JWT<br/>+ traceparent"| downstream
bff -. "OTLP / HTTP<br/>(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<br/>scope:portal-shell"]
bff["portal-bff<br/>scope:portal-bff"]
end
subgraph FeatureLibs["libs · type:feature"]
fauth["feature-auth<br/>scope:portal-shell"]
end
subgraph SharedShellLibs["libs · type:shared · scope:portal-shell"]
sui["shared-ui<br/>Angular components<br/>(spartan-style on CDK)"]
end
subgraph SharedAnyLibs["libs · type:shared · scope:shared"]
stokens["shared-tokens<br/>design tokens (a11y)"]
sutil["shared-util<br/>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<br/>lint-staged → prettier on staged"]
msg[".husky/commit-msg<br/>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<br/>nx affected -t<br/>format / lint / test / build"]
j2["scan<br/>pnpm audit + Trivy + gitleaks"]
j3["commits<br/>commitlint on PR range"]
j4["perf<br/>Lighthouse CI<br/>CWV thresholds (ADR-0017)"]
j5["a11y<br/>axe-core via Playwright<br/>(placeholder until first screens)"]
end
protection{"Branch protection on main<br/>· all 5 jobs green<br/>· ≥0 reviewers (v1, →≥1 with 2nd contributor)<br/>· linear history<br/>· no direct push, no force push"}
squash[Squash-merge<br/>subject = Conventional Commits<br/>(becomes the commit on main)]
tag[Tag vX.Y.Z]
release[".gitea/workflows/release.yml<br/>(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<br/>full-tree Trivy + gitleaks<br/>+ 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 |
@@ -60,6 +60,37 @@ The SPA must never hold tokens — that is the BFF security pattern, recommended
## Decision Outcome ## 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<br/>+ code_verifier + code_challenge
BFF-->>U: 302 → Entra /authorize<br/>(client_id, code_challenge, state, nonce, scope)
U->>E: authenticate<br/>(Conditional Access enforces MFA)
E-->>U: 302 → BFF /auth/callback?code&state
U->>BFF: GET /auth/callback?code&state
BFF->>BFF: verify state matches<br/>(CSRF defence on the OIDC roundtrip)
BFF->>E: POST /token<br/>(code + code_verifier + client_secret/cert)
E-->>BFF: id_token + access_token + refresh_token
BFF->>BFF: validate id_token<br/>(sig, iss in allowlist, aud, exp/nbf)<br/>+ sanity-check amr (ADR-0011)<br/>+ map audience claim → Audience enum
BFF->>R: SET session:{opaque_id}<br/>payload incl. tokens encrypted AES-256-GCM (ADR-0010)
BFF-->>U: 302 → returnTo<br/>+ Set-Cookie __Host-portal_session<br/>+ 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. **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. **Flow.** OAuth 2.0 Authorization Code Flow with PKCE, executed entirely on the BFF. The SPA never sees `code`, `code_verifier`, or any token.