Files
apf_portal/docs/decisions/0026-person-user-portal-data-model.md
T
Julien Gautier 7a4ebe75ea
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
docs(adr-0026): person + user portal-side data model (proposed)
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.
2026-05-24 02:10:40 +02:00

317 lines
22 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: proposed
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: continue │
│ b. Look up Person by email (case-insensitive) │
│ → if found: link User to existing Person │
│ → if not found: create Person with │
│ source='self-signin', then link User │
│ 3. PrincipalBuilder.build() now sees a real Person │
│ and a real User; Principal.user.personId carries │
│ Person.id rather than the entraOid placeholder │
└──────────────────────────────────────────────────────────┘
```
**ADR-0027 (later):** Pléiades / Acteurs+ syncs create Person rows ahead of any sign-in. The lookup path becomes:
1. by `Person.externalId` if the sign-in carries one (rare — only when the federated identity exposes the upstream id),
2. by `Person.email` (the existing v1 lookup),
3. fallback to creating a `source='self-signin'` Person.
The schema does not change; only the provisioner's lookup order extends.
### 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) |
### 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) can drop into a known shape; their lookup logic (email-based dedup, externalId carryover) is already part of v1.
- 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 `Person.email` as the v1 dedup key is fragile — two Pléiades records with the same email crash the unique constraint. Mitigation: ADR-0027 adds `externalId` precedence and surfaces conflicts to the operator.
- 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.
- 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.