From 11763142d592f957c4c8bb71a87af985378cff1f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 22:39:33 +0000 Subject: [PATCH 1/2] WP-01: tenancy, identity and capability authorization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv --- README.md | 20 +- .../migration.sql | 314 ++++++++++++++++++ .../migration.sql | 80 +++++ prisma/migrations/migration_lock.toml | 2 +- prisma/schema.prisma | 284 +++++++++++++++- prisma/seed.ts | 220 ++++++++++++ src/app/api/sante/route.ts | 13 +- src/domain/access/authorize.ts | 121 +++++++ src/domain/access/permissions.ts | 206 ++++++++++++ src/server/audit.ts | 85 +++++ src/server/auth/session.ts | 217 ++++++++++++ src/server/crypto.ts | 89 +++++ src/server/db-guard.ts | 75 +++++ src/server/tenant.ts | 138 ++++++++ tests/integration/rls.test.ts | 175 ++++++++++ vitest.config.ts | 7 +- 16 files changed, 2037 insertions(+), 9 deletions(-) create mode 100644 prisma/migrations/20260807223246_tenancy_identity_authorization/migration.sql create mode 100644 prisma/migrations/20260807230000_rls_and_audit_immutability/migration.sql create mode 100644 prisma/seed.ts create mode 100644 src/domain/access/authorize.ts create mode 100644 src/domain/access/permissions.ts create mode 100644 src/server/audit.ts create mode 100644 src/server/auth/session.ts create mode 100644 src/server/crypto.ts create mode 100644 src/server/db-guard.ts create mode 100644 src/server/tenant.ts create mode 100644 tests/integration/rls.test.ts diff --git a/README.md b/README.md index de1f67a..f878b16 100644 --- a/README.md +++ b/README.md @@ -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 : diff --git a/prisma/migrations/20260807223246_tenancy_identity_authorization/migration.sql b/prisma/migrations/20260807223246_tenancy_identity_authorization/migration.sql new file mode 100644 index 0000000..879f0dd --- /dev/null +++ b/prisma/migrations/20260807223246_tenancy_identity_authorization/migration.sql @@ -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; diff --git a/prisma/migrations/20260807230000_rls_and_audit_immutability/migration.sql b/prisma/migrations/20260807230000_rls_and_audit_immutability/migration.sql new file mode 100644 index 0000000..37e6f6c --- /dev/null +++ b/prisma/migrations/20260807230000_rls_and_audit_immutability/migration.sql @@ -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. diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml index ebb071d..044d57c 100644 --- a/prisma/migrations/migration_lock.toml +++ b/prisma/migrations/migration_lock.toml @@ -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" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 66c961c..61a918c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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]) +} diff --git a/prisma/seed.ts b/prisma/seed.ts new file mode 100644 index 0000000..0987641 --- /dev/null +++ b/prisma/seed.ts @@ -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(); + 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()); diff --git a/src/app/api/sante/route.ts b/src/app/api/sante/route.ts index c97ab7a..230564e 100644 --- a/src/app/api/sante/route.ts +++ b/src/app/api/sante/route.ts @@ -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' }, diff --git a/src/domain/access/authorize.ts b/src/domain/access/authorize.ts new file mode 100644 index 0000000..63ad100 --- /dev/null +++ b/src/domain/access/authorize.ts @@ -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; + 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); +} diff --git a/src/domain/access/permissions.ts b/src/domain/access/permissions.ts new file mode 100644 index 0000000..b61189d --- /dev/null +++ b/src/domain/access/permissions.ts @@ -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; + +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 = { + 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], +}; diff --git a/src/server/audit.ts b/src/server/audit.ts new file mode 100644 index 0000000..e1f2a21 --- /dev/null +++ b/src/server/audit.ts @@ -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 { + 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>( + value: T, +): Record { + const output: Record = {}; + for (const [key, entry] of Object.entries(value)) { + output[key] = REDACTED.has(key) ? '[masqué]' : entry; + } + return output; +} diff --git a/src/server/auth/session.ts b/src/server/auth/session.ts new file mode 100644 index 0000000..829d9c4 --- /dev/null +++ b/src/server/auth/session.ts @@ -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 { + return hash(password, ARGON2_OPTIONS); +} + +export async function verifyPassword( + passwordHash: string, + password: string, +): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; diff --git a/src/server/crypto.ts b/src/server/crypto.ts new file mode 100644 index 0000000..232db08 --- /dev/null +++ b/src/server/crypto.ts @@ -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); +} diff --git a/src/server/db-guard.ts b/src/server/db-guard.ts new file mode 100644 index 0000000..934baf4 --- /dev/null +++ b/src/server/db-guard.ts @@ -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 { + 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 { + 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}`); +} diff --git a/src/server/tenant.ts b/src/server/tenant.ts new file mode 100644 index 0000000..b2c7b98 --- /dev/null +++ b/src/server/tenant.ts @@ -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>(); + +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; + + 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( + accountId: string, + fn: (db: ScopedClient) => Promise, +): Promise { + // 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; + }); +} + +/** + * 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 }; diff --git a/tests/integration/rls.test.ts b/tests/integration/rls.test.ts new file mode 100644 index 0000000..a1cc120 --- /dev/null +++ b/tests/integration/rls.test.ts @@ -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); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index f05bfc1..5bd4606 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -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, }, }); From 2c0e9e8dd473cbe44e081fd13083a9beb8a9b3e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 22:45:24 +0000 Subject: [PATCH 2/2] Wire authentication into the application MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the sign-in screen, sign-out, and a server-side guard on every application route. The guard lives in the layout rather than the proxy because the proxy cannot query the database to check whether a session was revoked — and revocation is the reason sessions are stored there. Sign-in returns one message for an unknown account and for a wrong password, and verifies a dummy hash when the account does not exist, so neither the wording nor the timing enumerates staff addresses. An end-to-end test compares the two messages rather than trusting the code to keep them aligned. The shell now shows the signed-in person and their role from the database instead of hardcoded initials. Playwright signs in once in a setup project and shares the cookie; argon2 is deliberately slow, and logging in per test would also drive the shared failed-attempt counter toward a lockout. The seed resets that counter so repeated local runs cannot lock the demo account. Two test locators had to be scoped to the form: Next's route announcer carries role="alert" and an empty string, which silently satisfied the assertion. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv --- .github/workflows/ci.yml | 3 ++ playwright.config.ts | 40 ++++++++++---- prisma/seed.ts | 5 +- src/app/(app)/layout.tsx | 26 +++++++++- src/app/(auth)/connexion/SignInForm.tsx | 66 +++++++++++++++++++++++ src/app/(auth)/connexion/page.tsx | 36 +++++++++++++ src/components/shell/AppShell.tsx | 25 +++++++-- src/server/auth/actions.ts | 69 +++++++++++++++++++++++++ src/server/auth/session.ts | 26 +++++++++- tests/e2e/auth.setup.ts | 19 +++++++ tests/e2e/auth.spec.ts | 58 +++++++++++++++++++++ tests/e2e/storage.ts | 7 +++ 12 files changed, 361 insertions(+), 19 deletions(-) create mode 100644 src/app/(auth)/connexion/SignInForm.tsx create mode 100644 src/app/(auth)/connexion/page.tsx create mode 100644 src/server/auth/actions.ts create mode 100644 tests/e2e/auth.setup.ts create mode 100644 tests/e2e/auth.spec.ts create mode 100644 tests/e2e/storage.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb57651..aeac85a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,6 +50,9 @@ jobs: - name: Appliquer les migrations run: pnpm db:deploy + - name: Installer le jeu de données + run: pnpm db:seed + - run: pnpm typecheck - run: pnpm lint diff --git a/playwright.config.ts b/playwright.config.ts index 3758a96..8cc6123 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,5 +1,17 @@ import { defineConfig, devices } from '@playwright/test'; +import { STORAGE_STATE } from './tests/e2e/storage'; + +/** + * Certains environnements fournissent déjà un Chromium dont la révision ne + * correspond pas à celle qu'attend cette version de Playwright. + * PLAYWRIGHT_CHROMIUM_PATH permet de le réutiliser plutôt que d'en télécharger + * un second. + */ +const chromiumOverride = process.env.PLAYWRIGHT_CHROMIUM_PATH + ? { launchOptions: { executablePath: process.env.PLAYWRIGHT_CHROMIUM_PATH } } + : {}; + const PORT = Number(process.env.E2E_PORT ?? 3100); const baseURL = `http://127.0.0.1:${PORT}`; @@ -18,21 +30,27 @@ export default defineConfig({ }, projects: [ + // Ouvre une session et enregistre le cookie ; les autres projets + // le réutilisent. + { + name: 'setup', + testMatch: /auth\.setup\.ts/, + use: { ...devices['Desktop Chrome'], ...chromiumOverride }, + }, + // Parcours d'authentification : doit partir d'un navigateur vierge. + { + name: 'anonyme', + testMatch: /auth\.spec\.ts/, + use: { ...devices['Desktop Chrome'], ...chromiumOverride }, + }, { name: 'chromium', + testIgnore: /auth\.(setup|spec)\.ts/, + dependencies: ['setup'], use: { ...devices['Desktop Chrome'], - // Certains environnements fournissent déjà un Chromium dont la révision - // ne correspond pas à celle qu'attend cette version de Playwright. - // PLAYWRIGHT_CHROMIUM_PATH permet de le réutiliser plutôt que d'en - // télécharger un second. - ...(process.env.PLAYWRIGHT_CHROMIUM_PATH - ? { - launchOptions: { - executablePath: process.env.PLAYWRIGHT_CHROMIUM_PATH, - }, - } - : {}), + ...chromiumOverride, + storageState: STORAGE_STATE, }, }, ], diff --git a/prisma/seed.ts b/prisma/seed.ts index 0987641..614fc46 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -134,7 +134,10 @@ async function main() { for (const [index, person] of people.entries()) { const user = await prisma.user.upsert({ where: { email: person.email }, - update: {}, + // Remet le compteur d'échecs à zéro : sans cela, des exécutions + // répétées des tests finissent par verrouiller le compte au bout de + // huit tentatives, et l'échec suivant est incompréhensible. + update: { failedAttempts: 0, lockedUntil: null, passwordHash }, create: { email: person.email, firstName: person.firstName, diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index 91d9b4a..e7aaef8 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -1,7 +1,29 @@ +import { redirect } from 'next/navigation'; import type { ReactNode } from 'react'; import { AppShell } from '@/components/shell/AppShell'; +import { currentSession } from '@/server/auth/session'; -export default function AppLayout({ children }: { children: ReactNode }) { - return {children}; +/** + * Toutes les routes applicatives passent par ici. + * + * Le contrôle est fait côté serveur, dans le layout, et non dans le proxy : le + * proxy ne peut pas interroger la base pour vérifier qu'une session n'a pas été + * révoquée, et c'est précisément la révocation qui justifie de tenir les + * sessions en base (matrice n° 23). + */ +export default async function AppLayout({ children }: { children: ReactNode }) { + const session = await currentSession(); + if (!session) redirect('/connexion'); + + return ( + + {children} + + ); } diff --git a/src/app/(auth)/connexion/SignInForm.tsx b/src/app/(auth)/connexion/SignInForm.tsx new file mode 100644 index 0000000..633ae8e --- /dev/null +++ b/src/app/(auth)/connexion/SignInForm.tsx @@ -0,0 +1,66 @@ +'use client'; + +import { useActionState } from 'react'; +import { useFormStatus } from 'react-dom'; + +import { Button } from '@/components/ui/Button'; +import { signInAction, type SignInState } from '@/server/auth/actions'; + +function SubmitButton() { + const { pending } = useFormStatus(); + return ( + + ); +} + +export function SignInForm() { + const [state, formAction] = useActionState( + signInAction, + {}, + ); + + return ( +
+ + + + + {state.error ? ( + // `role="alert"` : l'échec doit être annoncé, pas seulement coloré. +

+ {state.error} +

+ ) : null} + + + + ); +} diff --git a/src/app/(auth)/connexion/page.tsx b/src/app/(auth)/connexion/page.tsx new file mode 100644 index 0000000..966fdea --- /dev/null +++ b/src/app/(auth)/connexion/page.tsx @@ -0,0 +1,36 @@ +import { redirect } from 'next/navigation'; + +import { SignInForm } from '@/app/(auth)/connexion/SignInForm'; +import { currentSession } from '@/server/auth/session'; + +export const metadata = { title: 'Connexion · PlanFlow' }; +export const dynamic = 'force-dynamic'; + +export default async function ConnexionPage() { + if (await currentSession()) redirect('/'); + + return ( +
+
+
+ + + PlanFlow + +
+ +
+

Connexion

+

+ Accédez à vos plannings et à votre équipe. +

+ +
+ +

+ Instance auto-hébergée · Maison Rivage +

+
+
+ ); +} diff --git a/src/components/shell/AppShell.tsx b/src/components/shell/AppShell.tsx index 3f269c4..a06f9d7 100644 --- a/src/components/shell/AppShell.tsx +++ b/src/components/shell/AppShell.tsx @@ -8,6 +8,7 @@ import { ThemeToggle } from '@/components/shell/ThemeToggle'; import { Badge } from '@/components/ui/Badge'; import { Button } from '@/components/ui/Button'; import { cx } from '@/lib/cx'; +import { signOutAction } from '@/server/auth/actions'; import { isActive, NAVIGATION, @@ -19,9 +20,19 @@ import { export interface AppShellProps { children: ReactNode; + initials: string; + fullName: string; + roleName: string; + accountName: string; } -export function AppShell({ children }: AppShellProps) { +export function AppShell({ + children, + initials, + fullName, + roleName, + accountName, +}: AppShellProps) { const pathname = usePathname(); const section = sectionForPath(pathname); @@ -67,11 +78,17 @@ export function AppShell({ children }: AppShellProps) { - MR + {initials} + {`${fullName}, ${roleName}`} +
+ +
@@ -86,7 +103,7 @@ export function AppShell({ children }: AppShellProps) {

Instance auto-hébergée
- Maison Rivage · 34 établissements + {accountName}

diff --git a/src/server/auth/actions.ts b/src/server/auth/actions.ts new file mode 100644 index 0000000..4bb5a9f --- /dev/null +++ b/src/server/auth/actions.ts @@ -0,0 +1,69 @@ +'use server'; + +import { cookies, headers } from 'next/headers'; +import { redirect } from 'next/navigation'; +import { z } from 'zod'; + +import { + SESSION_COOKIE, + SESSION_COOKIE_OPTIONS, + signIn, + signOut, +} from '@/server/auth/session'; + +const credentials = z.object({ + email: z.email('Adresse électronique invalide'), + password: z.string().min(1, 'Mot de passe requis'), +}); + +export interface SignInState { + error?: string; +} + +export async function signInAction( + _previous: SignInState, + formData: FormData, +): Promise { + const parsed = credentials.safeParse({ + email: formData.get('email'), + password: formData.get('password'), + }); + + if (!parsed.success) { + return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' }; + } + + const requestHeaders = await headers(); + const result = await signIn({ + email: parsed.data.email, + password: parsed.data.password, + ip: requestHeaders.get('x-forwarded-for'), + userAgent: requestHeaders.get('user-agent'), + }); + + if (!result.ok) { + // Un seul message pour « compte inconnu » et « mot de passe faux » : les + // distinguer permettrait d'énumérer les adresses du personnel. + return { + error: + result.reason === 'locked' + ? 'Compte temporairement verrouillé après plusieurs échecs. Réessayez dans quelques minutes.' + : 'Identifiants incorrects.', + }; + } + + (await cookies()).set(SESSION_COOKIE, result.token, { + ...SESSION_COOKIE_OPTIONS, + expires: result.expiresAt, + }); + + redirect('/'); +} + +export async function signOutAction(): Promise { + const store = await cookies(); + const token = store.get(SESSION_COOKIE)?.value; + if (token) await signOut(token); + store.delete(SESSION_COOKIE); + redirect('/connexion'); +} diff --git a/src/server/auth/session.ts b/src/server/auth/session.ts index 829d9c4..f8619c5 100644 --- a/src/server/auth/session.ts +++ b/src/server/auth/session.ts @@ -138,9 +138,19 @@ export async function revokeAllSessions( return result.count; } +export interface SessionUser { + firstName: string; + lastName: string; + email: string; + initials: string; +} + export interface SessionContext { actor: Actor; sessionId: string; + user: SessionUser; + accountName: string; + roleName: string; } /** @@ -162,6 +172,7 @@ export async function resolveSession( memberships: { where: { status: 'ACTIVE', archivedAt: null }, include: { + account: { select: { name: true } }, role: { include: { permissions: { include: { permission: true } } } }, scopes: true, }, @@ -199,7 +210,20 @@ export async function resolveSession( scope, }; - return { actor, sessionId: session.id }; + const { firstName, lastName, email } = session.user; + + return { + actor, + sessionId: session.id, + user: { + firstName, + lastName, + email, + initials: `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase(), + }, + accountName: membership.account.name, + roleName: membership.role.name, + }; } /** Session courante depuis le cookie, ou `null`. */ diff --git a/tests/e2e/auth.setup.ts b/tests/e2e/auth.setup.ts new file mode 100644 index 0000000..f64382b --- /dev/null +++ b/tests/e2e/auth.setup.ts @@ -0,0 +1,19 @@ +import { test as setup, expect } from '@playwright/test'; + +import { STORAGE_STATE } from './storage'; + +/** + * Ouvre une session une fois et enregistre le cookie pour les autres tests. + * + * Chaque test se connecterait sinon, ce qui coûterait un argon2 par test — + * volontairement lent — et ferait grimper le compteur d'échecs partagé. + */ +setup('authentifie la direction', async ({ page }) => { + await page.goto('/connexion'); + await page.getByLabel('Adresse électronique').fill('direction@example.test'); + await page.getByLabel('Mot de passe').fill('planflow-demo-2026'); + await page.getByRole('button', { name: 'Se connecter' }).click(); + + await expect(page.getByRole('heading', { name: 'Aperçu RH' })).toBeVisible(); + await page.context().storageState({ path: STORAGE_STATE }); +}); diff --git a/tests/e2e/auth.spec.ts b/tests/e2e/auth.spec.ts new file mode 100644 index 0000000..7b3e790 --- /dev/null +++ b/tests/e2e/auth.spec.ts @@ -0,0 +1,58 @@ +import { expect, test } from '@playwright/test'; + +const EMAIL = 'direction@example.test'; +const PASSWORD = 'planflow-demo-2026'; + +test('une route applicative redirige vers la connexion', async ({ page }) => { + await page.goto('/planning/semaine'); + await expect(page).toHaveURL(/\/connexion$/); + await expect(page.getByRole('heading', { name: 'Connexion' })).toBeVisible(); +}); + +test('un mot de passe faux ne dit pas si le compte existe', async ({ page }) => { + async function attempt(email: string): Promise { + // Une page neuve par tentative : le message précédent resterait sinon à + // l'écran et le test comparerait deux fois le même. + await page.goto('/connexion'); + await page.getByLabel('Adresse électronique').fill(email); + await page.getByLabel('Mot de passe').fill('mauvais-mot-de-passe'); + await page.getByRole('button', { name: 'Se connecter' }).click(); + + // Restreint au formulaire : Next pose un annonceur de route qui porte lui + // aussi role="alert" et qui est vide. + const alert = page.locator('form').getByRole('alert'); + await expect(alert).toBeVisible(); + return (await alert.textContent()) ?? ''; + } + + const knownAccount = await attempt(EMAIL); + const unknownAccount = await attempt('inconnu@example.test'); + + // Un message différent laisserait énumérer les adresses du personnel. + expect(unknownAccount).toBe(knownAccount); + expect(knownAccount).toContain('Identifiants incorrects'); +}); + +test('connexion, navigation, puis déconnexion', async ({ page }) => { + await page.goto('/connexion'); + await page.getByLabel('Adresse électronique').fill(EMAIL); + await page.getByLabel('Mot de passe').fill(PASSWORD); + await page.getByRole('button', { name: 'Se connecter' }).click(); + + await expect(page.getByRole('heading', { name: 'Aperçu RH' })).toBeVisible(); + // L'identité affichée vient de la base, pas d'un libellé en dur. + await expect(page.getByTitle(/Camille Ferrand/)).toBeVisible(); + + await page.getByRole('link', { name: 'Plannings' }).click(); + await expect( + page.getByRole('heading', { name: /Planning · semaine 33/ }), + ).toBeVisible(); + + await page.getByRole('button', { name: 'Déconnexion' }).click(); + await expect(page).toHaveURL(/\/connexion$/); + + // La session est révoquée en base : revenir en arrière ne doit pas rouvrir + // l'application. + await page.goto('/equipe'); + await expect(page).toHaveURL(/\/connexion$/); +}); diff --git a/tests/e2e/storage.ts b/tests/e2e/storage.ts new file mode 100644 index 0000000..f356b27 --- /dev/null +++ b/tests/e2e/storage.ts @@ -0,0 +1,7 @@ +/** + * Emplacement de l'état de session partagé entre les tests. + * + * Dans son propre module : le fichier de configuration Playwright ne peut pas + * importer un fichier qui appelle `test()`. + */ +export const STORAGE_STATE = 'test-results/.auth/direction.json';