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
289 lines
8.8 KiB
Plaintext
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])
|
|
}
|