diff --git a/.env.example b/.env.example index df446d8..bb2fe02 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,9 @@ DATABASE_URL=postgresql://planflow:change-me@localhost:5432/planflow # # openssl rand -base64 32 # +# En docker-compose, la laisser vide suffit : elle est produite au premier +# démarrage et conservée dans le volume `secrets`. +# # Cette clé vit hors de la base : une sauvegarde volée ne doit pas suffire à # lire ces colonnes. La perdre rend les données chiffrées irrécupérables — # la sauvegarder séparément et documenter sa rotation. diff --git a/Dockerfile b/Dockerfile index 589cf96..5e19c45 100644 --- a/Dockerfile +++ b/Dockerfile @@ -34,14 +34,15 @@ COPY --from=build --chown=nextjs:nodejs /app/prisma ./prisma COPY --from=build --chown=nextjs:nodejs /app/node_modules/prisma ./node_modules/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 --chown=nextjs:nodejs docker/entrypoint.sh ./docker/entrypoint.sh # 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 +RUN mkdir -p /data/documents /secrets && chown -R nextjs:nodejs /data /secrets USER nextjs EXPOSE 3000 ENV PORT=3000 HOSTNAME=0.0.0.0 -CMD ["sh", "-c", "./node_modules/.bin/prisma migrate deploy && node server.js"] +CMD ["./docker/entrypoint.sh"] diff --git a/README.md b/README.md index a0696f3..2e4b206 100644 --- a/README.md +++ b/README.md @@ -49,12 +49,11 @@ devinent pas. ### Avec Docker ```bash -cp .env.example .env -# ENCRYPTION_KEY est la seule variable sans valeur par défaut : -echo "ENCRYPTION_KEY=$(openssl rand -base64 32)" >> .env docker compose up --build ``` +Aucune variable n'est requise. La clé de chiffrement est **produite au premier démarrage** et affichée une fois dans les journaux — notez-la, elle vit dans un volume distinct de la base et des documents. + L'application écoute sur — port peu courant à dessein, le service étant censé passer par un reverse-proxy. Les migrations s'appliquent au démarrage du conteneur. La pile attend un réseau externe nommé `nginx_default`, celui du reverse-proxy. S'il n'existe pas encore : @@ -67,15 +66,13 @@ Seule l'application y est attachée. La base reste sur le réseau privé de la p ### 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 : +Portainer ne lit pas de fichier `.env`, mais **aucune variable n'est obligatoire** : la pile démarre telle quelle. -| Variable | Valeur | -|---|---| -| `ENCRYPTION_KEY` | `openssl rand -base64 32` | +Une seule mérite d'être renseignée dans la section **Environment variables** : `APP_URL`, avec l'adresse publique réelle. Sans elle, les liens des messages — invitations comprises — pointeront vers `localhost` et personne ne pourra les suivre. -Les autres ont une valeur par défaut utilisable telle quelle : `POSTGRES_PASSWORD`, `POSTGRES_USER`, `POSTGRES_DB`, `APP_PORT` (9317), `APP_URL`. +Les autres ont une valeur par défaut utilisable : `POSTGRES_PASSWORD`, `POSTGRES_USER`, `POSTGRES_DB`, `APP_PORT` (9317). `ENCRYPTION_KEY` peut être fournie si vous gérez vos secrets ailleurs ; sinon elle est produite au premier démarrage. -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. +**Après le premier déploiement, relevez la clé dans les journaux du conteneur `app` et conservez-la hors du serveur.** ### En local @@ -97,7 +94,7 @@ pnpm dev 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 n'est **pas** livrée avec l'image — une clé publiée dans un dépôt ne protégerait rien. Elle est produite au premier démarrage du conteneur, affichée une fois dans les journaux, et conservée dans le volume `planflow_secrets`. Fournir `ENCRYPTION_KEY` explicitement l'emporte toujours, pour un déploiement qui gère ses secrets par ailleurs — en gardant à l'esprit qu'une variable d'environnement s'affiche dans `docker inspect` et dans l'interface de gestion, ce qui n'en fait pas un meilleur coffre qu'un fichier. 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. @@ -109,7 +106,7 @@ Deux choses à sauvegarder **ensemble**, plus une à garder à part : |---|---| | 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 | +| Clé de chiffrement | volume `planflow_secrets` — **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. diff --git a/docker-compose.yml b/docker-compose.yml index f53781e..cf2e1ae 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -52,11 +52,12 @@ services: # 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} + # Laissée vide, elle est **produite au premier démarrage** et conservée + # dans le volume `secrets`. La renseigner ici reste possible pour un + # déploiement qui gère ses secrets par ailleurs — mais une variable + # d'environnement s'affiche dans `docker inspect` et dans l'interface de + # gestion, ce qui n'en fait pas un meilleur coffre qu'un fichier. + ENCRYPTION_KEY: ${ENCRYPTION_KEY:-} 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 @@ -64,6 +65,9 @@ services: DOCUMENT_STORE: /data/documents volumes: - documents:/data + # Volume distinct de la base et des documents : sauvegarder l'un ne doit + # pas emporter la clé qui déchiffre l'autre. + - secrets:/secrets networks: # `interne` pour joindre la base, `nginx_default` pour être joignable par # le reverse-proxy — qui atteint le conteneur sur son port 3000, sans @@ -98,6 +102,9 @@ networks: volumes: db-data: + # Clé de chiffrement produite au premier démarrage. À sauvegarder + # **séparément** du reste : réunis, le coffre et sa clé ne protègent plus rien. + secrets: # 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. diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 0000000..8fd364b --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,54 @@ +#!/bin/sh +set -eu + +# Démarrage du conteneur applicatif. +# +# La clé de chiffrement protège le NIR, l'IBAN, les secrets de second facteur et +# les pièces du dossier salarié. Elle doit exister avant que quoi que ce soit +# démarre — mais l'exiger en variable d'environnement rendait tout déploiement +# impossible sans une étape manuelle, et une variable d'environnement n'est de +# toute façon pas un bon coffre : elle s'affiche dans `docker inspect` et dans +# l'interface de gestion. +# +# Elle est donc produite au premier démarrage et conservée dans un volume +# **distinct** de la base et des documents : une sauvegarde de l'un ne doit pas +# emporter la clé de l'autre. +# +# `ENCRYPTION_KEY` fournie explicitement l'emporte toujours : un déploiement qui +# gère ses secrets par ailleurs ne doit pas être contrarié. + +KEY_FILE="${ENCRYPTION_KEY_FILE:-/secrets/encryption.key}" + +if [ -z "${ENCRYPTION_KEY:-}" ]; then + if [ -f "$KEY_FILE" ]; then + ENCRYPTION_KEY="$(cat "$KEY_FILE")" + else + mkdir -p "$(dirname "$KEY_FILE")" + ENCRYPTION_KEY="$(node -e "process.stdout.write(require('node:crypto').randomBytes(32).toString('base64'))")" + # Écriture puis restriction : le fichier ne doit jamais être lisible par + # d'autres, même brièvement. + (umask 077; printf '%s' "$ENCRYPTION_KEY" > "$KEY_FILE") + + echo '' + echo '════════════════════════════════════════════════════════════════' + echo ' Clé de chiffrement produite au premier démarrage.' + echo '' + echo " $ENCRYPTION_KEY" + echo '' + echo ' Conservez-la hors de ce serveur. Sans elle, le NIR, les IBAN et' + echo ' les pièces du dossier salarié sont définitivement illisibles.' + echo '' + echo " Elle est stockée dans $KEY_FILE, sur un volume distinct de la" + echo ' base et des documents — à sauvegarder séparément.' + echo '════════════════════════════════════════════════════════════════' + echo '' + fi +fi + +export ENCRYPTION_KEY + +# Les migrations s'appliquent au démarrage : l'image se déploie sans étape +# séparée. +./node_modules/.bin/prisma migrate deploy + +exec node server.js diff --git a/src/server/documents/actions.ts b/src/server/documents/actions.ts index 87cb8e1..e9b86f7 100644 --- a/src/server/documents/actions.ts +++ b/src/server/documents/actions.ts @@ -71,7 +71,17 @@ export async function uploadDocumentAction( }); if (!membership) throw new ValidationError('Salarié introuvable.'); - const stored = await storeFile(actor.accountId, content); + // Une écriture disque peut échouer — volume plein, droits, montage + // absent. Laisser l'exception remonter afficherait un formulaire muet : + // la pièce n'est pas déposée et rien ne le dit. + const stored = await storeFile(actor.accountId, content).catch( + (error: unknown) => { + console.error('Écriture de pièce impossible :', error); + throw new ValidationError( + 'Le fichier n’a pas pu être écrit sur le disque. Vérifiez l’espace disponible et les droits du volume de stockage.', + ); + }, + ); await db.document.create({ data: {