// Prisma schema for portal-bff. // // `multiSchema` preview is enabled because per ADR-0013 the audit log // lives in its own `audit` schema with role-based append-only access // (audit_owner / audit_writer / audit_reader / audit_archiver). The // public schema holds the regular business data; only audit.events // lives in audit. generator client { provider = "prisma-client-js" previewFeatures = ["multiSchema"] } datasource db { provider = "postgresql" url = env("DATABASE_URL") schemas = ["public", "audit"] } // ============================================================ // Audit log (per ADR-0013) // ============================================================ // // Append-only by Postgres role grants — the schema, roles, and // default privileges are provisioned by infra/local/init/postgres/ // 01-init.sql in dev and the equivalent production manifest. The // migration that creates this table re-applies the grants // explicitly and ALTERs the table owner to `audit_owner` so the // runtime role contract holds even when the migration runs as a // privileged migrator account. // // At runtime, the BFF wraps every INSERT into this table in a // transaction that begins with `SET LOCAL ROLE audit_writer`, so // even a compromised BFF connection cannot UPDATE / TRUNCATE / // DELETE — those grants are not on `audit_writer`. enum AuditAudience { workforce customer @@schema("audit") } enum AuditOutcome { success failure denied @@schema("audit") } // ============================================================ // User directory (per ADR-0020 §"v1 scope — User list") // ============================================================ // // Persistent ledger of every identity that has signed in to either // portal-shell or portal-admin. Upserted at sign-in by // `UserDirectoryService.recordSignIn` (called from // `SessionEstablisher.establish`), read by the future // `GET /api/admin/users` endpoint per ADR-0020 §"User list // (read-only)". // // **Not the source of truth for identity.** Entra ID is. This table // is a cache the BFF maintains so the admin UI can list "everyone // who's ever signed in" without re-querying the directory at // every render. Per-tenant `oid` is the join key on the audit side // (combined with the salted hash) — never the BFF's primary actor // identifier elsewhere. // // **Distinct from the upcoming ADR-0026 `User` model.** That one // (UUID PK + FK to `Person` + lazy-created at OIDC callback) is the // portal-account overlay on a `Person` golden record. This // `UserDirectoryEntry` is the ADR-0020 sign-in cache — different // semantics, kept apart so neither concept overloads the other. // // **No PII redaction on read.** Per ADR-0013 the audit module // hashes the actor id to defend against an audit-log dump leaking // who-did-what. This table is the *deliberate* PII storage: an // admin browsing the user list explicitly wants display names and // usernames. The trust boundary is the admin role gate // (ADR-0020 §"Auth — `admin` role claim"). model UserDirectoryEntry { // Entra `oid` — stable per-user identifier inside the tenant. // Used as the natural primary key. Per-tenant uniqueness is // sufficient: the dual-audience design (ADR-0008) currently // assumes single workforce tenant; cross-tenant collisions // become a separate ADR when we onboard the second one. oid String @id tid String audience String username String displayName String @map("display_name") // `first_seen_at` is set once at first sign-in and never // updated thereafter. Lets the admin list "users since // " without joining anything. firstSeenAt DateTime @default(now()) @map("first_seen_at") @db.Timestamptz(6) // `last_seen_at` is set every time the upsert fires (one upsert // per sign-in), so "most recently active" can be computed // without scanning audit.events. lastSeenAt DateTime @default(now()) @map("last_seen_at") @db.Timestamptz(6) @@map("user_directory_entries") @@schema("public") @@index([lastSeenAt(sort: Desc)]) @@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) // ============================================================ // // Region → Delegation → Structure. The portal's scope-axis // dereferences these three layers — the BFF guard // `principalCoversResource` walks etablissement → delegation → // region to decide whether a scope covers a resource (per // ADR-0025). // // Population in v1: a small inline seed in the // `add_org_hierarchy` migration (the codes the test tenant // exercises). The full APF inventory ships with ADR-0029's // cascade sync, which writes additively into these columns — // no schema churn at sync time. model Region { // INSEE region code (2 digits — '75' Nouvelle-Aquitaine, // '11' Île-de-France). Externally meaningful and stable // across reorgs; doubles as primary key. code String @id name String delegations Delegation[] @@map("regions") @@schema("public") } model Delegation { // French department code (2-3 chars — '33' Gironde, '2A', // '971'). Same rationale as Region.code. code String @id name String regionCode String @map("region_code") region Region @relation(fields: [regionCode], references: [code]) structures Structure[] @@map("delegations") @@schema("public") @@index([regionCode]) } model Structure { // Portal-internal stable code, externally meaningful. For // medico-social structures: code = FINESS (9 digits). For // non-medico-social: APF-internal slug ('siege', // 'apf-bdx-merignac', 'ea-toulouse', …). Opaque at the type // level; matching is string equality, not parsing. code String @id name String // Closed set, drift-gated. Legal values are tracked in // `apps/portal-bff/src/structures/structure-kind.ts` and // mirrored by a Postgres CHECK constraint on this column // (see the migration). `scripts/check-catalogue-drift.mjs` // asserts the TS constant matches the values used in code. kind String // FINESS (9 digits). NULL for non-medico-social structures. // Unique when present. Cascade's StructureSourceFiness is // the long-term authoritative carrier; the portal denormalises // it inline here for v1 scope-axis checks. ADR-0029's sync // owns the write path. finess String? @unique // SIRET (14 chars: 9 SIREN + 5 NIC). NULL when the structure // is not SIRENE-registered (most antennes, dispositifs). // Unique when present. siret String? @unique // Pléiades payroll code (6 chars). NULL in v1 — populated by // ADR-0029's Pléiades sync once it ships. codePaie String? @unique @map("code_paie") // Parent delegation. NULL for structures not attached to one // (siège, mouvement national, …). delegationCode String? @map("delegation_code") delegation Delegation? @relation(fields: [delegationCode], references: [code]) @@map("structures") @@schema("public") @@index([kind]) @@index([delegationCode]) } model AuditEvent { id String @id @default(uuid()) @db.Uuid createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) eventType String @map("event_type") audience AuditAudience // Salted hash (LOG_USER_ID_SALT) of the actor's stable id. NULL // when the actor is unauthenticated (e.g. failed login attempt // before resolving an identity). The same salt is used by the // BFF Pino logger so audit and app logs cross-correlate on this // field. actorIdHash String? @map("actor_id_hash") // W3C trace id (32 hex chars) of the request that produced the // event. Cross-correlates with traces in Jaeger and with Pino // log lines that carry the same `trace_id` field. NULL only if // the event was emitted outside any inbound request (e.g. a // future cron job). traceId String? @map("trace_id") // Free-form identifier of what the event is *about* — typically // a domain entity URI like `user:42` or `dossier:xyz`. NULL when // the event is system-wide and has no clear subject. subject String? outcome AuditOutcome // Event-specific structured detail. Redaction of PII is the // caller's responsibility; the BFF Pino redact list is the // reference allow-/deny-list. payload Json? @@map("events") @@schema("audit") @@index([createdAt]) @@index([eventType]) @@index([traceId]) }