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:
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,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
@@ -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
@@ -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());
|
||||
Reference in new issue
Block a user