Both ADRs shipped as proposed in #217 after the cascade / acteurs_plus source-of-truth audit reshaped the org-hierarchy model. No open questions left on either - promote to accepted in lockstep since they reference each other. CLAUDE.md gets two new Architecture bullets ("Portal-side identity model", "Portal-side organisational hierarchy") and a small touch-up on the ADR-0025 bullet to clarify the scope literal `etablissement:<structure-code>` semantics.
23 KiB
status, date, decision-makers, tags
| status | date | decision-makers | tags | |||
|---|---|---|---|---|---|---|
| accepted | 2026-05-24 | R&D Lead |
|
Person golden record + User portal-account — portal-side identity model
Context and Problem Statement
ADR-0025 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.idandPrincipal.user.personIdare populated with the Entraoidas a placeholder, because no portal-side identity record exists yet (see ADR-0025 §"Principal shape" and the BFF builder atapps/portal-bff/src/auth/principal-builder.ts).ScopeResolverreturns[{ kind: 'unrestricted' }]for every signed-in user (seeStubScopeResolverand ADR-0025 §"Sources of truth — apf_portal-sideuser_scopestable"). The intended per-persona scopes documented innotes/test-tenant-role-assignments.mdhave 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:
- Workforce (salariés on payroll) — almost certainly portal users; their identity, employment status, and current assignment come from Pléiades (the HR system).
- 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).
- Bénévoles + adhérents + bénéficiaires — most will not be portal users; their identity lands in the portal via the membership / dossier flows.
A naive "User table = anyone who ever signed in" model collapses these three populations into one bucket and loses two capabilities the portal needs from day one:
- Pré-provisioning — a structure director must be able to assign a scope to a salarié before the salarié first signs in. The salarié needs a portal-side identity that pre-exists their first OIDC callback.
- Person-without-User — a dossier is held by a bénéficiaire who may never sign in. The dossier still needs to reference a stable Person identifier; later, if the bénéficiaire signs up to portal-facing services, an account is overlaid on top of the existing Person record.
This ADR specifies the portal-side identity model. The organisational hierarchy that @RequireScope dereferences (Region, Delegation, Structure) is the sibling concern of ADR-0027, kept on a separate timeline because the source-of-truth audit (cascade + acteurs_plus) reshaped that model after ADR-0026 was first drafted. The upstream sync of Person rows from Pléiades / Acteurs+ — together with the facet shapes (Salarié, Élu, Adhérent, Bénéficiaire) that attach to a Person — is ADR-0028's territory.
ADR-0026's scope: the three core tables Person, User, UserScope.
Decision Drivers
Principal.user.personIdmust point at a real, stable identifier — guards consumingself-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.
- 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
Userrow the first time it sees a new Entraoid. 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.
entraOidis 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/lastNameare PII — they need the same redaction / hashing posture as the audit-log salt (ADR-0013).
Considered Options
- Option A —
User-only, noPerson. A single table for "anyone who ever signed in". Dossiers referenceuserIddirectly. Bénéficiaires who never sign in have no row at all. - Option B —
Persongolden record +Userportal-account overlay (chosen). Two tables, one-to-zero-or-one relationship.Personis the stable identity;Useris the portal-access overlay that exists when (and only when) someone has portal access. - Option C —
Personlives 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 inPerson. 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:
- lets a Person exist before they ever sign in (workforce pre-provisioning, dossier beneficiaries);
- 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; - matches the existing intuition in ADR-0025's
Principal.user.{id, personId}shape: the two ids exist because the two concerns are distinct.
Schema
model Person {
id String @id @default(uuid())
// Identity. firstName + lastName are PII; treat per ADR-0013's redact rules.
firstName String
lastName String
// Primary contact email. Indexed for operator-driven lookup (admin UI
// search, ADR-0028 reconciliation flow), NOT unique — two distinct
// humans genuinely can share an email (shared family alias, generic
// info@ mailbox at a small partner organisation, error in an upstream
// feed). A unique constraint would crash the first Pléiades import
// that surfaces such a pair.
email String?
// Provenance: which upstream owns the row. v1: 'self-signin'
// (lazy-created at OIDC callback), 'admin-ui' (manually entered
// by an admin before first sign-in), 'seed' (test-tenant
// provisioning). ADR-0028 will add 'pleiades' and 'acteurs-plus'.
// The field is a free-form string in v1; promoting to an enum is
// an ADR-0028 decision once the sync sources are concrete.
source String
// Upstream-system identifier (Pléiades matricule, Acteurs+ id).
// Nullable because v1 sources do not provide one.
externalId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User?
@@index([source])
@@index([externalId])
@@index([email])
}
model User {
id String @id @default(uuid())
personId String @unique
person Person @relation(fields: [personId], references: [id])
// Entra object id. Stable per-tenant; unique because two
// separate Person + User pairs cannot share an Entra identity.
entraOid String @unique
// Entra tenant id. Stored alongside the oid so a future
// dual-audience activation (ADR-0008) can disambiguate.
tenantId String
// Surfaced in the /admin/users list per ADR-0020. Refreshed by
// the SessionEstablisher (apps/portal-bff/src/auth/session-establisher.service.ts).
lastSignInAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
scopes UserScope[]
@@index([entraOid])
}
model UserScope {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
// Discriminator — one of the six ADR-0025 scope kinds.
kind String
// Per-kind payload — semantics owned by ADR-0027. For 'etablissement'
// the value is a Structure.code (string); for 'delegation' it is a
// Delegation.code; for 'region' it is a Region.code. Empty string for
// 'self' / 'siege' / 'unrestricted' (the unique constraint below
// tolerates empty strings).
value String @default("")
// Provenance, same posture as Person.source. v1: 'admin-ui' or
// 'seed'. ADR-0028 adds 'pleiades' / 'acteurs-plus' and the
// reconciliation rules between admin overrides and upstream data.
source String
createdAt DateTime @default(now())
// When non-null and past, the row is ignored at sign-in.
// Supports interim-director-for-two-months patterns and admin
// UI scope revocations.
expiresAt DateTime?
@@unique([userId, kind, value])
@@index([userId])
}
UserScope.value references the codes defined in ADR-0027 (Structure.code, Delegation.code, Region.code) but stores them as opaque strings — no foreign-key from UserScope to those tables. Rationale: a scope can outlive its target (a structure decommissioned mid-quarter still has historical UserScope rows referencing its code, useful for the audit log). The admin UI surface that creates UserScope rows validates the code against ADR-0027's tables at write time; the runtime guard does the same dereference at sign-in.
Lifecycle — Person + User creation
v1 (this ADR's implementation PR):
┌──────────────────────────────────────────────────────────┐
│ First sign-in for Entra oid X │
│ │
│ 1. SessionEstablisher.establish() callback │
│ 2. PersonAndUserProvisioner.ensureUser({ oid: X, … }) │
│ a. Look up User by entraOid │
│ → if found: update lastSignInAt, return │
│ → if not found: create a fresh Person + linked │
│ User in one transaction. │
│ Person.source = 'self-signin' │
│ Person.firstName / lastName = split(Entra │
│ displayName) — best effort │
│ Person.email = Entra preferred_username │
│ 3. PrincipalBuilder.build() now sees a real Person │
│ and a real User; Principal.user.personId carries │
│ Person.id rather than the entraOid placeholder │
└──────────────────────────────────────────────────────────┘
Why no email-based merging in v1. Two distinct people genuinely can share an email (shared family alias, generic info@ mailbox at a small partner organisation, error in an upstream feed). Auto-merging a fresh sign-in into an existing Person by email match would silently corrupt the golden record without surfacing a conflict. v1 takes the safe-by-default posture: entraOid is the only natural key the provisioner trusts; every other dedup happens on the explicit reconciliation flow that ships with ADR-0028.
ADR-0028 (later). Pléiades / Acteurs+ syncs create Person rows ahead of any sign-in. The provisioner's lookup path extends, in order:
- by
Person.externalIdwhen 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), - by an explicit operator-driven match flow on
Person.email— surfacing the candidate match for confirmation rather than auto-merging, - 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:
@Injectable()
export class PrismaScopeResolver extends ScopeResolver {
constructor(private readonly prisma: PrismaService) {
super();
}
async resolve({ userId }: { userId: string }): Promise<ReadonlyArray<Scope>> {
const rows = await this.prisma.userScope.findMany({
where: {
userId,
OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }],
},
});
return rows.map(toScope);
}
}
The signature resolve({ userId }) differs from the current resolve({ entraOid }) — the User UUID is the natural key once the schema exists. The PrincipalBuilder evolves to pass userId instead, which is fine because the provisioner above has just created / fetched it.
The full guard-side dereference path (principalCoversResource walking Structure.delegationCode → Delegation.regionCode → Region) requires ADR-0027's hierarchy to be live. PR sequencing reflects this — see "More Information" below.
Principal.user post-implementation
The two placeholders called out at the top of this ADR resolve:
| Field | Pre-ADR-0026 | Post-ADR-0026 |
|---|---|---|
user.id |
Entra oid |
User.id (portal UUID) |
user.personId |
Entra oid |
Person.id (portal UUID) |
user.entraOid |
Entra oid |
unchanged |
user.tenantId |
Entra tid |
unchanged |
user.displayName |
Entra displayName |
unchanged (cached at sign-in; admins can override later) |
What ADR-0026 ships vs adjacent ADRs
| Concern | ADR-0026 (this) | ADR-0027 (org hierarchy) | ADR-0028 (sync + facets) |
|---|---|---|---|
Person, User, UserScope schema |
✅ | — | — |
Region, Delegation, Structure schema |
— | ✅ | — |
Person.source = 'self-signin' lazy creation |
✅ | — | — |
Person.source = 'pleiades' / 'acteurs-plus' |
as a documented future value | — | Sync implementation |
| Salarié / Élu / Adhérent / Bénéficiaire facets | deferred | — | ✅ |
| Reconciliation between admin-UI overrides and upstream-driven user_scopes | deferred | — | ✅ |
The facet split was the main "tempting to ship now" item; deferring it keeps the schema migration small enough to land + roll back cleanly if the lazy-provisioner exposes a corner case.
Consequences
- Good, because
Principal.user.personIdis finally a real, stable identifier —@RequireScope({ kind: 'self' })can compare againstDossier.personIdend-to-end without "the entraOid happens to be the personId" gymnastics. - Good, because Pléiades and Acteurs+ syncs (ADR-0028) drop into a known shape; their reconciliation logic (
externalIdprecedence, 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_scopestable") now has a target row to write against (UserScope). - Bad, because lazy-create-at-sign-in produces a Person row with sparse fields (
firstName/lastNamefrom the EntradisplayNamesplit, 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, onepleiades) 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.valueis 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 theprincipalCoversResourcecheck on the resource that no longer exists, which is the correct behaviour.
Confirmation
- Migration tests. Prisma migration emits a SQL file; the
infra/localdev stack runs it on fresh boot. Manual + a smalle2espec exercising the lazy-create path with a stubbed Entra response. - PrismaScopeResolver integration test. Two personas signed in (one with a
kind='etablissement'scope, one withkind='unrestricted'), scopes round-trip from DB → Principal →principalCoversResource. Requires ADR-0027's seeded hierarchy to give the etablissement-scoped persona a realStructure.codeto point at. Person.sourceenum-as-string is single-sourced. A small constant array inapps/portal-bff/src/users/person-source.tslists the legal values; the catalogue-drift gate (ADR-0025 §"Confirmation") extends to assert every string written toPerson.sourceis in the catalogue. Same posture as thePrivilege/FunctionalRolecatalogues.
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 onUser, not on the shared Person record that Pléiades may overwrite. - Good, because the ADR-0025 stubs resolve naturally —
personId≠userIdreflects 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
includekeeps it to one round trip. - Neutral, because reconciling lazy-created Person rows with later Pléiades data needs a documented merge policy — that policy is ADR-0028's territory and is easier to design with this shape than with Option A.
Option A — User-only
- Good, because one table is the smallest possible thing.
- Bad, because dossiers held by non-portal-user beneficiaries have nowhere to anchor — either a
Dossier.beneficiaryUserIdthat 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_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
Personmeans 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:
- PR — Prisma schema + lazy provisioner.
Person,User,UserScopemodels;PersonAndUserProvisionercalled fromSessionEstablisher;Person.sourceconstants + drift-gate extension; updatedPrincipalBuilderto populatePrincipal.user.{id, personId}from the real rows. Independent of ADR-0027 — can ship in parallel. - PR —
PrismaScopeResolver+ admin UI scope-seeding screen + test-tenant seed. ReplacesStubScopeResolverin the AuthModule. Seeds the 19 test personas'user_scopesrows pernotes/test-tenant-role-assignments.md. Adds an/admin/users/:id/scopesadmin-app screen for manual scope management. Depends on ADR-0027 PR 1 — the seed referencesStructure.codevalues that must already exist in the database.
Follow-up ADRs.
- ADR-0027 — Portal-side organisational hierarchy.
Region/Delegation/Structureschema (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
Personrows 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 howuser_scopesis computed from facet relationships. - A future ADR — time-bound roles.
UserScope.expiresAtalready exists; the corresponding facet-level mechanism (interim director for two months, treasurer mandate from 2026 to 2029) lands when the use case is concrete.