← Retour au blog

Tester sur les données de production, avec les données personnelles masquées

Tutoriel · Mis à jour le · 15 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 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.

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. Masquer la lecture va dans ce sens, mais la branch contient toujours les e-mails réels : c’est encore un traitement de données personnelles, à encadrer comme tel.
Ce qu’on veut
Les volumes et les distributions réelles, des identités factices à la lecture, 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 bornée, avec une suppression à la fermeture de la pull request et un TTL (Time To Live) en filet.

É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})Une empreinte 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. 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.

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

É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 exclu du masquage email last_name [email protected] Dupont [email protected] Martin lampion_analyst · app_user · rôles masqués 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 "$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

CREATE EXTENSION
anon est installée sur le compute de la branch, puis anon.init() charge les dictionnaires de valeurs factices. L’API refuse l’opération sur la branche primaire (400).
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.
Les rôles masqués
Chaque rôle non système présent à l’activation reçoit SECURITY LABEL … IS 'MASKED', lampion_analyst compris ; un rôle créé ensuite sur la branch par l’API (console, lampion roles create) est masqué à son tour. Seul cloud_admin est exclu : il voit les données réelles. Un rôle créé autrement, en SQL avec cloud_admin, sort de cette garantie.
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 (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.

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

Un domaine connu
Après masquage, aucune adresse ne doit appartenir à vos domaines clients. Une requête qui les compte suffit, et fait échouer le script au premier résultat.
Une valeur témoin
Créez en production 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 lit des identités factices 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 s’il existait à l’activation ou a été créé ensuite par l’API. Faites tourner l’assertion avec ce rôle-là.
Jamais cloud_admin
Il est exclu du masquage par construction. 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 "$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é.

Étape 06

Le workflow complet

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.

01 PR ouverte workflow déclenché 02 Fork LSN courant · TTL 72 h 03 Masquage enable + règles 04 Vérification la donnée, pas le statut 05 Tests rôle masqué 06 Suppression à la fermeture Aucun frais par branche : seuls le compute consommé et le stockage sont facturés.
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-masques.yml
# 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.

Étape 07

Comparer les performances au passage

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.

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

É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
Le workflow crée la branch avec --ttl 72 (de 1 à 720 heures possibles) ; une fois le délai écoulé, la tâche de nettoyage périodique de la plateforme la supprime. C’est le filet qui joue si le job cleanup échoue ou si la pull request est abandonnée — et une pull request ouverte plus longtemps retrouve une branch neuve au push suivant.
Suppression explicite
Le job cleanup retrouve la branch par son nom et la supprime par son identifiant à 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.
Pas sur main
L’API refuse d’activer le masquage ou d’ajouter une règle sur la branche primaire (400) : le masquage se pose sur une branch de test, jamais sur la production elle-même.
Le coût
Aucun frais par branche ; un compute inactif se suspend après cinq minutes. Une branch de pull request oubliée ne consomme plus de compute une fois suspendue, et le TTL finit par la supprimer.
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 est créée avec --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. 09Les branches de test figurent au registre des traitements : masquées à la lecture, leurs données restent personnelles.
  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.