feat(users): add Person + User + UserScope + lazy provisioner (ADR-0026 PR 1) #232

Merged
julien merged 1 commits from feat/adr-0026-pr1-person-user-userscope into main 2026-05-26 13:57:38 +02:00

1 Commits

Author SHA1 Message Date
Julien Gautier 056848e502 feat(users): add Person + User + UserScope + lazy provisioner (ADR-0026 PR 1)
CI / scan (pull_request) Successful in 5m32s
CI / commits (pull_request) Successful in 5m34s
CI / check (pull_request) Successful in 6m8s
CI / a11y (pull_request) Successful in 4m51s
CI / perf (pull_request) Successful in 9m33s
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.
2026-05-26 13:54:41 +02:00