post-auth-track état-des-lieux of docs/architecture.md. three
small lies caught, one diagram added.
containers (§2):
portal-admin (adr-0020) was missing from the c4 level-2 view
despite being scaffolded under apps/portal-admin and shipping
its own session cookie. added as a sibling spa on a distinct
origin with its own session (__host-portal_admin_session),
and the bff box now distinguishes /api/* (end-user) from
/api/admin/* (rbac + @requiremfa).
nx boundaries (§3):
- shared-ui was drawn under scope:portal-shell. its actual tag
in project.json is scope:shared. same for shared-state, which
wasn't on the diagram at all. both moved to the scope:shared
bubble.
- portal-admin absent. added with planned (dashed) edges to the
shared libs; no scope:portal-admin row exists yet in
eslint.config.mjs, that's a follow-up when admin modules grow.
- the "forbidden" examples framed shared-tokens ⟶ shared-ui as a
narrower-scope violation. both are scope:shared / type:shared,
so depConstraints actually permit it. replaced with examples
that are genuinely lint-enforced.
local dev infrastructure (§5, new):
visualises what `docker compose up` starts: postgres + redis +
otel-collector always-up, plus opt-in profiles dbtools (pgweb),
observability (jaeger), serve-static (caddy per adr-0019).
shows ports, named volumes, bring-up cheat sheet, and the
separate ci-runners stack (apf-portal-ci-runners, dedicated
network). source links point at the compose files so a
contributor chases details in one click.
to-be-added table:
trace-context-propagation row updated — its trigger ("first
observability instrumentation lands") was already met. now
flagged as overdue / pending a follow-up.
also carries the one-line mermaid syntax fix on §4's `squash`
node (parens-inside-label needed quoting). pre-existing local
edit.
18 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), ADR-0020 (admin SPA).
flowchart TB
user([Workforce user])
admin([Admin user<br/>role:admin])
subgraph Browser["Browser"]
spa["portal-shell<br/>Angular 21 SPA<br/>zoneless / Signals / Tailwind 4"]
spaAdmin["portal-admin<br/>Angular 21 SPA<br/>distinct origin / bundle"]
end
subgraph OnPrem["On-prem deployment"]
bff["portal-bff<br/>NestJS 11 / Node 24<br/>Express adapter<br/>· /api/* (end-user)<br/>· /api/admin/* (RBAC + @RequireMfa)"]
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
admin -->|HTTPS| spaAdmin
spa -->|"HTTPS<br/>__Host-portal_session<br/>X-CSRF-Token<br/>traceparent"| bff
spaAdmin -->|"HTTPS<br/>distinct session cookie<br/>(separate sign-in flow,<br/>fresh-MFA enforced)<br/>traceparent"| bff
spa -. "OTLP / HTTP<br/>(browser spans)" .-> otel
spaAdmin -. "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:
- Two SPAs, one BFF.
portal-shellis the end-user surface;portal-adminis a separate Angular app on a distinct origin with its own bundle, sign-in flow, and session cookie (per ADR-0020). They share libs but never a runtime — an admin session is not silently authenticated toportal-shelland vice versa. The admin entry route is gated by an Entraadminapp-role claim +@RequireMfa({ freshness: 600 })per ADR-0011. - The SPAs carry no token. Only the opaque session id cookie (
__Host-portal_session/__Host-portal_admin_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 — sametrace_idend-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, orshared(for libs consumable by both apps).type:—app,feature, orshared.
A dependency is allowed only if both axes permit it.
flowchart LR
subgraph Apps["apps · type:app"]
shell["portal-shell<br/>scope:portal-shell"]
admin["portal-admin<br/>scope:portal-admin"]
bff["portal-bff<br/>scope:portal-bff"]
end
subgraph FeatureLibs["libs · type:feature"]
fauth["feature-auth<br/>scope:portal-shell"]
end
subgraph SharedAnyLibs["libs · type:shared · scope:shared"]
sui["shared-ui<br/>Angular components<br/>(spartan-style on CDK)"]
sstate["shared-state<br/>cross-surface signals<br/>(LayoutStateService, ...)"]
stokens["shared-tokens<br/>design tokens (a11y)"]
sutil["shared-util<br/>cross-runtime TS helpers"]
end
shell --> fauth
shell --> sui
shell --> sstate
shell --> stokens
shell --> sutil
admin -. "future" .-> sui
admin -. "future" .-> sstate
admin -. "future" .-> stokens
admin -. "future" .-> sutil
bff --> stokens
bff --> sutil
fauth --> sui
fauth --> stokens
fauth --> sutil
sui --> stokens
classDef forbidden stroke:#c00,stroke-dasharray: 4 4
Notes:
portal-adminis a v1 skeleton (per ADR-0020) — it currently imports no libs. Its planned dependencies on thescope:sharedset are drawn dashed; they materialise as the admin modules land. Noscope:portal-adminrow exists ineslint.config.mjsyet; one lands when the admin modules grow real lib dependencies (follow-up).- Every Angular-flavoured shared lib (
shared-ui,shared-state) lives underscope:shared, notscope:portal-shell. The naming was historically ambiguous; what matters is the tag inproject.json, which gates lint-time enforcement.
Forbidden by the depConstraints (and lint-enforced) — examples:
portal-bff⟶shared-ui/feature-auth(scope:portal-bffmay only reachscope:portal-bff+scope:shared; and the BFF runs in Node anyway, so Angular code is mechanically unusable).portal-shell⟶portal-admin(cross-app — apps don't reach into each other; they communicate via the BFF).shared-tokens⟶feature-auth(type:sharedmay only reachtype:shared, so feature libs are off-limits).- 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.
5. Local dev infrastructure
What docker compose up actually starts when you run the project locally. The aim is to make the runtime dependencies visible at a glance — services, ports, named volumes, optional profiles, and how they relate to the dev-loop tools (Jaeger, pgweb, Caddy).
Sources: infra/local/dev.compose.yml, infra/ci-runners.compose.yml, ADR-0006 (Postgres), ADR-0010 (Redis dev mode), ADR-0012 (OTel Collector), ADR-0015 (CI runners), ADR-0019 (Caddy locale routing).
flowchart TB
subgraph Host["Developer host"]
direction TB
subgraph DevStack["docker compose · name: apf-portal-dev · network: apf-portal-dev"]
direction TB
subgraph AlwaysUp["Always up"]
direction LR
pg["postgres<br/>image: postgres:17.2-alpine<br/>host port 5432<br/>POSTGRES_USER / _PASSWORD / _DB<br/>bootstrap SQL from init/postgres/"]
redis["redis<br/>image: redis:7.4-alpine<br/>host port 6379<br/>--requirepass + --appendonly yes"]
otel["otel-collector<br/>image: otel/opentelemetry-collector-contrib:0.150.1<br/>OTLP gRPC 4317 / HTTP 4318<br/>config: otel-collector.yaml"]
end
subgraph ProfileDbtools["profile: dbtools"]
direction LR
pgweb["pgweb<br/>image: sosedoff/pgweb:0.16.2<br/>host port 8081"]
end
subgraph ProfileObs["profile: observability"]
direction LR
jaeger["jaeger<br/>image: jaegertracing/jaeger:2.17.0<br/>UI host port 16686<br/>OTLP receiver internal only"]
end
subgraph ProfileServeStatic["profile: serve-static"]
direction LR
caddy["serve-static (Caddy)<br/>image: caddy:2.10-alpine<br/>host port 4200<br/>serves dist/apps/portal-shell/browser<br/>(per-locale routing per ADR-0019)"]
end
vols[("Named volumes<br/>apf-portal-postgres-data<br/>apf-portal-redis-data")]
end
subgraph CIStack["docker compose · name: apf-portal-ci-runners · network: act-runners"]
direction LR
r1["runner-1<br/>gitea/act_runner:0.2.13<br/>labels: self-hosted, on-prem<br/>image: catthehacker/ubuntu:act-22.04"]
r2["runner-2<br/>(same image / config)"]
r3["runner-3<br/>(same image / config)"]
end
bffProc["portal-bff<br/>local Node process<br/>(pnpm nx serve portal-bff)<br/>port 3000"]
spaProc["portal-shell<br/>Angular dev server<br/>(pnpm nx serve portal-shell)<br/>port 4200"]
end
pg -. "AOF / data" .-> vols
redis -. "AOF / data" .-> vols
pgweb --> pg
otel -. "forward" .-> jaeger
caddy -. "reads<br/>dist bind-mount" .-> caddy
bffProc -->|DATABASE_URL| pg
bffProc -->|REDIS_URL| redis
bffProc -. "OTLP HTTP" .-> otel
spaProc -. "OTLP HTTP<br/>browser spans" .-> otel
spaProc -->|"fetch /api/*"| bffProc
classDef profile fill:#f6f8fa,stroke:#bbb,stroke-dasharray: 3 3
class ProfileDbtools,ProfileObs,ProfileServeStatic profile
Bring-up cheat sheet:
# Always-up services only (postgres + redis + otel-collector).
docker compose -f infra/local/dev.compose.yml up -d
# With viewers (pgweb at :8081, Jaeger at :16686).
docker compose -f infra/local/dev.compose.yml \
--profile dbtools --profile observability up -d
# Plus the production-like static server (Caddy at :4200).
# Use after `pnpm exec nx build portal-shell --configuration=production`.
docker compose -f infra/local/dev.compose.yml --profile serve-static up -d
# Wipe state (recreates the bootstrap SQL on next up).
docker compose -f infra/local/dev.compose.yml down -v
Notes:
- The BFF and SPA themselves are not in compose. They run as local Node / Angular dev-server processes via
pnpm nx serve …. Compose only hosts the runtime dependencies — keeping the inner-loop edit/refresh fast. The Caddyserve-staticprofile is the production-shape exception: it lets you eyeball a prod build under the locale routing without standing up a real reverse proxy. - Named volumes are intentional. Wiping
down -vis the supported reset path; the Postgres bootstrap SQL (audit schema + role grants per ADR-0013) only runs on a fresh data volume. - The CI runners stack is separate (
infra/ci-runners.compose.yml,name: apf-portal-ci-runners) and lives on its own Docker network. Same host machine in v1, but co-located by convenience, not by coupling — they can move to a different host without touching the dev stack. The runners register with Gitea viaGITEA_RUNNER_REGISTRATION_TOKENon first boot and persist their credentials in./data/runner-N/. - Ports listed are the host-side defaults. All can be overridden through
infra/local/.env(POSTGRES_PORT,REDIS_PORT,OTEL_HTTP_PORT,JAEGER_UI_PORT,SERVE_STATIC_PORT, …). The container-side ports never change.
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 | trigger met — observability is wired end-to-end; diagram pending a follow-up PR |
| 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 |