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

+19 -1
View File
@@ -12,7 +12,9 @@ La spécification de construction est [`PLAN.md`](PLAN.md). Elle est normative :
## État
**WP-00 — socle.** Next.js, Prisma, base de données, en-têtes de sécurité, tests, CI, image Docker. Aucun écran métier : ils arrivent à partir de WP-01 (tenancy, identité, autorisation).
**WP-00 — socle** et **implémentation du design** : six écrans sur données de démonstration.
**WP-01 — tenancy, identité, autorisation** : en cours. Modèle de données, RLS, journal d'audit append-only, catalogue de 70 capacités, cinq rôles, sessions en base. Les écrans lisent encore `src/lib/demo`.
## Démarrer
@@ -57,6 +59,22 @@ pnpm test:e2e # build, serveur standalone, tests de bout en bout
`pnpm verify` est ce que la CI exécute sur chaque *pull request*, suivi du build et des tests end-to-end.
## Configuration de la base — à ne pas rater
**L'application ne doit pas se connecter en superutilisateur PostgreSQL.**
Un superutilisateur contourne la *row-level security*, y compris déclarée en `FORCE`. Connecter PlanFlow avec un tel compte désactive silencieusement la seconde couche d'isolation multi-tenant : les requêtes fonctionnent, les tests applicatifs passent, et rien n'indique que la protection a disparu — jusqu'au jour où quelqu'un lit les données d'un autre établissement.
```sql
CREATE ROLE planflow_app LOGIN PASSWORD '…' NOSUPERUSER NOBYPASSRLS;
GRANT USAGE ON SCHEMA public TO planflow_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO planflow_app;
```
Les migrations, elles, s'appliquent avec un compte propriétaire distinct.
L'application vérifie ce point au démarrage : elle refuse de démarrer en production sur une base mal configurée, et se contente d'un avertissement en développement. `GET /api/sante` expose l'état sous `tenantIsolation`.
## Choix structurants
**Aucun traceur tiers.** L'audit du produit de référence a intercepté 2102 requêtes de traçage — Segment, LinkedIn Ads, Google Ads, DoubleClick, Clarity, Hotjar — et aucune requête métier. Une application RH ne doit pas envoyer un contexte de navigation portant sur des salariés identifiables à des régies publicitaires. Deux garde-fous rendent la règle vérifiable plutôt que déclarative :
@@ -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());
+12 -1
View File
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server';
import { checkTenantIsolation } from '@/server/db-guard';
import { checkDatabase } from '@/server/health';
export const dynamic = 'force-dynamic';
@@ -14,8 +15,18 @@ export const dynamic = 'force-dynamic';
export async function GET() {
const database = await checkDatabase();
// L'isolation est signalée mais ne dégrade pas la sonde : une base joignable
// avec un compte trop privilégié reste une application qui répond. Le refus
// de démarrer, lui, est traité par assertTenantIsolation en production.
const isolation = database.ok
? await checkTenantIsolation().catch(() => null)
: null;
return NextResponse.json(
{ status: database.ok ? 'ok' : 'degraded' },
{
status: database.ok ? 'ok' : 'degraded',
tenantIsolation: isolation ? (isolation.ok ? 'enforced' : 'weakened') : 'unknown',
},
{
status: database.ok ? 200 : 503,
headers: { 'cache-control': 'no-store' },
+121
View File
@@ -0,0 +1,121 @@
import type { PermissionCode } from '@/domain/access/permissions';
/**
* Autorisation par capacités — PLAN.md §3.2.
*
* Point d'entrée unique. Deux règles qui ne se négocient pas :
*
* 1. La vérification a lieu **avant tout effet**, côté serveur. Masquer un
* bouton est un confort, pas une sécurité : la mutation reste appelable.
* 2. On teste une **capacité**, jamais un nom de rôle. Les rôles sont
* configurables par le client ; un écran qui teste `role === 'manager'`
* casse le jour où quelqu'un renomme le rôle ou en crée un cinquième.
*/
export interface Scope {
/** Accès à tous les établissements du compte. */
allLocations: boolean;
locationIds: string[];
teamIds: string[];
}
export interface Actor {
membershipId: string;
accountId: string;
userId: string | null;
roleKey: string;
permissions: ReadonlySet<string>;
scope: Scope;
}
/** Ressource visée, quand la capacité seule ne suffit pas à décider. */
export interface ResourceRef {
accountId?: string;
locationId?: string;
teamId?: string;
/** Membership concerné, pour distinguer « ses » données de celles d'autrui. */
membershipId?: string;
}
export class AuthorizationError extends Error {
readonly permission: string;
constructor(permission: string, detail?: string) {
super(
detail
? `Capacité « ${permission} » requise : ${detail}`
: `Capacité « ${permission} » requise`,
);
this.name = 'AuthorizationError';
this.permission = permission;
}
}
/** Le périmètre couvre-t-il la ressource visée ? */
export function inScope(actor: Actor, resource?: ResourceRef): boolean {
if (!resource) return true;
if (resource.accountId && resource.accountId !== actor.accountId) {
return false;
}
if (actor.scope.allLocations) return true;
if (resource.locationId) {
return actor.scope.locationIds.includes(resource.locationId);
}
if (resource.teamId) {
return actor.scope.teamIds.includes(resource.teamId);
}
return true;
}
export function can(
actor: Actor,
permission: PermissionCode,
resource?: ResourceRef,
): boolean {
if (!actor.permissions.has(permission)) return false;
return inScope(actor, resource);
}
/**
* Variante qui lève. À préférer dans les Server Actions : oublier de traiter le
* `false` d'un `can()` laisse passer la mutation, alors qu'une exception
* interrompt tout.
*/
export function authorize(
actor: Actor,
permission: PermissionCode,
resource?: ResourceRef,
): void {
if (!actor.permissions.has(permission)) {
throw new AuthorizationError(permission);
}
if (!inScope(actor, resource)) {
throw new AuthorizationError(
permission,
'la ressource est hors du périmètre du compte ou de l’établissement',
);
}
}
/**
* Choisit entre la capacité « sur soi » et « sur autrui ».
*
* Un salarié voit ses propres compteurs sans détenir le droit de voir ceux des
* autres — l'audit relève explicitement ces deux droits séparés.
*/
export function canForMember(
actor: Actor,
ownPermission: PermissionCode,
othersPermission: PermissionCode,
targetMembershipId: string,
): boolean {
if (targetMembershipId === actor.membershipId) {
return actor.permissions.has(ownPermission);
}
return actor.permissions.has(othersPermission);
}
+206
View File
@@ -0,0 +1,206 @@
/**
* Catalogue des capacités — PLAN.md §5.
*
* Les codes sont **stables** : le produit les référence, les écrans les
* testent, et un client peut renommer ses rôles sans rien casser. Ajouter une
* capacité est une migration de données ; en renommer une est une rupture.
*
* Forme : `ressource.action.qualificatif`.
*/
export interface PermissionDefinition {
code: string;
category: string;
label: string;
}
export const PERMISSIONS = [
// --- Planning ------------------------------------------------------------
['planning.view', 'Planning', 'Voir les plannings publiés'],
['planning.view_unpublished', 'Planning', 'Voir les semaines non publiées'],
['planning.create', 'Planning', 'Créer un créneau'],
['planning.create_on_published', 'Planning', 'Créer sur une semaine publiée'],
['planning.edit', 'Planning', 'Modifier un créneau'],
['planning.edit_published', 'Planning', 'Modifier une semaine publiée'],
['planning.delete', 'Planning', 'Supprimer un créneau'],
['planning.duplicate', 'Planning', 'Dupliquer une semaine'],
['planning.publish', 'Planning', 'Publier une semaine'],
['planning.unpublish', 'Planning', 'Dépublier une semaine'],
['planning.validate', 'Planning', 'Valider une semaine'],
['planning.invalidate', 'Planning', 'Invalider une semaine'],
['planning.bulk_actions', 'Planning', 'Actions de masse'],
['planning.unassigned.view', 'Planning', 'Voir les besoins non couverts'],
['planning.alerts.view', 'Planning', 'Voir les alertes de convention'],
['planning.alerts.acknowledge', 'Planning', 'Acquitter une alerte'],
['planning.counters.view', 'Planning', 'Voir les compteurs et le coût'],
['planning.labels.manage', 'Planning', 'Gérer les étiquettes'],
['planning.notes.manage', 'Planning', 'Gérer les notes de journée'],
['planning.print', 'Planning', 'Imprimer un planning'],
// --- Personnel -----------------------------------------------------------
['members.view', 'Personnel', 'Voir l’annuaire'],
['members.create', 'Personnel', 'Créer un salarié'],
['members.edit', 'Personnel', 'Modifier un dossier'],
['members.archive', 'Personnel', 'Archiver un salarié'],
['members.delete', 'Personnel', 'Supprimer un salarié'],
['members.salary.view', 'Personnel', 'Voir les rémunérations'],
['members.contract.create', 'Personnel', 'Créer un contrat'],
['members.contract.edit', 'Personnel', 'Modifier un contrat'],
['members.contract.delete_past', 'Personnel', 'Supprimer un contrat passé'],
['members.documents.view', 'Personnel', 'Consulter les documents'],
['members.documents.manage', 'Personnel', 'Gérer les documents'],
['members.register.export', 'Personnel', 'Exporter le registre du personnel'],
['members.dpae.check', 'Personnel', 'Contrôler l’éligibilité DPAE'],
// --- Heures --------------------------------------------------------------
['hours.view', 'Heures', 'Voir les heures'],
['hours.edit_actual', 'Heures', 'Saisir les heures réelles'],
['hours.validate', 'Heures', 'Valider les heures'],
// --- Absences et compteurs ----------------------------------------------
['timeoff.view_own', 'Absences', 'Voir ses absences'],
['timeoff.view_others', 'Absences', 'Voir les absences des autres'],
['timeoff.request', 'Absences', 'Demander une absence'],
['timeoff.decide', 'Absences', 'Accepter ou refuser une demande'],
['timeoff.delete', 'Absences', 'Supprimer une absence'],
['timeoff.bypass_notice', 'Absences', 'Contourner le délai de prévenance'],
['timeoff.forecast.view', 'Absences', 'Voir la prévision de solde'],
['counters.view_own', 'Absences', 'Voir ses compteurs'],
['counters.view_others', 'Absences', 'Voir les compteurs des autres'],
['counters.adjust', 'Absences', 'Ajuster un compteur'],
// --- Paie ----------------------------------------------------------------
['payroll.access', 'Paie', 'Accéder à la paie'],
['payroll.period.create', 'Paie', 'Créer une période'],
['payroll.period.update', 'Paie', 'Modifier une période'],
['payroll.period.delete', 'Paie', 'Supprimer une période'],
['payroll.period.alternative', 'Paie', 'Créer une période alternative'],
['payroll.period.lock', 'Paie', 'Verrouiller une période'],
['payroll.period.unlock', 'Paie', 'Déverrouiller une période'],
['payroll.export', 'Paie', 'Exporter les variables'],
['payroll.export.silae', 'Paie', 'Exporter au format Silae'],
['payroll.export.raw', 'Paie', 'Exporter au format brut'],
// --- Administration ------------------------------------------------------
['settings.access', 'Administration', 'Accéder aux réglages'],
['settings.locations.manage', 'Administration', 'Gérer les établissements'],
['settings.teams.manage', 'Administration', 'Gérer les équipes'],
['settings.agreement.manage', 'Administration', 'Gérer la convention'],
['settings.jobtitles.manage', 'Administration', 'Gérer les emplois'],
['settings.templates.manage', 'Administration', 'Gérer les modèles'],
['settings.integrations.manage', 'Administration', 'Gérer les intégrations'],
['settings.notifications.manage', 'Administration', 'Gérer les notifications'],
['settings.roles.manage', 'Administration', 'Gérer les rôles'],
['role_config.assign_owner_level', 'Administration', 'Déléguer le niveau propriétaire'],
['audit.view', 'Administration', 'Consulter le journal d’audit'],
// --- Communication -------------------------------------------------------
['articles.view', 'Communication', 'Lire les articles'],
['articles.manage', 'Communication', 'Publier des articles'],
['conversations.access', 'Communication', 'Accéder à la messagerie'],
] as const satisfies ReadonlyArray<readonly [string, string, string]>;
export type PermissionCode = (typeof PERMISSIONS)[number][0];
export const PERMISSION_DEFINITIONS: PermissionDefinition[] = PERMISSIONS.map(
([code, category, label]) => ({ code, category, label }),
);
export const PERMISSION_CODES: string[] = PERMISSION_DEFINITIONS.map(
(permission) => permission.code,
);
/**
* Rôles fournis — liste exhaustive relevée à l'audit des menus (PLAN.md §5).
*
* Point de départ modifiable par le client : un rôle est une donnée, pas une
* constante du code. Aucun écran ne doit tester ces clés.
*/
export const SYSTEM_ROLES = [
{ key: 'owner', name: 'Propriétaire' },
{ key: 'admin', name: 'Admin' },
{ key: 'director', name: 'Directeur' },
{ key: 'manager', name: 'Manager' },
{ key: 'employee', name: 'Employé' },
] as const;
export type SystemRoleKey = (typeof SYSTEM_ROLES)[number]['key'];
const EMPLOYEE_PERMISSIONS: string[] = [
'planning.view',
'timeoff.view_own',
'timeoff.request',
'counters.view_own',
'articles.view',
'conversations.access',
];
const MANAGER_PERMISSIONS: string[] = [
...EMPLOYEE_PERMISSIONS,
'planning.view_unpublished',
'planning.create',
'planning.edit',
'planning.delete',
'planning.duplicate',
'planning.publish',
'planning.unpublish',
'planning.validate',
'planning.invalidate',
'planning.bulk_actions',
'planning.unassigned.view',
'planning.alerts.view',
'planning.alerts.acknowledge',
'planning.counters.view',
'planning.notes.manage',
'planning.print',
'members.view',
'hours.view',
'hours.edit_actual',
'hours.validate',
'timeoff.view_others',
'timeoff.decide',
'timeoff.forecast.view',
'counters.view_others',
];
const DIRECTOR_PERMISSIONS: string[] = [
...MANAGER_PERMISSIONS,
'planning.create_on_published',
'planning.edit_published',
'planning.labels.manage',
'members.create',
'members.edit',
'members.archive',
'members.salary.view',
'members.contract.create',
'members.contract.edit',
'members.documents.view',
'members.documents.manage',
'members.register.export',
'members.dpae.check',
'timeoff.delete',
'timeoff.bypass_notice',
'counters.adjust',
'payroll.access',
'payroll.period.create',
'payroll.period.update',
'payroll.period.lock',
'payroll.export',
'payroll.export.silae',
'settings.access',
'articles.manage',
];
const ADMIN_PERMISSIONS: string[] = PERMISSION_CODES.filter(
(code) => code !== 'role_config.assign_owner_level',
);
/** Attribution par défaut. Le client la modifie ensuite depuis les réglages. */
export const DEFAULT_ROLE_PERMISSIONS: Record<SystemRoleKey, string[]> = {
employee: EMPLOYEE_PERMISSIONS,
manager: MANAGER_PERMISSIONS,
director: DIRECTOR_PERMISSIONS,
admin: ADMIN_PERMISSIONS,
// Seul le propriétaire peut déléguer le niveau propriétaire.
owner: [...PERMISSION_CODES],
};
+85
View File
@@ -0,0 +1,85 @@
import type { ScopedClient } from '@/server/tenant';
/**
* Journal d'audit — PLAN.md §3.5 et matrice n° 22.
*
* Append-only, imposé par un trigger PostgreSQL. La preuve exige de pouvoir
* reconstruire une décision telle qu'elle a été prise ; une piste que l'on peut
* réécrire ne prouve rien.
*/
export interface AuditEntry {
actorMembershipId?: string | null;
action: string;
entityType: string;
entityId: string;
before?: unknown;
after?: unknown;
/** Justification, obligatoire pour les opérations qui l'exigent. */
reason?: string | null;
ip?: string | null;
userAgent?: string | null;
}
/** Actions dont la matrice exige une justification explicite. */
const REASON_REQUIRED = new Set([
'counter.adjust',
'payroll.period.unlock',
'payroll.period.delete',
'planning.alert.acknowledge',
'membership.delete',
]);
export async function recordAudit(
db: ScopedClient,
entry: AuditEntry,
): Promise<void> {
if (REASON_REQUIRED.has(entry.action) && !entry.reason?.trim()) {
// Refuser ici plutôt qu'accepter une entrée creuse : une justification
// vide sur un ajustement de compteur ne vaut pas mieux que pas d'entrée,
// et laisse croire que la traçabilité est en place.
throw new Error(
`L'action « ${entry.action} » exige une justification (matrice n° 21 et 22).`,
);
}
await db.auditLog.create({
data: {
actorMembershipId: entry.actorMembershipId ?? null,
action: entry.action,
entityType: entry.entityType,
entityId: entry.entityId,
before: (entry.before ?? null) as never,
after: (entry.after ?? null) as never,
reason: entry.reason ?? null,
ip: entry.ip ?? null,
userAgent: entry.userAgent ?? null,
} as never,
});
}
/**
* Retire des valeurs à ne jamais écrire au journal.
*
* Le journal se relit, s'exporte et se conserve longtemps : y déposer un IBAN
* ou un mot de passe reviendrait à créer une seconde copie non chiffrée de ce
* que l'on a pris soin de chiffrer ailleurs.
*/
const REDACTED = new Set([
'passwordHash',
'socialSecurityNumberEnc',
'ibanEnc',
'bicEnc',
'mfaSecretEnc',
'tokenHash',
]);
export function redact<T extends Record<string, unknown>>(
value: T,
): Record<string, unknown> {
const output: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(value)) {
output[key] = REDACTED.has(key) ? '[masqué]' : entry;
}
return output;
}
+217
View File
@@ -0,0 +1,217 @@
import { hash, verify } from '@node-rs/argon2';
import { cookies } from 'next/headers';
import type { Actor, Scope } from '@/domain/access/authorize';
import { generateToken, hashToken } from '@/server/crypto';
import { unscoped } from '@/server/tenant';
/**
* Sessions serveur — PLAN.md §2 (écart assumé) et matrice n° 23.
*
* En base plutôt qu'en jeton signé : la matrice impose de pouvoir **révoquer**
* une session lors d'un incident. Un JWT reste valable jusqu'à expiration quoi
* qu'on fasse, sauf à tenir une liste de révocation — c'est-à-dire à
* reconstruire une table de sessions, en moins fiable.
*/
export const SESSION_COOKIE = 'planflow_session';
const SESSION_DURATION_MS = 12 * 60 * 60 * 1000; // 12 h : une journée de travail.
const MAX_FAILED_ATTEMPTS = 8;
const LOCK_DURATION_MS = 15 * 60 * 1000;
/**
* Paramètres argon2id. Les valeurs par défaut de la bibliothèque sont
* délibérément basses ; celles-ci suivent les recommandations OWASP.
*/
const ARGON2_OPTIONS = {
memoryCost: 19_456, // 19 Mio
timeCost: 2,
parallelism: 1,
} as const;
export function hashPassword(password: string): Promise<string> {
return hash(password, ARGON2_OPTIONS);
}
export async function verifyPassword(
passwordHash: string,
password: string,
): Promise<boolean> {
try {
return await verify(passwordHash, password);
} catch {
// Une empreinte illisible est un échec d'authentification, pas une panne :
// la remonter en erreur 500 renseignerait sur l'état du compte.
return false;
}
}
export interface SignInInput {
email: string;
password: string;
ip?: string | null;
userAgent?: string | null;
}
export type SignInResult =
| { ok: true; token: string; expiresAt: Date; userId: string }
| { ok: false; reason: 'invalid' | 'locked' };
export async function signIn(input: SignInInput): Promise<SignInResult> {
const db = unscoped();
const user = await db.user.findUnique({
where: { email: input.email.toLowerCase().trim() },
});
if (user?.lockedUntil && user.lockedUntil > new Date()) {
return { ok: false, reason: 'locked' };
}
// Vérifier une empreinte factice quand l'utilisateur n'existe pas : sans
// cela, un compte inconnu répond nettement plus vite qu'un mot de passe
// faux, ce qui laisse énumérer les adresses.
const passwordHash =
user?.passwordHash ??
'$argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHR2YWx1ZQ$0000000000000000000000000000000000000000000';
const valid = await verifyPassword(passwordHash, input.password);
if (!user || !user.passwordHash || !valid) {
if (user) {
const failed = user.failedAttempts + 1;
await db.user.update({
where: { id: user.id },
data: {
failedAttempts: failed,
lockedUntil:
failed >= MAX_FAILED_ATTEMPTS
? new Date(Date.now() + LOCK_DURATION_MS)
: null,
},
});
}
return { ok: false, reason: 'invalid' };
}
const token = generateToken();
const expiresAt = new Date(Date.now() + SESSION_DURATION_MS);
await db.$transaction([
db.session.create({
data: {
userId: user.id,
tokenHash: hashToken(token),
expiresAt,
ip: input.ip ?? null,
userAgent: input.userAgent ?? null,
},
}),
db.user.update({
where: { id: user.id },
data: {
failedAttempts: 0,
lockedUntil: null,
lastSignInAt: new Date(),
},
}),
]);
return { ok: true, token, expiresAt, userId: user.id };
}
export async function signOut(token: string): Promise<void> {
await unscoped().session.updateMany({
where: { tokenHash: hashToken(token), revokedAt: null },
data: { revokedAt: new Date() },
});
}
/** Révoque toutes les sessions d'un utilisateur — réponse à incident. */
export async function revokeAllSessions(
userId: string,
revokedBy: string,
): Promise<number> {
const result = await unscoped().session.updateMany({
where: { userId, revokedAt: null },
data: { revokedAt: new Date(), revokedBy },
});
return result.count;
}
export interface SessionContext {
actor: Actor;
sessionId: string;
}
/**
* Résout la session courante en acteur autorisable.
*
* Le périmètre est reconstruit ici, depuis la base, à chaque requête. Le
* transporter dans le cookie permettrait à un client de se l'élargir.
*/
export async function resolveSession(
token: string,
): Promise<SessionContext | null> {
const db = unscoped();
const session = await db.session.findUnique({
where: { tokenHash: hashToken(token) },
include: {
user: {
include: {
memberships: {
where: { status: 'ACTIVE', archivedAt: null },
include: {
role: { include: { permissions: { include: { permission: true } } } },
scopes: true,
},
},
},
},
},
});
if (!session || session.revokedAt || session.expiresAt < new Date()) {
return null;
}
const membership = session.user.memberships[0];
if (!membership) return null;
const scope: Scope = {
allLocations: membership.scopes.some((entry) => entry.allLocations),
locationIds: membership.scopes
.map((entry) => entry.locationId)
.filter((id): id is string => id !== null),
teamIds: membership.scopes
.map((entry) => entry.teamId)
.filter((id): id is string => id !== null),
};
const actor: Actor = {
membershipId: membership.id,
accountId: membership.accountId,
userId: session.user.id,
roleKey: membership.role.key,
permissions: new Set(
membership.role.permissions.map((entry) => entry.permission.code),
),
scope,
};
return { actor, sessionId: session.id };
}
/** Session courante depuis le cookie, ou `null`. */
export async function currentSession(): Promise<SessionContext | null> {
const token = (await cookies()).get(SESSION_COOKIE)?.value;
if (!token) return null;
return resolveSession(token);
}
export const SESSION_COOKIE_OPTIONS = {
httpOnly: true,
sameSite: 'lax',
path: '/',
secure: process.env.NODE_ENV === 'production',
} as const;
+89
View File
@@ -0,0 +1,89 @@
import {
createCipheriv,
createDecipheriv,
randomBytes,
timingSafeEqual,
createHash,
} from 'node:crypto';
import { env } from '@/lib/env';
/**
* Chiffrement applicatif des colonnes sensibles — PLAN.md §3.6.
*
* NIR, IBAN, BIC et secrets MFA. AES-256-GCM : le mode authentifié détecte
* qu'un chiffré a été modifié, ce qu'un CBC laisserait passer en produisant du
* clair corrompu.
*
* La clé vit hors de la base. Une sauvegarde volée ne doit pas suffire à lire
* ces colonnes — sans quoi le chiffrement ne protège que du vol de disque nu.
*/
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12;
const TAG_LENGTH = 16;
function key(): Buffer {
return Buffer.from(env.ENCRYPTION_KEY, 'base64');
}
/** Format : [iv (12) | tag (16) | chiffré]. */
export function encrypt(plaintext: string): Buffer {
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, key(), iv);
const encrypted = Buffer.concat([
cipher.update(plaintext, 'utf8'),
cipher.final(),
]);
return Buffer.concat([iv, cipher.getAuthTag(), encrypted]);
}
export function decrypt(payload: Buffer): string {
if (payload.length < IV_LENGTH + TAG_LENGTH) {
throw new Error('Chiffré invalide : trop court pour contenir iv et tag');
}
const iv = payload.subarray(0, IV_LENGTH);
const tag = payload.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
const encrypted = payload.subarray(IV_LENGTH + TAG_LENGTH);
const decipher = createDecipheriv(ALGORITHM, key(), iv);
decipher.setAuthTag(tag);
return Buffer.concat([
decipher.update(encrypted),
decipher.final(),
]).toString('utf8');
}
export function encryptOptional(value: string | null | undefined): Buffer | null {
return value ? encrypt(value) : null;
}
export function decryptOptional(payload: Buffer | null | undefined): string | null {
return payload ? decrypt(payload) : null;
}
/**
* Empreinte d'un jeton de session ou d'invitation.
*
* La base ne stocke que l'empreinte : une fuite de la table ne donne pas de
* sessions utilisables. SHA-256 suffit ici — le jeton est déjà 256 bits
* d'aléa, il n'y a pas de dictionnaire à ralentir, contrairement à un mot de
* passe.
*/
export function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
export function generateToken(): string {
return randomBytes(32).toString('base64url');
}
/** Comparaison à temps constant, pour ne pas fuiter par la durée. */
export function safeEqual(a: string, b: string): boolean {
const bufferA = Buffer.from(a);
const bufferB = Buffer.from(b);
if (bufferA.length !== bufferB.length) return false;
return timingSafeEqual(bufferA, bufferB);
}
+75
View File
@@ -0,0 +1,75 @@
import { prisma } from '@/server/db';
import { env } from '@/lib/env';
/**
* Vérifie que la base est configurée pour que la RLS s'applique réellement.
*
* **Un superutilisateur PostgreSQL contourne toute politique de sécurité au
* niveau ligne, y compris avec FORCE.** Connecter l'application avec un tel
* compte désactive silencieusement la seconde couche d'isolation : les
* requêtes continuent de fonctionner, les tests applicatifs passent, et rien
* n'indique que la protection a disparu.
*
* C'est exactement le genre de mauvaise configuration qu'on ne découvre qu'en
* lisant les données d'un autre client. D'où ce contrôle au démarrage.
*/
export interface TenantGuardResult {
ok: boolean;
isSuperuser: boolean;
bypassRls: boolean;
message?: string;
}
export async function checkTenantIsolation(): Promise<TenantGuardResult> {
const rows = await prisma.$queryRaw<
Array<{ usesuper: boolean; usebypassrls: boolean }>
>`SELECT usesuper, usebypassrls FROM pg_user WHERE usename = current_user`;
const row = rows[0];
if (!row) {
return {
ok: false,
isSuperuser: false,
bypassRls: false,
message: 'Impossible de déterminer les privilèges du compte de connexion.',
};
}
const ok = !row.usesuper && !row.usebypassrls;
return {
ok,
isSuperuser: row.usesuper,
bypassRls: row.usebypassrls,
...(ok
? {}
: {
message:
'Le compte de connexion contourne la row-level security ' +
`(superutilisateur : ${row.usesuper}, bypassrls : ${row.usebypassrls}). ` +
"L'isolation multi-tenant ne repose plus que sur la couche applicative. " +
'Créer un rôle dédié : CREATE ROLE planflow_app LOGIN NOSUPERUSER NOBYPASSRLS.',
}),
};
}
/**
* Refuse de démarrer en production sur une base mal configurée.
*
* En développement, un avertissement suffit : la base locale est souvent créée
* avec un compte administrateur, et bloquer rendrait la mise en route pénible
* sans rien protéger.
*/
export async function assertTenantIsolation(): Promise<void> {
const result = await checkTenantIsolation();
if (result.ok) return;
const message = `Isolation multi-tenant affaiblie — ${result.message}`;
if (env.NODE_ENV === 'production') {
throw new Error(message);
}
console.warn(`⚠ ${message}`);
}
+138
View File
@@ -0,0 +1,138 @@
import type { Prisma, PrismaClient } from '@prisma/client';
import { prisma } from '@/server/db';
/**
* Isolation multi-tenant — PLAN.md §3.1.
*
* Deux couches indépendantes :
*
* 1. `SET LOCAL app.account_id` active les politiques RLS de PostgreSQL, qui
* filtrent en base quoi qu'il arrive côté application.
* 2. L'extension Prisma injecte `accountId` dans chaque `where` et chaque
* `create`, pour que le filtre soit aussi visible dans le plan de requête
* et que l'erreur, quand il y en a une, soit lisible.
*
* La première seule laisserait passer des requêtes qui ramènent zéro ligne sans
* dire pourquoi ; la seconde seule tomberait avec le premier `$queryRaw`.
*/
/** Tables portant une colonne `accountId`. */
const SCOPED_MODELS = new Set([
'Location',
'Team',
'Membership',
'MembershipScope',
'Invitation',
'Role',
'AuditLog',
'RetentionPolicy',
'FeatureFlag',
]);
const READ_OPERATIONS = new Set([
'findFirst',
'findFirstOrThrow',
'findMany',
'findUnique',
'findUniqueOrThrow',
'count',
'aggregate',
'groupBy',
'updateMany',
'deleteMany',
]);
const CREATE_OPERATIONS = new Set(['create', 'createMany', 'upsert']);
export type ScopedClient = Omit<
PrismaClient,
'$connect' | '$disconnect' | '$transaction' | '$extends'
>;
/**
* Un client étendu par compte. Mémorisé : `$extends` construit un proxy à
* chaque appel, et on en ferait un par requête HTTP sinon.
*/
const scopedClients = new Map<string, ReturnType<typeof buildScopedClient>>();
function scopedClientFor(accountId: string) {
const existing = scopedClients.get(accountId);
if (existing) return existing;
const client = buildScopedClient(accountId);
scopedClients.set(accountId, client);
return client;
}
function buildScopedClient(accountId: string) {
return prisma.$extends({
query: {
$allModels: {
async $allOperations({ model, operation, args, query }) {
if (!model || !SCOPED_MODELS.has(model)) {
return query(args);
}
const input = args as Record<string, unknown>;
if (READ_OPERATIONS.has(operation)) {
input.where = { ...(input.where as object), accountId };
}
if (CREATE_OPERATIONS.has(operation)) {
if (operation === 'createMany') {
const data = input.data;
input.data = Array.isArray(data)
? data.map((row) => ({ ...(row as object), accountId }))
: { ...(data as object), accountId };
} else if (operation === 'upsert') {
input.where = { ...(input.where as object), accountId };
input.create = { ...(input.create as object), accountId };
} else {
input.data = { ...(input.data as object), accountId };
}
}
return query(input);
},
},
},
});
}
/**
* Exécute `fn` dans une transaction portant le compte courant.
*
* Tout accès aux données d'un compte passe par ici. Le périmètre vient de la
* session serveur, jamais d'un paramètre client — c'est la règle qui rend
* l'isolation vérifiable plutôt que confiante.
*/
export async function withTenant<T>(
accountId: string,
fn: (db: ScopedClient) => Promise<T>,
): Promise<T> {
// La transaction est ouverte **depuis le client étendu**, pour que le `tx`
// qu'elle fournit porte l'extension et partage la connexion sur laquelle
// `set_config` est posé. L'inverse — étendre le `tx` — n'est pas possible :
// Prisma retire `$extends` du client de transaction.
return scopedClientFor(accountId).$transaction(async (tx) => {
// `set_config(..., true)` est local à la transaction, donc remis à zéro
// automatiquement. Une connexion rendue au pool ne garde pas le compte
// précédent — ce serait la pire fuite possible.
await tx.$executeRaw`SELECT set_config('app.account_id', ${accountId}, true)`;
return fn(tx as unknown as ScopedClient) as Promise<T>;
});
}
/**
* Accès sans portée de compte, pour les opérations qui précèdent l'identité :
* authentification par e-mail, acceptation d'invitation, migrations.
*
* Volontairement nommé pour se voir en revue de code.
*/
export function unscoped(): PrismaClient {
return prisma;
}
export type { Prisma };
+175
View File
@@ -0,0 +1,175 @@
import { Client } from 'pg';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
/**
* Isolation multi-tenant au niveau base — PLAN.md §3.1.
*
* Ces tests parlent directement à PostgreSQL, sans passer par Prisma : ils
* vérifient que l'isolation tient **même si la couche applicative est
* contournée**. Un test qui passerait par l'extension Prisma ne prouverait que
* l'extension.
*
* Point d'attention : un **superutilisateur contourne la RLS**, y compris avec
* FORCE. Ces tests créent donc un rôle restreint, ce qui est aussi la
* configuration attendue en production (voir README).
*/
const APP_ROLE = 'planflow_rls_test';
const APP_PASSWORD = 'rls-test-only';
const adminUrl = process.env.DATABASE_URL ?? '';
const enabled = adminUrl.length > 0;
let admin: Client;
let app: Client;
let accountA: string;
let accountB: string;
const describeIfDb = enabled ? describe : describe.skip;
describeIfDb('row-level security', () => {
beforeAll(async () => {
admin = new Client({ connectionString: adminUrl });
await admin.connect();
// Rôle applicatif sans privilège particulier : c'est la seule façon de
// voir les politiques s'appliquer.
await admin.query(`DROP OWNED BY ${APP_ROLE}`).catch(() => undefined);
await admin.query(`DROP ROLE IF EXISTS ${APP_ROLE}`).catch(() => undefined);
await admin.query(
`CREATE ROLE ${APP_ROLE} LOGIN PASSWORD '${APP_PASSWORD}' NOSUPERUSER NOBYPASSRLS`,
);
await admin.query(
`GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO ${APP_ROLE}`,
);
await admin.query(
`GRANT USAGE ON SCHEMA public TO ${APP_ROLE}`,
);
accountA = `rls-a-${Date.now()}`;
accountB = `rls-b-${Date.now()}`;
for (const id of [accountA, accountB]) {
await admin.query(
'INSERT INTO "Account" (id, name, "createdAt") VALUES ($1, $2, now())',
[id, `Compte ${id}`],
);
await admin.query(
'INSERT INTO "Location" (id, "accountId", name, timezone, "employerContributionRate") VALUES ($1, $2, $3, $4, $5)',
[`${id}-loc`, id, `Établissement ${id}`, 'Europe/Paris', 0],
);
}
const url = new URL(adminUrl);
url.username = APP_ROLE;
url.password = APP_PASSWORD;
app = new Client({ connectionString: url.toString() });
await app.connect();
}, 30_000);
afterAll(async () => {
await app?.end().catch(() => undefined);
for (const id of [accountA, accountB]) {
await admin
?.query('DELETE FROM "Account" WHERE id = $1', [id])
.catch(() => undefined);
}
await admin?.query(`DROP OWNED BY ${APP_ROLE}`).catch(() => undefined);
await admin?.query(`DROP ROLE IF EXISTS ${APP_ROLE}`).catch(() => undefined);
await admin?.end().catch(() => undefined);
}, 30_000);
it('le rôle applicatif n’est pas superutilisateur', async () => {
// Sinon tous les tests suivants passeraient sans rien prouver.
const { rows } = await app.query(
'SELECT usesuper FROM pg_user WHERE usename = current_user',
);
expect(rows[0]?.usesuper).toBe(false);
});
it('ne renvoie rien tant que le compte courant n’est pas posé', async () => {
const { rows } = await app.query('SELECT count(*)::int AS n FROM "Location"');
expect(rows[0]?.n).toBe(0);
});
it('ne renvoie que les lignes du compte courant', async () => {
await app.query('BEGIN');
await app.query("SELECT set_config('app.account_id', $1, true)", [accountA]);
const { rows } = await app.query(
'SELECT "accountId" FROM "Location" ORDER BY id',
);
await app.query('COMMIT');
expect(rows).toHaveLength(1);
expect(rows[0]?.accountId).toBe(accountA);
});
it('refuse d’écrire pour un autre compte', async () => {
await app.query('BEGIN');
await app.query("SELECT set_config('app.account_id', $1, true)", [accountA]);
await expect(
app.query(
'INSERT INTO "Location" (id, "accountId", name, timezone, "employerContributionRate") VALUES ($1, $2, $3, $4, $5)',
[`intrus-${Date.now()}`, accountB, 'Intrusion', 'Europe/Paris', 0],
),
).rejects.toThrow(/row-level security/i);
await app.query('ROLLBACK');
});
it('remet le compte à zéro à la fin de la transaction', async () => {
// `set_config(..., true)` est local. Sans cela, une connexion rendue au
// pool garderait le compte du client précédent — la pire fuite possible.
await app.query('BEGIN');
await app.query("SELECT set_config('app.account_id', $1, true)", [accountA]);
await app.query('COMMIT');
const { rows } = await app.query('SELECT count(*)::int AS n FROM "Location"');
expect(rows[0]?.n).toBe(0);
});
});
describeIfDb('immutabilité du journal d’audit', () => {
let client: Client;
let accountId: string;
let entryId: string;
beforeAll(async () => {
client = new Client({ connectionString: adminUrl });
await client.connect();
accountId = `audit-${Date.now()}`;
entryId = `entry-${Date.now()}`;
await client.query(
'INSERT INTO "Account" (id, name, "createdAt") VALUES ($1, $2, now())',
[accountId, 'Compte audit'],
);
await client.query(
'INSERT INTO "AuditLog" (id, "accountId", action, "entityType", "entityId", "occurredAt") VALUES ($1, $2, $3, $4, $5, now())',
[entryId, accountId, 'test.action', 'Test', 'x'],
);
}, 30_000);
afterAll(async () => {
await client
?.query('DELETE FROM "Account" WHERE id = $1', [accountId])
.catch(() => undefined);
await client?.end().catch(() => undefined);
}, 30_000);
it('refuse la modification d’une entrée', async () => {
await expect(
client.query('UPDATE "AuditLog" SET action = $1 WHERE id = $2', [
'falsifie',
entryId,
]),
).rejects.toThrow(/append-only/i);
});
it('refuse la suppression d’une entrée', async () => {
// Même un superutilisateur est arrêté : le trigger ne dépend pas des
// privilèges, contrairement à la RLS.
await expect(
client.query('DELETE FROM "AuditLog" WHERE id = $1', [entryId]),
).rejects.toThrow(/append-only/i);
});
});
+6 -1
View File
@@ -1,5 +1,7 @@
import { fileURLToPath } from 'node:url';
import 'dotenv/config';
import { defineConfig } from 'vitest/config';
export default defineConfig({
@@ -10,7 +12,10 @@ export default defineConfig({
},
test: {
environment: 'node',
include: ['tests/unit/**/*.test.ts'],
// Les tests d'intégration parlent à une vraie base : ils se sautent
// d'eux-mêmes quand DATABASE_URL est absente.
include: ['tests/unit/**/*.test.ts', 'tests/integration/**/*.test.ts'],
testTimeout: 30_000,
globals: false,
},
});