8aec83f82e
Mechanical refactor, prerequisite to ADR-0026 PR 1. Frees the `User` name for the upcoming ADR-0026 model (UUID PK + FK to Person + lastSignInAt), which has materially different semantics from the ADR-0020 user-directory cache. Zero behavioural change. Same columns, same indexes, same constraints, same call sites - just a different identifier on the model, the table, and the corresponding Prisma client field. Two renames in user-directory.service.ts: the Prisma model rename collided with an existing TS interface also called UserDirectoryEntry (which was actually the input shape of recordSignIn). The interface gets renamed to RecordSignInInput at the same time, which is its correct semantic name. Net effect: clearer on both sides. Postgres ALTER TABLE RENAME does NOT cascade to PK constraint / index names; the migration explicitly ALTER INDEXes the three named indexes (pkey + last_seen_at + username). The class name AdminUsersReader, the endpoint URL /api/admin/users, the DTO AdminUserDto and the local interface UserRow are unchanged - these are SPA/HTTP-facing identifiers where the "admin user list" URL semantics hold regardless of the backing table name.
230 lines
8.4 KiB
Plaintext
230 lines
8.4 KiB
Plaintext
// 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
|
|
// <date>" 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])
|
|
}
|
|
|
|
// ============================================================
|
|
// 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])
|
|
}
|