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:
+84
@@ -0,0 +1,84 @@
|
||||
-- Identity model (per ADR-0026 PR 1).
|
||||
--
|
||||
-- Person golden record + User portal-account overlay (1-to-0-or-1)
|
||||
-- + UserScope (the table behind the ADR-0025 scope axis). Distinct
|
||||
-- from `user_directory_entries` (ADR-0020 sign-in cache, renamed in
|
||||
-- the previous migration) — those keep their role; these are new.
|
||||
--
|
||||
-- No seed data — the test-tenant scope rows ship in ADR-0026 PR 2
|
||||
-- via `prisma/seed.ts`, after this schema is in place.
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "persons" (
|
||||
"id" UUID NOT NULL,
|
||||
"first_name" TEXT NOT NULL,
|
||||
"last_name" TEXT NOT NULL,
|
||||
"email" TEXT,
|
||||
"source" TEXT NOT NULL,
|
||||
"external_id" TEXT,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(6) NOT NULL,
|
||||
|
||||
CONSTRAINT "persons_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "users" (
|
||||
"id" UUID NOT NULL,
|
||||
"person_id" UUID NOT NULL,
|
||||
"entra_oid" TEXT NOT NULL,
|
||||
"tenant_id" TEXT NOT NULL,
|
||||
"last_sign_in_at" TIMESTAMPTZ(6),
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(6) NOT NULL,
|
||||
|
||||
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "user_scopes" (
|
||||
"id" UUID NOT NULL,
|
||||
"user_id" UUID NOT NULL,
|
||||
"kind" TEXT NOT NULL,
|
||||
"value" TEXT NOT NULL DEFAULT '',
|
||||
"source" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expires_at" TIMESTAMPTZ(6),
|
||||
|
||||
CONSTRAINT "user_scopes_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- Person indexes
|
||||
CREATE INDEX "persons_source_idx" ON "persons"("source");
|
||||
|
||||
CREATE INDEX "persons_external_id_idx" ON "persons"("external_id");
|
||||
|
||||
CREATE INDEX "persons_email_idx" ON "persons"("email");
|
||||
|
||||
-- User indexes — both unique constraints back btree indexes.
|
||||
CREATE UNIQUE INDEX "users_person_id_key" ON "users"("person_id");
|
||||
|
||||
CREATE UNIQUE INDEX "users_entra_oid_key" ON "users"("entra_oid");
|
||||
|
||||
-- UserScope indexes — the unique constraint backs the composite,
|
||||
-- the plain index supports the read-by-userId hot path on sign-in.
|
||||
CREATE UNIQUE INDEX "user_scopes_user_id_kind_value_key" ON "user_scopes"("user_id", "kind", "value");
|
||||
|
||||
CREATE INDEX "user_scopes_user_id_idx" ON "user_scopes"("user_id");
|
||||
|
||||
-- Foreign keys.
|
||||
--
|
||||
-- User.person_id is REQUIRED → ON DELETE RESTRICT (Prisma default for
|
||||
-- required relations). Removing a Person with a portal User must be
|
||||
-- an explicit two-step in the admin path; never an accidental cascade.
|
||||
ALTER TABLE "users" ADD CONSTRAINT "users_person_id_fkey"
|
||||
FOREIGN KEY ("person_id") REFERENCES "persons"("id")
|
||||
ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- UserScope.user_id has explicit `onDelete: Cascade` in the Prisma
|
||||
-- schema — revoking a User wipes their scope rows in one delete,
|
||||
-- which is the desired admin-UI semantic (deactivating an account
|
||||
-- should not leave dangling authorisation rows).
|
||||
ALTER TABLE "user_scopes" ADD CONSTRAINT "user_scopes_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
Reference in New Issue
Block a user