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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user