Files
planflow/prisma/schema.prisma
T
Claude 11763142d5 WP-01: tenancy, identity and capability authorization
Adds the data model for accounts, locations, teams, users, memberships
and scopes, plus roles, the 70-capability catalogue, database-backed
sessions, and the audit log.

Isolation is enforced twice, independently. A Prisma extension injects
accountId into every query, and PostgreSQL row-level security filters
underneath it, keyed on a transaction-local setting. The first alone
leaves raw queries unguarded; the second alone returns empty results
without saying why.

Integration tests prove both against a real database rather than
through the application layer, which would only prove the application
layer. They create a restricted role to do it — and that exposed a trap
worth naming: **a PostgreSQL superuser bypasses row-level security even
with FORCE**. Connecting the app as one silently disables the second
layer while every application test still passes. checkTenantIsolation
now refuses to start in production on such a database, warns in
development, and reports through /api/sante. The README explains the
role to create.

The audit log is append-only by trigger, so it resists even a
superuser: a trail that can be rewritten proves nothing. Entries
carrying an adjustment or an unlock are rejected without a
justification, and known secret-bearing fields are redacted before
writing — the log is read, exported and kept for years, so it must not
become a second unencrypted copy of what is encrypted elsewhere.

Sensitive columns use AES-256-GCM with the key held outside the
database. Sign-in verifies a dummy hash for unknown accounts so timing
does not enumerate addresses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
2026-08-07 22:39:33 +00:00

289 lines
8.8 KiB
Plaintext

// PlanFlow — schéma de données. Voir PLAN.md §4.
//
// Prisma 7 : l'URL de connexion vit dans prisma.config.ts, plus ici.
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
}
// ============================================================================
// Tenancy — PLAN.md §4.1
// ============================================================================
model Account {
id String @id @default(cuid())
name String
siren String?
apeCode String?
collectiveAgreementId String?
/// Surcharges d'accord d'entreprise (PLAN.md §6.3). Vide aujourd'hui :
/// l'organisation auditée n'a pas d'accord d'entreprise.
agreementOverrides Json?
createdAt DateTime @default(now())
locations Location[]
memberships Membership[]
roles Role[]
auditLogs AuditLog[]
retention RetentionPolicy[]
featureFlags FeatureFlag[]
}
model Location {
id String @id @default(cuid())
accountId String
name String
siret String?
timezone String @default("Europe/Paris")
/// Taux moyen de cotisations patronales, en pourcentage.
employerContributionRate Decimal @default(0) @db.Decimal(5, 2)
silaeDossier String?
archivedAt DateTime?
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
teams Team[]
scopes MembershipScope[]
@@index([accountId])
}
model Team {
id String @id @default(cuid())
accountId String
locationId String
name String
position Int @default(0)
archivedAt DateTime?
location Location @relation(fields: [locationId], references: [id], onDelete: Cascade)
scopes MembershipScope[]
@@index([accountId])
@@index([locationId])
}
// ============================================================================
// Identité
// ============================================================================
model User {
id String @id @default(cuid())
email String @unique
passwordHash String?
firstName String
lastName String
locale String @default("fr")
/// Deuxième facteur, exigé des rôles administrateur et RH (matrice n° 15).
mfaSecretEnc Bytes?
mfaEnrolledAt DateTime?
lastSignInAt DateTime?
failedAttempts Int @default(0)
lockedUntil DateTime?
createdAt DateTime @default(now())
memberships Membership[]
sessions Session[]
}
/// Lien User ↔ Account. Porte le salarié : `userId` est nullable, car tous les
/// salariés ne se connectent pas — ils doivent rester plannifiables et
/// exportables sans compte (PLAN.md §4.1).
model Membership {
id String @id @default(cuid())
accountId String
userId String?
roleId String
lineManagerId String?
employeeNumber String
silaeMatricule String?
status MembershipStatus @default(INVITED)
invitedAt DateTime?
archivedAt DateTime?
createdAt DateTime @default(now())
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
role Role @relation(fields: [roleId], references: [id])
lineManager Membership? @relation("LineManager", fields: [lineManagerId], references: [id], onDelete: SetNull)
reports Membership[] @relation("LineManager")
scopes MembershipScope[]
invitations Invitation[]
auditLogs AuditLog[]
@@unique([accountId, employeeNumber])
@@index([accountId])
@@index([userId])
}
enum MembershipStatus {
INVITED
ACTIVE
ARCHIVED
}
/// Périmètre d'un membership. `allLocations` évite d'énumérer 34 établissements
/// pour un directeur — et de rater le 35ᵉ le jour de son ouverture.
model MembershipScope {
id String @id @default(cuid())
accountId String
membershipId String
allLocations Boolean @default(false)
locationId String?
teamId String?
membership Membership @relation(fields: [membershipId], references: [id], onDelete: Cascade)
location Location? @relation(fields: [locationId], references: [id], onDelete: Cascade)
team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
@@index([accountId])
@@index([membershipId])
}
/// Session serveur. En base plutôt qu'en JWT : la matrice n° 23 impose de
/// pouvoir révoquer une session, ce qu'un jeton signé ne permet pas.
model Session {
id String @id @default(cuid())
userId String
tokenHash String @unique
expiresAt DateTime
createdAt DateTime @default(now())
lastSeenAt DateTime @default(now())
ip String?
userAgent String?
revokedAt DateTime?
revokedBy String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([expiresAt])
}
model Invitation {
id String @id @default(cuid())
accountId String
membershipId String
tokenHash String @unique
email String
expiresAt DateTime
acceptedAt DateTime?
createdBy String
createdAt DateTime @default(now())
membership Membership @relation(fields: [membershipId], references: [id], onDelete: Cascade)
@@index([accountId])
}
// ============================================================================
// Autorisation — PLAN.md §4.2 et §5
// ============================================================================
/// Rôle configurable par le client. Le code référence `key`, jamais `name` :
/// renommer « Manager » en « Responsable » ne doit rien casser.
model Role {
id String @id @default(cuid())
accountId String
key String
name String
isSystem Boolean @default(false)
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
permissions RolePermission[]
memberships Membership[]
@@unique([accountId, key])
@@index([accountId])
}
/// Capacité stable, nommée `ressource.action.qualificatif`. Référentiel global :
/// les capacités sont définies par le produit, seule leur attribution varie.
model Permission {
id String @id @default(cuid())
code String @unique
category String
label String
roles RolePermission[]
}
model RolePermission {
roleId String
permissionId String
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade)
@@id([roleId, permissionId])
}
// ============================================================================
// Traçabilité — PLAN.md §3.5
// ============================================================================
/// Journal d'audit. Append-only, imposé par un trigger PostgreSQL : une piste
/// qu'on peut réécrire ne prouve rien.
model AuditLog {
id String @id @default(cuid())
accountId String
actorMembershipId String?
action String
entityType String
entityId String
before Json?
after Json?
reason String?
ip String?
userAgent String?
occurredAt DateTime @default(now())
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
actor Membership? @relation(fields: [actorMembershipId], references: [id], onDelete: SetNull)
@@index([accountId, entityType, entityId])
@@index([accountId, occurredAt])
}
/// Durées de conservation par objet — PLAN.md §12.5.
/// « 5 ans partout » est explicitement proscrit : chaque durée porte son point
/// de départ et sa justification.
model RetentionPolicy {
id String @id @default(cuid())
accountId String
objectType String
durationMonths Int
startPoint String
justification String
legalHold Boolean @default(false)
effectiveFrom DateTime @db.Date
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
@@unique([accountId, objectType, effectiveFrom])
@@index([accountId])
}
/// Verrou de conformité — PLAN.md §12.4.
/// Une fonctionnalité de contrôle reste inactive tant que la notice au salarié
/// et l'avis du CSE ne sont pas enregistrés.
model FeatureFlag {
id String @id @default(cuid())
accountId String
key String
enabled Boolean @default(false)
noticeDocumentRef String?
noticeDeliveredAt DateTime?
cseOpinionAt DateTime?
activatedAt DateTime?
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
@@unique([accountId, key])
}