8266e5b172
## Summary
[ADR-0025](docs/decisions/0025-authorization-model-privileges-roles-scopes.md) shipped the authorization model with three stubs explicitly deferred to a follow-up ADR:
- `Principal.user.id` and `Principal.user.personId` carry the Entra `oid` as a placeholder — `apps/portal-bff/src/auth/principal-builder.ts` documents the seam.
- `StubScopeResolver` returns `[{ kind: 'unrestricted' }]` for every signed-in user; the per-persona scope values documented in `notes/test-tenant-role-assignments.md` have nowhere to live.
- `@RequireScope`'s `ScopableResource` shape references an `Etablissement` / `Delegation` / `Region` chain that has no Prisma table.
ADR-0026 specifies the portal-side data model that closes those stubs. **Decision-only, status `proposed`** — implementation lands in two PRs once accepted.
## What lands
| File | Change |
| --- | --- |
| `docs/decisions/0026-person-user-portal-data-model.md` | New ADR. MADR 4.0.0 format. Tags: `data`, `backend`, `security`. ~310 lines. |
| `docs/decisions/README.md` | One new index row for 0026 (status `proposed`, 2026-05-24). |
## ADR scope
The ADR commits the **schema** for:
- **`Person`** golden record — stable identity, can exist without a portal account (Pléiades pre-provisioning, dossier bénéficiaires, alumni). `source` field tracks provenance (`self-signin` in v1; `pleiades` / `acteurs-plus` join the catalogue with ADR-0027).
- **`User`** portal-account overlay — one-to-zero-or-one with Person, lazy-created on first OIDC callback. Portal-only state (`lastSignInAt`, future a11y preferences) rides here, not on the shared Person row.
- **`UserScope`** — confirms the migration whose shape ADR-0025 §"Sources of truth — apf_portal-side `user_scopes` table" already specified.
- **`Region` / `Delegation` / `Etablissement`** — organisational hierarchy with externally-meaningful codes (INSEE / French dept / FINESS) as primary keys, matching the on-the-wire shape of the scope literals (`etablissement:0330800013`, `delegation:33`).
The ADR **explicitly defers**:
- Facet schemas (`Salarie`, `Adherent`, `Benevole`, `Elu`, `Beneficiaire`, `PartenaireExterne`) — they track upstream-system shape and ride alongside their producing sync.
- Pléiades / Acteurs+ sync logic, reconciliation policy between sync-owned and admin-UI-owned fields.
Both deferrals point at **ADR-0027** (Pléiades + Acteurs+ syncs + facet schemas) as the next-up ADR.
## Notes for the reviewer
- **Why `Person` + `User` split and not a single table.** The "Considered Options" section walks through this. Option A (User-only) breaks down the moment a dossier holder who never signs in needs a stable identifier. Option D (Person with embedded facet columns) forces every Pléiades or Acteurs+ schema change through a table that both shapes share, which is exactly the kind of coupling that ADR-0027 needs the freedom to design out.
- **Why externally-meaningful primary keys for the hierarchy.** `Region.code` / `Delegation.code` / `Etablissement.finess` are INSEE / FINESS codes — stable across reorgs and already on every URL the portal will mint. Adding a separate UUID would force every consumer to indirect through it for no gain. The trade-off (no in-place rename — if a délégation merges, delete + re-insert with the new code) is bounded by how rarely INSEE rebases.
- **Lazy User creation at first sign-in.** v1 has no Pléiades data; the OIDC callback creates the Person + User pair the first time it sees a new `oid`. When Pléiades sync ships (ADR-0027), the same provisioner extends with a `Person.externalId` lookup before falling back to `email`. The schema does not change between the two regimes — only the provisioner's lookup order does.
- **`Person.source` and `Etablissement.kind` as enum-as-string + drift-gate extension.** The catalogue-drift gate ([ADR-0025 §"Confirmation"](docs/decisions/0025-authorization-model-privileges-roles-scopes.md) and `scripts/check-catalogue-drift.mjs`) was built precisely to constrain hand-edited string columns. Extending it to assert every `Person.source` write is in the closed catalogue closes one of the soft spots in the v1 schema without standing up a Postgres ENUM.
- **PII posture.** `Person.firstName` / `lastName` / `email` are flagged in the ADR's Decision Drivers. The Pino redact list ([ADR-0012](docs/decisions/0012-observability-pino-opentelemetry.md)) and the audit-log salt ([ADR-0013](docs/decisions/0013-audit-trail-separated-postgres-append-only.md)) already cover Entra `oid`; the new PII fields ride the same posture (caller-redacted at the audit module, redacted at the Pino logger before the line ships).
- **What "two PRs" means in the More Information section.** PR 1: Prisma schema migration + `PersonAndUserProvisioner` called from `SessionEstablisher` + drift-gate extension. PR 2: `PrismaScopeResolver` replacing `StubScopeResolver` + `/admin/users/:id/scopes` screen + `prisma/seed.ts` populating the 19 test-tenant personas' `user_scopes` rows.
- **Backward compatibility for in-flight Redis sessions.** Sessions minted before the schema migration carry a Principal whose `user.id` is the Entra `oid` placeholder. The legacy-session bridge in [`apps/portal-bff/src/auth/principal-extractor.ts`](apps/portal-bff/src/auth/principal-extractor.ts) (added in #208) already handles this — when the migration deploys, those sessions continue to work for the 12 h absolute-TTL window, after which every session in Redis has the real `personId`.
## Open questions to resolve before acceptance
These were flagged in the ADR text and are worth surfacing here for the review pass:
- **`Person.email` as the v1 dedup key.** The ADR's "Bad, because" item — two Pléiades records sharing an email would crash the unique constraint. ADR-0027 will add the `externalId` precedence, but in the interim the v1 lazy-creator either trusts emails-are-unique (acceptable for the test tenant where every persona has a distinct one) or skips the dedup attempt and creates a fresh Person per `oid`. The ADR currently picks the dedup-by-email path; flag if the safer choice is "always fresh, reconcile later".
- **Should `Region` / `Delegation` / `Etablissement` migrations be seeded as part of the schema PR, or kept as a separate dataset import?** The ADR is silent on this; my default is "seed the geographic codes APF actually operates in" (a one-off SQL fixture in the migration directory). Worth confirming.
## Test plan
- [x] `pnpm exec prettier --check docs/decisions/0026-person-user-portal-data-model.md docs/decisions/README.md` — clean.
- [ ] **Review focus** — the chosen Option B vs the rejected A/C/D, the deferred facets list, the `Person.source` catalogue, the `Etablissement.kind` catalogue, and the two open questions above.
- [ ] Once accepted, the implementation phasing in the ADR's `§More Information` opens (2 PRs).
## What's next
- **This PR** — ADR-0026 ships as `proposed`.
- **Acceptance PR** — review pass, address open questions, promote `proposed → accepted` (same cadence as [#201 → #205](docs/decisions/0025-authorization-model-privileges-roles-scopes.md) for ADR-0025).
- **Implementation PR 1** — Prisma schema + lazy provisioner.
- **Implementation PR 2** — `PrismaScopeResolver` + admin-UI scope-seeding + test-tenant seed.
- **ADR-0027** — Pléiades + Acteurs+ syncs + facet schemas (the deferred surface from this ADR).
---------
Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #213
73 lines
10 KiB
Markdown
73 lines
10 KiB
Markdown
# Architectural Decision Records
|
||
|
||
This project records architecturally-significant decisions as **ADRs** in the [MADR 4.0.0](https://github.com/adr/madr) format. References: [adr.github.io](https://adr.github.io/).
|
||
|
||
## Why ADRs
|
||
|
||
ADRs capture the _why_ behind a decision — context, drivers, options considered, trade-offs accepted — at the moment the decision is made. They make architecture reviewable, onboarding faster, and prevent the same debate from being re-litigated later.
|
||
|
||
## Conventions
|
||
|
||
- **Format:** MADR 4.0.0. Start from [template.md](template.md).
|
||
- **Filename:** `NNNN-kebab-case-title.md`, e.g. `0007-adopt-tailwind-for-design-tokens.md`.
|
||
- **Numbering:** globally sequential 4-digit prefix. Numbers never reset, never get reused — even when an ADR is superseded or deprecated.
|
||
- **Layout:** flat folder. ADRs are not nested into category subfolders; topical organization happens via tags.
|
||
- **Tags:** every ADR carries a `tags:` array in the MADR frontmatter, drawn from the [tag vocabulary](#tag-vocabulary) below. An ADR may carry several tags. Propose new tags (or renames) in the same PR that needs them; never invent ad-hoc tags inline.
|
||
- **Status lifecycle:** `proposed` → `accepted` → optionally `deprecated` or `superseded by [ADR-NNNN](NNNN-other.md)`. Update the YAML frontmatter; never delete an ADR.
|
||
- **Index maintenance:** every ADR addition or status change must update the [Index](#index) below in the same commit.
|
||
|
||
## When to write an ADR
|
||
|
||
Write one whenever a development decision is non-trivial: tool or library choice, framework pattern, security control, perf budget, a11y target, naming convention, deprecation, breaking change, or any choice that future contributors would benefit from understanding the _why_ of.
|
||
|
||
## Tag vocabulary
|
||
|
||
The vocabulary below is the source of truth. It is intentionally coarse — propose extensions only when an existing tag genuinely doesn't fit, and avoid overly narrow tags.
|
||
|
||
| Tag | Scope |
|
||
| ---------------- | ------------------------------------------------------------------- |
|
||
| `frontend` | UI, Angular, components, design system, client-side state |
|
||
| `backend` | API, BFF, server-side services |
|
||
| `security` | AuthN, AuthZ, sessions, CSP, dependency scanning, secret management |
|
||
| `performance` | Perf budgets, caching, bundle size, Lighthouse |
|
||
| `accessibility` | WCAG, a11y testing, keyboard, ARIA, contrast |
|
||
| `infrastructure` | CI/CD, hosting, deployment, runtime |
|
||
| `observability` | Logs, metrics, traces, correlation IDs, monitoring |
|
||
| `data` | Persistence, schemas, migrations, data flow |
|
||
| `process` | Team conventions, workflows, repo policy |
|
||
|
||
> _Status: starter vocabulary, to be refined as ADRs accumulate. Update this table whenever a tag is added, renamed, or retired._
|
||
|
||
## Index
|
||
|
||
ADRs are listed in numerical order. To slice by topic, filter on the `Tags` column.
|
||
|
||
| # | Title | Status | Tags | Date |
|
||
| ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- | -------- | -------------------------------------------------------------- | ---------- |
|
||
| [0001](0001-use-adrs-to-record-architectural-decisions.md) | Use ADRs to record architectural decisions | accepted | `process` | 2026-04-29 |
|
||
| [0002](0002-adopt-nx-monorepo-apps-preset.md) | Adopt Nx monorepo with the `apps` preset | accepted | `infrastructure`, `frontend`, `backend` | 2026-04-29 |
|
||
| [0003](0003-workspace-and-app-naming-convention.md) | Workspace and app naming convention | accepted | `process` | 2026-04-29 |
|
||
| [0004](0004-frontend-stack-angular-csr-zoneless-signals.md) | Frontend stack — Angular (latest LTS), standalone, zoneless, Signals, CSR-only, Vitest | accepted | `frontend` | 2026-04-29 |
|
||
| [0005](0005-backend-stack-nestjs.md) | Backend stack — NestJS over Express, Fastify, Hono | accepted | `backend` | 2026-04-29 |
|
||
| [0006](0006-persistence-postgresql-prisma.md) | Persistence — PostgreSQL with Prisma | accepted | `data`, `backend` | 2026-04-29 |
|
||
| [0007](0007-pre-commit-hooks-and-conventional-commits.md) | Pre-commit hooks and Conventional Commits | accepted | `process` | 2026-04-29 |
|
||
| [0008](0008-identity-model-entra-workforce-dual-audience.md) | Identity model — multi-tenant Entra ID for workforce, dual-audience design for future External ID | accepted | `security`, `data` | 2026-04-29 |
|
||
| [0009](0009-auth-flow-oidc-pkce-msal-node.md) | Authentication flow — OIDC Authorization Code + PKCE via MSAL Node, BFF session pattern | accepted | `security`, `backend` | 2026-04-29 |
|
||
| [0010](0010-session-management-redis.md) | Session management — opaque session IDs in cookies, payload in self-hosted Redis with AES-GCM at rest | accepted | `security`, `backend`, `infrastructure` | 2026-04-29 |
|
||
| [0011](0011-mfa-enforcement-entra-conditional-access.md) | MFA enforcement — Entra ID Conditional Access baseline, BFF claim sanity-check, step-up hooks designed-in | accepted | `security` | 2026-04-29 |
|
||
| [0012](0012-observability-pino-opentelemetry.md) | Observability — Pino structured logs + OpenTelemetry tracing, W3C Trace Context propagation, stdout + collector | accepted | `observability`, `backend`, `frontend` | 2026-04-29 |
|
||
| [0013](0013-audit-trail-separated-postgres-append-only.md) | Audit trail — separated append-only Postgres schema, decoupled from app logs | accepted | `security`, `observability`, `data` | 2026-04-29 |
|
||
| [0014](0014-downstream-api-access-obo-pattern.md) | Downstream API access — On-Behalf-Of pattern, unified `DownstreamApiClient`, audience-aware authorization | accepted | `security`, `backend` | 2026-04-29 |
|
||
| [0015](0015-cicd-gitea-actions.md) | CI/CD pipeline — Gitea Actions, trunk-based + squash-merge, thin YAML over portable scripts | accepted | `infrastructure`, `process` | 2026-04-30 |
|
||
| [0016](0016-accessibility-baseline-wcag-aa-targeted-aaa.md) | Accessibility baseline — WCAG 2.2 AA + targeted AAA, Angular CDK + spartan-ng + Tailwind, APF panel testing | accepted | `accessibility`, `frontend`, `process` | 2026-04-30 |
|
||
| [0017](0017-performance-budgets-lighthouse-ci.md) | Performance budgets — Core Web Vitals + Lighthouse CI gates, bundle budgets, BFF p95/p99 SLOs | accepted | `performance`, `frontend`, `backend`, `process` | 2026-04-30 |
|
||
| [0018](0018-environment-configuration-strategy.md) | Environment configuration — Angular `environment.ts`, BFF env vars, audit pool split | accepted | `frontend`, `backend`, `infrastructure`, `process` | 2026-05-10 |
|
||
| [0019](0019-internationalisation-angular-localize.md) | Internationalisation — `@angular/localize`, build-time per-locale bundles, `/fr` + `/en` path-based routing | accepted | `frontend`, `accessibility`, `performance`, `process` | 2026-05-11 |
|
||
| [0020](0020-portal-admin-app.md) | `portal-admin` — dedicated SPA for portal administration, sharing the existing BFF | accepted | `frontend`, `backend`, `security`, `infrastructure`, `process` | 2026-05-11 |
|
||
| [0021](0021-phase-2-security-baseline.md) | Phase-2 security baseline — helmet, CORS allowlist, double-submit CSRF, rate limiting, structured error envelope | accepted | `security`, `backend` | 2026-05-13 |
|
||
| [0022](0022-docs-site-vitepress.md) | Documentation site — VitePress + Mermaid plugin, separate static deployment | accepted | `process`, `infrastructure` | 2026-05-15 |
|
||
| [0023](0023-charts-d3-observable-plot.md) | Charts + dashboards — D3 + Observable Plot wrapped in `libs/shared/charts` | accepted | `frontend`, `accessibility`, `performance` | 2026-05-16 |
|
||
| [0024](0024-ai-service-relay-grpc-sse-bridge.md) | AI service relay — vendored gRPC protos, NestJS gRPC client, SSE bridge to the SPA, POC unsigned principal | accepted | `backend`, `security`, `observability` | 2026-05-19 |
|
||
| [0025](0025-authorization-model-privileges-roles-scopes.md) | Authorization model — three orthogonal axes (privileges × functional roles × scopes), Entra-backed | accepted | `security`, `backend`, `data` | 2026-05-20 |
|
||
| [0026](0026-person-user-portal-data-model.md) | `Person` golden record + `User` portal-account + organisational hierarchy — portal-side data model | proposed | `data`, `backend`, `security` | 2026-05-24 |
|