Files
apf_portal/docs/decisions/0026-person-user-portal-data-model.md
julien 04ee535de9
CI / check (push) Successful in 3m28s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 3m3s
CI / a11y (push) Successful in 4m21s
CI / perf (push) Successful in 6m6s
Docs site / build (push) Successful in 5m59s
docs(adr-0028): propose CI/CD + git hosting migration Gitea -> GitLab (#226)
## Summary

Drafts [ADR-0028](docs/decisions/0028-migrate-cicd-and-git-hosting-to-gitlab.md) as `proposed`: migrate CI/CD + git hosting from Gitea (`git.unespace.com`) to GitLab CE self-hosted on `vm-gitlab` (`10.100.201.10`). The migration was anticipated by [ADR-0015](docs/decisions/0015-cicd-gitea-actions.md) ("level-2 implementation; will be superseded by a GitLab migration ADR within 6-18 months") — that window opens now. **Decision-only PR** — the actual 4-phase migration ships across follow-up PRs after acceptance.

ADR-0028's number was previously a placeholder reference in ADR-0026 and ADR-0027 for the Pléiades + Acteurs+ sync ADR. **Renumbering**: that future sync ADR shifts to `ADR-0029`, and the placeholder links in ADR-0026, ADR-0027 and `CLAUDE.md` update to match — included in the same PR so the chain stays consistent.

## What lands

| File | Change |
| --- | --- |
| `docs/decisions/0028-migrate-cicd-and-git-hosting-to-gitlab.md` | **New.** MADR 4.0.0 ADR, `proposed`. Decision = Option B (GitLab CE on `vm-gitlab`). Considered options A (status quo Gitea), C (Forgejo), D (cloud SaaS). Documents what carries over from ADR-0015 (architectural principles unchanged — thin YAML, trunk-based, all-gates-blocking, on-prem runners), what changes (host, pipeline file, runner type, scan tooling), the 4-phase migration sequence, and the signed-commits revisit. |
| `docs/decisions/0026-person-user-portal-data-model.md` | All 11 `ADR-0028` references → `ADR-0029` (sync + facets shifts to 0029). |
| `docs/decisions/0027-portal-side-organisational-hierarchy.md` | All 14 `ADR-0028` references → `ADR-0029`. |
| `docs/decisions/README.md` | New row for ADR-0028 (`proposed`, tags `infrastructure`, `process`, 2026-05-26). |
| `CLAUDE.md` | Roll-up updated: `ADRs 0001 → 0027 accepted; ADR-0028 + ADR-0029 proposed`. ADR-0028's relationship to ADR-0015 spelled out inline ("supersedes ADR-0015's Gitea Actions platform choice — the rest of ADR-0015's architectural principles carry over unchanged"). ADR-0026 + ADR-0027 architecture bullets renumbered 0028 → 0029 to track. |

## Key choices in the ADR

- **What carries over from ADR-0015 vs what changes** — explicit table so future readers see immediately that the migration is **platform-only**, not a re-litigation of CI principles. Trunk-based + squash, all-gates-blocking, thin YAML over portable scripts (`pnpm ci:check` etc. — unchanged), on-prem runners, Conventional Commits in CI + hook (defense in depth) — all carried over. Host, pipeline-file grammar, runner type, and scan tooling are the only things that move.
- **Native security scanning replaces the manual Trivy + gitleaks setup.** GitLab CE's built-in `Dependency-Scanning.gitlab-ci.yml` + `Secret-Detection.gitlab-ci.yml` includes consolidate the ~30 lines of inline `curl + tar` install dance currently in `.gitea/workflows/ci.yml`. Same blocking thresholds (CRITICAL+HIGH dependency vulns, any secret).
- **`vm-gitlab` is already provisioned.** No infra wait — the only sequencing constraint is operator-driven, not infrastructure-driven.
- **4-phase migration, parallel pipelines for ~1 week before cutover.** Phase 1 mirrors repos and bootstraps GitLab side (no `.gitlab-ci.yml` yet — Gitea still gates). Phase 2 lands `.gitlab-ci.yml` alongside `.gitea/workflows/ci.yml` so both pipelines run per PR until parity is confirmed. Phase 3 flips the remote URLs and deletes `.gitea/workflows/` + `infra/ci-runners.compose.yml`. Phase 4 sweeps stale references.
- **Gitea moves to read-only / archive, not decommissioned** at cutover. Existing references to Gitea PRs (`#213`, `#217`, `#219`, …) in commit messages and ADR bodies stay resolvable as historical artefacts. One VM at idle is a low long-term cost.
- **Signed commits revisit.** ADR-0015 noted "signed commits recommended, revisited at GitLab migration". ADR-0028 makes the recommendation: enable required signed commits on `main` once GitLab is live, paired with GnuPG agent forwarding (already documented in `docs/setup/01-dev-debian-vm-setup.md` §8.5). `apf-portal-bot` (Renovate) gets a dedicated signing key at PR 1.

## Renumbering — what moved and why

Before this PR, ADR-0026 and ADR-0027 used `ADR-0028` as a placeholder link for the Pléiades + Acteurs+ sync ADR. That sync ADR hasn't been drafted yet — the number was reserved.

This PR claims `ADR-0028` for the GitLab migration (the immediately-actionable decision), and shifts the sync placeholder to **ADR-0029**. All 25 link references across ADR-0026 (11) and ADR-0027 (14) update in lockstep — replace_all is safe here because in those files `ADR-0028` consistently meant "the sync ADR".

Content of the sync ADR is unchanged — only the number. When that ADR is eventually drafted as `0029-…md`, it gets the existing content reserved for it in the placeholder text.

## Test plan

- [x] `pnpm exec prettier --check` clean on the touched files.
- [x] All `ADR-0028` references in `0026-…md` / `0027-…md` / `CLAUDE.md` now read `ADR-0029` (grep confirms zero remaining references to the old number in those files).
- [x] The new `0028-…md` self-references (status frontmatter, title, internal anchors) are consistent — no leftover `0029`.
- [ ] **Review focus** — drivers / consequences / migration sequence in the ADR; the "what carries over from ADR-0015" table; the renumbering rationale.

## What's next

Per ADR-0028 §"Migration sequence", post-acceptance:

1. **ADR-0028 acceptance PR** — small status-flip, same pattern as #219 (ADR-0026 + ADR-0027 acceptance).
2. **`mirror-and-bootstrap` PR** — `git push --mirror` Gitea → GitLab; GitLab side groups / projects / branch protection / MR templates / deploy keys / Renovate reconfig. No `.gitlab-ci.yml` yet, Gitea pipelines still gate.
3. **`gitlab-ci-pipeline` PR** — `.gitlab-ci.yml` alongside the existing `.gitea/workflows/ci.yml`. Parallel runs ~1 week for parity. GitLab Runner registered on `vm-gitlab`.
4. **`cutover` PR** — remotes flip across docs, `.gitea/workflows/` + `infra/ci-runners.compose.yml` deleted, Gitea read-only.
5. **`cleanup` PR** — stale references sweep, signed-commit policy finalised on `main`.

In parallel — once the dev VM (#220 / #221 / #222 / #223 / #224) is fully bootstrapped — the paused **ADR-0027 PR 1** (Region / Delegation / Structure Prisma schema + inline seed) and **ADR-0026 PR 1** (Person / User / UserScope schema + provisioner) can ship on Gitea; they have no dependency on the GitLab migration and don't need to wait.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #226
2026-05-26 10:30:19 +02:00

284 lines
23 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
status: accepted
date: 2026-05-24
decision-makers: R&D Lead
tags: [data, backend, security]
---
# `Person` golden record + `User` portal-account — 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-0029](#)'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-0029](#)) 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-0029 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-0029 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-0029 will add 'pleiades' and 'acteurs-plus'.
// The field is a free-form string in v1; promoting to an enum is
// an ADR-0029 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-0029 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-0029.
**ADR-0029 (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-0029 (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-0029) 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-0029 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-0029'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-0029](#) — 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.