30cefc4488
## Summary
Splits [ADR-0026](docs/decisions/0026-person-user-portal-data-model.md) into two sibling ADRs after a cascade / acteurs_plus source-of-truth audit caught a design break: the first draft pinned `Etablissement.finess` as primary key, but ≥ 30 % of APF's real structure inventory has no FINESS (antennes, dispositifs, entreprises adaptées, mouvement, administratif, siège).
| ADR | Status | Scope |
| --- | --- | --- |
| [ADR-0026](docs/decisions/0026-person-user-portal-data-model.md) — narrowed | `proposed` | `Person` + `User` + `UserScope` only (identity model) |
| [ADR-0027](docs/decisions/0027-portal-side-organisational-hierarchy.md) — new | `proposed` | `Region` + `Delegation` + `Structure` (cascade-aligned: `kind` discriminator + nullable FINESS / SIRET / `codePaie`) |
| `ADR-0028` (future) | — | Pléiades + Acteurs+ + cascade syncs + facet schemas (renumbered from the old `ADR-0027` placeholder) |
No code changes — all three artefacts moving in this PR are markdown.
## What lands
| File | Change |
| --- | --- |
| `docs/decisions/0026-person-user-portal-data-model.md` | Title trimmed (drop `+ organisational hierarchy`); `Region` / `Delegation` / `Etablissement` schema removed; `Person.email` unique constraint dropped (two distinct humans can share an email — see Lifecycle); Lifecycle rewritten without email-based dedup; `UserScope.value` documented as opaque string referencing ADR-0027 codes; Confirmation drops `Etablissement.kind` bullet; ADR-0027 sync references renumbered to ADR-0028; "What ADR-0026 ships vs adjacent ADRs" rewritten to three columns. |
| `docs/decisions/0027-portal-side-organisational-hierarchy.md` | **New.** Decision = Option B (`Structure` with `kind` discriminator + nullable FINESS / SIRET / `codePaie`, internal `code` PK that doubles as FINESS for medico-social structures). Considered options A (FINESS-only — original ADR-0026 draft), B (chosen), C (full cascade replication), D (remote read against cascade). Inline-migration seed for the test tenant (Region 75 + Delegation 33 + a handful of medico-social structures + `siege`); full inventory deferred to ADR-0028's cascade sync. `Structure.kind` enum-as-string drift-gated. |
| `docs/decisions/README.md` | ADR-0026 row: title updated, status stays `proposed`. New ADR-0027 row: `proposed`, tags `data, backend`. |
| `CLAUDE.md` | Roll-up clarifies: `0001 → 0025 accepted`; `ADR-0026 + ADR-0027 proposed`. `@RequireScope` Prisma-resolver roadmap entry references both ADRs + the ADR-0028 follow-up. No new Architecture bullet (entries land when their ADRs ship). |
## Why split
The cascade audit was the trigger. Cascade — APF's medico-social structure registry + Pléiades/Talentia HR integration — models `Structure` with a **seven-value type discriminator**: `medico_social`, `antenne`, `dispositif`, `entreprise_adaptee`, `mouvement`, `administratif`, `sanitaire`. Three of those (`antenne`, `dispositif`, part of `entreprise_adaptee`) **do not have a FINESS** by construction. Cascade carries FINESS / SIRET / SIREN / Pléiades `codePaie` / Talentia `codeCompta` on **separate per-source enrichment rows** (`StructureSourceFiness`, `StructureSourceSirene`, `StructureSourcePleiades`, `StructureSourceTalentia`), nullable and many-to-one against `Structure`.
The acteurs_plus audit confirmed: acteurs_plus does not store FINESS / SIRET / SIREN on its hierarchy entities at all — it uses a portal-internal `code` (unique string) + an `externalId` pointer.
The first ADR-0026 draft's `Etablissement.finess` PK excluded all non-medico-social structures by construction. The fix is **not** to make FINESS nullable on `Etablissement` (that smuggles the discriminator into absence-of-value semantics) — it is to adopt cascade's `Structure` + `kind` discriminator directly. Doing that inside ADR-0026 would have ballooned its scope; splitting is the cleaner shape:
- **ADR-0026** keeps a tight focus on identity (`Person` + `User` + `UserScope`). The Person model is unchanged from the first draft except for the email-dedup rewrite (already discussed before the audit landed).
- **ADR-0027** owns the org hierarchy with the cascade-aligned schema, the seeding posture, and the deferred parts (`Pole`, `Service`, arbitrary nesting, per-source enrichment) called out explicitly as ADR-0028's territory.
## ADR-0027 schema highlights
```prisma
model Structure {
// Portal-internal stable code. For medico-social structures we set
// code = FINESS (round-trips through scope literals + URLs cleanly).
// For non-medico-social structures: APF-internal slug ('siege',
// 'apf-bdx-merignac', 'ea-toulouse', 'mvt-national', …).
code String @id
name String
// Aligned with cascade's Structure.type discriminator. Drift-gated.
kind String // 'medico_social' | 'antenne' | 'dispositif'
// | 'entreprise_adaptee' | 'mouvement'
// | 'administratif' | 'siege'
finess String? @unique // 9 digits, NULL for non-medico-social
siret String? @unique // 14 chars, NULL when not SIRENE-registered
codePaie String? @unique // Pléiades 6-char, NULL in v1
delegationCode String? // NULL for siège, mouvement national
delegation Delegation? @relation(fields: [delegationCode], references: [code])
@@index([kind])
@@index([delegationCode])
}
```
The vocabulary mismatch — ADR-0025's scope kind name is `etablissement` but the value is now a `Structure.code` of any kind — is documented as a known wart, with a possible ADR-0025 amendment as the rename path if a maintainer trips over it.
## Notes for the reviewer
- **`Person.email` unique constraint dropped.** Two distinct humans genuinely can share an email (shared family alias, generic `info@` mailbox, error in an upstream feed). The first draft had `email String? @unique` carried over from a "let's use it as a v1 dedup key" line of thinking that the audit reshaped. The lifecycle now treats `entraOid` as the only natural key the v1 provisioner trusts; email is an attribute, indexed for operator-driven lookup (admin UI search, ADR-0028 reconciliation flow), not a constraint.
- **`UserScope.value` has no FK to ADR-0027 tables.** Deliberate: a scope can outlive its target (a structure decommissioned mid-quarter still has historical UserScope rows pointing at its code, which the audit log needs to read). Admin UI write path validates; runtime guard tolerates stale codes (they fail the resource match, not the sign-in).
- **The old open PR `docs/adr-0026-accept-and-tighten-lifecycle` is superseded by this one.** That branch promoted ADR-0026 (full first draft) to `accepted`. The Q1 / Q2 resolutions from that PR are preserved here — Q1 (no email-dedup) is the new ADR-0026 Lifecycle section; Q2 (inline-migration seed) moves to ADR-0027's "Seeding posture" section since it is org-hierarchy-specific. The old PR can be closed without merging.
- **No code changes**, so no `pnpm ci:check` impact. `pnpm exec prettier --check` clean on the four touched files.
## Test plan
- [x] `pnpm exec prettier --check` — clean on the four touched files.
- [x] ADR cross-references resolve (every `ADR-NNNN` link in the two ADRs round-trips; the new ADR-0028 reference is a known dangling marker for the future sync ADR).
- [ ] **Review focus** — cascade / acteurs_plus audit findings as cited in ADR-0027 §"Context"; the schema choices in ADR-0027 (kind enum, nullable FINESS/SIRET, internal `code` PK); the `Person.email` non-unique change in ADR-0026; the scope-kind vocabulary mismatch documented in ADR-0027.
## What's next (post-merge)
Per ADR-0026 §"Phasing" and ADR-0027 §"Phasing" — the two ADR PRs ship in parallel once accepted:
1. **ADR-0027 Implementation PR 1** — `Region` / `Delegation` / `Structure` Prisma schema + inline reference-data migration + `Structure.kind` catalogue + drift-gate extension.
2. **ADR-0026 Implementation PR 1** — `Person` / `User` / `UserScope` Prisma schema + `PersonAndUserProvisioner` called from `SessionEstablisher` + `Person.source` catalogue + drift-gate extension + updated `PrincipalBuilder`. Independent of (1) at the schema level — can ship in parallel.
3. **ADR-0026 Implementation PR 2** — `PrismaScopeResolver` replacing `StubScopeResolver` + `/admin/users/:id/scopes` admin screen + `prisma/seed.ts` populating the 19 test personas' `user_scopes` per `notes/test-tenant-role-assignments.md`. **Depends on both (1) and (2)** — the seed references `Structure.code` values from (1) and writes `UserScope` rows from (2).
4. **ADR-0028 (proposed)** — Pléiades + Acteurs+ + cascade syncs + facet schemas (Salarié / Élu / Adhérent / Bénévole / Bénéficiaire / PartenaireExterne) + operator-confirmed Person-reconciliation flow + schema extensions (`Pole`, `Service`, per-source enrichment) the sync needs.
---------
Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #217
284 lines
23 KiB
Markdown
284 lines
23 KiB
Markdown
---
|
||
status: proposed
|
||
date: 2026-05-24
|
||
decision-makers: R&D Lead
|
||
tags: [data, backend, security]
|
||
---
|
||
|
||
# `Person` golden record + `User` portal-account — portal-side identity 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 two identity 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.
|
||
|
||
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 capabilities the portal needs from day one:
|
||
|
||
- **Pré-provisioning** — a structure 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 **identity** model. The organisational hierarchy that `@RequireScope` dereferences (`Region`, `Delegation`, `Structure`) is the sibling concern of [ADR-0027](#), kept on a separate timeline because the source-of-truth audit (cascade + acteurs_plus) reshaped that model after ADR-0026 was first drafted. The upstream **sync** of Person rows from Pléiades / Acteurs+ — together with the **facet shapes** (Salarié, Élu, Adhérent, Bénéficiaire) that attach to a Person — is [ADR-0028](#)'s territory.
|
||
|
||
ADR-0026's scope: the three core tables `Person`, `User`, `UserScope`.
|
||
|
||
## 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.
|
||
- **No premature normalisation of facets.** Salarié / Élu / Adhérent / Bénéficiaire schemas track their upstream system; specifying them before the sync ADR ([ADR-0028](#)) 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-0028 and updates the lookup path, not the schema.
|
||
- **Safe-by-default dedup.** `entraOid` is the only natural key the v1 provisioner trusts. Email is an attribute, not an identifier — see "Lifecycle" below for why.
|
||
- **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-structure-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
|
||
|
||
```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. Indexed for operator-driven lookup (admin UI
|
||
// search, ADR-0028 reconciliation flow), NOT unique — two distinct
|
||
// humans genuinely can share an email (shared family alias, generic
|
||
// info@ mailbox at a small partner organisation, error in an upstream
|
||
// feed). A unique constraint would crash the first Pléiades import
|
||
// that surfaces such a pair.
|
||
email String?
|
||
// 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-0028 will add 'pleiades' and 'acteurs-plus'.
|
||
// The field is a free-form string in v1; promoting to an enum is
|
||
// an ADR-0028 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])
|
||
@@index([email])
|
||
}
|
||
|
||
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 — semantics owned by ADR-0027. For 'etablissement'
|
||
// the value is a Structure.code (string); for 'delegation' it is a
|
||
// Delegation.code; for 'region' it is a Region.code. 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-0028 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])
|
||
}
|
||
```
|
||
|
||
`UserScope.value` references the codes defined in [ADR-0027](#) (`Structure.code`, `Delegation.code`, `Region.code`) but stores them as opaque strings — no foreign-key from `UserScope` to those tables. Rationale: a scope can outlive its target (a structure decommissioned mid-quarter still has historical UserScope rows referencing its code, useful for the audit log). The admin UI surface that creates UserScope rows validates the code against ADR-0027's tables at write time; the runtime guard does the same dereference at sign-in.
|
||
|
||
### 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-0028.
|
||
|
||
**ADR-0028 (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.
|
||
|
||
The full guard-side dereference path (`principalCoversResource` walking `Structure.delegationCode` → `Delegation.regionCode` → `Region`) requires ADR-0027's hierarchy to be live. PR sequencing reflects this — see "More Information" below.
|
||
|
||
### `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 adjacent ADRs
|
||
|
||
| Concern | ADR-0026 (this) | ADR-0027 (org hierarchy) | ADR-0028 (sync + facets) |
|
||
| ------------------------------------------------------------------------- | ---------------------------- | ------------------------ | ------------------------ |
|
||
| `Person`, `User`, `UserScope` schema | ✅ | — | — |
|
||
| `Region`, `Delegation`, `Structure` schema | — | ✅ | — |
|
||
| `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 upstream-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-0028) 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-0028 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 `UserScope.value` is an opaque string with no FK to ADR-0027's hierarchy tables. The write path validates against those tables; runtime read tolerates stale codes. Stale entries do not crash sign-in — they fail the `principalCoversResource` check on the resource that no longer exists, which is the correct behaviour.
|
||
|
||
### 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 signed in (one with a `kind='etablissement'` scope, one with `kind='unrestricted'`), scopes round-trip from DB → Principal → `principalCoversResource`. Requires ADR-0027's seeded hierarchy to give the etablissement-scoped persona a real `Structure.code` to point at.
|
||
- **`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.
|
||
|
||
## 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-0028'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_structure_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 is sequenced against [ADR-0027](#) because the PrismaScopeResolver test matrix needs real `Structure.code` / `Delegation.code` / `Region.code` rows:
|
||
|
||
1. **PR — Prisma schema + lazy provisioner.** `Person`, `User`, `UserScope` models; `PersonAndUserProvisioner` called from `SessionEstablisher`; `Person.source` constants + drift-gate extension; updated `PrincipalBuilder` to populate `Principal.user.{id, personId}` from the real rows. Independent of ADR-0027 — can ship in parallel.
|
||
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. **Depends on ADR-0027 PR 1** — the seed references `Structure.code` values that must already exist in the database.
|
||
|
||
**Follow-up ADRs.**
|
||
|
||
- **[ADR-0027](#) — Portal-side organisational hierarchy.** `Region` / `Delegation` / `Structure` schema (Structure with kind discriminator + nullable FINESS / SIRET keys, cascade-aligned). Sibling to ADR-0026, shipped at the same cadence.
|
||
- **[ADR-0028](#) — 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.
|