// 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. // // **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 User { // 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("users") @@schema("public") @@index([lastSeenAt(sort: Desc)]) @@index([username]) } // ============================================================ // 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]) }