feat(users): add Person + User + UserScope + lazy provisioner (ADR-0026 PR 1)
Schema: Person golden record + User portal-account overlay (1-to-0-or-1)
+ UserScope (the table behind the ADR-0025 scope axis). All in the
public schema. UserScope.value is opaque (no FK to ADR-0027 tables -
historical scope rows survive structure decommissioning).
Migration: hand-written, follows the existing convention. FKs with
correct ON DELETE actions (RESTRICT on User.personId for the required
relation, CASCADE on UserScope.userId via explicit onDelete: Cascade
in the schema).
Catalogue: PERSON_SOURCES = [self-signin, admin-ui, seed] as const
under apps/portal-bff/src/users/person-source.ts. Defense in depth =
TS type union + drift gate scanner extension (property literal
"source: 'X'" in files importing person-source.ts).
PersonAndUserProvisioner: blocking lazy-create at first OIDC callback.
Fast path = findUnique by entraOid + update lastSignInAt. Cold path =
nested create of Person + User in one transaction. Race-condition
handling: catches P2002 on User.entraOid unique constraint and re-runs
ensureUser. Defense-in-depth isPersonSource check on the constant.
PrincipalBuilder signature: build(user, identity: { userId, personId }).
Principal.user.{id, personId} populated from real UUIDs. ScopeResolver
seam moved from { entraOid } to { userId } so PR 2's PrismaScopeResolver
can key queries on User.id.
SessionEstablisher: new constructor arg personUserProvisioner.
establish() calls ensureUser BEFORE principalBuilder.build so the
identity is available. Entra preferred_username (= AuthenticatedUser.
username) maps to Person.email per ADR-0026 lifecycle. Provisioner
failure short-circuits the whole flow before save / cookie / audit /
directory.
UserDirectoryService stays as the ADR-0020 admin-list cache - folding
it into Person+User is a follow-up after ADR-0026 PR 2 stabilises.
Local verification: drift gate clean (4 / 24 / 7 / 3), 29 drift-gate
spec tests passing, portal-bff lint 0 errors, 766 portal-bff specs
passing.
This commit is contained in:
@@ -106,6 +106,118 @@ model UserDirectoryEntry {
|
||||
@@index([username])
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Identity model (per ADR-0026)
|
||||
// ============================================================
|
||||
//
|
||||
// `Person` golden record + `User` portal-account overlay (one-to-zero-
|
||||
// or-one). Distinct from `UserDirectoryEntry` above — that one is the
|
||||
// ADR-0020 sign-in cache keyed on Entra `oid`; these are the portal's
|
||||
// stable identity model keyed on UUIDs and form the basis for the
|
||||
// `@RequireScope` Prisma resolver landing in ADR-0026 PR 2.
|
||||
//
|
||||
// `Person` exists whether or not the human ever signs in (workforce
|
||||
// pre-provisioning, dossier bénéficiaires, alumni). `User` is the
|
||||
// portal-access overlay, lazy-created at first OIDC callback by
|
||||
// `PersonAndUserProvisioner.ensureUser`. `UserScope` backs the ADR-0025
|
||||
// scope axis with opaque `value` strings referencing ADR-0027's
|
||||
// `Structure.code` / `Delegation.code` / `Region.code` — no FK at the
|
||||
// DB level so historical scope rows survive structure decommissioning.
|
||||
|
||||
model Person {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
// PII — firstName + lastName are subject to ADR-0013 redaction rules
|
||||
// when emitted to logs.
|
||||
firstName String @map("first_name")
|
||||
lastName String @map("last_name")
|
||||
// Primary contact email. Indexed for operator lookup but NOT unique
|
||||
// — two distinct humans genuinely can share emails (shared family
|
||||
// alias, generic info@ at a small partner organisation, error in an
|
||||
// upstream feed). ADR-0029's reconciliation flow surfaces candidate
|
||||
// duplicates for operator confirmation rather than auto-merging.
|
||||
email String?
|
||||
// Closed-set catalogue, drift-gated. See
|
||||
// `apps/portal-bff/src/users/person-source.ts` for the legal values.
|
||||
// v1: 'self-signin' / 'admin-ui' / 'seed'. ADR-0029 adds 'pleiades'
|
||||
// and 'acteurs-plus'.
|
||||
source String
|
||||
// Upstream-system identifier (Pléiades matricule, Acteurs+ id).
|
||||
// Nullable in v1 — populated by ADR-0029's syncs.
|
||||
externalId String? @map("external_id")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
|
||||
// Back-ref. NULL when the Person has no portal account (workforce
|
||||
// pre-provisioning, dossier bénéficiaire, alumni).
|
||||
user User?
|
||||
|
||||
@@map("persons")
|
||||
@@schema("public")
|
||||
@@index([source])
|
||||
@@index([externalId])
|
||||
@@index([email])
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
// 1-to-1 with Person. The unique constraint on personId enforces
|
||||
// "at most one User per Person"; back-ref `Person.user` is the
|
||||
// other half.
|
||||
personId String @unique @map("person_id") @db.Uuid
|
||||
person Person @relation(fields: [personId], references: [id])
|
||||
// Entra object id — stable per-user identifier inside the tenant.
|
||||
// Unique because two separate Person+User pairs cannot share an
|
||||
// Entra identity.
|
||||
entraOid String @unique @map("entra_oid")
|
||||
// Entra tenant id. Stored alongside the oid so a future dual-
|
||||
// audience activation (ADR-0008) can disambiguate.
|
||||
tenantId String @map("tenant_id")
|
||||
// Refreshed by `PersonAndUserProvisioner.ensureUser` on every
|
||||
// sign-in. Surfaced in the future /admin/users/:id screen.
|
||||
lastSignInAt DateTime? @map("last_sign_in_at") @db.Timestamptz(6)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
|
||||
scopes UserScope[]
|
||||
|
||||
@@map("users")
|
||||
@@schema("public")
|
||||
}
|
||||
|
||||
model UserScope {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
userId String @map("user_id") @db.Uuid
|
||||
// CASCADE so revoking a User wipes their scope rows in one delete.
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
// One of the six ADR-0025 scope kinds: 'self' / 'etablissement' /
|
||||
// 'delegation' / 'region' / 'siege' / 'unrestricted'. Not pulled
|
||||
// from a Prisma enum because the value's semantics belong to the
|
||||
// `shared-auth` catalogue, not the database; the drift gate
|
||||
// already enforces the closed set at the call sites.
|
||||
kind String
|
||||
// Per-kind payload — semantics owned by ADR-0027. For 'etablissement'
|
||||
// the value is a Structure.code; for 'delegation' it is a
|
||||
// Delegation.code; for 'region' it is a Region.code. Empty string
|
||||
// for 'self' / 'siege' / 'unrestricted'. NO FK to ADR-0027 tables —
|
||||
// historical scope rows survive structure decommissioning; the
|
||||
// admin-UI write path (ADR-0026 PR 2) validates at insert time.
|
||||
value String @default("")
|
||||
// Provenance, same catalogue posture as Person.source. v1:
|
||||
// 'admin-ui' / 'seed'. ADR-0029 adds upstream-sync values and a
|
||||
// reconciliation policy.
|
||||
source String
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
// 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? @map("expires_at") @db.Timestamptz(6)
|
||||
|
||||
@@map("user_scopes")
|
||||
@@schema("public")
|
||||
@@unique([userId, kind, value])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Organisational hierarchy (per ADR-0027)
|
||||
// ============================================================
|
||||
|
||||
Reference in New Issue
Block a user