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 la troisième voie : à 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 avec de fausses identités, et une destruction à la fermeture. Une heure de mise en place, puis plus rien à faire.
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 qui supprime la branch quand la pull request se ferme.
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}) | Un SHA-256 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. Retenez son identifiant, tout le reste s’y accroche.
# La branch de la PR, forkée depuis main $ lampion branches create prj_a1b2c3d4 pr-482 --parent prj_a1b2c3d4 ✓ pr-482 · fork copy-on-write au LSN courant · seeds rejoués (3) # Son identifiant : c'est lui qu'attendent les commandes de masquage $ lampion branches list prj_a1b2c3d4 --json | jq -r '.[] | select(.name == "pr-482") | .id' br-9c4a71f2e08d
À 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é anonymisé, 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.
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 prj_a1b2c3d4 br-9c4a71f2e08d ✓ 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 prj_a1b2c3d4 br-9c4a71f2e08d \ --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 $ lampion anon status prj_a1b2c3d4 br-9c4a71f2e08d Enabled: true id schema table column masking_function a3f9c210b74e public users email anon.fake_email()
Cette double lecture n’est pas de la ceinture-bretelle. Quand l’application d’une règle échoue sur le compute, elle est malgré tout enregistrée côté console et l’API répond 201 : le statut peut donc afficher une règle qui ne masque rien. La donnée est le seul témoin fiable, et c’est aussi l’assertion à mettre dans la CI.
-- La même requête, avec les deux connection strings.
DO $$
DECLARE leaked int;
BEGIN
SELECT count(*) INTO leaked
FROM users
WHERE email LIKE '%@acme.fr'
OR email LIKE '%@beta-sa.fr';
IF leaked <> 0 THEN
RAISE EXCEPTION 'masquage inactif : % adresse(s) reelle(s) lisibles', leaked;
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 voit des identités fausses 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 prj_a1b2c3d4 --json \ | jq -r '.[] | select(.name == "pr-482") | .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 six commandes précédentes, assemblées : ouverture de la pull request, masquage, vérification, tests, puis suppression à la fermeture.
# Une base de production masquée par pull request, détruite à la fermeture
name: tests-anonymises
on:
pull_request:
types: [opened, synchronize, closed]
env:
# Clé générée par un compte admin : anon enable l'exige.
LAMPION_TOKEN: ${{ secrets.LAMPION_TOKEN }}
PROJECT_ID: prj_a1b2c3d4
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
- name: Forker la production
run: |
lampion branches create "$PROJECT_ID" "$BRANCH" --parent "$PROJECT_ID"
BRANCH_ID=$(lampion branches list "$PROJECT_ID" --json \
| jq -r --arg b "$BRANCH" '.[] | select(.name == $b) | .id')
echo "BRANCH_ID=$BRANCH_ID" >> "$GITHUB_ENV"
- name: Masquer
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 b "$BRANCH" \
'.[] | select(.name == $b) | .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
run: npm test
cleanup:
if: github.event.action == 'closed'
runs-on: ubuntu-latest
steps:
- run: pip install lampion-cli
- run: |
BRANCH_ID=$(lampion branches list "$PROJECT_ID" --json \
| jq -r --arg b "$BRANCH" '.[] | select(.name == $b) | .id')
lampion branches delete "$PROJECT_ID" "$BRANCH_ID" Le job de vérification est volontairement placé avant les tests : si le masquage n’est pas en place, la suite ne doit pas s’exécuter du tout. Et comme les règles ne sont pas héritées à la création d’une branch, elles sont réappliquées à chaque fois — c’est fastidieux à la main, gratuit dans un workflow.
Puisque la branch porte les volumes réels, autant en profiter pour comparer les requêtes lourdes entre la production et la pull request.
# Comparer les requêtes coûteuses entre deux endpoints $ curl -X POST "$LAMPION_API/v1/projects/prj_a1b2c3d4/replay" \ -H "Authorization: Bearer $LAMPION_TOKEN" \ -H "Content-Type: application/json" \ -d '{"source_endpoint_id": "ep-4f9c21ab8d3e", "target_endpoint_id": "ep-7d31c8f0a94b", "limit": 20}' {"summary": {"total": 20, "regressions": 1, "improvements": 3, "stable": 12}}
Appelons les choses par leur nom : ce n’est pas un rejeu du trafic de production, c’est un banc de comparaison sur les requêtes lourdes non paramétrées. Utile pour attraper un index supprimé par mégarde ou une jointure devenue quadratique ; inutile pour prédire un comportement sous charge. Il n’existe pas de commande CLI pour cet appel — c’est l’API ou la console.
Ce que fait réellement ce comparateur
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.