Files
apf_portal/docs/decisions/0026-person-user-portal-data-model.md
T
Julien Gautier 07cb466d51
CI / commits (pull_request) Successful in 2m49s
CI / check (pull_request) Successful in 3m25s
CI / scan (pull_request) Successful in 3m37s
CI / a11y (pull_request) Successful in 3m25s
Docs site / build (pull_request) Successful in 3m32s
CI / perf (pull_request) Successful in 5m33s
docs(adr-0026): promote to accepted + resolve open questions
ADR-0026 was reviewed; status flips proposed -> accepted with the
two open questions closed:

1. Person dedup at first sign-in. v1 looks up by entraOid only;
   no email-based merging. Two distinct people genuinely can
   share an email, and silent auto-merge would corrupt the
   golden record. ADR-0027 introduces operator-confirmed
   reconciliation; v1 takes the safe-by-default posture.

2. Org-hierarchy seed placement. Region / Delegation /
   Etablissement codes ship inline in the Prisma migration as
   reference data — slow-moving, environment-agnostic, single
   atomic deploy. ADR-0027's Pléiades sync supersedes the
   inline seed when it ships. user_scopes for the 19 test
   personas stay in prisma/seed.ts (implementation PR 2) since
   those are test-tenant-specific data.

Consequences section reworked to reflect the new dedup choice:
the email-fragility Bad-because is removed; a new Bad-because
documents the v1 cost (a self-signed-in Person + a later
Pléiades-imported Person look like duplicates until ADR-0027's
operator merge flow resolves them — observable, not silent).

CLAUDE.md updated:
- Roll-up bumped 0001 -> 0025 -> 0001 -> 0026.
- New Architecture bullet for the portal-side data model.
- Roadmap entry on @RequireScope Prisma resolver updated to
  reflect ADR-0026 (accepted) and the two-PR implementation
  phasing.

Index row flipped to accepted.
2026-05-24 05:46:48 +02:00

333 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
status: accepted
date: 2026-05-24
decision-makers: R&D Lead
tags: [data, backend, security]
---
# `Person` golden record + `User` portal-account + organisational hierarchy — portal-side data model
## Context and Problem Statement
[ADR-0025](0025-authorization-model-privileges-roles-scopes.md) shipped the authorization model — three orthogonal axes (privileges × functional roles × scopes) — but left several anchors as proposed-follow-up. In v1 those anchors are stubbed:
- `Principal.user.id` and `Principal.user.personId` are populated with the Entra `oid` as a placeholder, because no portal-side identity record exists yet (see [ADR-0025 §"Principal shape"](0025-authorization-model-privileges-roles-scopes.md) and the BFF builder at `apps/portal-bff/src/auth/principal-builder.ts`).
- `ScopeResolver` returns `[{ kind: 'unrestricted' }]` for every signed-in user (see `StubScopeResolver` and [ADR-0025 §"Sources of truth — apf_portal-side `user_scopes` table"](0025-authorization-model-privileges-roles-scopes.md)). The intended per-persona scopes documented in `notes/test-tenant-role-assignments.md` have nowhere to live.
- `@RequireScope`'s `ScopableResource` shape — `etablissementFiness`, `delegationCode`, `regionCode`, `isSiege` — describes a parentage chain the BFF cannot actually fetch because there is no `Etablissement` / `Delegation` / `Region` table.
The portal also needs a representation of **people who are not yet portal users**. APF France handicap touches three populations with very different relationships to the portal:
1. **Workforce** (salariés on payroll) — almost certainly portal users; their identity, employment status, and current assignment come from Pléiades (the HR system).
2. **Governance** (élus du CA national + élus des CD départementaux) — many will be portal users; their identity, mandate type, and mandate scope come from Acteurs+ (the governance system).
3. **Bénévoles + adhérents + bénéficiaires** — most will not be portal users; their identity lands in the portal via the membership / dossier flows.
A naive "User table = anyone who ever signed in" model collapses these three populations into one bucket and loses two important capabilities the portal needs from day one:
- **Pré-provisioning** — an établissement director must be able to assign a scope to a salarié before the salarié first signs in. The salarié needs a portal-side identity that pre-exists their first OIDC callback.
- **Person-without-User** — a dossier is held by a bénéficiaire who may never sign in. The dossier still needs to reference a stable Person identifier; later, if the bénéficiaire signs up to portal-facing services, an account is overlaid on top of the existing Person record.
This ADR specifies the portal-side data model that resolves both the ADR-0025 stubs and these business shapes. It deliberately does **not** specify the upstream sync (Pléiades, Acteurs+, membership / dossier feeds) — that is [ADR-0027](#)'s territory. Likewise, the **facet shapes** (Salarié, Élu, Adhérent, Bénéficiaire) attach to a Person but their fields ride alongside their producing sync, so they land with ADR-0027.
ADR-0026's scope: the three core tables (`Person`, `User`, `UserScope`) plus the geographic / organisational hierarchy (`Region`, `Delegation`, `Etablissement`) that scope checks dereference today.
## Decision Drivers
- **`Principal.user.personId` must point at a real, stable identifier** — guards consuming `self`-scoped resources compare against it, and the AI service uses it (hashed) for audit-log joining per [ADR-0024](0024-ai-service-relay-grpc-sse-bridge.md).
- **Person-without-User** must be a valid state — Pléiades pre-provisioning, dossier beneficiaries, alumni.
- **`Etablissement.finess`, `.delegationCode`, `.regionCode`** must resolve from a real row, not a hardcoded map — the [ADR-0016](0016-accessibility-baseline-wcag-aa-targeted-aaa.md) panel-tested admin UI will list établissements; the BFF guards traverse the chain on scope checks.
- **No premature normalisation of facets.** Salarié / Élu / Adhérent / Bénéficiaire schemas track their upstream system; specifying them before the sync ADR ([ADR-0027](#)) lands invites churn. Mark them as deferred and keep ADR-0026 narrow.
- **Lazy User creation on first sign-in** — the OIDC callback creates the `User` row the first time it sees a new Entra `oid`. This avoids a Pléiades-side dependency before any sign-in works; Pléiades-driven pre-provisioning lands with ADR-0027 and updates the lookup path, not the schema.
- **No PII outside the boundary it belongs in.** `Person.firstName` / `lastName` are PII — they need the same redaction / hashing posture as the audit-log salt ([ADR-0013](0013-audit-trail-separated-postgres-append-only.md)).
## Considered Options
- **Option A — `User`-only, no `Person`.** A single table for "anyone who ever signed in". Dossiers reference `userId` directly. Bénéficiaires who never sign in have no row at all.
- **Option B — `Person` golden record + `User` portal-account overlay (chosen).** Two tables, one-to-zero-or-one relationship. `Person` is the stable identity; `User` is the portal-access overlay that exists when (and only when) someone has portal access.
- **Option C — `Person` lives in Entra (no portal-side row).** Use Entra's user object as the golden record; portal stores only deltas (scopes, etc.).
- **Option D — `Person` + per-relationship junction (Salarié-of-établissement-X, Élu-of-CD-Y, …) embedded directly in `Person`.** Single table with discriminator columns for every facet.
## Decision Outcome
Chosen option: **B — `Person` golden record + `User` portal-account overlay**, because it is the only option that:
1. lets a Person exist before they ever sign in (workforce pre-provisioning, dossier beneficiaries);
2. lets a User row carry portal-only state (`lastSignInAt`, future audit-flagging, future user-preferences in [ADR-0016](0016-accessibility-baseline-wcag-aa-targeted-aaa.md)) without polluting the Person record that Pléiades / Acteurs+ owns;
3. matches the existing intuition in ADR-0025's `Principal.user.{id, personId}` shape: the two ids exist because the two concerns are distinct.
### Schema — core tables
```prisma
model Person {
id String @id @default(uuid())
// Identity. firstName + lastName are PII; treat per ADR-0013's redact rules.
firstName String
lastName String
// Primary contact email. Unique because it serves as the fallback
// dedup key for Pléiades / Acteurs+ syncs (ADR-0027) — a future
// sync receiving a Pléiades row with email X looks up Person by
// email before deciding insert vs update.
email String? @unique
// Provenance: which upstream owns the row. v1: 'self-signin'
// (lazy-created at OIDC callback), 'admin-ui' (manually entered
// by an admin before first sign-in), 'seed' (test-tenant
// provisioning).
// ADR-0027 will add 'pleiades' and 'acteurs-plus'. The field is
// a free-form string in v1; promoting to an enum is an ADR-0027
// decision once the sync sources are concrete.
source String
// Upstream-system identifier (Pléiades matricule, Acteurs+ id).
// Nullable because v1 sources do not provide one.
externalId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User?
@@index([source])
@@index([externalId])
}
model User {
id String @id @default(uuid())
personId String @unique
person Person @relation(fields: [personId], references: [id])
// Entra object id. Stable per-tenant; unique because two
// separate Person + User pairs cannot share an Entra identity.
entraOid String @unique
// Entra tenant id. Stored alongside the oid so a future
// dual-audience activation (ADR-0008) can disambiguate.
tenantId String
// Surfaced in the /admin/users list per ADR-0020. Refreshed by
// the SessionEstablisher (apps/portal-bff/src/auth/session-establisher.service.ts).
lastSignInAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
scopes UserScope[]
@@index([entraOid])
}
model UserScope {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
// Discriminator — one of the six ADR-0025 scope kinds.
kind String
// Per-kind payload. FINESS for etablissement, dept code for
// delegation, INSEE region code for region, empty string for
// self / siege / unrestricted (the unique constraint below
// tolerates empty strings).
value String @default("")
// Provenance same posture as Person.source. v1: 'admin-ui' or
// 'seed'. ADR-0027 adds 'pleiades' / 'acteurs-plus' and the
// reconciliation rules between admin overrides and upstream
// data.
source String
createdAt DateTime @default(now())
// When non-null and past, the row is ignored at sign-in.
// Supports interim-director-for-two-months patterns and admin
// UI scope revocations.
expiresAt DateTime?
@@unique([userId, kind, value])
@@index([userId])
}
```
### Schema — organisational hierarchy
```prisma
model Region {
// INSEE region code (2 digits — '11' for Île-de-France, '75' for
// Nouvelle-Aquitaine). Stable across reorgs; doubles as the
// primary key because the codes are short and stable.
code String @id
name String
delegations Delegation[]
}
model Delegation {
// French department code (2-3 chars — '33', '2A', '971'). Same
// rationale as Region.code.
code String @id
name String
regionCode String
region Region @relation(fields: [regionCode], references: [code])
etablissements Etablissement[]
}
model Etablissement {
// FINESS code (9 digits — '0330800013'). The legal identifier
// for medico-social establishments in France; stable across
// reorgs.
finess String @id
name String
// What this row is: a real établissement vs the siège vs a
// future placeholder. Drives scope matching: the matcher
// resolves `principal.scopes` against this column.
kind String // 'etablissement' | 'siege'
delegationCode String
delegation Delegation @relation(fields: [delegationCode], references: [code])
@@index([delegationCode])
}
```
`Region.code` / `Delegation.code` / `Etablissement.finess` doubling as primary keys is a deliberate choice: they are externally-meaningful identifiers that already exist in every upstream system, they appear in URLs (`/api/etablissements/0330800013`), and they round-trip cleanly through scope literals (`etablissement:0330800013`). Adding a separate UUID would force every consumer to indirect through it for no gain.
### Lifecycle — Person + User creation
**v1 (this ADR's implementation PR):**
```
┌──────────────────────────────────────────────────────────┐
│ First sign-in for Entra oid X │
│ │
│ 1. SessionEstablisher.establish() callback │
│ 2. PersonAndUserProvisioner.ensureUser({ oid: X, … }) │
│ a. Look up User by entraOid │
│ → if found: update lastSignInAt, return │
│ → if not found: create a fresh Person + linked │
│ User in one transaction. │
│ Person.source = 'self-signin' │
│ Person.firstName / lastName = split(Entra │
│ displayName) — best effort │
│ Person.email = Entra preferred_username │
│ 3. PrincipalBuilder.build() now sees a real Person │
│ and a real User; Principal.user.personId carries │
│ Person.id rather than the entraOid placeholder │
└──────────────────────────────────────────────────────────┘
```
**Why no email-based merging in v1.** Two distinct people genuinely can share an email (shared family alias, generic `info@` mailbox at a small partner organisation, error in an upstream feed). Auto-merging a fresh sign-in into an existing Person by email match would silently corrupt the golden record without surfacing a conflict. v1 takes the safe-by-default posture: `entraOid` is the only natural key the provisioner trusts; every other dedup happens on the explicit reconciliation flow that ships with ADR-0027.
**ADR-0027 (later).** Pléiades / Acteurs+ syncs create Person rows ahead of any sign-in. The provisioner's lookup path extends, in order:
1. by `Person.externalId` when the sign-in carries one (only when the federated identity exposes the upstream id, or when an admin has linked the two via the admin UI),
2. by an explicit operator-driven match flow on `Person.email` — surfacing the candidate match for confirmation rather than auto-merging,
3. fallback to creating a `source='self-signin'` Person — same as v1.
The schema does not change between the two regimes; only the provisioner's logic and the conflict-resolution UI extend.
### Scope resolution — `PrismaScopeResolver`
`StubScopeResolver` in v1 returns `[{ kind: 'unrestricted' }]`. After ADR-0026 lands:
```ts
@Injectable()
export class PrismaScopeResolver extends ScopeResolver {
constructor(private readonly prisma: PrismaService) {
super();
}
async resolve({ userId }: { userId: string }): Promise<ReadonlyArray<Scope>> {
const rows = await this.prisma.userScope.findMany({
where: {
userId,
OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }],
},
});
return rows.map(toScope);
}
}
```
The signature `resolve({ userId })` differs from the current `resolve({ entraOid })` — the User UUID is the natural key once the schema exists. The PrincipalBuilder evolves to pass `userId` instead, which is fine because the provisioner above has just created / fetched it.
### `Principal.user` post-implementation
The two placeholders called out at the top of this ADR resolve:
| Field | Pre-ADR-0026 | Post-ADR-0026 |
| ------------------ | ------------------- | -------------------------------------------------------- |
| `user.id` | Entra `oid` | `User.id` (portal UUID) |
| `user.personId` | Entra `oid` | `Person.id` (portal UUID) |
| `user.entraOid` | Entra `oid` | unchanged |
| `user.tenantId` | Entra `tid` | unchanged |
| `user.displayName` | Entra `displayName` | unchanged (cached at sign-in; admins can override later) |
### Seeding the org hierarchy
Two viable shapes for landing `Region` / `Delegation` / `Etablissement` data:
- **Reference data inline in the Prisma migration.** The first migration carries the schema + an INSERT block populating the codes APF actually operates in. Every environment that runs the migration gets the same starting set; nothing to forget at deploy time.
- **Separate `prisma/seed.ts`.** Schema migration ships empty tables; a seed script populates them. The seed is rerun on demand (`prisma db seed`) and skipped when the tables are non-empty.
**Chosen: reference data inline in the migration.** The geographic / organisational hierarchy is _reference data_ — slow-moving, every environment needs it, none of the rows are environment-specific. Inline-in-migration is the conservative posture: one commit ships the schema and the rows that make the schema usable, and `prisma migrate deploy` in a fresh environment produces a working portal in a single step.
The v1 seed is intentionally small — only the codes the test tenant uses (a handful of établissements + a Délégation + Région Nouvelle-Aquitaine + `siege`). ADR-0027's Pléiades sync becomes the authoritative source once it ships and supersedes the v1 inline seed.
User-scope rows for the 19 test personas are NOT in this seed — they are test-tenant-specific data and ride in `prisma/seed.ts` (implementation PR 2), which is `prisma db seed`'d separately on environments where the test personas should exist.
### What ADR-0026 ships vs what stays for ADR-0027
| Concern | ADR-0026 (this) | ADR-0027 (next) |
| --------------------------------------------------------------------------- | ---------------------------- | ------------------------ |
| `Person`, `User`, `UserScope` schema | ✅ | — |
| `Region`, `Delegation`, `Etablissement` schema | ✅ (manually-seeded in v1) | Pléiades-sync population |
| `Person.source = 'self-signin'` lazy creation | ✅ | — |
| `Person.source = 'pleiades'` / `'acteurs-plus'` | as a documented future value | Sync implementation |
| Salarié / Élu / Adhérent / Bénéficiaire facets | deferred | ✅ |
| Reconciliation between admin-UI overrides and Pléiades-driven `user_scopes` | deferred | ✅ |
The facet split was the main "tempting to ship now" item; deferring it keeps the schema migration small enough to land + roll back cleanly if the lazy-provisioner exposes a corner case.
### Consequences
- Good, because `Principal.user.personId` is finally a real, stable identifier — `@RequireScope({ kind: 'self' })` can compare against `Dossier.personId` end-to-end without "the entraOid happens to be the personId" gymnastics.
- Good, because Pléiades and Acteurs+ syncs (ADR-0027) drop into a known shape; their reconciliation logic (`externalId` precedence, operator-surfaced email-based candidates) extends the v1 provisioner rather than rewriting it.
- Good, because the admin UI scope-seeding flow (v1 manual entry per ADR-0025 §"Sources of truth — apf_portal-side `user_scopes` table") now has a target row to write against (`UserScope`).
- Bad, because lazy-create-at-sign-in produces a Person row with sparse fields (`firstName` / `lastName` from the Entra `displayName` split, no postal address, no phone). Acceptable for v1 since dossiers / RH consumers do not exist yet; reconciliation with Pléiades data at sync time fills the gaps.
- Bad, because a user who signs in once with an Entra account and later gets imported from Pléiades produces two Person rows (one `self-signin`, one `pleiades`) that look like the same human. ADR-0027 handles the merge as an operator-confirmed flow; v1 simply accepts the duplicate as the cost of safe-by-default dedup. The duplicate is observable (same email) and easy to surface, not hidden.
- Neutral, because `Region.code` / `Delegation.code` / `Etablissement.finess` as primary keys preclude renames. APF's INSEE codes are stable; if a délégation merges, the row is deleted + a new one is inserted with the new code. The cost is well-bounded.
### Confirmation
- **Migration tests.** Prisma migration emits a SQL file; the `infra/local` dev stack runs it on fresh boot. Manual + a small `e2e` spec exercising the lazy-create path with a stubbed Entra response.
- **PrismaScopeResolver integration test.** Two personas (one with `etablissement:0330800013`, one with `unrestricted`) signed in, scopes round-trip from DB → Principal → `principalCoversResource`.
- **`Person.source` enum-as-string is single-sourced.** A small constant array in `apps/portal-bff/src/users/person-source.ts` lists the legal values; the catalogue-drift gate ([ADR-0025 §"Confirmation"](0025-authorization-model-privileges-roles-scopes.md)) extends to assert every string written to `Person.source` is in the catalogue. Same posture as the `Privilege` / `FunctionalRole` catalogues.
- **`Etablissement.kind` enum-as-string.** Same posture as `Person.source`. Legal values: `'etablissement'`, `'siege'`. Extending to `'preprod-placeholder'` etc. is an ADR amendment.
## Pros and Cons of the Options
### Option B — `Person` golden record + `User` overlay (chosen)
- Good, because Person-without-User is a valid state — bénéficiaires + alumni + Pléiades pre-provisioning.
- Good, because portal-only fields (`lastSignInAt`, future a11y preferences, future audit flags) ride on `User`, not on the shared Person record that Pléiades may overwrite.
- Good, because the ADR-0025 stubs resolve naturally — `personId``userId` reflects the two-concern split that was already designed in.
- Bad, because two tables means two queries on the OIDC callback hot path. Mitigation: a single Prisma `include` keeps it to one round trip.
- Neutral, because reconciling lazy-created Person rows with later Pléiades data needs a documented merge policy — that policy is ADR-0027's territory and is easier to design with this shape than with Option A.
### Option A — `User`-only
- Good, because one table is the smallest possible thing.
- Bad, because dossiers held by non-portal-user beneficiaries have nowhere to anchor — either a `Dossier.beneficiaryUserId` that violates the "User = signed-in user" invariant, or a denormalised string copy of the beneficiary's name (no golden record).
- Bad, because Pléiades pre-provisioning requires a User row to exist before any sign-in; that User would be a non-signed-in account, contradicting the table's name.
- Bad, because portal-only state (`lastSignInAt`) coexists with HR-owned data in a single table — Pléiades sync would overwrite portal-only fields unless the sync logic carefully avoids them.
### Option C — `Person` lives in Entra
- Good, because there is no portal-side identity drift to manage.
- Bad, because non-employees + non-elus do not exist in Entra (bénéficiaires, alumni, dossier-holders). They are by far the larger population.
- Bad, because Entra's storage of attributes is per-tenant and not designed for queryable golden records — listing every salarié in Bordeaux means walking the API, not SELECT.
- Bad, because the [ADR-0008](0008-identity-model-entra-workforce-dual-audience.md) dual-audience design anticipated External ID activation for non-workforce — that flips the audience but does not give bénéficiaires Entra identities by default.
### Option D — `Person` + embedded facet columns
- Good, because one less join when reading "the salarié record for Person X".
- Bad, because `Person.salarie_etablissement_id`, `Person.elu_mandat_type`, `Person.adherent_cotisation_status`, … each introduce a NULL column that 95% of rows leave unused — schema bloat without normalization gains.
- Bad, because Pléiades data shape evolves independently from Acteurs+ data shape; embedding both in `Person` means every Pléiades schema change touches the table Acteurs+ rows also live in. Separate facet tables ride their own sync's lifecycle.
## More Information
**Phasing.** This ADR is decision-only. Implementation lands in two PRs once the ADR is accepted:
1. **PR — Prisma schema + lazy provisioner.** `Person`, `User`, `Region`, `Delegation`, `Etablissement`, `UserScope` models; `PersonAndUserProvisioner` called from `SessionEstablisher`; `Person.source` + `Etablissement.kind` constants + drift-gate extension; updated `PrincipalBuilder` to populate `Principal.user.{id, personId}` from the real rows.
2. **PR — `PrismaScopeResolver` + admin UI scope-seeding screen + test-tenant seed.** Replaces `StubScopeResolver` in the AuthModule. Seeds the 19 test personas' `user_scopes` rows per `notes/test-tenant-role-assignments.md`. Adds an `/admin/users/:id/scopes` admin-app screen for manual scope management.
**Follow-up ADRs.**
- **[ADR-0027](#) — Pléiades + Acteurs+ syncs + facet schemas.** Specifies how `Person` rows are populated from upstream, how facets attach (`Salarie`, `Adherent`, `Benevole`, `Elu`, `Beneficiaire`, `PartenaireExterne`), the reconciliation policy between sync-owned and admin-UI-owned fields, and how `user_scopes` is computed from facet relationships.
- **A future ADR — time-bound roles.** `UserScope.expiresAt` already exists; the corresponding facet-level mechanism (interim director for two months, treasurer mandate from 2026 to 2029) lands when the use case is concrete.