diff --git a/.env.example b/.env.example index a7d5896..df446d8 100644 --- a/.env.example +++ b/.env.example @@ -2,9 +2,23 @@ # --- Base de données -------------------------------------------------------- POSTGRES_USER=planflow +# Une valeur par défaut existe dans docker-compose.yml : la base n'étant jamais +# publiée ni attachée au réseau du reverse-proxy, ce mot de passe protège d'un +# conteneur voisin, pas d'Internet. Le changer reste recommandé. POSTGRES_PASSWORD=change-me POSTGRES_DB=planflow +# Compte de connexion de l'application. Distinct du superutilisateur +# d'amorçage : un superutilisateur contourne la row-level security, y compris +# déclarée en FORCE, et l'isolation ne reposerait plus que sur la couche +# applicative. Créé au premier démarrage par docker/init-app-role.sh. +APP_DB_USER=planflow_app +APP_DB_PASSWORD=change-me-aussi + +# Connexion administrative, utilisée uniquement par le harnais de tests de bout +# en bout. Inutile en production. +# ADMIN_DATABASE_URL=postgresql://planflow@localhost:5432/planflow + # Utilisée par l'application et par Prisma. # En docker-compose l'hôte est `db` ; en développement local, `localhost`. DATABASE_URL=postgresql://planflow:change-me@localhost:5432/planflow @@ -21,5 +35,13 @@ DATABASE_URL=postgresql://planflow:change-me@localhost:5432/planflow ENCRYPTION_KEY= # --- Application ------------------------------------------------------------ -APP_URL=http://localhost:3000 -APP_PORT=3000 +APP_URL=http://localhost:9317 +# Port publié sur l'hôte. Peu courant à dessein : le service passe normalement +# par le reverse-proxy, qui joint le conteneur sur son port 3000. +APP_PORT=9317 + +# --- Pièces du dossier salarié ---------------------------------------------- +# Répertoire de stockage, chiffré au repos avec ENCRYPTION_KEY. +# En docker-compose il vaut /data/documents, sur un volume nommé — ne pas le +# changer sans déplacer le volume, les pièces déjà déposées y resteraient. +DOCUMENT_STORE=./storage/documents diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aeac85a..24390ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,14 @@ concurrency: env: # Test-only values. Real secrets never live in CI configuration. - DATABASE_URL: postgresql://planflow:planflow@localhost:5432/planflow_test + # Compte applicatif, **pas** le superutilisateur d'amorçage : un + # superutilisateur contourne la row-level security, et la suite passerait sans + # jamais éprouver la seconde couche d'isolation — présente en base, absente + # des faits. + DATABASE_URL: postgresql://planflow_app:planflow_app@localhost:5432/planflow_test + # Le harnais de tests fabrique des états que l'interface ne pose pas ; il lui + # faut une connexion qui traverse les comptes, comme un exploitant. + ADMIN_DATABASE_URL: postgresql://planflow:planflow@localhost:5432/planflow_test ENCRYPTION_KEY: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= APP_URL: http://127.0.0.1:3100 @@ -45,6 +52,16 @@ jobs: - run: pnpm install --frozen-lockfile + - name: Créer le rôle applicatif, soumis à la RLS + run: | + PGPASSWORD=planflow psql -h localhost -U planflow -d planflow_test -v ON_ERROR_STOP=1 <<'SQL' + CREATE ROLE planflow_app LOGIN PASSWORD 'planflow_app' + NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS; + ALTER DATABASE planflow_test OWNER TO planflow_app; + ALTER SCHEMA public OWNER TO planflow_app; + GRANT ALL ON SCHEMA public TO planflow_app; + SQL + - run: pnpm db:generate - name: Appliquer les migrations diff --git a/Dockerfile b/Dockerfile index 4e3557a..589cf96 100644 --- a/Dockerfile +++ b/Dockerfile @@ -35,6 +35,11 @@ COPY --from=build --chown=nextjs:nodejs /app/node_modules/prisma ./node_modules/ COPY --from=build --chown=nextjs:nodejs /app/node_modules/.bin/prisma ./node_modules/.bin/prisma COPY --from=build --chown=nextjs:nodejs /app/node_modules/@prisma ./node_modules/@prisma +# Créé dans l'image, et non laissé au montage : un volume nommé hérite du +# propriétaire du répertoire qu'il recouvre, et sans cela l'application — +# qui ne tourne pas en root — ne pourrait pas y écrire. +RUN mkdir -p /data/documents && chown -R nextjs:nodejs /data + USER nextjs EXPOSE 3000 ENV PORT=3000 HOSTNAME=0.0.0.0 diff --git a/README.md b/README.md index fbdbae5..a0696f3 100644 --- a/README.md +++ b/README.md @@ -50,11 +50,32 @@ devinent pas. ```bash cp .env.example .env -# Renseigner POSTGRES_PASSWORD et ENCRYPTION_KEY (voir ci-dessous) +# ENCRYPTION_KEY est la seule variable sans valeur par défaut : +echo "ENCRYPTION_KEY=$(openssl rand -base64 32)" >> .env docker compose up --build ``` -L'application écoute sur . Les migrations s'appliquent au démarrage du conteneur. +L'application écoute sur — port peu courant à dessein, le service étant censé passer par un reverse-proxy. Les migrations s'appliquent au démarrage du conteneur. + +La pile attend un réseau externe nommé `nginx_default`, celui du reverse-proxy. S'il n'existe pas encore : + +```bash +docker network create nginx_default +``` + +Seule l'application y est attachée. La base reste sur le réseau privé de la pile : l'exposer au réseau du proxy la rendrait joignable par tout ce qu'il héberge. + +### Avec Portainer + +Portainer ne lit pas de fichier `.env` : les variables se déclarent dans l'écran de la pile, section **Environment variables**. Une seule est obligatoire : + +| Variable | Valeur | +|---|---| +| `ENCRYPTION_KEY` | `openssl rand -base64 32` | + +Les autres ont une valeur par défaut utilisable telle quelle : `POSTGRES_PASSWORD`, `POSTGRES_USER`, `POSTGRES_DB`, `APP_PORT` (9317), `APP_URL`. + +Renseignez `APP_URL` avec l'adresse publique réelle, sans quoi les liens des messages — invitations comprises — pointeront vers `localhost` et personne ne pourra les suivre. ### En local @@ -70,14 +91,28 @@ pnpm dev ### Clé de chiffrement -`ENCRYPTION_KEY` chiffre au repos les colonnes sensibles exigées par le plan (§3.6) : NIR, IBAN, BIC. +`ENCRYPTION_KEY` chiffre au repos les colonnes sensibles exigées par le plan (§3.6) — NIR, IBAN, BIC — ainsi que les secrets de second facteur, le mot de passe du serveur d'envoi et **les pièces du dossier salarié**. ```bash openssl rand -base64 32 ``` +`ENCRYPTION_KEY` n'a **délibérément pas de valeur par défaut**, et n'en aura pas : une clé livrée avec l'image serait connue de quiconque lit ce dépôt, et le chiffrement ne protégerait plus rien. C'est la seule variable qui bloque le démarrage tant qu'elle manque. + Elle vit **hors de la base** : une sauvegarde volée ne doit pas suffire à lire ces colonnes. La perdre rend ces données irrécupérables — la sauvegarder séparément et documenter sa rotation. Elle chiffre également les secrets de second facteur et le mot de passe du serveur d'envoi. +### Sauvegardes + +Deux choses à sauvegarder **ensemble**, plus une à garder à part : + +| Quoi | Où | +|---|---| +| Base de données | volume `planflow_db-data` | +| Pièces du dossier salarié | volume `planflow_documents` | +| `ENCRYPTION_KEY` | **ailleurs**, jamais dans la même sauvegarde | + +Restaurer l'un sans l'autre rend un dossier amputé : les pièces référencées en base pointeraient vers des fichiers absents. Et sans la clé, le volume des documents est illisible — c'est précisément ce qu'on attend de lui si quelqu'un l'emporte. + ### Second facteur — accès de secours Les rôles qui lisent les rémunérations ou distribuent les droits doivent porter un second facteur (matrice n° 15) : tant qu'il n'est pas activé, l'application ne leur ouvre aucun écran. Chaque activation délivre dix codes de secours, affichés **une seule fois**. @@ -116,6 +151,12 @@ pnpm test:e2e # build, serveur standalone, tests de bout en bout **L'application ne doit pas se connecter en superutilisateur PostgreSQL.** +En docker-compose c'est déjà réglé : `docker/init-app-role.sh` crée au premier démarrage un rôle `planflow_app`, `NOSUPERUSER NOBYPASSRLS`, propriétaire de la base — il lui faut ce droit pour appliquer les migrations, et les politiques sont déclarées en `FORCE` précisément pour s'appliquer aussi au propriétaire. + +Le script ne s'exécute qu'à la **première** initialisation du volume. Sur une installation déjà en place, jouer le même SQL à la main puis basculer `DATABASE_URL` sur ce rôle. + +Au démarrage, l'application vérifie ses propres privilèges : elle refuse de se lancer si la base porte plus d'un compte, et se contente d'un avertissement visible dans les journaux s'il n'y en a qu'un — bloquer une installation mono-compte fermerait l'accès de l'entreprise à ses données pour un risque de fuite entre clients qui n'existe pas. + 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 diff --git a/docker-compose.yml b/docker-compose.yml index 7786d64..f53781e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,20 +6,36 @@ services: restart: unless-stopped environment: POSTGRES_USER: ${POSTGRES_USER:-planflow} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD est requis} + # Valeur par défaut assumée : la base n'est jamais publiée et reste sur le + # réseau privé de la pile, où seule l'application l'atteint. Ce mot de + # passe ne protège donc pas d'Internet — il protège d'un autre conteneur + # du même réseau. Le changer reste recommandé. + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-planflow-interne} POSTGRES_DB: ${POSTGRES_DB:-planflow} + # Transmis au script d'initialisation, qui crée le rôle de connexion de + # l'application — celui-ci ne doit surtout pas être le superutilisateur. + APP_DB_USER: ${APP_DB_USER:-planflow_app} + APP_DB_PASSWORD: ${APP_DB_PASSWORD:-planflow-app-interne} # Deterministic collation: ordering of employee names must not depend on # the host locale, or exports differ between machines. LANG: C.UTF-8 volumes: - db-data:/var/lib/postgresql/data + # Crée le rôle applicatif à la première initialisation du volume. + - ./docker/init-app-role.sh:/docker-entrypoint-initdb.d/10-init-app-role.sh:ro healthcheck: test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:-planflow} -d ${POSTGRES_DB:-planflow}'] interval: 5s timeout: 5s retries: 10 - # Not published by default: nothing outside the compose network needs the - # database, and an HR dataset should not be one firewall rule from the world. + # Volontairement absente du réseau externe : elle n'a besoin que de + # l'application. L'y attacher exposerait la base à tout ce que le + # reverse-proxy héberge, et le mot de passe par défaut deviendrait alors un + # vrai problème. + networks: + - interne + # Jamais publiée : rien hors de la pile n'a besoin de la base, et un jeu de + # données RH ne doit pas être à une règle de pare-feu du monde entier. expose: - '5432' @@ -32,11 +48,32 @@ services: condition: service_healthy environment: NODE_ENV: production - DATABASE_URL: postgresql://${POSTGRES_USER:-planflow}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-planflow} - ENCRYPTION_KEY: ${ENCRYPTION_KEY:?ENCRYPTION_KEY est requis — voir .env.example} - APP_URL: ${APP_URL:-http://localhost:3000} + # Le compte applicatif, **pas** le superutilisateur d'amorçage : un + # superutilisateur contourne la row-level security, y compris déclarée en + # FORCE, et l'isolation ne reposerait plus que sur la couche applicative. + DATABASE_URL: postgresql://${APP_DB_USER:-planflow_app}:${APP_DB_PASSWORD:-planflow-app-interne}@db:5432/${POSTGRES_DB:-planflow} + # Seule variable sans valeur par défaut, et il n'y en aura pas : elle + # chiffre le NIR, l'IBAN et les arrêts de travail. Une clé livrée avec + # l'image serait connue de tous et le chiffrement ne protégerait plus + # rien. La produire : openssl rand -base64 32 + ENCRYPTION_KEY: ${ENCRYPTION_KEY:?ENCRYPTION_KEY est requis. Produire une clé avec - openssl rand -base64 32} + APP_URL: ${APP_URL:-http://localhost:9317} + # Chemin **dans le volume**, pas dans l'image : les pièces du dossier + # salarié écrites dans la couche du conteneur disparaîtraient au premier + # redéploiement, et une pièce d'identité perdue ne se reconstitue pas. + DOCUMENT_STORE: /data/documents + volumes: + - documents:/data + networks: + # `interne` pour joindre la base, `nginx_default` pour être joignable par + # le reverse-proxy — qui atteint le conteneur sur son port 3000, sans + # passer par le port publié. + - interne + - nginx_default ports: - - '${APP_PORT:-3000}:3000' + # Port peu courant : le service est censé passer par le reverse-proxy, et + # un 3000 publié sur l'hôte se heurte à tout ce qui traîne. + - '${APP_PORT:-9317}:3000' healthcheck: test: [ @@ -50,5 +87,18 @@ services: retries: 5 start_period: 30s +networks: + # Réseau privé de la pile : base et application, rien d'autre. + interne: + + # Réseau du reverse-proxy, créé en dehors de cette pile. Le déclarer externe + # évite d'en fabriquer un second du même nom, sur lequel nginx ne verrait rien. + nginx_default: + external: true + volumes: db-data: + # Pièces du dossier salarié, chiffrées au repos avec ENCRYPTION_KEY. À + # sauvegarder avec la base : l'une sans l'autre restitue un dossier amputé, + # et sans la clé le volume est illisible. + documents: diff --git a/docker/init-app-role.sh b/docker/init-app-role.sh new file mode 100755 index 0000000..c7ec82f --- /dev/null +++ b/docker/init-app-role.sh @@ -0,0 +1,38 @@ +#!/bin/sh +set -eu + +# Rôle de connexion de l'application — README « Configuration de la base ». +# +# **Un superutilisateur PostgreSQL contourne toute politique de sécurité au +# niveau ligne, y compris déclarée en FORCE.** Laisser l'application se +# connecter avec le compte d'amorçage désactiverait silencieusement la seconde +# couche d'isolation : les requêtes fonctionnent, les tests passent, et rien +# n'indique que la protection a disparu. +# +# Le rôle créé ici est **propriétaire de la base** — il lui faut ce droit pour +# appliquer les migrations, qui créent tables, déclencheurs et politiques — mais +# ni superutilisateur ni BYPASSRLS. C'est précisément pourquoi les politiques +# sont déclarées en FORCE : elles s'appliquent aussi au propriétaire. +# +# Ce script ne s'exécute qu'à la **première** initialisation du volume. Pour une +# installation déjà en place, jouer le même SQL à la main (voir README). + +APP_ROLE="${APP_DB_USER:-planflow_app}" +APP_PASSWORD="${APP_DB_PASSWORD:-planflow-app-interne}" + +psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" </dev/null 2>&1 || true + +echo "postgres prêt sur 127.0.0.1:$PGPORT — DATABASE_URL=postgresql://$APP_ROLE:$APP_PASSWORD@127.0.0.1:$PGPORT/$DB" diff --git a/src/instrumentation.ts b/src/instrumentation.ts new file mode 100644 index 0000000..7d75ac4 --- /dev/null +++ b/src/instrumentation.ts @@ -0,0 +1,49 @@ +/** + * Contrôles au démarrage. + * + * Next.js appelle `register()` une fois, au lancement du serveur. + */ +export async function register(): Promise { + // `instrumentation` est aussi chargée par le runtime edge, où ni Prisma ni + // les sockets PostgreSQL n'existent. + if (process.env.NEXT_RUNTIME !== 'nodejs') return; + + const { checkTenantIsolation } = await import('@/server/db-guard'); + const { unscoped } = await import('@/server/tenant'); + + const isolation = await checkTenantIsolation().catch((error: unknown) => { + console.error('Contrôle d’isolation impossible :', error); + return null; + }); + + if (!isolation || isolation.ok) return; + + /* + * Le compte de connexion contourne la row-level security. + * + * Refuser de démarrer se justifie **dès qu'il y a plus d'un compte** : c'est + * alors une fuite entre clients qui devient possible, et aucune donnée ne + * vaut de tourner ainsi. Avec un compte unique — le cas d'une installation + * auto-hébergée ordinaire — il n'y a rien à faire fuir vers un voisin qui + * n'existe pas, et bloquer le démarrage fermerait l'accès de l'entreprise à + * ses propres données pour un risque théorique. + * + * Dans les deux cas l'avertissement est écrit à chaque démarrage, et + * `/api/sante` rapporte `tenantIsolation: weakened`. + */ + const accounts = await unscoped() + .account.count() + .catch(() => 0); + + const message = `Isolation multi-tenant affaiblie — ${isolation.message}`; + + if (accounts > 1) { + throw new Error( + `${message} La base porte ${accounts} comptes : démarrer ainsi exposerait les données de l'un à l'autre.`, + ); + } + + console.warn( + `\n⚠ ${message}\n Un seul compte en base : le démarrage se poursuit, mais corrigez la configuration.\n Voir README, « Configuration de la base — à ne pas rater ».\n`, + ); +} diff --git a/src/server/auth/session.ts b/src/server/auth/session.ts index eb01ec7..7171785 100644 --- a/src/server/auth/session.ts +++ b/src/server/auth/session.ts @@ -3,7 +3,7 @@ import { cookies } from 'next/headers'; import type { Actor, Scope } from '@/domain/access/authorize'; import { generateToken, hashToken } from '@/server/crypto'; -import { unscoped } from '@/server/tenant'; +import { unscoped, withTenant, withUser } from '@/server/tenant'; /** * Sessions serveur — PLAN.md §2 (écart assumé) et matrice n° 23. @@ -247,22 +247,11 @@ export async function resolveSession( ): Promise { const db = unscoped(); + // `Session` et `User` ne portent pas de compte : ce sont les seules tables + // lisibles avant de savoir de quel compte il s'agit. const session = await db.session.findUnique({ where: { tokenHash: hashToken(token) }, - include: { - user: { - include: { - memberships: { - where: { status: 'ACTIVE', archivedAt: null }, - include: { - account: { select: { name: true } }, - role: { include: { permissions: { include: { permission: true } } } }, - scopes: true, - }, - }, - }, - }, - }, + include: { user: true }, }); // Une session en attente du second facteur ne résout aucun acteur : elle ne @@ -277,45 +266,70 @@ export async function resolveSession( return null; } - const membership = session.user.memberships[0]; - if (!membership) return null; + // Amorçage : trouver le compte suppose de lire le rattachement, lequel est + // filtré par compte. La porte est étroite — seules les lignes dont cet + // utilisateur est titulaire — et ne sert qu'ici. + const membershipId = await withUser(session.userId, async (tx) => { + const own = await tx.membership.findFirst({ + where: { userId: session.userId, status: 'ACTIVE', archivedAt: null }, + select: { id: true, accountId: true }, + }); + return own; + }); - 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), - }; + if (!membershipId) return 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, - }; + // Tout le reste passe par le périmètre du compte, comme n'importe quelle + // lecture métier. + return withTenant(membershipId.accountId, async (tx) => { + const membership = await tx.membership.findUnique({ + where: { id: membershipId.id }, + include: { + account: { select: { name: true } }, + role: { include: { permissions: { include: { permission: true } } } }, + scopes: true, + }, + }); - const { firstName, lastName, email } = session.user; + if (!membership) return null; - return { - actor, - sessionId: session.id, - user: { - firstName, - lastName, - email, - initials: `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase(), - mfaEnrolled: session.user.mfaEnrolledAt !== null, - }, - accountName: membership.account.name, - roleName: membership.role.name, - }; + 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.userId, + roleKey: membership.role.key, + permissions: new Set( + membership.role.permissions.map((entry) => entry.permission.code), + ), + scope, + }; + + const { firstName, lastName, email } = session.user; + + return { + actor, + sessionId: session.id, + user: { + firstName, + lastName, + email, + initials: `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase(), + mfaEnrolled: session.user.mfaEnrolledAt !== null, + }, + accountName: membership.account.name, + roleName: membership.role.name, + }; + }); } /** Session courante depuis le cookie, ou `null`. */ diff --git a/src/server/tenant.ts b/src/server/tenant.ts index ac17671..07c2276 100644 --- a/src/server/tenant.ts +++ b/src/server/tenant.ts @@ -131,10 +131,36 @@ export async function withTenant( }); } +/** + * Exécute `fn` au nom d'un utilisateur, **avant** que son compte soit connu. + * + * Sert au seul amorçage de session : trouver à quel compte un utilisateur + * appartient suppose de lire son rattachement, et le rattachement est + * lui-même filtré par compte. Sans cette porte étroite, la seule issue serait + * de connecter l'application avec un rôle qui contourne la RLS — c'est-à-dire + * de la désactiver partout pour résoudre un cas d'amorçage. + * + * La politique associée n'ouvre que les lignes dont l'utilisateur est le + * titulaire (voir la migration `membership_self_read`). + */ +export async function withUser( + userId: string, + fn: (db: ScopedClient) => Promise, +): Promise { + return prisma.$transaction(async (tx) => { + await tx.$executeRaw`SELECT set_config('app.user_id', ${userId}, 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. * + * Ne donne accès qu'aux tables **sans** colonne `accountId` — sessions, + * utilisateurs, codes de secours. Les autres restent filtrées par la RLS, qui + * ne laisse rien passer hors d'une transaction scopée. + * * Volontairement nommé pour se voir en revue de code. */ export function unscoped(): PrismaClient { diff --git a/tests/e2e/support/db.ts b/tests/e2e/support/db.ts index dc126f0..a7fbea9 100644 --- a/tests/e2e/support/db.ts +++ b/tests/e2e/support/db.ts @@ -7,14 +7,25 @@ import { PrismaClient } from '@prisma/client'; * Certains états ne se posent pas par l'interface — retirer un second facteur * dont on a perdu le secret, par exemple. Les fabriquer ici garde la suite * rejouable sans ajouter au produit une porte qui n'aurait pas lieu d'exister. + * + * La connexion est **administrative**, distincte de celle de l'application : + * cette dernière est soumise à la row-level security et ne voit rien hors du + * compte courant, ce qui est précisément le but. Un harnais de test fabrique + * l'état comme le ferait un exploitant, depuis le serveur. */ let client: PrismaClient | null = null; +function adminUrl(): string { + const url = process.env.ADMIN_DATABASE_URL ?? process.env.DATABASE_URL; + if (!url) throw new Error('ADMIN_DATABASE_URL ou DATABASE_URL requis'); + return url; +} + function db(): PrismaClient { // Prisma 7 exige un adaptateur : le client applicatif n'est pas réutilisable // ici, il vit derrière `server-only`. client ??= new PrismaClient({ - adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }), + adapter: new PrismaPg({ connectionString: adminUrl() }), }); return client; } diff --git a/tests/integration/admin-db.ts b/tests/integration/admin-db.ts new file mode 100644 index 0000000..b4fc3fa --- /dev/null +++ b/tests/integration/admin-db.ts @@ -0,0 +1,27 @@ +import { PrismaPg } from '@prisma/adapter-pg'; +import { PrismaClient } from '@prisma/client'; + +/** + * Connexion administrative, pour la mise en place des tests d'intégration. + * + * L'application se connecte avec un rôle **soumis à la row-level security** : + * il ne voit rien hors du compte courant, et ne peut pas créer de compte de + * toutes pièces. C'est exactement ce qu'on attend de lui. + * + * Ces tests fabriquent pourtant des comptes entiers pour éprouver l'isolation + * entre eux. Ils passent donc par la connexion d'amorçage, comme le ferait un + * exploitant depuis le serveur — jamais par celle de l'application, dont la + * limitation est le sujet même de plusieurs de ces tests. + */ +export function adminDatabaseUrl(): string { + return process.env.ADMIN_DATABASE_URL ?? process.env.DATABASE_URL ?? ''; +} + +let client: PrismaClient | null = null; + +export function adminPrisma(): PrismaClient { + client ??= new PrismaClient({ + adapter: new PrismaPg({ connectionString: adminDatabaseUrl() }), + }); + return client; +} diff --git a/tests/integration/compliance.test.ts b/tests/integration/compliance.test.ts index 1530fd1..e030c4c 100644 --- a/tests/integration/compliance.test.ts +++ b/tests/integration/compliance.test.ts @@ -1,5 +1,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { adminPrisma } from './admin-db'; + import { IDCC_1517_PARAMETERS } from '@/domain/compliance/idcc1517'; import { zonedInstant } from '@/domain/planning/week'; @@ -13,7 +15,7 @@ import { zonedInstant } from '@/domain/planning/week'; * démontre qu'avec deux versions coexistantes en base. */ -const enabled = (process.env.DATABASE_URL ?? '').length > 0; +const enabled = (process.env.ADMIN_DATABASE_URL ?? process.env.DATABASE_URL ?? '').length > 0; const describeIfDb = enabled ? describe : describe.skip; const TZ = 'Europe/Paris'; @@ -26,7 +28,6 @@ const membershipId = `${suffix}-member`; let agreementFor: typeof import('@/server/compliance/evaluate').agreementFor; let evaluateSchedule: typeof import('@/server/compliance/evaluate').evaluateSchedule; let withTenant: typeof import('@/server/tenant').withTenant; -let unscoped: typeof import('@/server/tenant').unscoped; /** 10 h de travail : conforme à 10 h, non conforme à 8 h. */ function tenHourShift(scheduleId: string, date: string) { @@ -50,9 +51,9 @@ describeIfDb('moteur de conformité en base', () => { ({ agreementFor, evaluateSchedule } = await import( '@/server/compliance/evaluate' )); - ({ withTenant, unscoped } = await import('@/server/tenant')); + ({ withTenant } = await import('@/server/tenant')); - const db = unscoped(); + const db = adminPrisma(); await db.account.create({ data: { id: accountId, name: `Compte ${suffix}` }, @@ -137,7 +138,7 @@ describeIfDb('moteur de conformité en base', () => { afterAll(async () => { if (!enabled) return; - await unscoped().account.delete({ where: { id: accountId } }); + await adminPrisma().account.delete({ where: { id: accountId } }); }); it('choisit la version en vigueur à la date, pas la plus récente', async () => { @@ -208,7 +209,7 @@ describeIfDb('moteur de conformité en base', () => { // Le figeage est en base, pas seulement dans l'application : une paie // antérieure doit rester reproductible même si quelqu'un écrit // directement en SQL. - const db = unscoped(); + const db = adminPrisma(); const agreement = await db.collectiveAgreement.findFirst({ where: { accountId, version: 1 }, }); diff --git a/tests/integration/ledger.test.ts b/tests/integration/ledger.test.ts index 6de577a..40ba152 100644 --- a/tests/integration/ledger.test.ts +++ b/tests/integration/ledger.test.ts @@ -1,5 +1,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { adminPrisma } from './admin-db'; + /** * Immutabilité du registre — critère d'acceptation de WP-06. * @@ -9,22 +11,21 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; * sans passer par les Server Actions, pour le prouver. */ -const enabled = (process.env.DATABASE_URL ?? '').length > 0; +const enabled = (process.env.ADMIN_DATABASE_URL ?? process.env.DATABASE_URL ?? '').length > 0; const describeIfDb = enabled ? describe : describe.skip; const suffix = `ledger-${Date.now()}`; const accountId = `${suffix}-account`; const membershipId = `${suffix}-member`; -let unscoped: typeof import('@/server/tenant').unscoped; let counterId = ''; let operationId = ''; describeIfDb('registre des compteurs', () => { beforeAll(async () => { process.env.ENCRYPTION_KEY ??= Buffer.alloc(32, 3).toString('base64'); - ({ unscoped } = await import('@/server/tenant')); - const db = unscoped(); + + const db = adminPrisma(); await db.account.create({ data: { id: accountId, name: `Compte ${suffix}` }, @@ -69,7 +70,7 @@ describeIfDb('registre des compteurs', () => { afterAll(async () => { if (!enabled) return; - const db = unscoped(); + const db = adminPrisma(); // Le trigger interdit DELETE sur les écritures : la suppression du compte // ne peut donc pas cascader. On le désactive le temps du ménage. await db.$executeRawUnsafe( @@ -83,7 +84,7 @@ describeIfDb('registre des compteurs', () => { it('refuse toute modification d’écriture', async () => { await expect( - unscoped().ledgerOperation.update({ + adminPrisma().ledgerOperation.update({ where: { id: operationId }, data: { quantity: 999 }, }), @@ -92,14 +93,14 @@ describeIfDb('registre des compteurs', () => { it('refuse toute suppression d’écriture', async () => { await expect( - unscoped().ledgerOperation.delete({ where: { id: operationId } }), + adminPrisma().ledgerOperation.delete({ where: { id: operationId } }), ).rejects.toThrow(/append-only/); }); it('accepte une contre-passation, qui laisse les deux écritures', async () => { // Une correction s'écrit ; elle ne se réécrit pas. Les deux lignes // coexistent, et le solde redevient juste par addition. - const db = unscoped(); + const db = adminPrisma(); await db.ledgerOperation.create({ data: { accountId, @@ -130,7 +131,7 @@ describeIfDb('registre des compteurs', () => { // `reversesId` est unique : contre-passer deux fois la même écriture // doublerait la correction, et le solde partirait dans l'autre sens. await expect( - unscoped().ledgerOperation.create({ + adminPrisma().ledgerOperation.create({ data: { accountId, counterId, diff --git a/tests/integration/pay-period.test.ts b/tests/integration/pay-period.test.ts index e1d29b7..35278d3 100644 --- a/tests/integration/pay-period.test.ts +++ b/tests/integration/pay-period.test.ts @@ -1,5 +1,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { adminPrisma } from './admin-db'; + import { IDCC_1517_PARAMETERS } from '@/domain/compliance/idcc1517'; import { zonedInstant } from '@/domain/planning/week'; @@ -15,7 +17,7 @@ import { zonedInstant } from '@/domain/planning/week'; * Trois calculs séparés divergent, et l'écart ne se voit qu'au bulletin. */ -const enabled = (process.env.DATABASE_URL ?? '').length > 0; +const enabled = (process.env.ADMIN_DATABASE_URL ?? process.env.DATABASE_URL ?? '').length > 0; const describeIfDb = enabled ? describe : describe.skip; const TZ = 'Europe/Paris'; @@ -25,7 +27,6 @@ const locationId = `${suffix}-loc`; const teamId = `${suffix}-team`; const membershipId = `${suffix}-member`; -let unscoped: typeof import('@/server/tenant').unscoped; let withTenant: typeof import('@/server/tenant').withTenant; let assertPeriodOpen: typeof import('@/server/payroll/periods').assertPeriodOpen; let PeriodLockedError: typeof import('@/server/payroll/periods').PeriodLockedError; @@ -41,13 +42,13 @@ const MONTH = { year: YEAR, month: 8 }; describeIfDb('période de paie', () => { beforeAll(async () => { process.env.ENCRYPTION_KEY ??= Buffer.alloc(32, 3).toString('base64'); - ({ unscoped, withTenant } = await import('@/server/tenant')); + ({ withTenant } = await import('@/server/tenant')); ({ assertPeriodOpen, PeriodLockedError, computeSnapshots } = await import( '@/server/payroll/periods' )); ({ buildPayrollPeriod } = await import('@/server/payroll/build')); - const db = unscoped(); + const db = adminPrisma(); await db.account.create({ data: { id: accountId, name: `Compte ${suffix}` } }); await db.location.create({ @@ -147,7 +148,7 @@ describeIfDb('période de paie', () => { afterAll(async () => { if (!enabled) return; - await unscoped().account.delete({ where: { id: accountId } }); + await adminPrisma().account.delete({ where: { id: accountId } }); }); it('laisse passer une mutation sur une période ouverte', async () => { @@ -191,7 +192,7 @@ describeIfDb('période de paie', () => { }); it('refuse toute mutation une fois la période verrouillée', async () => { - await unscoped().payPeriod.update({ + await adminPrisma().payPeriod.update({ where: { id: periodId }, data: { status: 'LOCKED', lockedAt: new Date() }, }); diff --git a/tests/integration/rls.test.ts b/tests/integration/rls.test.ts index c50f70c..c58bbf6 100644 --- a/tests/integration/rls.test.ts +++ b/tests/integration/rls.test.ts @@ -17,7 +17,11 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; const APP_ROLE = 'planflow_rls_test'; const APP_PASSWORD = 'rls-test-only'; -const adminUrl = process.env.DATABASE_URL ?? ''; +// Connexion d'amorçage : créer un rôle et deux comptes de toutes pièces est +// hors de portée du compte applicatif — c'est précisément ce que ces tests +// vérifient par ailleurs. +const adminUrl = + process.env.ADMIN_DATABASE_URL ?? process.env.DATABASE_URL ?? ''; const enabled = adminUrl.length > 0; let admin: Client;