From 8266e5b17215cfa6bceb8cb747781e50598aca25 Mon Sep 17 00:00:00 2001 From: Julien Gautier Date: Sun, 24 May 2026 02:19:29 +0200 Subject: [PATCH] docs(adr-0026): person + user portal-side data model (proposed) (#213) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary [ADR-0025](docs/decisions/0025-authorization-model-privileges-roles-scopes.md) shipped the authorization model with three stubs explicitly deferred to a follow-up ADR: - `Principal.user.id` and `Principal.user.personId` carry the Entra `oid` as a placeholder — `apps/portal-bff/src/auth/principal-builder.ts` documents the seam. - `StubScopeResolver` returns `[{ kind: 'unrestricted' }]` for every signed-in user; the per-persona scope values documented in `notes/test-tenant-role-assignments.md` have nowhere to live. - `@RequireScope`'s `ScopableResource` shape references an `Etablissement` / `Delegation` / `Region` chain that has no Prisma table. ADR-0026 specifies the portal-side data model that closes those stubs. **Decision-only, status `proposed`** — implementation lands in two PRs once accepted. ## What lands | File | Change | | --- | --- | | `docs/decisions/0026-person-user-portal-data-model.md` | New ADR. MADR 4.0.0 format. Tags: `data`, `backend`, `security`. ~310 lines. | | `docs/decisions/README.md` | One new index row for 0026 (status `proposed`, 2026-05-24). | ## ADR scope The ADR commits the **schema** for: - **`Person`** golden record — stable identity, can exist without a portal account (Pléiades pre-provisioning, dossier bénéficiaires, alumni). `source` field tracks provenance (`self-signin` in v1; `pleiades` / `acteurs-plus` join the catalogue with ADR-0027). - **`User`** portal-account overlay — one-to-zero-or-one with Person, lazy-created on first OIDC callback. Portal-only state (`lastSignInAt`, future a11y preferences) rides here, not on the shared Person row. - **`UserScope`** — confirms the migration whose shape ADR-0025 §"Sources of truth — apf_portal-side `user_scopes` table" already specified. - **`Region` / `Delegation` / `Etablissement`** — organisational hierarchy with externally-meaningful codes (INSEE / French dept / FINESS) as primary keys, matching the on-the-wire shape of the scope literals (`etablissement:0330800013`, `delegation:33`). The ADR **explicitly defers**: - Facet schemas (`Salarie`, `Adherent`, `Benevole`, `Elu`, `Beneficiaire`, `PartenaireExterne`) — they track upstream-system shape and ride alongside their producing sync. - Pléiades / Acteurs+ sync logic, reconciliation policy between sync-owned and admin-UI-owned fields. Both deferrals point at **ADR-0027** (Pléiades + Acteurs+ syncs + facet schemas) as the next-up ADR. ## Notes for the reviewer - **Why `Person` + `User` split and not a single table.** The "Considered Options" section walks through this. Option A (User-only) breaks down the moment a dossier holder who never signs in needs a stable identifier. Option D (Person with embedded facet columns) forces every Pléiades or Acteurs+ schema change through a table that both shapes share, which is exactly the kind of coupling that ADR-0027 needs the freedom to design out. - **Why externally-meaningful primary keys for the hierarchy.** `Region.code` / `Delegation.code` / `Etablissement.finess` are INSEE / FINESS codes — stable across reorgs and already on every URL the portal will mint. Adding a separate UUID would force every consumer to indirect through it for no gain. The trade-off (no in-place rename — if a délégation merges, delete + re-insert with the new code) is bounded by how rarely INSEE rebases. - **Lazy User creation at first sign-in.** v1 has no Pléiades data; the OIDC callback creates the Person + User pair the first time it sees a new `oid`. When Pléiades sync ships (ADR-0027), the same provisioner extends with a `Person.externalId` lookup before falling back to `email`. The schema does not change between the two regimes — only the provisioner's lookup order does. - **`Person.source` and `Etablissement.kind` as enum-as-string + drift-gate extension.** The catalogue-drift gate ([ADR-0025 §"Confirmation"](docs/decisions/0025-authorization-model-privileges-roles-scopes.md) and `scripts/check-catalogue-drift.mjs`) was built precisely to constrain hand-edited string columns. Extending it to assert every `Person.source` write is in the closed catalogue closes one of the soft spots in the v1 schema without standing up a Postgres ENUM. - **PII posture.** `Person.firstName` / `lastName` / `email` are flagged in the ADR's Decision Drivers. The Pino redact list ([ADR-0012](docs/decisions/0012-observability-pino-opentelemetry.md)) and the audit-log salt ([ADR-0013](docs/decisions/0013-audit-trail-separated-postgres-append-only.md)) already cover Entra `oid`; the new PII fields ride the same posture (caller-redacted at the audit module, redacted at the Pino logger before the line ships). - **What "two PRs" means in the More Information section.** PR 1: Prisma schema migration + `PersonAndUserProvisioner` called from `SessionEstablisher` + drift-gate extension. PR 2: `PrismaScopeResolver` replacing `StubScopeResolver` + `/admin/users/:id/scopes` screen + `prisma/seed.ts` populating the 19 test-tenant personas' `user_scopes` rows. - **Backward compatibility for in-flight Redis sessions.** Sessions minted before the schema migration carry a Principal whose `user.id` is the Entra `oid` placeholder. The legacy-session bridge in [`apps/portal-bff/src/auth/principal-extractor.ts`](apps/portal-bff/src/auth/principal-extractor.ts) (added in #208) already handles this — when the migration deploys, those sessions continue to work for the 12 h absolute-TTL window, after which every session in Redis has the real `personId`. ## Open questions to resolve before acceptance These were flagged in the ADR text and are worth surfacing here for the review pass: - **`Person.email` as the v1 dedup key.** The ADR's "Bad, because" item — two Pléiades records sharing an email would crash the unique constraint. ADR-0027 will add the `externalId` precedence, but in the interim the v1 lazy-creator either trusts emails-are-unique (acceptable for the test tenant where every persona has a distinct one) or skips the dedup attempt and creates a fresh Person per `oid`. The ADR currently picks the dedup-by-email path; flag if the safer choice is "always fresh, reconcile later". - **Should `Region` / `Delegation` / `Etablissement` migrations be seeded as part of the schema PR, or kept as a separate dataset import?** The ADR is silent on this; my default is "seed the geographic codes APF actually operates in" (a one-off SQL fixture in the migration directory). Worth confirming. ## Test plan - [x] `pnpm exec prettier --check docs/decisions/0026-person-user-portal-data-model.md docs/decisions/README.md` — clean. - [ ] **Review focus** — the chosen Option B vs the rejected A/C/D, the deferred facets list, the `Person.source` catalogue, the `Etablissement.kind` catalogue, and the two open questions above. - [ ] Once accepted, the implementation phasing in the ADR's `§More Information` opens (2 PRs). ## What's next - **This PR** — ADR-0026 ships as `proposed`. - **Acceptance PR** — review pass, address open questions, promote `proposed → accepted` (same cadence as [#201 → #205](docs/decisions/0025-authorization-model-privileges-roles-scopes.md) for ADR-0025). - **Implementation PR 1** — Prisma schema + lazy provisioner. - **Implementation PR 2** — `PrismaScopeResolver` + admin-UI scope-seeding + test-tenant seed. - **ADR-0027** — Pléiades + Acteurs+ syncs + facet schemas (the deferred surface from this ADR). --------- Co-authored-by: Julien Gautier Reviewed-on: https://git.unespace.com/julien/apf_portal/pulls/213 --- .../0026-person-user-portal-data-model.md | 316 ++++++++++++++++++ docs/decisions/README.md | 1 + 2 files changed, 317 insertions(+) create mode 100644 docs/decisions/0026-person-user-portal-data-model.md diff --git a/docs/decisions/0026-person-user-portal-data-model.md b/docs/decisions/0026-person-user-portal-data-model.md new file mode 100644 index 0000000..152df9f --- /dev/null +++ b/docs/decisions/0026-person-user-portal-data-model.md @@ -0,0 +1,316 @@ +--- +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> { + 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. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 145c533..1a9d99e 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -69,3 +69,4 @@ 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 |