Files
apf_portal/docs/decisions/0026-person-user-portal-data-model.md
T
Julien Gautier 7a4ebe75ea
CI / commits (pull_request) Successful in 2m36s
CI / check (pull_request) Successful in 2m46s
CI / scan (pull_request) Successful in 2m56s
CI / a11y (pull_request) Successful in 3m23s
Docs site / build (pull_request) Successful in 3m27s
CI / perf (pull_request) Successful in 6m17s
docs(adr-0026): person + user portal-side data model (proposed)
ADR-0025 left several anchors as proposed-follow-up:
- Principal.user.id / personId carry the Entra oid as a placeholder.
- StubScopeResolver returns unrestricted for everyone.
- @RequireScope's ScopableResource shape references an
  Etablissement / Delegation / Region chain that has no table.

ADR-0026 specifies the portal-side data model that closes those
stubs without dragging the upstream Pléiades / Acteurs+ syncs into
scope:

- Person golden record (stable identity; lazy-created on first
  sign-in in v1, source-stamped 'self-signin' for traceability).
- User overlay (one-to-zero-or-one with Person; portal-only state
  like lastSignInAt rides here, not on the shared Person row).
- UserScope migration confirming the ADR-0025 §"Sources of truth"
  shape.
- Region / Delegation / Etablissement hierarchy with
  externally-meaningful codes (INSEE / dept / FINESS) as primary
  keys, matching scope literals' on-the-wire shape.

Facet schemas (Salarié / Élu / Adhérent / Bénéficiaire) are
deferred to ADR-0027 alongside the Pléiades and Acteurs+ sync
that populates them - keeping the v1 migration small enough to
land + roll back cleanly.

Status: proposed. Implementation lands in two PRs once accepted:
the schema + lazy provisioner, then the PrismaScopeResolver +
admin-UI scope-seeding screen + test-tenant seed for the 19
personas.

Index updated.
2026-05-24 02:10:40 +02:00

22 KiB
Raw Blame History

status, date, decision-makers, tags
status date decision-makers tags
proposed 2026-05-24 R&D Lead
data
backend
security

Person golden record + User portal-account + organisational hierarchy — portal-side data model

Context and Problem Statement

ADR-0025 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" 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"). 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.
  • 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 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).

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) 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

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

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:

@Injectable()
export class PrismaScopeResolver extends ScopeResolver {
  constructor(private readonly prisma: PrismaService) {
    super();
  }

  async resolve({ userId }: { userId: string }): Promise<ReadonlyArray<Scope>> {
    const rows = await this.prisma.userScope.findMany({
      where: {
        userId,
        OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }],
      },
    });
    return rows.map(toScope);
  }
}

The signature resolve({ userId }) differs from the current resolve({ entraOid }) — the User UUID is the natural key once the schema exists. The PrincipalBuilder evolves to pass userId instead, which is fine because the provisioner above has just created / fetched it.

Principal.user post-implementation

The two placeholders called out at the top of this ADR resolve:

Field Pre-ADR-0026 Post-ADR-0026
user.id Entra oid User.id (portal UUID)
user.personId Entra oid Person.id (portal UUID)
user.entraOid Entra oid unchanged
user.tenantId Entra tid unchanged
user.displayName Entra displayName unchanged (cached at sign-in; admins can override later)

What ADR-0026 ships vs what stays for ADR-0027

Concern ADR-0026 (this) ADR-0027 (next)
Person, User, UserScope schema
Region, Delegation, Etablissement schema (manually-seeded in v1) Pléiades-sync population
Person.source = 'self-signin' lazy creation
Person.source = 'pleiades' / 'acteurs-plus' as a documented future value Sync implementation
Salarié / Élu / Adhérent / Bénéficiaire facets deferred
Reconciliation between admin-UI overrides and Pléiades-driven user_scopes deferred

The facet split was the main "tempting to ship now" item; deferring it keeps the schema migration small enough to land + roll back cleanly if the lazy-provisioner exposes a corner case.

Consequences

  • Good, because Principal.user.personId is finally a real, stable identifier — @RequireScope({ kind: 'self' }) can compare against Dossier.personId end-to-end without "the entraOid happens to be the personId" gymnastics.
  • Good, because Pléiades and Acteurs+ syncs (ADR-0027) can drop into a known shape; their lookup logic (email-based dedup, externalId carryover) is already part of v1.
  • Good, because the admin UI scope-seeding flow (v1 manual entry per ADR-0025 §"Sources of truth — apf_portal-side user_scopes table") now has a target row to write against (UserScope).
  • Bad, because Person.email as the v1 dedup key is fragile — two Pléiades records with the same email crash the unique constraint. Mitigation: ADR-0027 adds externalId precedence and surfaces conflicts to the operator.
  • Bad, because lazy-create-at-sign-in produces a Person row with sparse fields (firstName / lastName from the Entra displayName split, no postal address, no phone). Acceptable for v1 since dossiers / RH consumers do not exist yet; reconciliation with Pléiades data at sync time fills the gaps.
  • Neutral, because Region.code / Delegation.code / Etablissement.finess as primary keys preclude renames. APF's INSEE codes are stable; if a délégation merges, the row is deleted + a new one is inserted with the new code. The cost is well-bounded.

Confirmation

  • Migration tests. Prisma migration emits a SQL file; the infra/local dev stack runs it on fresh boot. Manual + a small e2e spec exercising the lazy-create path with a stubbed Entra response.
  • PrismaScopeResolver integration test. Two personas (one with etablissement:0330800013, one with unrestricted) signed in, scopes round-trip from DB → Principal → principalCoversResource.
  • Person.source enum-as-string is single-sourced. A small constant array in apps/portal-bff/src/users/person-source.ts lists the legal values; the catalogue-drift gate (ADR-0025 §"Confirmation") 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 — personIduserId 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 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.