Files
apf_portal/docs/architecture.md
T
Julien Gautier 312791b74e docs: add docs/architecture.md with C4 contexts, Nx boundaries, and CI/CD pipeline
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').
2026-04-30 20:55:21 +02:00

11 KiB

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/; diagrams reflect the current accepted state.

Diagrams are written in Mermaid — 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 (identity model), ADR-0014 (downstream APIs), ADR-0013 (audit access).

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 (Nx layout), ADR-0004 (Angular SPA), ADR-0005 (NestJS BFF), ADR-0006 (Postgres + Prisma), ADR-0009 (auth flow), ADR-0010 (sessions in Redis), ADR-0012 (OTel collector), ADR-0014 (downstream calls).

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.

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.

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-bffshared-ui (back imports Angular code: scope clash).
  • portal-bfffeature-auth (back imports a frontend feature).
  • shared-tokensshared-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 (local hooks), ADR-0015 (Gitea Actions, trunk-based + squash-merge), ADR-0016 (a11y gate), ADR-0017 (perf gate).

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 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 first @RequireMfa() route
Database ERD inline in ADR-0006 or a dedicated data.md first business model in schema.prisma
Audit event lifecycle (writer → store → archiver → reader) inline in ADR-0013 audit module shipped
Downstream call lifecycle (audience pre-check → strategy → cache → resilience → trace) inline in ADR-0014 first downstream integration
Trust boundaries (security review view) here when the RSSI requests a security architecture review