Files
apf_portal/docs/decisions
julien 8266e5b172
CI / check (push) Successful in 2m43s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m36s
CI / a11y (push) Successful in 3m4s
CI / perf (push) Successful in 4m54s
Docs site / build (push) Successful in 4m50s
docs(adr-0026): person + user portal-side data model (proposed) (#213)
## 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
2026-05-24 02:19:29 +02:00
..

Architectural Decision Records

This project records architecturally-significant decisions as ADRs in the MADR 4.0.0 format. References: 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.
  • 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 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: proposedaccepted → 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 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 Use ADRs to record architectural decisions accepted process 2026-04-29
0002 Adopt Nx monorepo with the apps preset accepted infrastructure, frontend, backend 2026-04-29
0003 Workspace and app naming convention accepted process 2026-04-29
0004 Frontend stack — Angular (latest LTS), standalone, zoneless, Signals, CSR-only, Vitest accepted frontend 2026-04-29
0005 Backend stack — NestJS over Express, Fastify, Hono accepted backend 2026-04-29
0006 Persistence — PostgreSQL with Prisma accepted data, backend 2026-04-29
0007 Pre-commit hooks and Conventional Commits accepted process 2026-04-29
0008 Identity model — multi-tenant Entra ID for workforce, dual-audience design for future External ID accepted security, data 2026-04-29
0009 Authentication flow — OIDC Authorization Code + PKCE via MSAL Node, BFF session pattern accepted security, backend 2026-04-29
0010 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 MFA enforcement — Entra ID Conditional Access baseline, BFF claim sanity-check, step-up hooks designed-in accepted security 2026-04-29
0012 Observability — Pino structured logs + OpenTelemetry tracing, W3C Trace Context propagation, stdout + collector accepted observability, backend, frontend 2026-04-29
0013 Audit trail — separated append-only Postgres schema, decoupled from app logs accepted security, observability, data 2026-04-29
0014 Downstream API access — On-Behalf-Of pattern, unified DownstreamApiClient, audience-aware authorization accepted security, backend 2026-04-29
0015 CI/CD pipeline — Gitea Actions, trunk-based + squash-merge, thin YAML over portable scripts accepted infrastructure, process 2026-04-30
0016 Accessibility baseline — WCAG 2.2 AA + targeted AAA, Angular CDK + spartan-ng + Tailwind, APF panel testing accepted accessibility, frontend, process 2026-04-30
0017 Performance budgets — Core Web Vitals + Lighthouse CI gates, bundle budgets, BFF p95/p99 SLOs accepted performance, frontend, backend, process 2026-04-30
0018 Environment configuration — Angular environment.ts, BFF env vars, audit pool split accepted frontend, backend, infrastructure, process 2026-05-10
0019 Internationalisation — @angular/localize, build-time per-locale bundles, /fr + /en path-based routing accepted frontend, accessibility, performance, process 2026-05-11
0020 portal-admin — dedicated SPA for portal administration, sharing the existing BFF accepted frontend, backend, security, infrastructure, process 2026-05-11
0021 Phase-2 security baseline — helmet, CORS allowlist, double-submit CSRF, rate limiting, structured error envelope accepted security, backend 2026-05-13
0022 Documentation site — VitePress + Mermaid plugin, separate static deployment accepted process, infrastructure 2026-05-15
0023 Charts + dashboards — D3 + Observable Plot wrapped in libs/shared/charts accepted frontend, accessibility, performance 2026-05-16
0024 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 Authorization model — three orthogonal axes (privileges × functional roles × scopes), Entra-backed accepted security, backend, data 2026-05-20
0026 Person golden record + User portal-account + organisational hierarchy — portal-side data model proposed data, backend, security 2026-05-24