Le client voulait les données de prod en préprod. J'ai dit non, puis je lui ai donné mieux.
Une demande raisonnable d'un client raisonnable : que la préprod ressemble à la prod, avec de vraies données. Pourquoi ç'aurait été la pire décision de sécurité du projet, comment j'ai dit non sans conflit, et le pipeline de seed anonymisé qui a rendu la préprod plus utile qu'une copie ne l'aurait été.
« La préprod ne peut pas juste utiliser la base de production ? Tester avec des fausses données ne nous montre pas les vrais problèmes. »
Le client avait raison sur la deuxième phrase. La préprod avait une douzaine d’enregistrements faits à la main, chaque campagne s’appelait « Test campagne 3 », et n’importe quel bug qui dépendait du volume, d’entrées réelles bizarres ou de la forme de l’activité réelle des clients était invisible jusqu’à ce qu’il atteigne la production. Ils l’avaient remarqué. C’était une plainte légitime, et la solution proposée était l’évidente.
C’était aussi la solution qui aurait transformé un environnement de préprod en seconde copie des données personnelles de chaque client, sur une machine moins protégée, avec du code de test pointé dessus.
Ceci est la partie 10 d’une série sur le durcissement d’un SaaS construit en solo en production. Le contexte et la liste complète des points faibles sont dans l’article pilier. C’est la dernière, et la seule où l’incident est une conversation plutôt qu’une panne.
Pourquoi « copier la prod » est la pire option
J’ai écrit les raisons avant l’appel, parce que « c’est une mauvaise idée » n’est pas un argument et que le client méritait mieux. Quatre ont tenu.
La préprod est moins protégée, par conception. La production est derrière CrowdSec, un nginx durci et maintenant Cloudflare. La préprod est un VPS séparé, plus petit, avec une config nginx plus lâche, pas de CrowdSec, et des identifiants que la moitié de l’agence connaît parce que c’est à ça que sert une préprod. Copier la base de production là-bas n’étend pas la protection de la production à la copie. Ça étend la faiblesse de la préprod aux données.
Ça crée une seconde copie de données personnelles sans raison d’exister. Le produit est une marketplace qui met en relation des marques et des créateurs : noms, emails, téléphones, coordonnées de paiement, contrats. Sous le RGPD, chaque copie de ça a besoin d’une finalité, d’une durée de conservation, et d’une place dans le processus de suppression. L’article sur la rétention montre à quel point l’effacement est déjà difficile avec une seule base et ses sauvegardes. Une copie de préprod rafraîchie « de temps en temps » est une copie qui n’est jamais effacée, et « on avait besoin de données de test réalistes » n’est pas une finalité qu’un régulateur reconnaît.
Le code de test fait de vraies choses. C’est l’argument qui a porté. La préprod fait tourner la même application avec les mêmes intégrations configurées par variables d’environnement : le serveur SMTP, les clés Stripe, le fournisseur de SMS. Pointez ça sur de vrais enregistrements clients et la première personne qui teste « envoyer le rappel de campagne » envoie un email à tous les vrais créateurs de la base. Le premier test d’un flux de remboursement touche un vrai paiement. On ne testerait pas sur une copie de la production ; on opérerait sur les clients de la production depuis une machine à laquelle on ne fait pas confiance.
Les bugs qu’ils voulaient trouver tiennent à la forme, pas à l’identité. Le volume, les entrées bizarres, les distributions réelles de statuts et de dates : rien de tout ça ne demande le vrai nom attaché à la ligne. Un jeu de données qui a la forme de la production et l’identité de personne trouve les mêmes bugs et ne peut rien laisser fuir qui compte.
Ce dernier point est ce qui a transformé la conversation d’un refus en une proposition.
Comment la conversation s’est vraiment passée
Je n’ai pas dit non. J’ai dit « oui à des données réalistes, non à des données réelles, et voici la différence », puis j’ai montré le scénario de l’email à tous les créateurs. Cette seule phrase a fait plus que le paragraphe RGPD. La conformité est abstraite jusqu’au jour où elle ne l’est plus ; un bouton de test qui écrit à tous les clients est concret immédiatement, et tout le monde sur l’appel avait une histoire d’email de masse envoyé par erreur.
Puis j’ai proposé un calendrier : deux jours pour construire un pipeline de seed anonymisé, après quoi la préprod aurait le volume et la forme complets de la production, rafraîchis chaque semaine, avec chaque champ personnel remplacé. Ils ont accepté en une minute environ. Ce que les clients veulent vraiment est rarement ce qu’ils demandent ; ils voulaient que la préprod trouve de vrais bugs, et n’importe quel chemin vers ça convenait.
Le pipeline
Le dépôt avait déjà un système de seed : un bun run seed piloté par umzug qui génère des enregistrements avec @faker-js/faker. Il avait été écrit pour des bases locales vides et produisait quelques dizaines de lignes. Le volume et le réalisme étaient le trou, et la réponse a été de garder faker pour l’identité et de prendre tout le reste de la production.
La forme de la chose :
db prod --pg_dump--> conteneur jetable --anonymise.sql--> pg_dump --> préprod
Quatre étapes, lancées chaque semaine depuis la machine de production, jamais depuis la préprod, pour que les identifiants de préprod ne touchent jamais la production.
Étape 1 : dumper, et restaurer dans une boîte qui vit dix minutes
#!/usr/bin/env bash
set -euo pipefail
STAMP=$(date +%F)
pg_dump -Fc -h 127.0.0.1 -U app app > /tmp/prod-$STAMP.dump
docker run -d --name anon-db -e POSTGRES_PASSWORD=x postgres:15
sleep 5
pg_restore -h localhost -U postgres -d postgres --create --no-owner /tmp/prod-$STAMP.dump
L’anonymisation ne tourne jamais contre la production elle-même. Elle tourne contre une copie dans un conteneur supprimé à la fin du script, donc un bug dans le SQL d’anonymisation peut au pire produire une mauvaise préprod, jamais une prod abîmée.
Étape 2 : remplacer chaque champ personnel
C’est la partie qui doit être complète, et « complète » veut dire chaque colonne, pas seulement les évidentes. J’ai parcouru le schéma table par table avec l’article sur le masquage de PII ouvert à côté. Élagué et anonymisé :
-- anonymise.sql, exécuté dans le conteneur jetable
BEGIN;
-- déterministe par ligne : le même utilisateur reçoit la même fausse identité
-- à chaque rafraîchissement, donc les bugs de préprod restent reproductibles d'une semaine à l'autre
UPDATE users SET
first_name = 'User',
last_name = 'Number ' || id,
email = 'user-' || id || '@preprod.example.com',
phone = '+33 6 00 00 ' || lpad((id % 10000)::text, 4, '0'),
avatar_url = NULL;
UPDATE creators SET
bio = 'Bio for creator ' || id,
instagram_id = 'creator_' || id,
iban = NULL,
payout_address = NULL;
UPDATE brands SET
legal_name = 'Brand ' || id || ' SAS',
billing_email = 'billing-' || id || '@preprod.example.com',
vat_number = NULL;
-- le texte libre est là où les PII se cachent : messages, notes, corps de contrats
UPDATE messages SET body = 'Message ' || id || ' (' || length(body) || ' chars)';
UPDATE contracts SET terms = 'Contract terms for ' || id;
-- les jetons et secrets n'ont rien à faire en préprod
UPDATE users SET password_hash = crypt('preprod', gen_salt('bf'));
TRUNCATE sessions, password_resets, webhook_events;
-- garder la forme : statuts, montants, dates, relations restent intacts
COMMIT;
Trois choses sur lesquelles je veux attirer l’attention.
C’est déterministe. user-42@preprod.example.com est la même personne chaque semaine, ce qui veut dire qu’un bug signalé sur la préprod reste reproductible après le rafraîchissement suivant. Les noms aléatoires de faker auraient été plus jolis et pires.
Les colonnes de texte libre sont anonymisées par remplacement, pas par caviardage. Les messages, notes et corps de contrats sont là où les gens tapent des numéros de téléphone et des adresses, et une regex qui retire ce qu’elle reconnaît laisse ce qu’elle ne reconnaît pas. Remplacer tout le champ par un placeholder qui préserve la longueur garde les bugs de mise en page et perd le contenu.
Tout ce qui est un identifiant est détruit, pas déguisé. Chaque mot de passe devient preprod. Les sessions, jetons de réinitialisation et payloads de webhook sont tronqués. Les connexions en préprod passent par un compte partagé connu, et rien dans cette base ne peut être rejoué contre la production.
Étape 3 : vérifier avant d’envoyer où que ce soit
Le scan qui décide si le dump quitte la machine :
-- doit renvoyer zéro ligne, sinon le script s'arrête
SELECT 'users.email' AS col, count(*) FROM users
WHERE email NOT LIKE '%@preprod.example.com'
UNION ALL
SELECT 'creators.iban', count(*) FROM creators WHERE iban IS NOT NULL
UNION ALL
SELECT 'messages.body', count(*) FROM messages
WHERE body ~ '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}';
Si une migration ajoute une colonne avec des données personnelles et que personne ne met à jour anonymise.sql, c’est la ligne qui l’empêche d’atteindre la préprod. Ce n’est pas parfait (une nouvelle colonne nickname passerait sans encombre), donc la revue de schéma de chaque migration inclut maintenant une question : cette colonne a-t-elle besoin d’une règle d’anonymisation ? Elle est dans le template de pull request de la partie 4.
Étape 4 : expédier, et nettoyer
pg_dump -Fc -h localhost -U postgres app > /tmp/preprod-$STAMP.dump
docker rm -f anon-db
shred -u /tmp/prod-$STAMP.dump
scp /tmp/preprod-$STAMP.dump preprod:/tmp/
ssh preprod "pg_restore --clean --if-exists -d app /tmp/preprod-$STAMP.dump && rm /tmp/preprod-$STAMP.dump"
rm /tmp/preprod-$STAMP.dump
Le dump de production est broyé, pas supprimé, dès que la copie anonymisée existe. Le seul artefact qui quitte la machine de production en est un qui a déjà passé le scan de vérification.
À quoi ressemble la préprod maintenant
Le volume complet de la production, rafraîchi chaque lundi matin. Chaque campagne, chaque créateur, chaque relation de marque intacts. De vraies distributions de statuts, de vrais cas limites dans les dates et les montants, de vraies bizarreries du genre « pourquoi cet enregistrement a-t-il cette forme ». Et pas un seul vrai nom, email, téléphone ou compte bancaire où que ce soit.
La plainte d’origine du client, que les fausses données ne montraient pas les vrais problèmes, a disparu la première semaine. Le premier rafraîchissement a fait remonter deux bugs qui n’apparaissaient qu’avec un nombre réaliste de campagnes par créateur, exactement la classe de problème qu’ils décrivaient. Les données étaient réalistes. Elles n’étaient simplement à personne.
Ce qui n’est toujours pas corrigé
Le scan de vérification vérifie les colonnes auxquelles j’ai pensé. Une colonne à laquelle je n’ai pas pensé est une fuite, et la checklist de pull request est un contrôle de processus, pas technique. La correction technique est une liste blanche au niveau du schéma, où chaque colonne est soit explicitement marquée « non personnelle » soit explicitement anonymisée, et où le script refuse de tourner sur toute colonne qui n’est ni l’un ni l’autre. J’ai la liste ; je ne l’ai pas câblée.
Le rafraîchissement tourne depuis la machine de production elle-même, ce qui veut dire que la machine de production a un cron qui connaît l’hôte SSH de la préprod. C’est une chose de plus sur cette machine que je ne le voudrais. Pousser vers un stockage objet et laisser la préprod tirer serait plus propre.
Et le dump existe dans /tmp sur le serveur de production pendant les dix minutes où le script tourne. Broyé après, chiffré jamais. Pour dix minutes par semaine, c’est un risque que j’ai décidé d’accepter, et je l’écris pour qu’il reste une décision plutôt que de devenir une habitude.
La leçon
« Copier la prod vers le staging » est une de ces demandes qui sonne comme une tâche d’ingénierie et qui est en fait une décision de gouvernance des données, prise dans une fenêtre de chat, par des gens qui seraient horrifiés de la voir formulée ainsi. La bonne réponse n’a jamais été de la refuser. C’était de remarquer que le client voulait des bugs réalistes, pas des identités réelles, et que ces deux choses se séparent proprement avec un dimanche de SQL.
Chaque article de cette série parlait d’une chose qui a cassé. Celui-ci parle d’une chose qui n’a pas cassé, parce que pour une fois la décision a été prise avant l’incident au lieu d’après. J’aurais aimé que plus de la série se passe ainsi. C’est la seule leçon que les neuf autres ont en commun.