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
This commit is contained in:
Claude committed 2026-08-07 22:39:33 +00:00
1 parent 93852c2602
commit 11763142d5
16 files changed
+2037 -9

No files matched your search

@@ -0,0 +1,314 @@
-- CreateEnum
CREATE TYPE "MembershipStatus" AS ENUM ('INVITED', 'ACTIVE', 'ARCHIVED');
-- CreateTable
CREATE TABLE "Account" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"siren" TEXT,
"apeCode" TEXT,
"collectiveAgreementId" TEXT,
"agreementOverrides" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Location" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"siret" TEXT,
"timezone" TEXT NOT NULL DEFAULT 'Europe/Paris',
"employerContributionRate" DECIMAL(5,2) NOT NULL DEFAULT 0,
"silaeDossier" TEXT,
"archivedAt" TIMESTAMP(3),
CONSTRAINT "Location_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Team" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"locationId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"position" INTEGER NOT NULL DEFAULT 0,
"archivedAt" TIMESTAMP(3),
CONSTRAINT "Team_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"passwordHash" TEXT,
"firstName" TEXT NOT NULL,
"lastName" TEXT NOT NULL,
"locale" TEXT NOT NULL DEFAULT 'fr',
"mfaSecretEnc" BYTEA,
"mfaEnrolledAt" TIMESTAMP(3),
"lastSignInAt" TIMESTAMP(3),
"failedAttempts" INTEGER NOT NULL DEFAULT 0,
"lockedUntil" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Membership" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"userId" TEXT,
"roleId" TEXT NOT NULL,
"lineManagerId" TEXT,
"employeeNumber" TEXT NOT NULL,
"silaeMatricule" TEXT,
"status" "MembershipStatus" NOT NULL DEFAULT 'INVITED',
"invitedAt" TIMESTAMP(3),
"archivedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Membership_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "MembershipScope" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"membershipId" TEXT NOT NULL,
"allLocations" BOOLEAN NOT NULL DEFAULT false,
"locationId" TEXT,
"teamId" TEXT,
CONSTRAINT "MembershipScope_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"ip" TEXT,
"userAgent" TEXT,
"revokedAt" TIMESTAMP(3),
"revokedBy" TEXT,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Invitation" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"membershipId" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"email" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"acceptedAt" TIMESTAMP(3),
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Invitation_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Role" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"key" TEXT NOT NULL,
"name" TEXT NOT NULL,
"isSystem" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "Role_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Permission" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"category" TEXT NOT NULL,
"label" TEXT NOT NULL,
CONSTRAINT "Permission_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RolePermission" (
"roleId" TEXT NOT NULL,
"permissionId" TEXT NOT NULL,
CONSTRAINT "RolePermission_pkey" PRIMARY KEY ("roleId","permissionId")
);
-- CreateTable
CREATE TABLE "AuditLog" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"actorMembershipId" TEXT,
"action" TEXT NOT NULL,
"entityType" TEXT NOT NULL,
"entityId" TEXT NOT NULL,
"before" JSONB,
"after" JSONB,
"reason" TEXT,
"ip" TEXT,
"userAgent" TEXT,
"occurredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RetentionPolicy" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"objectType" TEXT NOT NULL,
"durationMonths" INTEGER NOT NULL,
"startPoint" TEXT NOT NULL,
"justification" TEXT NOT NULL,
"legalHold" BOOLEAN NOT NULL DEFAULT false,
"effectiveFrom" DATE NOT NULL,
CONSTRAINT "RetentionPolicy_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FeatureFlag" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"key" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT false,
"noticeDocumentRef" TEXT,
"noticeDeliveredAt" TIMESTAMP(3),
"cseOpinionAt" TIMESTAMP(3),
"activatedAt" TIMESTAMP(3),
CONSTRAINT "FeatureFlag_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Location_accountId_idx" ON "Location"("accountId");
-- CreateIndex
CREATE INDEX "Team_accountId_idx" ON "Team"("accountId");
-- CreateIndex
CREATE INDEX "Team_locationId_idx" ON "Team"("locationId");
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE INDEX "Membership_accountId_idx" ON "Membership"("accountId");
-- CreateIndex
CREATE INDEX "Membership_userId_idx" ON "Membership"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "Membership_accountId_employeeNumber_key" ON "Membership"("accountId", "employeeNumber");
-- CreateIndex
CREATE INDEX "MembershipScope_accountId_idx" ON "MembershipScope"("accountId");
-- CreateIndex
CREATE INDEX "MembershipScope_membershipId_idx" ON "MembershipScope"("membershipId");
-- CreateIndex
CREATE UNIQUE INDEX "Session_tokenHash_key" ON "Session"("tokenHash");
-- CreateIndex
CREATE INDEX "Session_userId_idx" ON "Session"("userId");
-- CreateIndex
CREATE INDEX "Session_expiresAt_idx" ON "Session"("expiresAt");
-- CreateIndex
CREATE UNIQUE INDEX "Invitation_tokenHash_key" ON "Invitation"("tokenHash");
-- CreateIndex
CREATE INDEX "Invitation_accountId_idx" ON "Invitation"("accountId");
-- CreateIndex
CREATE INDEX "Role_accountId_idx" ON "Role"("accountId");
-- CreateIndex
CREATE UNIQUE INDEX "Role_accountId_key_key" ON "Role"("accountId", "key");
-- CreateIndex
CREATE UNIQUE INDEX "Permission_code_key" ON "Permission"("code");
-- CreateIndex
CREATE INDEX "AuditLog_accountId_entityType_entityId_idx" ON "AuditLog"("accountId", "entityType", "entityId");
-- CreateIndex
CREATE INDEX "AuditLog_accountId_occurredAt_idx" ON "AuditLog"("accountId", "occurredAt");
-- CreateIndex
CREATE INDEX "RetentionPolicy_accountId_idx" ON "RetentionPolicy"("accountId");
-- CreateIndex
CREATE UNIQUE INDEX "RetentionPolicy_accountId_objectType_effectiveFrom_key" ON "RetentionPolicy"("accountId", "objectType", "effectiveFrom");
-- CreateIndex
CREATE UNIQUE INDEX "FeatureFlag_accountId_key_key" ON "FeatureFlag"("accountId", "key");
-- AddForeignKey
ALTER TABLE "Location" ADD CONSTRAINT "Location_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Team" ADD CONSTRAINT "Team_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Membership" ADD CONSTRAINT "Membership_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Membership" ADD CONSTRAINT "Membership_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Membership" ADD CONSTRAINT "Membership_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Membership" ADD CONSTRAINT "Membership_lineManagerId_fkey" FOREIGN KEY ("lineManagerId") REFERENCES "Membership"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MembershipScope" ADD CONSTRAINT "MembershipScope_membershipId_fkey" FOREIGN KEY ("membershipId") REFERENCES "Membership"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MembershipScope" ADD CONSTRAINT "MembershipScope_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MembershipScope" ADD CONSTRAINT "MembershipScope_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Invitation" ADD CONSTRAINT "Invitation_membershipId_fkey" FOREIGN KEY ("membershipId") REFERENCES "Membership"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Role" ADD CONSTRAINT "Role_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RolePermission" ADD CONSTRAINT "RolePermission_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RolePermission" ADD CONSTRAINT "RolePermission_permissionId_fkey" FOREIGN KEY ("permissionId") REFERENCES "Permission"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_actorMembershipId_fkey" FOREIGN KEY ("actorMembershipId") REFERENCES "Membership"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RetentionPolicy" ADD CONSTRAINT "RetentionPolicy_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FeatureFlag" ADD CONSTRAINT "FeatureFlag_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,80 @@
-- Défense en profondeur — PLAN.md §3.1 et §3.5.
--
-- L'extension Prisma filtre déjà chaque requête par compte. Ces règles-ci
-- valent pour le cas où elle serait contournée : requête brute, bug de
-- câblage, ou console de maintenance. Une isolation qui repose sur une seule
-- couche applicative n'est pas une isolation.
-- ---------------------------------------------------------------------------
-- 1. Journal d'audit append-only
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION planflow_deny_write() RETURNS trigger AS $$
BEGIN
RAISE EXCEPTION
'La table % est en append-only : ni UPDATE ni DELETE. Une correction s''écrit, elle ne se réécrit pas.',
TG_TABLE_NAME
USING ERRCODE = 'restrict_violation';
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER audit_log_append_only
BEFORE UPDATE OR DELETE ON "AuditLog"
FOR EACH ROW EXECUTE FUNCTION planflow_deny_write();
-- ---------------------------------------------------------------------------
-- 2. Row-level security, portée par le compte courant
-- ---------------------------------------------------------------------------
--
-- Le compte est transmis par `SET LOCAL app.account_id` à l'ouverture d'une
-- transaction (voir src/server/tenant.ts). Hors transaction scopée, le réglage
-- est absent et les politiques ne laissent rien passer.
--
-- current_setting(..., true) renvoie NULL plutôt que d'échouer quand le
-- réglage n'existe pas : c'est ce qui permet à la politique de refuser au lieu
-- de faire planter la requête avec une erreur peu parlante.
CREATE OR REPLACE FUNCTION planflow_current_account() RETURNS text AS $$
SELECT NULLIF(current_setting('app.account_id', true), '');
$$ LANGUAGE sql STABLE;
DO $$
DECLARE
t text;
BEGIN
FOREACH t IN ARRAY ARRAY[
'Location', 'Team', 'Membership', 'MembershipScope', 'Invitation',
'Role', 'AuditLog', 'RetentionPolicy', 'FeatureFlag'
]
LOOP
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', t);
EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', t);
EXECUTE format(
'CREATE POLICY tenant_isolation ON %I USING ("accountId" = planflow_current_account())',
t
);
EXECUTE format(
'CREATE POLICY tenant_insert ON %I FOR INSERT WITH CHECK ("accountId" = planflow_current_account())',
t
);
END LOOP;
END;
$$;
-- Account lui-même : lecture limitée au compte courant.
ALTER TABLE "Account" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "Account" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON "Account"
USING (id = planflow_current_account());
CREATE POLICY tenant_insert ON "Account"
FOR INSERT WITH CHECK (true);
-- ---------------------------------------------------------------------------
-- 3. Rôle applicatif
-- ---------------------------------------------------------------------------
--
-- Le propriétaire d'une table contourne la RLS, sauf FORCE ci-dessus. FORCE
-- l'applique aussi au propriétaire, donc les migrations et les tâches
-- d'administration doivent passer explicitement par un compte à privilèges.
-- On ne crée pas de rôle séparé ici : le déploiement auto-hébergé n'en a qu'un,
-- et FORCE suffit à rendre les politiques effectives.
+1 -1
View File
@@ -1,3 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g. Git)
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
+279 -5
View File
@@ -1,14 +1,288 @@
// PlanFlow — schéma de données.
// PlanFlow — schéma de données. Voir PLAN.md §4.
//
// WP-00 n'installe que la plomberie : datasource, generator et les extensions
// PostgreSQL dont les lots suivants dépendent. Les modèles métier arrivent au
// WP-01 (tenancy, identité, autorisation) — voir PLAN.md §4.
// Prisma 7 : l'URL de connexion vit dans prisma.config.ts, plus ici.
generator client {
provider = "prisma-client-js"
}
// Prisma 7 : l'URL de connexion vit dans prisma.config.ts, plus ici.
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])
}
+220
View File
@@ -0,0 +1,220 @@
import 'dotenv/config';
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '@prisma/client';
import { hash } from '@node-rs/argon2';
import {
DEFAULT_ROLE_PERMISSIONS,
PERMISSION_DEFINITIONS,
SYSTEM_ROLES,
} from '../src/domain/access/permissions';
/**
* Jeu de données de départ — PLAN.md §11.
*
* **Entièrement fictif.** Deux établissements, des équipes, et les rôles
* fournis. Le mot de passe de démonstration n'a de sens qu'en développement ;
* il est refusé si NODE_ENV vaut production.
*/
const DEMO_PASSWORD = 'planflow-demo-2026';
const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL ?? '',
});
const prisma = new PrismaClient({ adapter });
async function main() {
if (process.env.NODE_ENV === 'production') {
throw new Error(
'Le seed installe un compte de démonstration : refusé en production.',
);
}
console.log('→ Capacités');
for (const permission of PERMISSION_DEFINITIONS) {
await prisma.permission.upsert({
where: { code: permission.code },
update: { category: permission.category, label: permission.label },
create: permission,
});
}
console.log(` ${PERMISSION_DEFINITIONS.length} capacités`);
console.log('→ Compte');
const account = await prisma.account.upsert({
where: { id: 'demo-account' },
update: {},
create: {
id: 'demo-account',
name: 'Maison Rivage',
siren: '000000000',
apeCode: '4759B',
collectiveAgreementId: '1517',
},
});
console.log('→ Rôles');
const roleIds = new Map<string, string>();
for (const role of SYSTEM_ROLES) {
const created = await prisma.role.upsert({
where: { accountId_key: { accountId: account.id, key: role.key } },
update: { name: role.name },
create: {
accountId: account.id,
key: role.key,
name: role.name,
isSystem: true,
},
});
roleIds.set(role.key, created.id);
const codes = DEFAULT_ROLE_PERMISSIONS[role.key];
const permissions = await prisma.permission.findMany({
where: { code: { in: codes } },
});
await prisma.rolePermission.deleteMany({ where: { roleId: created.id } });
await prisma.rolePermission.createMany({
data: permissions.map((permission) => ({
roleId: created.id,
permissionId: permission.id,
})),
skipDuplicates: true,
});
console.log(` ${role.name} — ${permissions.length} capacités`);
}
console.log('→ Établissements');
const locations = [
{ id: 'loc-nantes', name: 'Nantes Atlantis' },
{ id: 'loc-rennes', name: 'Rennes Alma' },
];
for (const location of locations) {
await prisma.location.upsert({
where: { id: location.id },
update: { name: location.name },
create: {
id: location.id,
accountId: account.id,
name: location.name,
employerContributionRate: 42,
},
});
for (const [index, team] of ['Vente', 'Caisse', 'Réserve'].entries()) {
await prisma.team.upsert({
where: { id: `${location.id}-${index}` },
update: {},
create: {
id: `${location.id}-${index}`,
accountId: account.id,
locationId: location.id,
name: team,
position: index,
},
});
}
}
console.log('→ Comptes utilisateurs');
const passwordHash = await hash(DEMO_PASSWORD, {
memoryCost: 19_456,
timeCost: 2,
parallelism: 1,
});
const people = [
{ email: 'direction@example.test', firstName: 'Camille', lastName: 'Ferrand', role: 'owner', all: true },
{ email: 'manager.nantes@example.test', firstName: 'Jonas', lastName: 'Meyer', role: 'manager', all: false },
{ email: 'manager.rennes@example.test', firstName: 'Inès', lastName: 'Bakhti', role: 'manager', all: false },
{ email: 'salarie@example.test', firstName: 'Rémi', lastName: 'Chartier', role: 'employee', all: false },
];
for (const [index, person] of people.entries()) {
const user = await prisma.user.upsert({
where: { email: person.email },
update: {},
create: {
email: person.email,
firstName: person.firstName,
lastName: person.lastName,
passwordHash,
},
});
const membership = await prisma.membership.upsert({
where: {
accountId_employeeNumber: {
accountId: account.id,
employeeNumber: `E${String(index + 1).padStart(4, '0')}`,
},
},
update: { userId: user.id, status: 'ACTIVE' },
create: {
accountId: account.id,
userId: user.id,
roleId: roleIds.get(person.role) as string,
employeeNumber: `E${String(index + 1).padStart(4, '0')}`,
status: 'ACTIVE',
},
});
await prisma.membershipScope.deleteMany({
where: { membershipId: membership.id },
});
await prisma.membershipScope.create({
data: {
accountId: account.id,
membershipId: membership.id,
allLocations: person.all,
locationId: person.all
? null
: person.email.includes('rennes')
? 'loc-rennes'
: 'loc-nantes',
},
});
console.log(` ${person.email} — ${person.role}`);
}
console.log('→ Durées de conservation');
const retention = [
['Shift', 12, 'creation', 'Décompte des horaires : 1 an minimum (matrice n° 21).'],
['ForfaitDayEntry', 36, 'creation', 'Décompte des jours de forfait : 3 ans minimum.'],
['UserContract', 60, 'contract_end', 'Pièces contractuelles : 5 ans.'],
['PersonnelRegister', 60, 'employee_departure', 'Registre du personnel : 5 ans après le départ.'],
['PayrollVariable', 72, 'period_end', "Éléments d'assiette transmis à Silae : 6 ans."],
] as const;
for (const [objectType, durationMonths, startPoint, justification] of retention) {
await prisma.retentionPolicy.upsert({
where: {
accountId_objectType_effectiveFrom: {
accountId: account.id,
objectType,
effectiveFrom: new Date('2026-01-01'),
},
},
update: { durationMonths, startPoint, justification },
create: {
accountId: account.id,
objectType,
durationMonths,
startPoint,
justification,
effectiveFrom: new Date('2026-01-01'),
},
});
}
console.log(` ${retention.length} politiques`);
console.log(`\nMot de passe de démonstration : ${DEMO_PASSWORD}`);
}
main()
.catch((error) => {
console.error(error);
process.exitCode = 1;
})
.finally(() => prisma.$disconnect());