docs(adr-0026): person + user portal-side data model (proposed) #213

Merged
julien merged 1 commits from docs/adr-0026-person-user-schema-proposed into main 2026-05-24 02:19:30 +02:00
Owner

Summary

ADR-0025 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" 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) and the audit-log salt (ADR-0013) 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 (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

  • 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 for ADR-0025).
  • Implementation PR 1 — Prisma schema + lazy provisioner.
  • Implementation PR 2PrismaScopeResolver + admin-UI scope-seeding + test-tenant seed.
  • ADR-0027 — Pléiades + Acteurs+ syncs + facet schemas (the deferred surface from this ADR).
## 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).
julien added 1 commit 2026-05-24 02:13:55 +02:00
docs(adr-0026): person + user portal-side data model (proposed)
CI / commits (pull_request) Successful in 2m36s
CI / check (pull_request) Successful in 2m46s
CI / scan (pull_request) Successful in 2m56s
CI / a11y (pull_request) Successful in 3m23s
Docs site / build (pull_request) Successful in 3m27s
CI / perf (pull_request) Successful in 6m17s
7a4ebe75ea
ADR-0025 left several anchors as proposed-follow-up:
- Principal.user.id / personId carry the Entra oid as a placeholder.
- StubScopeResolver returns unrestricted for everyone.
- @RequireScope's ScopableResource shape references an
  Etablissement / Delegation / Region chain that has no table.

ADR-0026 specifies the portal-side data model that closes those
stubs without dragging the upstream Pléiades / Acteurs+ syncs into
scope:

- Person golden record (stable identity; lazy-created on first
  sign-in in v1, source-stamped 'self-signin' for traceability).
- User overlay (one-to-zero-or-one with Person; portal-only state
  like lastSignInAt rides here, not on the shared Person row).
- UserScope migration confirming the ADR-0025 §"Sources of truth"
  shape.
- Region / Delegation / Etablissement hierarchy with
  externally-meaningful codes (INSEE / dept / FINESS) as primary
  keys, matching scope literals' on-the-wire shape.

Facet schemas (Salarié / Élu / Adhérent / Bénéficiaire) are
deferred to ADR-0027 alongside the Pléiades and Acteurs+ sync
that populates them - keeping the v1 migration small enough to
land + roll back cleanly.

Status: proposed. Implementation lands in two PRs once accepted:
the schema + lazy provisioner, then the PrismaScopeResolver +
admin-UI scope-seeding screen + test-tenant seed for the 19
personas.

Index updated.
julien merged commit 8266e5b172 into main 2026-05-24 02:19:30 +02:00
julien deleted branch docs/adr-0026-person-user-schema-proposed 2026-05-24 02:19:32 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: julien/apf_portal#213