← Retour au blog

Tester sur les données de production, sans les données personnelles

Tutoriel · 14 min de lecture · Équipe Lampion

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.

Contexte

Le dilemme du jeu de test

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.

Les fixtures
Volumétrie, cardinalité et distributions inventées. Les plans d’exécution y sont faux, les index inutiles et les régressions invisibles.
Le dump de prod
Une copie des données personnelles dans un environnement de test, souvent sur un poste, parfois dans un bucket : un traitement à part entière, rarement au registre.
Ce que dit le texte
La minimisation (RGPD, article 5) veut qu’on ne traite que ce qui est nécessaire à la finalité. Tester une requête ne nécessite pas de lire un e-mail réel.
Ce qu’on veut
Les volumes et les distributions réelles, des identités fausses, et rien qui survive à la pull request.

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.

Étape 01

Déclarer les colonnes sensibles

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.

jsonmasking.json
[
  { "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.

Les fonctions de masquage acceptées

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.

FonctionCe qu’elle rendBon usage
anon.fake_email()Une adresse plausible et fausseColonnes e-mail — le format reste valide
anon.fake_first_name()Un prénom du dictionnairePrénoms, colonnes d’affichage
anon.fake_last_name()Un nom du dictionnaireNoms de famille
anon.fake_city()Une villeAdresses postales
anon.fake_company()Une raison socialeNoms de clients B2B
anon.fake_iban()Un IBAN de forme valideCoordonnées bancaires
anon.fake_siret()Un SIRET de forme valideIdentifiants 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 stableQuand il faut conserver l’égalité entre deux lignes
anon.random_string(10)Dix caractères aléatoiresChamps libres sans format imposé
anon.random_zip()Un code postalCodes postaux
anon.random_date()Une date aléatoireDates de naissance
anon.random_phone($$0X XX XX XX XX$$)Un numéro au format françaisTéléphones quand le format compte
$$REDACTED$$Une constanteChamps de commentaire libre
NULLRienColonnes dont le test n’a pas besoin
anon.hash() mérite une mention : c’est la seule fonction qui préserve l’égalité. Deux lignes qui partagent la même adresse la partageront encore après masquage — indispensable si vos tests portent sur des doublons ou des jointures par e-mail, et à éviter si la colonne a peu de valeurs distinctes, auquel cas l’empreinte se ré-identifie par simple comptage.
Étape 02

Créer la branch de la pull request

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.

bashterminal
# 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
Ce que la branch contient vraiment

À 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.

Étape 03

Activer le masquage et poser les règles

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.

public.users — une seule table, un seul stockage cloud_admin non masqué — le seul email last_name [email protected] Dupont [email protected] Martin lampion_analyst · app_user · tout le reste SECURITY LABEL … IS 'MASKED' email last_name [email protected] Lefevre [email protected] Girard
Fig. 1 — Le masquage dynamique ne touche pas les lignes : il réécrit la lecture. La même requête, sur la même table, rend des valeurs différentes selon le rôle qui la pose.
bashterminal
# 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

CREATE EXTENSION
anon est installée sur le compute de la branch, puis anon.init() charge les dictionnaires de valeurs factices.
Rôle analyste
lampion_analyst est créé (ou son mot de passe est renouvelé) en lecture seule sur le schéma public, avec sa propre connection string.
Tous masqués sauf un
Chaque rôle non système reçoit SECURITY LABEL … IS 'MASKED'. Seul cloud_admin en est exclu : c’est le seul à voir les données réelles.
Masquage dynamique
anon.start_dynamic_masking() met le tout en service. Les lignes ne sont pas modifiées — la substitution se fait à la lecture.
Le rôle de la clé d’API compte

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.

Étape 04

Vérifier que le masquage tient

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.

bashterminal
# 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.

sqltests/assert_masked.sql
-- 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

Un domaine connu
Après masquage, aucune adresse ne doit appartenir à vos domaines clients. Une requête qui compte les adresses hors dictionnaire factice suffit.
Une valeur témoin
Prenez une ligne dont vous connaissez la valeur réelle et vérifiez qu’elle ne ressort pas. C’est le test qui échoue le jour où une règle disparaît.
La couverture
Comparez la liste des colonnes de masking.json à celle que renvoie anon status : une règle en moins, la CI s’arrête.
Le bon rôle
Vérifiez avec le rôle qu’utiliseront les tests, pas avec cloud_admin — qui, lui, verra toujours les valeurs réelles.
Étape 05

Lancer la suite de tests

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

lampion_analyst
Lecture seule. Parfait pour une suite qui ne fait que lire, des tests de requêtes ou une revue de plans d’exécution.
Le rôle applicatif
Si la suite écrit, connectez-vous avec le rôle de l’application : il est masqué lui aussi, puisque tous les rôles le sont sauf cloud_admin.
Jamais cloud_admin
C’est le seul rôle non masqué. L’utiliser pour les tests annule tout ce qui précède, en silence.
Ce qui reste vrai
Volumétrie, cardinalité, distributions, index, plans d’exécution : le masquage ne change que les valeurs rendues, pas la forme des données.
bashterminal
# 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é.

Étape 06

Le workflow complet

Les six commandes précédentes, assemblées : ouverture de la pull request, masquage, vérification, tests, puis suppression à la fermeture.

01 PR ouverte workflow déclenché 02 Fork branch au LSN courant 03 Masquage enable + règles 04 Vérification la donnée, pas le statut 05 Tests rôle masqué 06 Suppression à la fermeture Le branching n’est jamais facturé : seuls le compute consommé et le stockage le sont.
Fig. 2 — Le cycle complet. Les quatre premières étapes prennent quelques secondes ; la dernière est celle qu’il ne faut jamais rendre optionnelle.
yaml.github/workflows/tests-anonymises.yml
# 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.

Étape 07

Contrôler les performances au passage

Puisque la branch porte les volumes réels, autant en profiter pour comparer les requêtes lourdes entre la production et la pull request.

bashbash
# 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

La capture
Les requêtes les plus coûteuses de la source, lues dans pg_stat_statements et classées par temps total cumulé.
Le rejeu
Chacune est réexécutée une fois sur la cible, et le temps mesuré est comparé au temps moyen de la source.
Le verdict
Un ratio supérieur à 2 est signalé en régression, inférieur à 0,5 en amélioration, le reste est stable.
Les angles morts
Les requêtes paramétrées sont ignorées, chacune n’est jouée qu’une fois, sans concurrence ni respect de l’ordre réel.
Étape 08

Détruire la branch, et pourquoi ce n’est pas optionnel

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.

Stockage partagé avec main — les valeurs réelles, intactes anon · masquage appliqué à la lecture lampion_analyst, app_user, CI → valeurs factices cloud_admin → valeurs réelles, masque contourné Une branch masquée reste une copie de la production : TTL court, pas d’export, pas de connection string propriétaire dans les logs.
Fig. 3 — Ce que le masquage dynamique ne fait pas. Les données réelles sont toujours là, dans le stockage partagé avec la production. Ce qui est contrôlé, c’est qui peut les lire en clair — pas leur présence.
TTL
Donnez à la branch une durée de vie d’une heure à trente jours. C’est le filet qui joue quand le job de nettoyage échoue ou que la pull request est abandonnée.
Suppression explicite
Le job cleanup supprime la branch à la fermeture de la pull request, sans attendre l’expiration.
Pas d’export
lampion dump depuis une branch masquée s’exécute avec le rôle propriétaire : le fichier produit contient les valeurs réelles. Ce n’est pas un moyen de fabriquer un jeu de test à distribuer.
Secrets
Masquez la connection string dans les logs du workflow et n’exposez jamais celle de cloud_admin à un job de test.
Jamais sur main
L’usage prévu est la branch de test. Rien dans l’API n’empêche techniquement d’activer le masquage sur la branche primaire — c’est donc une règle d’équipe à tenir, pas un garde-fou sur lequel se reposer.
Le coût
Le branching n’est jamais facturé ; un compute inactif se suspend après cinq minutes. Une branch de pull request oubliée coûte le stockage de ses écritures, rien de plus.
Checklist

Avant de brancher ça sur toutes vos pull requests

Dix vérifications. Les quatre premières évitent une fuite, les six suivantes évitent une fausse confiance.

  1. 01masking.json est versionné dans le dépôt et relu en pull request au même titre que les migrations.
  2. 02Chaque colonne personnelle du schéma a sa règle — la liste est comparée automatiquement à ce que renvoie anon status.
  3. 03La suite de tests se connecte avec un rôle masqué, jamais avec cloud_admin.
  4. 04La vérification par la donnée s’exécute avant les tests, et la CI s’arrête si elle échoue.
  5. 05La clé d’API du workflow a été générée par un compte admin ou owner, sinon anon enable renverra 403.
  6. 06La connection string est masquée dans les logs du workflow.
  7. 07La branch a un TTL, et le job de nettoyage la supprime à la fermeture de la pull request.
  8. 08Aucun export n’est produit depuis une branch masquée : le dump sort les valeurs réelles.
  9. 09Le masquage n’est jamais activé sur la branche primaire, faute de garde-fou technique.
  10. 10anon.hash() n’est utilisé que sur des colonnes à forte cardinalité, sans quoi l’empreinte se ré-identifie par comptage.

Des tests qui mentent
un peu moins.

Free : 3 projets, 3 branches, 0,25 CU, 512 Mo de stockage — sans carte bancaire. Données hébergées en France.