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 --------------------------------------------------------
|
||||
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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <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
|
||||
|
||||
@@ -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
|
||||
|
||||
+57
-7
@@ -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:
|
||||
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}
|
||||
DB=${DB:-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.
|
||||
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 -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 { 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<SessionContext | null> {
|
||||
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`. */
|
||||
|
||||
@@ -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é :
|
||||
* 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 {
|
||||
|
||||
+12
-1
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 { 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 },
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() },
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user