docs(adr): split ADR-0026 + propose ADR-0027 (Structure hierarchy)
CI / commits (pull_request) Successful in 3m1s
CI / check (pull_request) Successful in 3m3s
CI / scan (pull_request) Successful in 3m12s
CI / a11y (pull_request) Successful in 3m1s
Docs site / build (pull_request) Successful in 3m16s
CI / perf (pull_request) Successful in 5m59s

Cascade + acteurs_plus source-of-truth audit caught the original
ADR-0026 draft pinning Etablissement.finess as PK while >=30% of
APF's real structure inventory has no FINESS (antennes, dispositifs,
entreprises adaptees, mouvement, administratif, siege).

Narrow ADR-0026 to identity only (Person + User + UserScope). Drop
Person.email unique (two distinct humans can share an email) and
drop email-based dedup from the v1 provisioner lifecycle - entraOid
is the only natural key trusted in v1.

New ADR-0027 owns the organisational hierarchy: cascade-aligned
Structure with kind discriminator + nullable FINESS / SIRET /
codePaie + internal code PK that doubles as FINESS for medico-social
structures. Pole / Service / arbitrary nesting / per-source
enrichment deferred to ADR-0028 (sync), renumbered from the prior
ADR-0027 placeholder.

Both ADRs stay proposed pending review.
This commit is contained in:
Julien Gautier
2026-05-24 06:52:02 +02:00
parent abd1f91809
commit 204b0e35fa
4 changed files with 280 additions and 101 deletions
@@ -5,15 +5,14 @@ decision-makers: R&D Lead
tags: [data, backend, security]
---
# `Person` golden record + `User` portal-account + organisational hierarchy — portal-side data model
# `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 several anchors as proposed-follow-up. In v1 those anchors are stubbed:
[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.
- `@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:
@@ -21,22 +20,22 @@ The portal also needs a representation of **people who are not yet portal users*
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:
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** — 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.
- **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 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.
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`) plus the geographic / organisational hierarchy (`Region`, `Delegation`, `Etablissement`) that scope checks dereference today.
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.
- **`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 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
@@ -44,7 +43,7 @@ ADR-0026's scope: the three core tables (`Person`, `User`, `UserScope`) plus the
- **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.
- **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
@@ -54,7 +53,7 @@ Chosen option: **B — `Person` golden record + `User` portal-account overlay**,
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
### Schema
```prisma
model Person {
@@ -62,18 +61,19 @@ model Person {
// 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
// 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-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.
// 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.
@@ -85,6 +85,7 @@ model Person {
@@index([source])
@@index([externalId])
@@index([email])
}
model User {
@@ -114,15 +115,15 @@ model UserScope {
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
// 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-0027 adds 'pleiades' / 'acteurs-plus' and the
// reconciliation rules between admin overrides and upstream
// data.
// 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.
@@ -135,46 +136,7 @@ model UserScope {
}
```
### 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.
`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
@@ -188,24 +150,27 @@ model Etablissement {
│ 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
│ → 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 │
└──────────────────────────────────────────────────────────┘
```
**ADR-0027 (later):** Pléiades / Acteurs+ syncs create Person rows ahead of any sign-in. The lookup path becomes:
**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.
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.
**ADR-0028 (later).** Pléiades / Acteurs+ syncs create Person rows ahead of any sign-in. The provisioner's lookup path extends, in order:
The schema does not change; only the provisioner's lookup order extends.
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`
@@ -232,6 +197,8 @@ export class PrismaScopeResolver extends ScopeResolver {
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:
@@ -244,34 +211,33 @@ The two placeholders called out at the top of this ADR resolve:
| `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
### What ADR-0026 ships vs adjacent ADRs
| 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 | ✅ |
| 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-0027) can drop into a known shape; their lookup logic (email-based dedup, externalId carryover) is already part of v1.
- 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 `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.
- 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 (one with `etablissement:0330800013`, one with `unrestricted`) signed in, scopes round-trip from DB → Principal → `principalCoversResource`.
- **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.
- **`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
@@ -281,7 +247,7 @@ The facet split was the main "tempting to ship now" item; deferring it keeps the
- 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.
- 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
@@ -300,17 +266,18 @@ The facet split was the main "tempting to ship now" item; deferring it keeps the
### 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 `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 lands in two PRs once the ADR is accepted:
**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`, `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.
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](#) — 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.
- **[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.
@@ -0,0 +1,211 @@
---
status: proposed
date: 2026-05-24
decision-makers: R&D Lead
tags: [data, backend]
---
# Portal-side organisational hierarchy — `Structure` with kind discriminator and nullable FINESS / SIRET
## Context and Problem Statement
[ADR-0025](0025-authorization-model-privileges-roles-scopes.md)'s scope axis dereferences three real-world hierarchy levels: `etablissement:<code>`, `delegation:<dept>`, `region:<insee>`. The BFF guard `principalCoversResource` walks `etablissement → delegation → region` to decide whether a scope covers a resource — that walk needs persisted rows.
[ADR-0026](0026-person-user-portal-data-model.md)'s first draft included those three tables (`Region`, `Delegation`, `Etablissement`) with `Etablissement.finess` as the primary key, on the assumption that every APF structure has a FINESS code and the FINESS doubles as the natural identifier on the wire (URL paths, scope literals).
That assumption is wrong, and was caught before merge. The two APF systems that already model the organisational hierarchy at scale — **`cascade`** (medico-social structure registry + Pléiades/Talentia HR integration) and **`acteurs_plus`** (member + governance registry) — were audited for their source-of-truth shapes. The findings:
- **Cascade explicitly carries seven types of `Structure`** via a discriminator column: `medico_social`, `antenne`, `dispositif`, `entreprise_adaptee`, `mouvement`, `administratif`, `sanitaire`. Three of those (`antenne`, `dispositif`, parts of `entreprise_adaptee`) **do not have a FINESS** by construction — they are branches, programmatic services, or businesses without medico-social registration. Cascade's `Structure` PK is an internal auto-increment; FINESS / SIRET / SIREN / Pléiades `codePaie` / Talentia `codeCompta` are all carried on **separate per-source enrichment rows** (`StructureSourceFiness`, `StructureSourceSirene`, `StructureSourcePleiades`, `StructureSourceTalentia`), nullable and many-to-one against `Structure`.
- **Acteurs_plus does not store FINESS / SIREN / SIRET at all** on its org-hierarchy entities. It uses a portal-internal `code` (unique string) plus an `externalId` integer pointing at upstream registries.
- **Neither system uses INSEE region codes as primary keys** — both have auto-PK rows with `code` carried as a unique attribute. (The portal's choice to make INSEE / dept / FINESS doubly-meaningful as PKs is defensible — only one Region table exists, externally-keyed is cleaner here — but it must not assume the same identifier strategy works for every structure.)
A portal model that pins `Etablissement.finess` as PK therefore **excludes ≥ 30 % of APF's real structure inventory** by construction. The fix is not "make FINESS nullable on Etablissement" — that just smuggles the discriminator into the absence-of-value semantics. The fix is to adopt cascade's discriminator-based model directly.
This ADR specifies the `Region` / `Delegation` / `Structure` schema that the portal persists, and the seeding posture for v1. The portal-side **sync** of these rows from cascade / Pléiades / Acteurs+ is [ADR-0028](#)'s territory.
## Decision Drivers
- **All APF structures must be representable**, not just the medico-social ones. Antennes, dispositifs, entreprises adaptées, mouvement, administratif, siège — every category needs a row.
- **Externally-meaningful identifiers must round-trip** through URLs (`/api/structures/<code>`) and scope literals (`etablissement:<code>`) without server-side translation. For medico-social structures the FINESS already plays this role; for the rest we adopt the cascade/acteurs_plus posture of a portal-internal `code` string.
- **No FK from `UserScope.value` to `Structure.code`.** ADR-0026 already commits to this — the value is an opaque string at the type level so historical scope rows survive structure decommissioning. Validation happens at the admin-UI write path, not at the database level.
- **Cascade is the long-term source of truth**, not the portal. v1 ships a small inline seed (the structures the test tenant exercises) precisely because ADR-0028's cascade sync will overwrite it.
- **Do not replicate cascade's full model in v1.** Cascade carries `Pole` (lateral org grouping), `Service` (sub-units inside a structure), arbitrary self-referencing `structureParent`, and four per-source enrichment tables. None of those are required by the v1 scope axis. Defer to ADR-0028 (which will need them for the sync) or beyond, and accept that the portal's v1 schema is a subset.
## Considered Options
- **Option A — `Etablissement` keyed on FINESS only** (the original ADR-0026 first draft). Discarded — see Context above.
- **Option B — `Structure` with internal `code` PK + `kind` discriminator + nullable FINESS/SIRET (chosen).** Cascade-aligned but simplified: no Pole, no Service, no self-reference, no per-source enrichment tables. One row per structure, with the upstream identifiers as nullable attributes carrying unique indexes when present.
- **Option C — Full cascade replication.** Structure + Pole + Service + arbitrary parent chain + four per-source enrichment tables. Faithful to cascade but the join cost on every scope check is non-zero, and v1 has no consumer for Pole / Service / arbitrary nesting.
- **Option D — No portal-side Structure table; resolve scope literals via a remote call into cascade.** Defers all schema work but adds a remote call to every guarded request — latency, cascade-availability coupling, and audit-log enrichment all degrade.
## Decision Outcome
Chosen option: **B — `Structure` with internal `code` PK + `kind` discriminator + nullable FINESS / SIRET**, because:
1. it covers 100 % of APF's structure categories without smuggling a discriminator into nullable FINESS;
2. it keeps the scope-check hot path local (no remote call to cascade);
3. it leaves a clean extension surface for ADR-0028 — adding `Pole`, `Service`, or per-source enrichment tables is additive, not destructive;
4. it preserves the externally-meaningful round-trip property: for medico-social structures we set `Structure.code = FINESS` at seed time, so `etablissement:0330800013` stays a readable scope literal where it can.
### Schema
```prisma
model Region {
// INSEE region code (2 digits — '11' for Île-de-France,
// '75' for Nouvelle-Aquitaine). Doubles as primary key:
// INSEE codes are short, stable, and externally meaningful.
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])
structures Structure[]
}
model Structure {
// Portal-internal stable code, externally meaningful.
// - For medico-social structures: code = FINESS (9 digits).
// The round-trip from URL (/api/structures/0330800013) and
// scope literal (etablissement:0330800013) stays readable.
// - For non-medico-social structures: code is an APF-internal
// string assigned at seed / admin-UI time. Examples:
// 'siege', 'apf-bdx-merignac', 'ea-toulouse', 'mvt-national'.
// - The code is opaque at the type level; matching against it
// is string equality, not parsing.
code String @id
name String
// Discriminator aligned with cascade's Structure.type. Closed
// set, drift-gated alongside the ADR-0025 catalogues. Legal
// values:
// 'medico_social' — has FINESS, governed by the FINESS registry
// 'antenne' — APF branch office without FINESS
// 'dispositif' — programmatic service without establishment status
// 'entreprise_adaptee' — EA — usually has SIREN/SIRET, no FINESS
// 'mouvement' — APF mouvement (national-level org)
// 'administratif' — administrative entity (e.g. siège regional)
// 'siege' — APF national headquarters
// Extensions ('sanitaire', 'autre', …) are an ADR amendment.
kind String
// FINESS code (9 digits). NULL for non-medico-social structures.
// Unique when present. Cascade's StructureSourceFiness is the
// long-term authoritative carrier; the portal keeps it inline
// on Structure as a denormalised attribute for the v1 scope-axis
// checks. ADR-0028's sync owns the write path.
finess String? @unique
// SIRET (14 chars: 9 SIREN + 5 NIC). NULL when the structure is
// not registered with SIRENE (most antennes, dispositifs).
// Unique when present.
siret String? @unique
// Pléiades payroll code (6 chars). NULL in v1 — populated by
// ADR-0028's Pléiades sync once it ships.
codePaie String? @unique
// Parent delegation. NULL for structures not attached to a
// delegation (siège, mouvement national, …).
delegationCode String?
delegation Delegation? @relation(fields: [delegationCode], references: [code])
@@index([kind])
@@index([delegationCode])
}
```
### Scope-literal interpretation
The ADR-0025 catalogue defines the scope kind name `etablissement`. With ADR-0027's schema, `etablissement:<value>` carries a **`Structure.code`** as `<value>` — not a FINESS specifically. For medico-social structures the two are the same digit-string; for non-FINESS structures the value is the portal-internal code.
The vocabulary mismatch ("scope name `etablissement` referencing any kind of `Structure`") is a known wart inherited from ADR-0025's catalogue, which shipped before the cascade audit reshaped the model. Renaming the scope kind to `structure` is plausible but invasive (catalogue, drift gate, decorator names, all references). The cost is not paid today; the rename is an ADR-0025 amendment if and when a maintainer trips over it. Until then, the matcher contract is documented in code (`apps/portal-bff/src/auth/scope-matcher.ts`).
The `principalCoversResource` matcher receives a `ScopableResource` that has been pre-resolved against the Structure / Delegation / Region tables — it does not consult the database itself. Resolution happens once at request-handler entry: the handler reads `Structure` by code, joins `Delegation` and `Region`, and passes the populated `ScopableResource` to the guard.
### Seeding posture
`Region`, `Delegation`, `Structure` are **reference data**: slow-moving, every environment needs them, none of the rows are environment-specific (until the test-tenant scope-seeding step in ADR-0026 PR 2, which seeds `UserScope` rows pointing at these structures).
**Chosen: inline in the Prisma migration.** The first migration carries the schema + an INSERT block populating the codes APF actually operates on. Every environment that runs `prisma migrate deploy` gets the same starting set; nothing to forget at deploy time; the schema and the rows that make the schema usable land in one atomic step.
The v1 seed is intentionally small — only what the ADR-0026 test-tenant flow needs:
- **Region** Nouvelle-Aquitaine (INSEE `75`) — sole region for v1.
- **Delegation** Gironde (`33`) — sole delegation for v1.
- **Structure** rows: a handful of medico-social établissements (Bordeaux + complexe-merignac with `kind='medico_social'`, FINESS = code), one `siege` (no delegation, no FINESS), one `entreprise_adaptee` placeholder if the test matrix needs it.
The full APF inventory ships once ADR-0028's cascade sync is live. The v1 inline seed is **superseded**, not extended, by the sync — the migration that adds the sync is also responsible for the cleanup truncation if needed.
### What ADR-0027 ships vs adjacent ADRs
| Concern | ADR-0026 (identity) | ADR-0027 (this) | ADR-0028 (sync + facets) |
| ----------------------------------------------------------- | ------------------- | ---------------------- | ------------------------ |
| `Region`, `Delegation`, `Structure` schema | — | ✅ | — |
| Inline reference-data seed for the test tenant | — | ✅ | — |
| Full APF inventory (~ ten thousand structures) | — | — | Cascade-sync population |
| `Pole`, `Service`, arbitrary nesting, per-source enrichment | — | deferred | as ADR-0028's sync needs |
| `Structure.codePaie` populated | — | nullable (column only) | ✅ |
### Consequences
- Good, because every APF structure category fits the schema — antennes and dispositifs are first-class, not absence-of-FINESS edge cases.
- Good, because the scope-axis check stays local (no remote call to cascade on the guard hot path).
- Good, because cascade's eventual sync (ADR-0028) writes into the existing column set — no schema churn at sync time, just a write strategy.
- Good, because the medico-social FINESS round-trip (`/api/structures/0330800013`) and scope-literal readability (`etablissement:0330800013`) are preserved for the structures that have a FINESS.
- Bad, because `Structure.code` semantics differ across kinds — sometimes it's a FINESS, sometimes a portal-internal slug. Operators reading the table need to consult `kind` to interpret `code`. Mitigation: the admin-UI scope-seeding screen ([ADR-0026](0026-person-user-portal-data-model.md) PR 2) shows `(code, name, kind)` together, not just the code.
- Bad, because the schema is a strict subset of cascade's — `Pole` (lateral org grouping) and `Service` (sub-units) are missing. If a v1 consumer asks "show me everyone in the Pôle Santé" the portal cannot answer. Mitigation: documented as deferred; ADR-0028 picks it up alongside the sync.
- Bad, because the scope kind name `etablissement` no longer matches its value semantics (a `Structure.code` of any kind, not just `medico_social`). The matcher contract is documented; rename deferred to a possible ADR-0025 amendment.
- Neutral, because `UserScope.value` has no FK to `Structure.code`. ADR-0026 already commits to this; stale codes are tolerated at runtime and surface as a failed resource match, not a crash.
### Confirmation
- **Migration tests.** Prisma migration emits a SQL file; the `infra/local` dev stack runs it on fresh boot. Verified by seeing the seed rows under `prisma studio` after `prisma migrate dev`.
- **`Structure.kind` enum-as-string is single-sourced.** A constant array in `apps/portal-bff/src/structures/structure-kind.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 `Structure.kind` is in the catalogue. Same posture as the `Privilege` / `FunctionalRole` / `Person.source` catalogues.
- **PrismaScopeResolver integration test (jointly with ADR-0026 PR 2).** A persona with `kind='etablissement', value='0330800013'` (a medico-social Structure) and a persona with `value='apf-bdx-merignac'` (a non-FINESS Structure) both successfully cover their respective `ScopableResource` after a single Structure read + Delegation/Region join. Same matcher, both `code` values.
## Pros and Cons of the Options
### Option B — `Structure` with kind discriminator + nullable FINESS / SIRET (chosen)
- Good, because every APF structure category is representable.
- Good, because the round-trip property is preserved for medico-social structures.
- Good, because cascade's eventual sync writes additively, not destructively.
- Bad, because `code` semantics depend on `kind` — operators need to read both.
- Neutral, because the schema is a subset of cascade's. Acceptable for v1; revisit when a consumer needs Pole / Service.
### Option A — `Etablissement` keyed on FINESS only
- Good, because the schema is the smallest possible thing.
- Bad, because it excludes ≥ 30 % of APF's structure inventory (antennes, dispositifs, entreprises adaptées, …) by construction.
- Bad, because making FINESS nullable to fix this smuggles the discriminator into absence-of-value semantics, which is harder to reason about than an explicit `kind` column.
- Bad, because cascade's seven-type discriminator model demonstrably exists upstream — the portal not reflecting it just means the sync (ADR-0028) has to compress / discard information.
### Option C — Full cascade replication
- Good, because the schema is a perfect mirror — no information is lost at sync time, no consumer is ever blocked by an absent table.
- Bad, because the v1 scope-axis check does not need Pole, Service, or self-referencing nesting — the join cost is paid every request for unused capability.
- Bad, because the four per-source enrichment tables (`StructureSourceFiness`, `StructureSourceSirene`, …) reflect cascade's role as the multi-source aggregator; the portal is downstream of cascade, not parallel to it. Owning four sync feeds the portal does not consume is misallocation.
- Neutral, because additive extension from Option B's schema toward Option C's is straightforward — ADR-0028 picks up what its consumers need.
### Option D — Remote read against cascade
- Good, because the portal owns zero structure schema — single source of truth, no drift.
- Bad, because every guarded request becomes a remote call. Latency adds at the 95p; cascade availability is now in the portal's hot path.
- Bad, because audit-log enrichment (which structure was the resource scoped to?) requires the remote call to succeed, on the audit-write hot path that ADR-0013 keeps blocking.
- Bad, because the AI-relay's `PrincipalProjector` ([ADR-0024](0024-ai-service-relay-grpc-sse-bridge.md)) needs to emit structure codes in the AI-side `roles[]` payload — a remote call there means cascade is on the AI-chat hot path too.
## More Information
**Phasing.** This ADR is decision-only. Implementation is sibling to [ADR-0026](0026-person-user-portal-data-model.md) PR 1 — both schema migrations can ship in one PR or split, but the test-tenant scope-seed (ADR-0026 PR 2) needs both to be live.
1. **PR — Prisma schema + inline reference-data seed.** `Region` / `Delegation` / `Structure` models + the v1 inline migration carrying the codes APF's test tenant operates on + `Structure.kind` constants + drift-gate extension for `Structure.kind`. Can ship alongside or after [ADR-0026](0026-person-user-portal-data-model.md) PR 1; the two PRs do not share schema, so order is flexible.
2. ADR-0026 PR 2 then lands on top — the `PrismaScopeResolver` + admin UI scope-seeding screen seeds `UserScope` rows pointing at this ADR's `Structure.code` values.
**Follow-up ADRs.**
- **[ADR-0028](#) — Pléiades + Acteurs+ + cascade syncs + facet schemas.** Specifies the structure-catalogue sync (cascade → portal), the person-catalogue sync (Pléiades + Acteurs+ → portal), the facet shapes (Salarié, Élu, Adhérent, Bénévole, Bénéficiaire, PartenaireExterne), the reconciliation policy between sync-owned and admin-UI-owned fields, and the schema extensions (Pole, Service, per-source enrichment) the sync may need.
- **Possible future ADR-0025 amendment — rename scope kind `etablissement` to `structure`.** Triggered only if the vocabulary mismatch causes confusion in code review or operator-facing UIs; carries a closed-set catalogue rename + drift-gate update + all decorator literals.
**Source-of-truth investigations.** The cascade and acteurs_plus audits that drove this ADR live alongside the project lead's working notes (gitignored). The audit findings summarised in §"Context" are the durable artefacts — the raw notes are not part of the repo.
+2 -1
View File
@@ -69,4 +69,5 @@ ADRs are listed in numerical order. To slice by topic, filter on the `Tags` colu
| [0023](0023-charts-d3-observable-plot.md) | Charts + dashboards — D3 + Observable Plot wrapped in `libs/shared/charts` | accepted | `frontend`, `accessibility`, `performance` | 2026-05-16 |
| [0024](0024-ai-service-relay-grpc-sse-bridge.md) | AI service relay — vendored gRPC protos, NestJS gRPC client, SSE bridge to the SPA, POC unsigned principal | accepted | `backend`, `security`, `observability` | 2026-05-19 |
| [0025](0025-authorization-model-privileges-roles-scopes.md) | Authorization model — three orthogonal axes (privileges × functional roles × scopes), Entra-backed | accepted | `security`, `backend`, `data` | 2026-05-20 |
| [0026](0026-person-user-portal-data-model.md) | `Person` golden record + `User` portal-account + organisational hierarchy — portal-side data model | proposed | `data`, `backend`, `security` | 2026-05-24 |
| [0026](0026-person-user-portal-data-model.md) | `Person` golden record + `User` portal-account — portal-side identity model | proposed | `data`, `backend`, `security` | 2026-05-24 |
| [0027](0027-portal-side-organisational-hierarchy.md) | Portal-side organisational hierarchy — `Structure` with kind discriminator and nullable FINESS / SIRET | proposed | `data`, `backend` | 2026-05-24 |