Un jeu de test qui ressemble à la production, ça n’existe pas. Les fixtures ont deux cents lignes bien réparties ; la production a un client avec quatre millions de lignes, trois adresses en double et une colonne que personne ne remplit depuis 2023.
Ce tutoriel monte une voie intermédiaire : à chaque pull request, une branch forkée de la production, un masquage dynamique posé dessus, la suite de tests qui tourne sur la vraie volumétrie en ne lisant que des valeurs factices, et une destruction à la fermeture. Précision d’emblée : les données réelles restent dans la branch, seule leur lecture est masquée. Ce n’est pas une anonymisation au sens du RGPD — au mieux une pseudonymisation, qui réduit l’exposition des données personnelles en test sans faire de la branch autre chose qu’un traitement de ces données.
Entre des fixtures qui ne ressemblent à rien et une copie de la production sur un poste de développeur, la plupart des équipes choisissent le premier et découvrent le second en incident.
Les bugs qui coûtent cher ne se voient pas sur deux cents lignes bien réparties. Ils apparaissent sur une table à quarante millions de lignes, sur une distribution où un client pèse à lui seul 60 % du volume, sur des valeurs nulles que personne n’avait prévues. Un jeu de test synthétique ne les produit jamais — il produit exactement ce que son auteur avait en tête, ce qui est précisément le problème.
Le troisième chemin tient en trois briques déjà présentes : une branch Lampion, qui est un fork copy-on-write de la production au LSN (Log Sequence Number) courant ; l’extension postgresql_anonymizer, qui masque à la lecture sans réécrire une seule ligne ; et une durée de vie bornée, avec une suppression à la fermeture de la pull request et un TTL (Time To Live) en filet.
Quelles colonnes sont personnelles, et par quoi les remplacer : c’est une décision d’équipe, pas un réglage d’infrastructure. Elle a donc sa place dans le dépôt, relue en pull request comme le reste.
[
{ "table": "users", "column": "email", "function": "anon.fake_email()" },
{ "table": "users", "column": "last_name", "function": "anon.fake_last_name()" },
{ "table": "users", "column": "phone", "function": "anon.random_phone($$0X XX XX XX XX$$)" },
{ "table": "customers", "column": "company", "function": "anon.fake_company()" },
{ "table": "customers", "column": "iban", "function": "anon.fake_iban()" },
{ "table": "orders", "column": "notes", "function": "$$REDACTED$$" }
] Versionner ce fichier a un effet secondaire utile : ajouter une colonne personnelle sans ajouter sa règle devient visible en revue de code. C’est aussi le document que vous montrerez quand on vous demandera quelles données partent en test.
L’API n’accepte que cette liste — toute autre valeur est refusée avec un 400. C’est une allowlist, et c’est ce qui empêche qu’une règle de masquage devienne un vecteur d’injection SQL.
| Fonction | Ce qu’elle rend | Bon usage |
|---|---|---|
| anon.fake_email() | Une adresse plausible et fausse | Colonnes e-mail — le format reste valide |
| anon.fake_first_name() | Un prénom du dictionnaire | Prénoms, colonnes d’affichage |
| anon.fake_last_name() | Un nom du dictionnaire | Noms de famille |
| anon.fake_city() | Une ville | Adresses postales |
| anon.fake_company() | Une raison sociale | Noms de clients B2B |
| anon.fake_iban() | Un IBAN de forme valide | Coordonnées bancaires |
| anon.fake_siret() | Un SIRET de forme valide | Identifiants d’entreprise |
| anon.partial({COL},2,$$***$$,2) | Les 2 premiers et 2 derniers caractères, le reste masqué | Téléphones, références — garde la longueur |
| anon.hash({COL}) | Une empreinte stable | Quand il faut conserver l’égalité entre deux lignes |
| anon.random_string(10) | Dix caractères aléatoires | Champs libres sans format imposé |
| anon.random_zip() | Un code postal | Codes postaux |
| anon.random_date() | Une date aléatoire | Dates de naissance |
| anon.random_phone($$0X XX XX XX XX$$) | Un numéro au format français | Téléphones quand le format compte |
| $$REDACTED$$ | Une constante | Champs de commentaire libre |
| NULL | Rien | Colonnes dont le test n’a pas besoin |
Un fork copy-on-write au LSN courant de main : les vraies données, en quelques secondes, sans copie physique. Le nom est unique dans le projet — une seconde création sous le même nom renvoie 409. Retenez son identifiant, tout le reste s’y accroche.
# La branch de la PR, forkée depuis main (pour --parent, l'ID du projet désigne main), supprimée d'office au bout de 72 h $ export PROJECT_ID=3f8a2c1e9b7d4f60a5e2c8d1b4f7a903 $ export BRANCH_ID=$(lampion branches create "$PROJECT_ID" pr-482 \ --parent "$PROJECT_ID" --ttl 72 --json | jq -r .id) # Son identifiant : c'est lui qu'attendent toutes les commandes qui suivent $ echo "$BRANCH_ID" 9c4a71f2e08d4b3a8f61d2c7e5b09a14
À cet instant, la branch porte les données de production, en clair. Le fork copy-on-write partage le stockage du parent : rien n’a été masqué, rien n’a même été copié. Le masquage de l’étape suivante agit à la lecture, pas sur le contenu. Tant qu’il n’est pas activé et vérifié, cette branch se traite exactement comme la production — et même ensuite, elle en reste une copie.
Une commande installe l’extension et bascule le compute en masquage dynamique ; une boucle applique les règles du dépôt. Aucune donnée n’est réécrite.
# Installe l'extension anon, crée le rôle analyste, bascule en masquage dynamique $ lampion anon enable "$PROJECT_ID" "$BRANCH_ID" Anonymization enabled. lampion_analyst role created. # Applique les règles du dépôt, une par colonne $ jq -c '.[]' masking.json | while read -r r; do lampion anon add-rule "$PROJECT_ID" "$BRANCH_ID" \ --table "$(jq -r .table <<<"$r")" \ --column "$(jq -r .column <<<"$r")" \ --function "$(jq -r .function <<<"$r")" done Rule added: public.users.email -> anon.fake_email()
Ce que fait enable, précisément
Activer ou désactiver le masquage et gérer les règles demandent le rôle admin ; lire le statut et la liste des règles suffit avec un accès projet. Une API key hérite du rôle de la personne qui l’a créée dans l’organisation — une clé fabriquée par un compte developer renverra donc 403 sur anon enable. Faites générer la clé de CI par un admin ou un owner, et rangez-la en secret de dépôt.
C’est l’étape qu’on saute, et c’est la seule qui prouve quelque chose. Le statut affiché par la console dit ce qui a été demandé ; seule une lecture dit ce qui s’applique.
# Ce que la console croit savoir (sortie abrégée) $ lampion anon status "$PROJECT_ID" "$BRANCH_ID" Enabled: True id schema_name table_name column_name masking_function a3f9c210b74e public users email anon.fake_email()
Cette double lecture n’est pas de la ceinture-bretelle. Une règle qui ne s’applique pas sur le compute est désormais refusée (502) et n’est pas enregistrée — longtemps, elle l’était quand même, avec un 201 et une règle affichée qui ne masquait rien. Mais le statut reste une déclaration : il ne voit ni une colonne personnelle oubliée dans masking.json, ni une suite branchée sur le mauvais rôle. La donnée est le seul témoin fiable, et c’est l’assertion à mettre dans la CI.
-- À exécuter avec le rôle des tests (ici lampion_analyst), jamais avec cloud_admin.
-- La première erreur arrête le script, et psql sort en échec.
\set ON_ERROR_STOP on
DO $$
DECLARE
total bigint;
leaked bigint;
BEGIN
-- 0. Une table vide ne prouve rien.
SELECT count(*) INTO total FROM users;
IF total = 0 THEN
RAISE EXCEPTION 'table users vide : rien à vérifier';
END IF;
-- 1. Aucune adresse ne doit appartenir à un domaine client connu.
SELECT count(*) INTO leaked
FROM users
WHERE lower(split_part(email, '@', 2)) IN ('acme.fr', 'beta-sa.fr');
IF leaked > 0 THEN
RAISE EXCEPTION 'masquage inactif : % adresse(s) de domaine client lisible(s) en clair', leaked;
END IF;
-- 2. Ligne témoin, créée exprès en production : sa valeur réelle ne doit jamais ressortir.
IF EXISTS (SELECT 1 FROM customers WHERE company = 'Temoin CI SAS') THEN
RAISE EXCEPTION 'masquage inactif : la ligne témoin ressort en clair';
END IF;
END $$; L’assertion à automatiser
Rien à changer dans le code des tests : seule la connection string change. Le masquage étant porté par le rôle, la suite lit des identités factices sans le savoir.
Choisir le rôle de la suite
# La connection string analyste, publiée par l'endpoint une fois le masquage actif $ export DATABASE_URL=$(lampion endpoints list "$PROJECT_ID" --json \ | jq -r --arg id "$BRANCH_ID" '.[] | select(.branch_id == $id) | .analyst_connection_string') # Puis la suite, telle quelle $ npm test ✓ 214 passed · 0 failed
Une conséquence agréable : les tests s’exécutent sur la même quantité de données que la production, donc les requêtes lentes le sont aussi en test. C’est le principal intérêt de la manœuvre, avant même la conformité.
Les étapes précédentes, assemblées : une branch par pull request, créée au premier passage et réutilisée aux suivants, masquage, vérification, tests, puis suppression à la fermeture.
# Une base de production masquée par pull request, détruite à la fermeture
name: tests-masques
on:
pull_request:
types: [opened, reopened, synchronize, closed]
# Un seul passage à la fois par pull request : deux pushs rapprochés
# ne créent pas deux branches.
concurrency:
group: ${{ github.workflow }}-${{ github.event.number }}
defaults:
run:
shell: bash # bash -eo pipefail : la moindre commande en échec arrête l'étape
env:
# Clé générée par un compte admin : anon enable l'exige.
LAMPION_TOKEN: ${{ secrets.LAMPION_TOKEN }}
PROJECT_ID: 3f8a2c1e9b7d4f60a5e2c8d1b4f7a903
BRANCH: pr-${{ github.event.number }}
jobs:
test:
if: github.event.action != 'closed'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pip install "lampion-cli>=0.2.1"
# Créée au premier passage, retrouvée par son nom aux pushs suivants.
- name: Forker la production
run: |
BRANCH_ID=$(lampion branches list "$PROJECT_ID" --json \
| jq -r --arg b "$BRANCH" '.[] | select(.name == $b) | .id')
if [ -z "$BRANCH_ID" ]; then
# --parent = ID du projet, donc main ; --ttl = filet si cleanup ne passe pas.
BRANCH_ID=$(lampion branches create "$PROJECT_ID" "$BRANCH" \
--parent "$PROJECT_ID" --ttl 72 --json | jq -r .id)
echo "NEW_BRANCH=true" >> "$GITHUB_ENV"
fi
test -n "$BRANCH_ID" && test "$BRANCH_ID" != "null"
echo "BRANCH_ID=$BRANCH_ID" >> "$GITHUB_ENV"
# main n'est pas masquée : la branch n'hérite d'aucune règle, on les pose.
- name: Masquer
if: env.NEW_BRANCH == 'true'
run: |
lampion anon enable "$PROJECT_ID" "$BRANCH_ID"
jq -c '.[]' masking.json | while read -r rule; do
lampion anon add-rule "$PROJECT_ID" "$BRANCH_ID" \
--table "$(jq -r .table <<<"$rule")" \
--column "$(jq -r .column <<<"$rule")" \
--function "$(jq -r .function <<<"$rule")"
done
- name: Récupérer la connection string masquée
run: |
URL=$(lampion endpoints list "$PROJECT_ID" --json \
| jq -r --arg id "$BRANCH_ID" \
'.[] | select(.branch_id == $id) | .analyst_connection_string')
test -n "$URL" && test "$URL" != "null"
echo "::add-mask::$URL"
echo "DATABASE_URL=$URL" >> "$GITHUB_ENV"
# Le masquage se prouve sur la donnée, jamais sur le statut.
- name: Vérifier le masquage
run: psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f tests/assert_masked.sql
- name: Tests
id: tests
run: npm test
# Échec avant les tests (masquage absent ou non prouvé) : la branch est
# supprimée, le push suivant repart d'un fork neuf.
- name: Supprimer une branch mal masquée
if: failure() && env.BRANCH_ID != '' && steps.tests.outcome == 'skipped'
run: lampion branches delete "$PROJECT_ID" "$BRANCH_ID"
cleanup:
if: github.event.action == 'closed'
runs-on: ubuntu-latest
steps:
- run: pip install "lampion-cli>=0.2.1"
- name: Supprimer la branch de la pull request
run: |
BRANCH_ID=$(lampion branches list "$PROJECT_ID" --json \
| jq -r --arg b "$BRANCH" '.[] | select(.name == $b) | .id')
if [ -n "$BRANCH_ID" ]; then
lampion branches delete "$PROJECT_ID" "$BRANCH_ID"
fi La vérification passe avant les tests : si le masquage n’est pas prouvé, la suite ne s’exécute pas, et la branch est supprimée pour que le push suivant reparte d’un fork neuf. Le masquage est posé une fois, à la création de la branch. Une branch créée depuis un parent masqué en reprend le statut et les règles ; mais ici le parent est main, qui n’est pas masquée et ne peut pas l’être — le workflow applique donc les règles lui-même, ce qui est fastidieux à la main et gratuit dans un workflow.
Puisque la branch porte les volumes réels, un comparateur — par l’API ou la console, sans commande CLI — rejoue sur elle les requêtes les plus coûteuses de main.
# Comparer les requêtes coûteuses entre l'endpoint de main et celui de la PR $ curl -X POST "https://api.lampion.cloud/v1/projects/$PROJECT_ID/replay" \ -H "Authorization: Bearer $LAMPION_TOKEN" \ -H "Content-Type: application/json" \ -d '{"source_endpoint_id": "ep-3f8a2c1e9b7d", "target_endpoint_id": "ep-9c4a71f2e08d", "limit": 20}' # Réponse abrégée : le détail par requête est dans "queries" {"summary": {"total": 20, "regressions": 1, "improvements": 3, "stable": 12}}
Il joue une fois sur la cible chacune des requêtes qui ont coûté le plus de temps cumulé à la source (pg_stat_statements) : au-delà de ×2, régression ; en deçà de ×0,5, amélioration. Requêtes paramétrées écartées, ni concurrence ni ordre réel : c’est un banc de comparaison, pas un rejeu du trafic.
Le masquage réécrit la lecture, pas le stockage. Une branch masquée reste une copie logique de la production : sa suppression fait partie du dispositif, pas du ménage.
Dix vérifications. Les quatre premières évitent une fuite, les six suivantes évitent une fausse confiance.
Free : 3 projets, 3 branches, 0,25 CU, 512 Mo de stockage — sans carte bancaire. Données hébergées en France.