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:
Claude committed 2026-08-09 08:39:54 +00:00
1 parent ba6482d4ff
commit ef2868b980
17 files changed
+441 -87

No files matched your search

+24 -2
View File
@@ -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
+18 -1
View File
@@ -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
+5
View File
@@ -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
+44 -3
View File
@@ -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
View File
@@ -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:
+38
View File
@@ -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
View File
@@ -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"
+49
View File
@@ -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
View File
@@ -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`. */
+26
View File
@@ -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
View File
@@ -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;
}
+27
View File
@@ -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;
}
+7 -6
View File
@@ -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 },
});
+10 -9
View File
@@ -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,
+7 -6
View File
@@ -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() },
});
+5 -1
View File
@@ -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;