Rendre le déploiement Docker correct, et la RLS réellement active
Trois problèmes, dont un grave, trouvés en corrigeant un échec de déploiement. **Le compose connectait l'application en superutilisateur PostgreSQL.** Un superutilisateur contourne toute politique de sécurité au niveau ligne, y compris déclarée en FORCE : la seconde couche d'isolation était présente en base et absente des faits. Le README l'interdisait déjà noir sur blanc ; le chemin de déploiement que nous livrons faisait exactement l'inverse. Un script d'initialisation crée désormais un rôle `planflow_app` NOSUPERUSER NOBYPASSRLS, propriétaire de la base — il lui faut ce droit pour migrer, et les politiques sont en FORCE précisément pour s'appliquer aussi au propriétaire. Mesuré : en superutilisateur, deux lignes visibles sans compte courant ; avec le rôle dédié, zéro. Basculer la base de développement sur ce même rôle a révélé le défaut que le superutilisateur masquait : `resolveSession` lisait `Membership`, table filtrée par compte, sans périmètre. Avec la RLS active, plus personne ne pouvait se connecter. La résolution passe maintenant par une porte étroite — une politique qui n'ouvre que les lignes dont l'utilisateur est titulaire, sous `app.user_id` — le temps de trouver le compte, puis repasse par le périmètre ordinaire. La suite de tests traverse enfin la RLS au lieu de la contourner. **Les pièces du dossier salarié n'avaient aucun volume.** Elles étaient écrites dans la couche du conteneur et disparaissaient au premier redéploiement. Une pièce d'identité perdue ne se reconstitue pas. Le reste répond à la demande : Postgres préconfiguré — la base n'étant ni publiée ni attachée au réseau du proxy, ce mot de passe protège d'un conteneur voisin, pas d'Internet — réseau `nginx_default` déclaré externe avec l'application seule dessus, et port publié peu courant. ENCRYPTION_KEY reste la seule variable sans valeur par défaut, et n'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 quiconque lit ce dépôt. Au démarrage, l'application contrôle ses propres privilèges : elle refuse de se lancer si la base porte plus d'un compte, et se contente d'un avertissement s'il n'y en a qu'un — bloquer une installation mono-compte fermerait l'accès de l'entreprise à ses données pour une fuite entre clients qui ne peut pas se produire. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
This commit is contained in:
17 files changed
+441
-87
No files matched your search
+24
-2
@@ -2,9 +2,23 @@
|
|||||||
|
|
||||||
# --- Base de données --------------------------------------------------------
|
# --- Base de données --------------------------------------------------------
|
||||||
POSTGRES_USER=planflow
|
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_PASSWORD=change-me
|
||||||
POSTGRES_DB=planflow
|
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.
|
# Utilisée par l'application et par Prisma.
|
||||||
# En docker-compose l'hôte est `db` ; en développement local, `localhost`.
|
# En docker-compose l'hôte est `db` ; en développement local, `localhost`.
|
||||||
DATABASE_URL=postgresql://planflow:change-me@localhost:5432/planflow
|
DATABASE_URL=postgresql://planflow:change-me@localhost:5432/planflow
|
||||||
@@ -21,5 +35,13 @@ DATABASE_URL=postgresql://planflow:change-me@localhost:5432/planflow
|
|||||||
ENCRYPTION_KEY=
|
ENCRYPTION_KEY=
|
||||||
|
|
||||||
# --- Application ------------------------------------------------------------
|
# --- Application ------------------------------------------------------------
|
||||||
APP_URL=http://localhost:3000
|
APP_URL=http://localhost:9317
|
||||||
APP_PORT=3000
|
# 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
|
||||||
@@ -11,7 +11,14 @@ concurrency:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
# Test-only values. Real secrets never live in CI configuration.
|
# 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=
|
ENCRYPTION_KEY: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
|
||||||
APP_URL: http://127.0.0.1:3100
|
APP_URL: http://127.0.0.1:3100
|
||||||
|
|
||||||
@@ -45,6 +52,16 @@ jobs:
|
|||||||
|
|
||||||
- run: pnpm install --frozen-lockfile
|
- 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
|
- run: pnpm db:generate
|
||||||
|
|
||||||
- name: Appliquer les migrations
|
- name: Appliquer les migrations
|
||||||
|
|||||||
@@ -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/.bin/prisma ./node_modules/.bin/prisma
|
||||||
COPY --from=build --chown=nextjs:nodejs /app/node_modules/@prisma ./node_modules/@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
|
USER nextjs
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
ENV PORT=3000 HOSTNAME=0.0.0.0
|
ENV PORT=3000 HOSTNAME=0.0.0.0
|
||||||
|
|||||||
@@ -50,11 +50,32 @@ devinent pas.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
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
|
docker compose up --build
|
||||||
```
|
```
|
||||||
|
|
||||||
L'application écoute sur <http://localhost:3000>. Les migrations s'appliquent au démarrage du conteneur.
|
L'application écoute sur <http://localhost:9317> — 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
|
### En local
|
||||||
|
|
||||||
@@ -70,14 +91,28 @@ pnpm dev
|
|||||||
|
|
||||||
### Clé de chiffrement
|
### 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
|
```bash
|
||||||
openssl rand -base64 32
|
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.
|
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
|
### 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**.
|
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.**
|
**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.
|
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
|
```sql
|
||||||
|
|||||||
+57
-7
@@ -6,20 +6,36 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-planflow}
|
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}
|
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
|
# Deterministic collation: ordering of employee names must not depend on
|
||||||
# the host locale, or exports differ between machines.
|
# the host locale, or exports differ between machines.
|
||||||
LANG: C.UTF-8
|
LANG: C.UTF-8
|
||||||
volumes:
|
volumes:
|
||||||
- db-data:/var/lib/postgresql/data
|
- 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:
|
healthcheck:
|
||||||
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:-planflow} -d ${POSTGRES_DB:-planflow}']
|
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:-planflow} -d ${POSTGRES_DB:-planflow}']
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
# Not published by default: nothing outside the compose network needs the
|
# Volontairement absente du réseau externe : elle n'a besoin que de
|
||||||
# database, and an HR dataset should not be one firewall rule from the world.
|
# 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:
|
expose:
|
||||||
- '5432'
|
- '5432'
|
||||||
|
|
||||||
@@ -32,11 +48,32 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
DATABASE_URL: postgresql://${POSTGRES_USER:-planflow}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-planflow}
|
# Le compte applicatif, **pas** le superutilisateur d'amorçage : un
|
||||||
ENCRYPTION_KEY: ${ENCRYPTION_KEY:?ENCRYPTION_KEY est requis — voir .env.example}
|
# superutilisateur contourne la row-level security, y compris déclarée en
|
||||||
APP_URL: ${APP_URL:-http://localhost:3000}
|
# 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:
|
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:
|
healthcheck:
|
||||||
test:
|
test:
|
||||||
[
|
[
|
||||||
@@ -50,5 +87,18 @@ services:
|
|||||||
retries: 5
|
retries: 5
|
||||||
start_period: 30s
|
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:
|
volumes:
|
||||||
db-data:
|
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:
|
||||||
Executable
+38
@@ -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" <<SQL
|
||||||
|
DO \$\$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '${APP_ROLE}') THEN
|
||||||
|
CREATE ROLE ${APP_ROLE} LOGIN PASSWORD '${APP_PASSWORD}'
|
||||||
|
NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS;
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
\$\$;
|
||||||
|
|
||||||
|
ALTER DATABASE ${POSTGRES_DB} OWNER TO ${APP_ROLE};
|
||||||
|
ALTER SCHEMA public OWNER TO ${APP_ROLE};
|
||||||
|
GRANT ALL ON SCHEMA public TO ${APP_ROLE};
|
||||||
|
SQL
|
||||||
|
|
||||||
|
echo "Rôle applicatif ${APP_ROLE} prêt — NOSUPERUSER NOBYPASSRLS, propriétaire de ${POSTGRES_DB}."
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- Lecture de son propre rattachement — préalable à l'authentification.
|
||||||
|
--
|
||||||
|
-- `resolveSession` doit trouver le compte d'un utilisateur **avant** de pouvoir
|
||||||
|
-- poser `app.account_id` : c'est le rattachement qui le désigne. Sans cette
|
||||||
|
-- politique, la seule façon d'y parvenir serait de connecter l'application avec
|
||||||
|
-- un compte qui contourne la row-level security — c'est-à-dire de la désactiver
|
||||||
|
-- partout pour résoudre un cas d'amorçage.
|
||||||
|
--
|
||||||
|
-- La politique reste étroite : elle n'ouvre que les lignes dont l'utilisateur
|
||||||
|
-- est le titulaire, et seulement quand `app.user_id` a été posé — ce que ne fait
|
||||||
|
-- que la résolution de session, sur un identifiant tiré d'un jeton déjà validé.
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION planflow_current_user() RETURNS text AS $$
|
||||||
|
SELECT NULLIF(current_setting('app.user_id', true), '');
|
||||||
|
$$ LANGUAGE sql STABLE;
|
||||||
|
|
||||||
|
CREATE POLICY membership_self_read ON "Membership"
|
||||||
|
FOR SELECT
|
||||||
|
USING ("userId" IS NOT NULL AND "userId" = planflow_current_user());
|
||||||
+29
-1
@@ -18,6 +18,8 @@ PGDATA=${PGDATA:-/tmp/planflow-pg}
|
|||||||
PGPORT=${PGPORT:-55432}
|
PGPORT=${PGPORT:-55432}
|
||||||
DB=${DB:-planflow}
|
DB=${DB:-planflow}
|
||||||
ADMIN=${ADMIN:-planflow}
|
ADMIN=${ADMIN:-planflow}
|
||||||
|
APP_ROLE=${APP_ROLE:-planflow_app}
|
||||||
|
APP_PASSWORD=${APP_PASSWORD:-planflow-app-dev}
|
||||||
|
|
||||||
# PostgreSQL refuse de tourner en root ; on passe par le compte système.
|
# PostgreSQL refuse de tourner en root ; on passe par le compte système.
|
||||||
as_postgres() { if [ "$(id -u)" = 0 ]; then su postgres -c "$1"; else sh -c "$1"; fi; }
|
as_postgres() { if [ "$(id -u)" = 0 ]; then su postgres -c "$1"; else sh -c "$1"; fi; }
|
||||||
@@ -38,4 +40,30 @@ psql() { "$PGBIN/psql" -h 127.0.0.1 -p "$PGPORT" -U "$ADMIN" -v ON_ERROR_STOP=1
|
|||||||
psql -d postgres -tAc "SELECT 1 FROM pg_database WHERE datname='$DB'" | grep -q 1 ||
|
psql -d postgres -tAc "SELECT 1 FROM pg_database WHERE datname='$DB'" | grep -q 1 ||
|
||||||
psql -d postgres -c "CREATE DATABASE $DB OWNER $ADMIN"
|
psql -d postgres -c "CREATE DATABASE $DB OWNER $ADMIN"
|
||||||
|
|
||||||
echo "postgres prêt sur 127.0.0.1:$PGPORT — DATABASE_URL=postgresql://$ADMIN@127.0.0.1:$PGPORT/$DB"
|
# Rôle applicatif, comme en production.
|
||||||
|
#
|
||||||
|
# `$ADMIN` est le superutilisateur d'amorçage : s'y connecter contournerait
|
||||||
|
# toute politique de sécurité au niveau ligne, y compris déclarée en FORCE. Les
|
||||||
|
# tests passeraient alors sans jamais éprouver la seconde couche d'isolation —
|
||||||
|
# elle serait présente en base et absente des faits.
|
||||||
|
psql -d postgres -tAc "SELECT 1 FROM pg_roles WHERE rolname='$APP_ROLE'" | grep -q 1 ||
|
||||||
|
psql -d postgres -c "CREATE ROLE $APP_ROLE LOGIN PASSWORD '$APP_PASSWORD' NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS"
|
||||||
|
|
||||||
|
psql -d postgres -c "ALTER DATABASE $DB OWNER TO $APP_ROLE"
|
||||||
|
psql -d "$DB" -c "ALTER SCHEMA public OWNER TO $APP_ROLE" -c "GRANT ALL ON SCHEMA public TO $APP_ROLE"
|
||||||
|
|
||||||
|
# Les objets déjà créés appartiennent encore au compte d'amorçage : sans ce
|
||||||
|
# transfert, le rôle applicatif ne pourrait ni migrer ni lire.
|
||||||
|
psql -d "$DB" -tAc "
|
||||||
|
SELECT format('ALTER TABLE %I.%I OWNER TO $APP_ROLE;', schemaname, tablename)
|
||||||
|
FROM pg_tables WHERE schemaname = 'public'
|
||||||
|
UNION ALL
|
||||||
|
SELECT format('ALTER SEQUENCE %I.%I OWNER TO $APP_ROLE;', sequence_schema, sequence_name)
|
||||||
|
FROM information_schema.sequences WHERE sequence_schema = 'public'
|
||||||
|
UNION ALL
|
||||||
|
SELECT format('ALTER FUNCTION %I.%I() OWNER TO $APP_ROLE;', n.nspname, p.proname)
|
||||||
|
FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
|
||||||
|
WHERE n.nspname = 'public' AND p.pronargs = 0
|
||||||
|
" | psql -d "$DB" -q -f - >/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"
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* Contrôles au démarrage.
|
||||||
|
*
|
||||||
|
* Next.js appelle `register()` une fois, au lancement du serveur.
|
||||||
|
*/
|
||||||
|
export async function register(): Promise<void> {
|
||||||
|
// `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`,
|
||||||
|
);
|
||||||
|
}
|
||||||
+64
-50
@@ -3,7 +3,7 @@ import { cookies } from 'next/headers';
|
|||||||
|
|
||||||
import type { Actor, Scope } from '@/domain/access/authorize';
|
import type { Actor, Scope } from '@/domain/access/authorize';
|
||||||
import { generateToken, hashToken } from '@/server/crypto';
|
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.
|
* Sessions serveur — PLAN.md §2 (écart assumé) et matrice n° 23.
|
||||||
@@ -247,22 +247,11 @@ export async function resolveSession(
|
|||||||
): Promise<SessionContext | null> {
|
): Promise<SessionContext | null> {
|
||||||
const db = unscoped();
|
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({
|
const session = await db.session.findUnique({
|
||||||
where: { tokenHash: hashToken(token) },
|
where: { tokenHash: hashToken(token) },
|
||||||
include: {
|
include: { user: true },
|
||||||
user: {
|
|
||||||
include: {
|
|
||||||
memberships: {
|
|
||||||
where: { status: 'ACTIVE', archivedAt: null },
|
|
||||||
include: {
|
|
||||||
account: { select: { name: true } },
|
|
||||||
role: { include: { permissions: { include: { permission: true } } } },
|
|
||||||
scopes: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Une session en attente du second facteur ne résout aucun acteur : elle ne
|
// Une session en attente du second facteur ne résout aucun acteur : elle ne
|
||||||
@@ -277,45 +266,70 @@ export async function resolveSession(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const membership = session.user.memberships[0];
|
// Amorçage : trouver le compte suppose de lire le rattachement, lequel est
|
||||||
if (!membership) return null;
|
// 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 = {
|
if (!membershipId) return null;
|
||||||
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 = {
|
// Tout le reste passe par le périmètre du compte, comme n'importe quelle
|
||||||
membershipId: membership.id,
|
// lecture métier.
|
||||||
accountId: membership.accountId,
|
return withTenant(membershipId.accountId, async (tx) => {
|
||||||
userId: session.user.id,
|
const membership = await tx.membership.findUnique({
|
||||||
roleKey: membership.role.key,
|
where: { id: membershipId.id },
|
||||||
permissions: new Set(
|
include: {
|
||||||
membership.role.permissions.map((entry) => entry.permission.code),
|
account: { select: { name: true } },
|
||||||
),
|
role: { include: { permissions: { include: { permission: true } } } },
|
||||||
scope,
|
scopes: true,
|
||||||
};
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const { firstName, lastName, email } = session.user;
|
if (!membership) return null;
|
||||||
|
|
||||||
return {
|
const scope: Scope = {
|
||||||
actor,
|
allLocations: membership.scopes.some((entry) => entry.allLocations),
|
||||||
sessionId: session.id,
|
locationIds: membership.scopes
|
||||||
user: {
|
.map((entry) => entry.locationId)
|
||||||
firstName,
|
.filter((id): id is string => id !== null),
|
||||||
lastName,
|
teamIds: membership.scopes
|
||||||
email,
|
.map((entry) => entry.teamId)
|
||||||
initials: `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase(),
|
.filter((id): id is string => id !== null),
|
||||||
mfaEnrolled: session.user.mfaEnrolledAt !== null,
|
};
|
||||||
},
|
|
||||||
accountName: membership.account.name,
|
const actor: Actor = {
|
||||||
roleName: membership.role.name,
|
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`. */
|
/** Session courante depuis le cookie, ou `null`. */
|
||||||
|
|||||||
@@ -131,10 +131,36 @@ export async function withTenant<T>(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<T>(
|
||||||
|
userId: string,
|
||||||
|
fn: (db: ScopedClient) => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
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<T>;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Accès sans portée de compte, pour les opérations qui précèdent l'identité :
|
* 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.
|
* 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.
|
* Volontairement nommé pour se voir en revue de code.
|
||||||
*/
|
*/
|
||||||
export function unscoped(): PrismaClient {
|
export function unscoped(): PrismaClient {
|
||||||
|
|||||||
+12
-1
@@ -7,14 +7,25 @@ import { PrismaClient } from '@prisma/client';
|
|||||||
* Certains états ne se posent pas par l'interface — retirer un second facteur
|
* 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
|
* 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.
|
* 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;
|
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 {
|
function db(): PrismaClient {
|
||||||
// Prisma 7 exige un adaptateur : le client applicatif n'est pas réutilisable
|
// Prisma 7 exige un adaptateur : le client applicatif n'est pas réutilisable
|
||||||
// ici, il vit derrière `server-only`.
|
// ici, il vit derrière `server-only`.
|
||||||
client ??= new PrismaClient({
|
client ??= new PrismaClient({
|
||||||
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
|
adapter: new PrismaPg({ connectionString: adminUrl() }),
|
||||||
});
|
});
|
||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { adminPrisma } from './admin-db';
|
||||||
|
|
||||||
import { IDCC_1517_PARAMETERS } from '@/domain/compliance/idcc1517';
|
import { IDCC_1517_PARAMETERS } from '@/domain/compliance/idcc1517';
|
||||||
import { zonedInstant } from '@/domain/planning/week';
|
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.
|
* 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 describeIfDb = enabled ? describe : describe.skip;
|
||||||
|
|
||||||
const TZ = 'Europe/Paris';
|
const TZ = 'Europe/Paris';
|
||||||
@@ -26,7 +28,6 @@ const membershipId = `${suffix}-member`;
|
|||||||
let agreementFor: typeof import('@/server/compliance/evaluate').agreementFor;
|
let agreementFor: typeof import('@/server/compliance/evaluate').agreementFor;
|
||||||
let evaluateSchedule: typeof import('@/server/compliance/evaluate').evaluateSchedule;
|
let evaluateSchedule: typeof import('@/server/compliance/evaluate').evaluateSchedule;
|
||||||
let withTenant: typeof import('@/server/tenant').withTenant;
|
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. */
|
/** 10 h de travail : conforme à 10 h, non conforme à 8 h. */
|
||||||
function tenHourShift(scheduleId: string, date: string) {
|
function tenHourShift(scheduleId: string, date: string) {
|
||||||
@@ -50,9 +51,9 @@ describeIfDb('moteur de conformité en base', () => {
|
|||||||
({ agreementFor, evaluateSchedule } = await import(
|
({ agreementFor, evaluateSchedule } = await import(
|
||||||
'@/server/compliance/evaluate'
|
'@/server/compliance/evaluate'
|
||||||
));
|
));
|
||||||
({ withTenant, unscoped } = await import('@/server/tenant'));
|
({ withTenant } = await import('@/server/tenant'));
|
||||||
|
|
||||||
const db = unscoped();
|
const db = adminPrisma();
|
||||||
|
|
||||||
await db.account.create({
|
await db.account.create({
|
||||||
data: { id: accountId, name: `Compte ${suffix}` },
|
data: { id: accountId, name: `Compte ${suffix}` },
|
||||||
@@ -137,7 +138,7 @@ describeIfDb('moteur de conformité en base', () => {
|
|||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
if (!enabled) return;
|
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 () => {
|
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
|
// Le figeage est en base, pas seulement dans l'application : une paie
|
||||||
// antérieure doit rester reproductible même si quelqu'un écrit
|
// antérieure doit rester reproductible même si quelqu'un écrit
|
||||||
// directement en SQL.
|
// directement en SQL.
|
||||||
const db = unscoped();
|
const db = adminPrisma();
|
||||||
const agreement = await db.collectiveAgreement.findFirst({
|
const agreement = await db.collectiveAgreement.findFirst({
|
||||||
where: { accountId, version: 1 },
|
where: { accountId, version: 1 },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { adminPrisma } from './admin-db';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Immutabilité du registre — critère d'acceptation de WP-06.
|
* 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.
|
* 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 describeIfDb = enabled ? describe : describe.skip;
|
||||||
|
|
||||||
const suffix = `ledger-${Date.now()}`;
|
const suffix = `ledger-${Date.now()}`;
|
||||||
const accountId = `${suffix}-account`;
|
const accountId = `${suffix}-account`;
|
||||||
const membershipId = `${suffix}-member`;
|
const membershipId = `${suffix}-member`;
|
||||||
|
|
||||||
let unscoped: typeof import('@/server/tenant').unscoped;
|
|
||||||
let counterId = '';
|
let counterId = '';
|
||||||
let operationId = '';
|
let operationId = '';
|
||||||
|
|
||||||
describeIfDb('registre des compteurs', () => {
|
describeIfDb('registre des compteurs', () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
process.env.ENCRYPTION_KEY ??= Buffer.alloc(32, 3).toString('base64');
|
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({
|
await db.account.create({
|
||||||
data: { id: accountId, name: `Compte ${suffix}` },
|
data: { id: accountId, name: `Compte ${suffix}` },
|
||||||
@@ -69,7 +70,7 @@ describeIfDb('registre des compteurs', () => {
|
|||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
if (!enabled) return;
|
if (!enabled) return;
|
||||||
const db = unscoped();
|
const db = adminPrisma();
|
||||||
// Le trigger interdit DELETE sur les écritures : la suppression du compte
|
// 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.
|
// ne peut donc pas cascader. On le désactive le temps du ménage.
|
||||||
await db.$executeRawUnsafe(
|
await db.$executeRawUnsafe(
|
||||||
@@ -83,7 +84,7 @@ describeIfDb('registre des compteurs', () => {
|
|||||||
|
|
||||||
it('refuse toute modification d’écriture', async () => {
|
it('refuse toute modification d’écriture', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
unscoped().ledgerOperation.update({
|
adminPrisma().ledgerOperation.update({
|
||||||
where: { id: operationId },
|
where: { id: operationId },
|
||||||
data: { quantity: 999 },
|
data: { quantity: 999 },
|
||||||
}),
|
}),
|
||||||
@@ -92,14 +93,14 @@ describeIfDb('registre des compteurs', () => {
|
|||||||
|
|
||||||
it('refuse toute suppression d’écriture', async () => {
|
it('refuse toute suppression d’écriture', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
unscoped().ledgerOperation.delete({ where: { id: operationId } }),
|
adminPrisma().ledgerOperation.delete({ where: { id: operationId } }),
|
||||||
).rejects.toThrow(/append-only/);
|
).rejects.toThrow(/append-only/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('accepte une contre-passation, qui laisse les deux écritures', async () => {
|
it('accepte une contre-passation, qui laisse les deux écritures', async () => {
|
||||||
// Une correction s'écrit ; elle ne se réécrit pas. Les deux lignes
|
// Une correction s'écrit ; elle ne se réécrit pas. Les deux lignes
|
||||||
// coexistent, et le solde redevient juste par addition.
|
// coexistent, et le solde redevient juste par addition.
|
||||||
const db = unscoped();
|
const db = adminPrisma();
|
||||||
await db.ledgerOperation.create({
|
await db.ledgerOperation.create({
|
||||||
data: {
|
data: {
|
||||||
accountId,
|
accountId,
|
||||||
@@ -130,7 +131,7 @@ describeIfDb('registre des compteurs', () => {
|
|||||||
// `reversesId` est unique : contre-passer deux fois la même écriture
|
// `reversesId` est unique : contre-passer deux fois la même écriture
|
||||||
// doublerait la correction, et le solde partirait dans l'autre sens.
|
// doublerait la correction, et le solde partirait dans l'autre sens.
|
||||||
await expect(
|
await expect(
|
||||||
unscoped().ledgerOperation.create({
|
adminPrisma().ledgerOperation.create({
|
||||||
data: {
|
data: {
|
||||||
accountId,
|
accountId,
|
||||||
counterId,
|
counterId,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { adminPrisma } from './admin-db';
|
||||||
|
|
||||||
import { IDCC_1517_PARAMETERS } from '@/domain/compliance/idcc1517';
|
import { IDCC_1517_PARAMETERS } from '@/domain/compliance/idcc1517';
|
||||||
import { zonedInstant } from '@/domain/planning/week';
|
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.
|
* 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 describeIfDb = enabled ? describe : describe.skip;
|
||||||
|
|
||||||
const TZ = 'Europe/Paris';
|
const TZ = 'Europe/Paris';
|
||||||
@@ -25,7 +27,6 @@ const locationId = `${suffix}-loc`;
|
|||||||
const teamId = `${suffix}-team`;
|
const teamId = `${suffix}-team`;
|
||||||
const membershipId = `${suffix}-member`;
|
const membershipId = `${suffix}-member`;
|
||||||
|
|
||||||
let unscoped: typeof import('@/server/tenant').unscoped;
|
|
||||||
let withTenant: typeof import('@/server/tenant').withTenant;
|
let withTenant: typeof import('@/server/tenant').withTenant;
|
||||||
let assertPeriodOpen: typeof import('@/server/payroll/periods').assertPeriodOpen;
|
let assertPeriodOpen: typeof import('@/server/payroll/periods').assertPeriodOpen;
|
||||||
let PeriodLockedError: typeof import('@/server/payroll/periods').PeriodLockedError;
|
let PeriodLockedError: typeof import('@/server/payroll/periods').PeriodLockedError;
|
||||||
@@ -41,13 +42,13 @@ const MONTH = { year: YEAR, month: 8 };
|
|||||||
describeIfDb('période de paie', () => {
|
describeIfDb('période de paie', () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
process.env.ENCRYPTION_KEY ??= Buffer.alloc(32, 3).toString('base64');
|
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(
|
({ assertPeriodOpen, PeriodLockedError, computeSnapshots } = await import(
|
||||||
'@/server/payroll/periods'
|
'@/server/payroll/periods'
|
||||||
));
|
));
|
||||||
({ buildPayrollPeriod } = await import('@/server/payroll/build'));
|
({ buildPayrollPeriod } = await import('@/server/payroll/build'));
|
||||||
|
|
||||||
const db = unscoped();
|
const db = adminPrisma();
|
||||||
|
|
||||||
await db.account.create({ data: { id: accountId, name: `Compte ${suffix}` } });
|
await db.account.create({ data: { id: accountId, name: `Compte ${suffix}` } });
|
||||||
await db.location.create({
|
await db.location.create({
|
||||||
@@ -147,7 +148,7 @@ describeIfDb('période de paie', () => {
|
|||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
if (!enabled) return;
|
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 () => {
|
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 () => {
|
it('refuse toute mutation une fois la période verrouillée', async () => {
|
||||||
await unscoped().payPeriod.update({
|
await adminPrisma().payPeriod.update({
|
||||||
where: { id: periodId },
|
where: { id: periodId },
|
||||||
data: { status: 'LOCKED', lockedAt: new Date() },
|
data: { status: 'LOCKED', lockedAt: new Date() },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,7 +17,11 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|||||||
const APP_ROLE = 'planflow_rls_test';
|
const APP_ROLE = 'planflow_rls_test';
|
||||||
const APP_PASSWORD = 'rls-test-only';
|
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;
|
const enabled = adminUrl.length > 0;
|
||||||
|
|
||||||
let admin: Client;
|
let admin: Client;
|
||||||
|
|||||||
Reference in new issue
Block a user