Merge pull request #24 from R0m1k3/claude/combohr-app-recreation-plan-yu7wre
Produire la clé de chiffrement au premier démarrage
This commit is contained in:
6 files changed
+91
-19
No files matched your search
@@ -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.
|
||||
|
||||
+3
-2
@@ -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"]
|
||||
@@ -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 <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 :
|
||||
@@ -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.
|
||||
|
||||
|
||||
+12
-5
@@ -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.
|
||||
|
||||
Executable
+54
@@ -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
|
||||
@@ -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: {
|
||||
|
||||
Reference in new issue
Block a user