2480d0dd6d
## Summary The [`AdminRoleGuard`](apps/portal-bff/src/admin/admin-role.guard.ts) was matching on the literal `'admin'`, but the Entra app registration declares the admin app role with `value: "Portal.Admin"`. End result: an authenticated user with the role assigned in Entra still landed with `roles: []` in their session (claim simply not present in the id token), and every request to `/api/admin/audit` and `/api/admin/users` returned a **403**. Caught manually in the portal-admin SPA: login succeeded, sidebar links to "Audit log" / "User list" returned 403. The [`/api/admin/auth/me`](apps/portal-bff/src/admin/admin-auth.controller.ts) self-test confirmed the missing claim was the cause. ## What lands ### Constant value — single source of truth [`apps/portal-bff/src/admin/admin-role.guard.ts:18`](apps/portal-bff/src/admin/admin-role.guard.ts#L18): ```diff -export const ADMIN_ROLE = 'admin'; +export const ADMIN_ROLE = 'Portal.Admin'; ``` [`admin-role.guard.spec.ts`](apps/portal-bff/src/admin/admin-role.guard.spec.ts) already imports `ADMIN_ROLE` from the source rather than hardcoding the literal, so the guard contract spec rolls through unchanged. The fixtures elsewhere ([`auth.service.spec.ts`](apps/portal-bff/src/auth/auth.service.spec.ts), [`admin.controller.spec.ts`](apps/portal-bff/src/admin/admin.controller.spec.ts), [`admin-auth.controller.spec.ts`](apps/portal-bff/src/admin/admin-auth.controller.spec.ts), [`require-mfa.guard.spec.ts`](apps/portal-bff/src/auth/require-mfa.guard.spec.ts)) keep `roles: ['admin']` as fixture data — those tests exercise the extraction / serialization pipeline, which is role-value-agnostic; touching them would be incidental cleanup with no behaviour signal. ### Doc-comment refresh Inline references to the role name updated so future readers don't grep `'admin'` and find a phantom value: - [`admin-role.guard.ts`](apps/portal-bff/src/admin/admin-role.guard.ts) — class doc-block (3 mentions). - [`admin.controller.ts`](apps/portal-bff/src/admin/admin.controller.ts) — class doc-block + inline guard-contract comment. - [`audit.service.ts`](apps/portal-bff/src/audit/audit.service.ts) — `adminAccessDenied` doc-block (2 mentions). ### Documentation - [`docs/decisions/0020-portal-admin-app.md`](docs/decisions/0020-portal-admin-app.md) — 5 references to the role across §"How is admin access enforced", §"Auth — same Entra ID …", and the Consequences §. - [`docs/architecture.md`](docs/architecture.md) — note next to the C4 container diagram describing the admin entry gate. - [`CLAUDE.md`](CLAUDE.md) — "Admin application" project rule. ## Notes for the reviewer - **Why `Portal.Admin` rather than `admin`?** Operator's call on the Entra side. The `<Application>.<Role>` namespace is the conventional Entra App Role pattern when the directory may host roles for multiple applications, and `admin` alone is ambiguous in a directory shared across products. - **Why no migration / backfill?** The role value lives only in two places: Entra app-registration manifest (operator-managed) and the BFF constant (this PR). Existing Redis sessions captured `roles: []` (claim absent) — they'll naturally pick up the correct value on next sign-in. No persisted data references the old value. - **No ADR.** ADR-0020 §"How is admin access enforced" already commits to "Entra ID role claim + BFF guard"; the literal role string is an implementation detail the ADR happened to spell. Updated the ADR's prose to the new value to keep the doc honest, but the decision is unchanged. ## Test plan - [x] `pnpm nx test portal-bff` — **396 specs pass**, unchanged from `main`. The `AdminRoleGuard` contract spec (covers 401-on-no-session, 403-on-missing-role + audit emission, pass-through-on-role-present) imports `ADMIN_ROLE` and re-exercises with the new value. - [x] `pnpm exec nx affected -t format:check lint test build --base=origin/main` — clean. - [ ] Manual verification — pending Entra-side App Role declaration with `value: "Portal.Admin"` + assignment to the test user. Once both exist: sign out + sign in on portal-admin, hit `/api/admin/auth/me` and confirm `roles: ["Portal.Admin"]`, then click "Audit log" + "User list" and confirm both render. An `admin.access_denied` row in `audit.events` is the negative-test signal (still emitted for any user without the role). --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #145
306 lines
18 KiB
Markdown
306 lines
18 KiB
Markdown
# 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), [ADR-0020](decisions/0020-portal-admin-app.md) (admin SPA).
|
|
|
|
```mermaid
|
|
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-shell` is the end-user surface; `portal-admin` is 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 to `portal-shell` and vice versa. The admin entry route is gated by an Entra `Portal.Admin` app-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 — 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"]
|
|
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-admin` is a v1 skeleton (per ADR-0020) — it currently imports no libs. Its planned dependencies on the `scope:shared` set are drawn dashed; they materialise as the admin modules land. No `scope:portal-admin` row exists in `eslint.config.mjs` yet; one lands when the admin modules grow real lib dependencies (follow-up).
|
|
- Every Angular-flavoured shared lib (`shared-ui`, `shared-state`) lives under `scope:shared`, not `scope:portal-shell`. The naming was historically ambiguous; what matters is the tag in `project.json`, which gates lint-time enforcement.
|
|
|
|
Forbidden by the depConstraints (and lint-enforced) — examples:
|
|
|
|
- `portal-bff` ⟶ `shared-ui` / `feature-auth` (`scope:portal-bff` may only reach `scope: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:shared` may only reach `type: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](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.
|
|
|
|
---
|
|
|
|
## 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/local/dev.compose.yml), [infra/ci-runners.compose.yml](../infra/ci-runners.compose.yml), [ADR-0006](decisions/0006-persistence-postgresql-prisma.md) (Postgres), [ADR-0010](decisions/0010-session-management-redis.md) (Redis dev mode), [ADR-0012](decisions/0012-observability-pino-opentelemetry.md) (OTel Collector), [ADR-0015](decisions/0015-cicd-gitea-actions.md) (CI runners), [ADR-0019](decisions/0019-internationalisation-angular-localize.md) (Caddy locale routing).
|
|
|
|
```mermaid
|
|
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:
|
|
|
|
```bash
|
|
# 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 Caddy `serve-static` profile 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 -v` is 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 via `GITEA_RUNNER_REGISTRATION_TOKEN` on 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](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 | 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](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 |
|