Masquage de données & postgresql_anonymizer

12 mars 2025

Dalibo SCOP

Sur ce document

Formation Module Y5
Titre Masquage de données & postgresql_anonymizer
Révision 25.03
PDF https://dali.bo/y5_pdf
EPUB https://dali.bo/y5_epub
HTML https://dali.bo/y5_html
Slides https://dali.bo/y5_slides

Vous trouverez en ligne les différentes versions complètes de ce document.


Chers lectrices & lecteurs,

Nos formations PostgreSQL sont issues de nombreuses années d’études, d’expérience de terrain et de passion pour les logiciels libres. Pour Dalibo, l’utilisation de PostgreSQL n’est pas une marque d’opportunisme commercial, mais l’expression d’un engagement de longue date. Le choix de l’Open Source est aussi le choix de l’implication dans la communauté du logiciel.

Au‑delà du contenu technique en lui‑même, notre intention est de transmettre les valeurs qui animent et unissent les développeurs de PostgreSQL depuis toujours : partage, ouverture, transparence, créativité, dynamisme… Le but premier de nos formations est de vous aider à mieux exploiter toute la puissance de PostgreSQL mais nous espérons également qu’elles vous inciteront à devenir un membre actif de la communauté en partageant à votre tour le savoir‑faire que vous aurez acquis avec nous.

Nous mettons un point d’honneur à maintenir nos manuels à jour, avec des informations précises et des exemples détaillés. Toutefois malgré nos efforts et nos multiples relectures, il est probable que ce document contienne des oublis, des coquilles, des imprécisions ou des erreurs. Si vous constatez un souci, n’hésitez pas à le signaler via l’adresse !

À propos de DALIBO

DALIBO est le spécialiste français de PostgreSQL. Nous proposons du support, de la formation et du conseil depuis 2005.

Retrouvez toutes nos formations sur https://dalibo.com/formations

Remerciements

Ce manuel de formation est une aventure collective qui se transmet au sein de notre société depuis des années. Nous remercions chaleureusement ici toutes les personnes qui ont contribué directement ou indirectement à cet ouvrage, notamment :

Alexandre Anriot, Jean‑Paul Argudo, Carole Arnaud, Alexandre Baron, David Bidoc, Sharon Bonan, Franck Boudehen, Arnaud Bruniquel, Pierrick Chovelon, Damien Clochard, Christophe Courtois, Marc Cousin, Gilles Darold, Ronan Dunklau, Vik Fearing, Stefan Fercot, Dimitri Fontaine, Pierre Giraud, Nicolas Gollet, Florent Jardin, Virginie Jourdan, Luc Lamarle, Denis Laxalde, Guillaume Lelarge, Alain Lesage, Benoit Lobréau, Jean‑Louis Louër, Thibaut Madelaine, Adrien Nayrat, Alexandre Pereira, Flavie Perette, Robin Portigliatti, Thomas Reiss, Maël Rimbault, Jehan-Guillaume de Rorthais, Julien Rouhaud, Stéphane Schildknecht, Julien Tachoires, Nicolas Thauvin, Be Hai Tran, Christophe Truffier, Arnaud de Vathaire, Cédric Villemain, Thibaud Walkowiak, Frédéric Yhuel.

Forme de ce manuel

Les versions PDF, EPUB ou HTML de ce document sont structurées autour des slides de nos formations. Le texte suivant chaque slide contient le cours et de nombreux détails qui ne peuvent être données à l’oral.

Licence Creative Commons CC-BY-NC-SA

Cette formation est sous licence CC-BY-NC-SA. Vous êtes libre de la redistribuer et/ou modifier aux conditions suivantes :

  • Paternité
  • Pas d’utilisation commerciale
  • Partage des conditions initiales à l’identique

Vous n’avez pas le droit d’utiliser cette création à des fins commerciales.

Si vous modifiez, transformez ou adaptez cette création, vous n’avez le droit de distribuer la création qui en résulte que sous un contrat identique à celui-ci.

Vous devez citer le nom de l’auteur original de la manière indiquée par l’auteur de l’œuvre ou le titulaire des droits qui vous confère cette autorisation (mais pas d’une manière qui suggérerait qu’ils vous soutiennent ou approuvent votre utilisation de l’œuvre). À chaque réutilisation ou distribution de cette création, vous devez faire apparaître clairement au public les conditions contractuelles de sa mise à disposition. La meilleure manière de les indiquer est un lien vers cette page web. Chacune de ces conditions peut être levée si vous obtenez l’autorisation du titulaire des droits sur cette œuvre. Rien dans ce contrat ne diminue ou ne restreint le droit moral de l’auteur ou des auteurs.

Le texte complet de la licence est disponible sur http://creativecommons.org/licenses/by-nc-sa/2.0/fr/legalcode

Cela inclut les diapositives, les manuels eux-mêmes et les travaux pratiques. Cette formation peut également contenir quelques images et schémas dont la redistribution est soumise à des licences différentes qui sont alors précisées.

Marques déposées

PostgreSQL® Postgres® et le logo Slonik sont des marques déposées par PostgreSQL Community Association of Canada.

Versions de PostgreSQL couvertes

Ce document ne couvre que les versions supportées de PostgreSQL au moment de sa rédaction, soit les versions 13 à 17.

Sur les versions précédentes susceptibles d’être encore rencontrées en production, seuls quelques points très importants sont évoqués, en plus éventuellement de quelques éléments historiques.

Sauf précision contraire, le système d’exploitation utilisé est Linux.

Masquage de données & postgresql_anonymizer

postgresql_anonymizer

Photo d’Erwin Bernal, Montreal, 2015, licence CC BY 2.0


Cas d’usage

  • Paul : le propriétaire
  • Pierre : data scientist
  • Jack : employé chargé des fournisseurs

La boutique de Paul a beaucoup de clients. Paul demande à son ami Pierre, data scientist, des statistiques sur ses clients (âge moyen, etc.).

Pierre demande un accès direct à la base de données pour écrire ses requêtes SQL.

Jack est un employé de Paul, chargé des relations avec les divers fournisseurs de la boutique.

Paul respecte la vie privée de ses fournisseurs. Il doit masquer les informations personnelles à Pierre, mais Jack doit pouvoir lire les vraies données, et y écrire.

Crédits

Cet exemple pratique est un travail collectif de Damien Clochard, Be Hai Tran, Florent Jardin et Frédéric Yhuel.

La version originale en anglais est diffusée sous licence PostgreSQL.

Le présent document en est l’adaptation en français.

Paul’s Boutique est le second album studio du groupe de hip-hop américain les Beastie Boys, sorti le 25 juillet 1989 chez Capitol Records.

La photo ci-dessus est d’Erwin Bernal, sous licence CC BY 2.0.


Objectifs

Nous allons découvrir :

  • comment écrire des règles de masquage
  • la différence entre masquage dynamique et masquage statique
  • comment implémenter un masquage avancé

PostgreSQL Anonymizer


Principe

Principe :

  • Extension
  • Déclaratif (DDL)

postgresql_anonymizer est une extension pour masquer ou remplacer des données à caractère personnel (ou PII pour personnally identifiable information) ou toute donnée sensible dans une base de données PostgreSQL.

Le projet a une approche déclarative de l’anonymisation. Vous pouvez déclarer les règles de masquage dans PostgreSQL avec du DDL (Data Definition Language, ou langage de définition des données) et spécifier votre stratégie d’anonymisation dans la définition de la table.


4 méthodes de masquage

Principe :

  • masquage statique
  • masquage dynamique
  • sauvegardes anonymisées
  • vues de masquage

Une fois les règles de masquage définies, vous pouvez accéder aux données anonymisées de quatre manières différentes :

Cette présentation n’entrera pas dans le détail du RGPD et des principes généraux d’anonymisation. Pour plus d’informations, référez-vous aux présentations de Damien Clochard ci-dessous :


Prérequis

Installation

Cet exemple nécessite :

  • une instance PostgreSQL ;
  • l’extension PostgreSQL Anonymizer (anon)
    • installée, initialisée par un super-utilisateur
  • une base boutique
    • dont le propriétaire est paul, super-utilisateur
  • les rôles pierre et jack
    • avec droits de connexion à boutique

Voir section « Installation » dans la documentation pour savoir comment installer l’extension dans votre instance PostgreSQL.

Par exemple :

Sous Rocky Linux 8

sudo dnf install -y https://download.postgresql.org/pub/repos/yum/reporpms/EL-8-x86_64/pgdg-redhat-repo-latest.noarch.rpm
sudo dnf -qy module disable postgresql
sudo dnf install -y postgresql17-server
sudo /usr/pgsql-17/bin/postgresql-17-setup initdb
sudo systemctl enable postgresql-17
sudo systemctl start postgresql-17
  • Ajout du dépôt RPM DaLibo Labs :
sudo dnf install -y https://yum.dalibo.org/labs/dalibo-labs-4-1.noarch.rpm
  • Lancer l’installation :
sudo dnf install -y postgresql_anonymizer_17

Sous Debian / Ubuntu

  • L’utilisation des dépôts du PGDG est optionnelle, mais recommandée (pour le choix des versions de PostgreSQL et divers outils) :
sudo apt install -y postgresql-common
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y
sudo apt -y install postgresql-17
  • Ajout du dépôt DaLibo Labs DEB :
sudo apt install -y curl lsb-release
echo deb http://apt.dalibo.org/labs $(lsb_release -cs)-dalibo main \
 |sudo tee /etc/apt/sources.list.d/dalibo-labs.list
sudo curl -fsSL -o /etc/apt/trusted.gpg.d/dalibo-labs.gpg \
 https://apt.dalibo.org/labs/debian-dalibo.gpg
sudo apt update
  • Lancer :
sudo apt install postgresql_anonymizer_17

Base d’exemple

Création des rôles pierre, paul, jack et la base boutique :

CREATE ROLE paul LOGIN SUPERUSER ;
CREATE ROLE pierre LOGIN ;
CREATE ROLE jack LOGIN ;

GRANT pg_read_all_data TO jack;
GRANT pg_write_all_data TO jack;

CREATE DATABASE boutique OWNER paul;
  • Définir un mot de passe pour chaque utilisateur :
\password paul
\password pierre
\password jack

En tant qu’utilisateur système postgres, créer le fichier .pgpass, le compléter et mettre à jour les droits :

touch ~/.pgpass
echo "*:*:boutique:pierre:<mot_de_passe>" >> ~/.pgpass
echo "*:*:boutique:paul:<mot_de_passe>" >> ~/.pgpass
echo "*:*:boutique:jack:<mot_de_passe>" >> ~/.pgpass
chmod 0600 ~/.pgpass

Remplacer par le mot de passe attribué à chaque rôle


Ouverture des accès

  • Mise à jour du fichier pg_hba.conf

Mettre à jour le fichier pg_hba.conf, situé :

  • sur RHEL : /var/lib/pgsql/17/data/pg_hba.conf
  • sur Debian : /etc/postgresql/17/main/pg_hba.conf
# TYPE  DATABASE        USER            ADDRESS                 METHOD
local   all             postgres                                peer
local   all             all                                     scram-sha-256

Les autres lignes peuvent être supprimées.

  • Charger l’extension :
ALTER DATABASE boutique
  SET session_preload_libraries = 'anon';

La modification du paramètre session_preload_libraries s’applique aux nouvelles sessions, une déconnexion et reconnexion est nécessaire pour que les changements soient visibles.

Recharger la configuration :

SELECT pg_reload_conf();

Activer l’extension

  • Activer l’extension
\c boutique
CREATE EXTENSION IF NOT EXISTS anon ;
SELECT anon.init() ;

On peut vérifier que la version installée est la bonne :

SELECT anon.version();
 version 
---------
 2.0.0-beta.1
(1 ligne)

Si la version correspond à la 1.3.2, cela signifie que le dépôt PGDG a été utilisé pour l’installation, et non le dépôt DaLibo Labs.

Désormais, sauf précision contraire, toutes les commandes sont à exécuter en tant que paul (le propriétaire).


Masquage statique

  • Le plus simple
  • Destructif

Le masquage statique est la méthode la plus simple pour cacher des données personnelles. L’idée est simplement de détruire les données originales et de les remplacer par des données artificielles.


L’histoire

  • Au fil des années, Paul a accumulé des données sur ses clients et leurs achats dans une base de données très simple.

  • Il a récemment installé un nouveau logiciel de ventes, et l’ancienne base est obsolète.

  • Avant de l’archiver, il voudrait en supprimer toutes les données personnelles.


Comment ça marche ?


Objectifs

Nous allons voir :

  • comment écrire des règles de masquage simples
  • les intérêts et limitations du masquage statique
  • le concept de « singularisation » d’une personne (singling out)

Table « customer »

\c boutique paul
DROP TABLE IF EXISTS customer CASCADE;
DROP TABLE IF EXISTS payout CASCADE;

CREATE TABLE customer (
    id SERIAL PRIMARY KEY,
    firstname TEXT,
    lastname TEXT,
    phone TEXT,
    birth DATE,
    postcode TEXT
);

Quelques clients

Insertion de quelques personnes :

INSERT INTO customer
VALUES
(107,'Sarah','Conor','060-911-0911', '1965-10-10', '90016'),
(258,'Luke', 'Skywalker', NULL, '1951-09-25', '90120'),
(341,'Don', 'Draper','347-515-3423', '1926-06-01', '04520')
;
SELECT * FROM customer;
 id  | firstname | lastname  |    phone     |   birth    | postcode
-----+-----------+-----------+--------------+------------+----------
 107 | Sarah     | Conor     | 060-911-0911 | 1965-10-10 | 90016
 258 | Luke      | Skywalker |              | 1951-09-25 | 90120
 341 | Don       | Draper    | 347-515-3423 | 1926-06-01 | 04520
(3 lignes)

Table « payout »

Les ventes sont suivies dans cette simple table :

CREATE TABLE payout (
    id SERIAL PRIMARY KEY,
    fk_customer_id INT REFERENCES customer(id),
    order_date DATE,
    payment_date DATE,
    amount INT
);

Quelques données

Quelques commandes :

INSERT INTO payout
VALUES
(1,107,'2021-10-01','2021-10-01', '7'),
(2,258,'2021-10-02','2021-10-03', '20'),
(3,341,'2021-10-02','2021-10-02', '543'),
(4,258,'2021-10-05','2021-10-05', '12'),
(5,258,'2021-10-06','2021-10-06', '92')
;

Déclarer les règles de masquage

SECURITY LABEL FOR anon ON COLUMN customer.lastname
IS 'MASKED WITH FUNCTION anon.dummy_last_name()';

SECURITY LABEL FOR anon ON COLUMN customer.phone
IS 'MASKED WITH FUNCTION anon.partial(phone,2,$$X-XXX-XX$$,2)';

Paul veut masquer le nom de famille et le numéro de téléphone de ses clients.

Pour cela, il utilise les fonctions dummy_last_name() et partial().


Gestion des SECURITY LABEL

  • Obtenir la liste des labels :
SELECT pg_class.relname AS table, pg_seclabel.label
FROM pg_seclabel INNER JOIN pg_class ON pg_seclabel.objoid = pg_class.oid
WHERE provider = 'anon';
  • Suppression d’un label :
SECURITY LABEL FOR anon ON COLUMN <table>.<colonne> IS NULL;

Les règles de masquage passent systématiquement par des SECURITY LABEL définis pour le schéma anon.

En cas d’erreur lors de la déclaration d’une fonction de masquage, il n’y a pas d’autre solution que de supprimer et de recréer le label.


Appliquer les règles de manière permanente

SELECT anon.anonymize_table('customer');

Cette fonction ne fait qu’appliquer la règle et doit renvoyer True. Ensuite :

SELECT id, firstname, lastname, phone
FROM customer;
 id  | firstname | lastname |    phone
-----+-----------+----------+--------------
 107 | Sarah     | Okuneva  | 06X-XXX-XX11
 258 | Luke      | Okuneva  |
 341 | Don       | Boyle    | 34X-XXX-XX23

Cette technique est nommée « masquage statique » car la donnée réelle a été détruite de manière définitive. L’anonymisation dynamique et les exports seront vus plus loin.


Exercices

E101 - Masquer les prénoms des clients

Déclarer une nouvelle règle de masquage et relancer l’anonymisation statique.

E102 - Masquer les 3 derniers chiffres du code postal

Paul réalise que le code postal est un bon indice sur l’endroit où vivent ses clients. Cependant, il voudrait pouvoir faire des statistiques par département.

Créer une règle de masquage pour remplacer les 3 derniers chiffres du code postal par ‘x’.

E103 - Compter le nombre de clients dans chaque département.

Agréger les clients selon le code postal anonymisé.

E104 - Ne garder que l’année dans les dates de naissance

Paul veut des statistiques selon l’âge. Mais il veut aussi masquer les vraies dates de naissance de ses clients.

Remplacer toutes les dates de naissance par le 1er janvier, en conservant l’année réelle. Utiliser la fonction make_date.

E105 - Identifier un client particulier

Même si un client est correctement anonymisé, il est possible d’isoler un individu grâce à des données d’autres tables. Par exemple, il est possible d’identifier le meilleur client de Paul avec une telle requête :

WITH best_client AS (
    SELECT SUM(amount), fk_customer_id
    FROM payout
    GROUP BY fk_customer_id
    ORDER BY 1 DESC
    LIMIT 1
)
SELECT c.*
FROM customer c
JOIN best_client b ON (c.id = b.fk_customer_id) ;
 id  | firstname | lastname |    phone     |   birth    | postcode
-----+-----------+----------+--------------+------------+----------
 341 | Don       | Boyle    | 34X-XXX-XX23 | 1926-06-01 | 04520

Ce processus est appelé « singularisation » (singling out) d’une personne.

Il faut donc aller plus loin dans l’anonymisation, en supprimant le lien entre une personne et sa société. Dans la table des commandes order, ce lien est matérialisé par une clé étrangère sur le champ fk_company_id. Mais nous ne pouvons supprimer des valeurs de cette colonne ou y insérer de faux identifiants, car cela briserait la contrainte de clé étrangère.

Comment séparer les clients de leurs paiements tout en respectant l’intégrité des données ?

Trouver une fonction qui mélange les valeurs de fk_company_id dans la table payout. Consulter la section shuffling de la documentation.

Solutions

S101

Déclarer une nouvelle règle de masquage et relancer l’anonymisation statique.

SECURITY LABEL FOR anon ON COLUMN customer.firstname
IS 'MASKED WITH FUNCTION anon.dummy_first_name()' ;

SELECT anon.anonymize_table('customer') ;

La table anonymisée devient :

SELECT id, firstname, lastname
FROM customer ;
 id  | firstname | lastname
-----+-----------+----------
 107 | Hans       | Barton
 258 | Jacqueline | Dare
 341 | Sibyl      | Runte

S102

Créer une règle de masquage pour remplacer les 3 derniers chiffres du code postal par ‘x’.

SECURITY LABEL FOR anon ON COLUMN customer.postcode
IS 'MASKED WITH FUNCTION anon.partial(postcode,2,$$xxx$$,0)' ;

SELECT anon.anonymize_table('customer') ;

Le code postal anonymisé devient :

SELECT id, firstname, lastname, postcode
FROM customer ;
 id  | firstname | lastname | postcode
-----+-----------+----------+----------
 107 | Curt      | O'Hara   | 90xxx
 258 | Agusta    | Towne    | 90xxx
 341 | Sid       | Hane     | 04xxx

Noter que les noms ont encore changé après application de anon.anonymize_table().

S103

Agréger les clients selon le code postal anonymisé.

SELECT postcode, COUNT(id)
FROM customer
GROUP BY postcode;
 postcode | count
----------+-------
 90xxx    |     2
 04xxx    |     1

S104

Remplacer toutes les dates de naissance par le 1er janvier, en conservant l’année réelle. Utiliser la fonction make_date.

SECURITY LABEL FOR anon ON FUNCTION pg_catalog.date_trunc(text,interval) IS 'TRUSTED';

SECURITY LABEL FOR anon ON COLUMN customer.birth IS
$$ MASKED WITH FUNCTION pg_catalog.date_trunc('year',birth) $$;

SELECT anon.anonymize_table('customer');

Les dates de naissance anonymisées deviennent :

SELECT id, firstname, lastname, birth
FROM customer ;
 id  | firstname | lastname |   birth
-----+-----------+----------+------------
 107 | Pinkie    | Sporer   | 1965-01-01
 258 | Zebulon   | Gerlach  | 1951-01-01
 341 | Erna      | Emmerich | 1926-01-01

S105

Trouver une fonction qui mélange les valeurs de fk_company_id dans la table payout. Consulter la section shuffling de la documentation.

Pour mélanger les valeurs de fk_customer_id :

SELECT anon.shuffle_column('payout','fk_customer_id','id') ;

Si l’on essaie à nouveau d’identifier le meilleur client :

WITH best_client AS (
    SELECT SUM(amount), fk_customer_id
    FROM payout
    GROUP BY fk_customer_id
    ORDER BY 1 DESC
    LIMIT 1
)
SELECT c.*
FROM customer c
JOIN best_client b ON (c.id = b.fk_customer_id) ;
 id  | firstname | lastname | phone |   birth    | postcode
-----+-----------+----------+-------+------------+----------
 258 | Zebulon   | Gerlach  |       | 1951-01-01 | 90xxx

Noter que le lien entre un client (customer) et ses paiements (payout) est à présent complètement faux !

Par exemple, si un client A a deux paiements, l’un se retrouvera associé à un client B, et l’autre à un client C. En d’autres termes, cette méthode de mélange respectera la contrainte d’intégrité technique, mais brisera l’intégrité des données. Pour certaines utilisations, ce peut être problématique.

Ici, Pierre ne pourra pas produire de rapport avec les données mélangées.


Masquage dynamique


Principe du masquage dynamique

  • Masquer les données personnelles à certains utilisateurs
    • mais pas tous

Avec le masquage dynamique, le propriétaire de la base peut masquer les données personnelles à certains utilisateurs, tout en laissant aux autres les droits de lire et modifier les données réelles.


L’histoire

Paul a 2 employés :

  • Jack s’occupe du nouveau logiciel de ventes.
    • il a besoin d’accéder aux vraies données
    • pour le RGPD c’est un « processeur de données »
  • Pierre est un analyste qui exécute des requêtes statistiques
    • il ne doit pas avoir accès aux données personnelles

Comment ça marche


Objectifs de la section

Nous allons voir :

  • comment écrire des règles de masquage simple
  • les avantages et limitations du masquage dynamique
  • le concept de « recoupement » d’une personne (linkability)

Table « company »

DROP TABLE IF EXISTS supplier CASCADE;
DROP TABLE IF EXISTS company CASCADE;

CREATE TABLE company (
    id SERIAL PRIMARY KEY,
    name TEXT,
    vat_id TEXT UNIQUE
);

Quelques données

INSERT INTO company
VALUES
(952,'Shadrach', 'FR62684255667'),
(194,E'Johnny\'s Shoe Store','CHE670945644'),
(346,'Capitol Records','GB663829617823')
;
SELECT * FROM company ;
 id  |        name         |     vat_id
-----+---------------------+----------------
 952 | Shadrach            | FR62684255667
 194 | Johnny's Shoe Store | CHE670945644
 346 | Capitol Records     | GB663829617823

Table « supplier »

CREATE TABLE supplier (
    id SERIAL PRIMARY KEY,
    fk_company_id INT REFERENCES company(id),
    contact TEXT,
    phone TEXT,
    job_title TEXT
) ;

Quelques données

INSERT INTO supplier
VALUES
(299,194,'Johnny Ryall','597-500-569','CEO'),
(157,346,'George Clinton', '131-002-530','Sales manager')
;
SELECT * FROM supplier;
 id  | fk_company_id |    contact     |    phone    |   job_title
-----+---------------+----------------+-------------+---------------
 299 |           194 | Johnny Ryall   | 597-500-569 | CEO
 157 |           346 | George Clinton | 131-002-530 | Sales manager

Activer l’extension

CREATE EXTENSION IF NOT EXISTS anon CASCADE ;
SELECT anon.init() ;

Activer le masquage dynamique

ALTER DATABASE boutique
SET anon.transparent_dynamic_masking TO true ;
-- reconnexion nécessaire

Rôle masqué

SECURITY LABEL FOR anon ON ROLE pierre IS 'MASKED' ;

GRANT pg_read_all_data TO pierre;
  • Attention : pas d’héritage du SECURITY LABEL !

Le rôle pierre devient « masqué », dans le sens où le super-utilisateur va pouvoir lui imposer un masque qui va changer sa vision des données.

En tant que Pierre, on essaie de lire la table des fournisseurs :

\c boutique pierre
SELECT * FROM supplier;
 id  | fk_company_id |    contact     |    phone    |   job_title
-----+---------------+----------------+-------------+---------------
 299 |           194 | Johnny Ryall   | 597-500-569 | CEO
 157 |           346 | George Clinton | 131-002-530 | Sales manager

Pour le moment, il n’y a pas de règle de masquage : Pierre peut voir les données originales dans chaque table.

PostgreSQL ne gère pas l’héritage des SECURITY LABEL. Il n’est donc pas possible de l’attribuer à un groupe. Il faut attribuer explicitement un SECURITY LABEL à chaque rôle concerné.


Masquer le nom des fournisseurs

En tant que Paul, une règle de masquage se définit ainsi :

\c boutique paul
SECURITY LABEL FOR anon ON COLUMN supplier.contact
IS 'MASKED WITH VALUE $$CONFIDENTIAL$$';

Pierre essaie de lire la table des fournisseurs :

\c boutique pierre
SELECT * FROM supplier ;
 id  | fk_company_id |   contact    |    phone    |   job_title
-----+---------------+--------------+-------------+---------------
 299 |           194 | CONFIDENTIAL | 597-500-569 | CEO
 157 |           346 | CONFIDENTIAL | 131-002-530 | Sales manager

Si Jack essaie de lire les vraies données, ce sont encore les bonnes :

\c boutique jack
SELECT * FROM supplier;
 id  | fk_company_id |    contact     |    phone    |   job_title
-----+---------------+----------------+-------------+---------------
 299 |           194 | Johnny Ryall   | 597-500-569 | CEO
 157 |           346 | George Clinton | 131-002-530 | Sales manager

Exercices

E201 - Deviner qui est le PDG de « Johnny’s Shoe Store »

Masquer le nom du fournisseur n’est pas suffisant pour anonymiser les données.

Se connecter en tant que Pierre. Écrire une requête simple permettant de recouper certains fournisseurs en se basant sur leur poste et leur société.

Les noms des sociétés et les postes de travail sont disponibles dans de nombreux jeux de données publics. Une simple recherche sur LinkedIn ou Google révèle les noms des principaux dirigeants de la plupart des sociétés…

On nomme « recoupement » la possibilité de rapprocher plusieurs données concernant la même personne.

E202 - Anonymiser les sociétés

Nous devons donc anonymiser aussi la table company. Même si elle ne contient pas d’informations personnelles, certains champs peuvent être utilisés pour identifier certains de leurs employés…

Écrire deux règles de masquage pour la table company. La première doit remplacer le champ nom avec un faux nom. La seconde remplacer vat_id avec une suite aléatoire de dix caractères. NB : dans la documentation, consulter les générateurs de données factices et fonctions aléatoires (faking functions).

Vérifier que Pierre ne peut pas voir les vraies données sur la société.

E203 - Pseudonymiser le nom des sociétés

À cause du masquage dynamique, les valeurs artificielles sont différentes à chaque fois que Pierre lit la table. Ce n’est pas toujours très pratique.

Pierre préfère appliquer tout le temps les mêmes valeurs artificielles pour une même société. Cela correspond à la « pseudonymisation ».

La pseudonymisation consiste à générer systématiquement les mêmes données artificielles pour un individu donné à la place de ses données réelles.

Écrire une nouvelle règle de masquage à partir du champ name, grâce à une fonction de pseudonymisation.

Solutions

S201

Se connecter en tant que Pierre. Écrire une requête simple permettant de recouper certains fournisseurs en se basant sur leur poste et leur société.

\c boutique pierre
SELECT s.id, s.contact, s.job_title, c.name
FROM supplier s
JOIN company c ON s.fk_company_id = c.id ;
 id  |   contact    |   job_title   |        name
-----+--------------+---------------+---------------------
 299 | CONFIDENTIAL | CEO           | Johnny's Shoe Store
 157 | CONFIDENTIAL | Sales manager | Capitol Records

S202

Écrire deux règles de masquage pour la table company. La première doit remplacer le champ nom avec un faux nom. La seconde remplacer vat_id avec une suite aléatoire de dix caractères.
\c boutique paul

SECURITY LABEL FOR anon ON COLUMN company.name
IS 'MASKED WITH FUNCTION anon.fake_company()';

SECURITY LABEL FOR anon ON COLUMN company.vat_id
IS 'MASKED WITH FUNCTION anon.random_string(10)';

En tant Pierre, relire la table :

\c boutique pierre

SELECT * FROM company;
 id  |            name             |   vat_id
-----+-----------------------------+------------
 952 | Graham, Davis and Bauer     | LYFVSI3WT5
 194 | Martinez-Smith              | 9N62K8M6JD
 346 | James, Rodriguez and Nelson | OHB20OZ4Q3

À chaque lecture de la table, Pierre voit des données différentes :

SELECT * FROM company;
 id  |            name             |   vat_id
-----+-----------------------------+------------
 952 | Holt, Moreno and Richardson | KPAJP2Q4PK
 194 | Castillo Group              | NVGHZ1K50Z
 346 | Mccarthy-Davis              | GS3AHXBQTK

S203

Écrire une nouvelle règle de masquage à partir du champ name, grâce à une fonction de pseudonymisation.

\c boutique paul

SECURITY LABEL FOR  anon ON COLUMN company.name
IS 'MASKED WITH FUNCTION anon.pseudo_company(id)';

Pour Pierre, les valeurs pseudonymisées resteront identiques entre deux appels (mais pas le code TVA) :

\c boutique pierre

SELECT * FROM company;
 id  |      name       |   vat_id
-----+-----------------+------------
 952 | Wilkinson LLC   | IKL88GJVT4
 194 | Johnson PLC     | VOOJ6UKR6H
 346 | Young-Carpenter | DUR78F15VD
SELECT * FROM company;
 id  |      name       |   vat_id
-----+-----------------+------------
 952 | Wilkinson LLC   | DIUAMTI653
 194 | Johnson PLC     | UND2DQGL4S
 346 | Young-Carpenter | X6EOT023AK

Sauvegardes anonymes

Dans beaucoup de situations, le besoin est simplement d’exporter les données anonymisées pour les importer dans une autre base de données, pour mener des tests ou produire des statistiques.

postgresql_anonymizer permet de générer des sauvegardes logiques anonymisées avec pg_dump.

Les outils pg_dump_anon.sh et pg_dump_anon des versions 0.x ou 1.x de postgresql_anonymizer sont dépréciés. Modifiez vos scripts pour utiliser pg_dump.


L’histoire

  • Paul a un site web qui dispose d’une section commentaires où les utilisateurs peuvent partager leurs points de vue.

  • Paul a engagé un prestataire pour développer le nouveau design de son site web.

  • Le prestataire lui demande un export de la base de données.

  • Paul veut « nettoyer » le dump et y retirer toute information personnelle qui pourrait figurer dans la section commentaire.


Comment ça marche ?


Objectifs

  • Extraire les données anonymisées de la base de données
  • Écrire une fonction de masquage personnalisée pour gérer une colonne de type JSON

Table « website_comment »

\c boutique paul

DROP TABLE IF EXISTS website_comment CASCADE ;

CREATE TABLE website_comment (
  id SERIAL PRIMARY KEY,
  message jsonb
) ;

Quelques données

curl -kLs https://dali.bo/website_comment -o /tmp/website_comment.tsv
head /tmp/website_comment.tsv
    1   {"meta": {"name": "Lee Perry", "ip_addr": "40.87.29.113"}, "content": "Hello Nasty!"}
    2   {"meta": {"name": "", "email": "biz@bizmarkie.com"}, "content": "Great Shop"}
    3   {"meta": {"name": "Jimmy"}, "content": "Hi! This is me, Jimmy James "}
\c boutique paul
\copy website_comment from '/tmp/website_comment.tsv'
SELECT jsonb_pretty(message)
FROM website_comment
ORDER BY id ASC
LIMIT 1 ;
           jsonb_pretty
-----------------------------------
 {                                +
     "meta": {                    +
         "name": "Lee Perry",     +
         "ip_addr": "40.87.29.113"+
     },                           +
     "content": "Hello Nasty!"    +
 }

Activer l’extension

CREATE EXTENSION IF NOT EXISTS anon;
SELECT anon.init();

Masquer une colonne de type JSON

Généralement, les données non structurées sont difficiles à masquer…

SELECT message - ARRAY['content']
FROM website_comment
WHERE id=1 ;

La colonne comment contient beaucoup d’informations personnelles. Le fait de ne pas utiliser un schéma standard pour les commentaires rend ici la tâche plus complexe.

Comme on peut le voir, les visiteurs du site peuvent écrire toutes sortes d’informations dans la section « commentaire ». La meilleure option serait donc de supprimer entièrement la clé JSON car il est impossible d’y exclure les données sensibles.

Il est possible de nettoyer la colonne comment en supprimant la clé content :

SELECT message - ARRAY['content']
FROM website_comment
WHERE id=1 ;

Fonctions de masquage personnalisées

\c boutique paul

CREATE SCHEMA IF NOT EXISTS my_masks;

SECURITY LABEL FOR anon ON SCHEMA my_masks IS 'TRUSTED';
CREATE OR REPLACE FUNCTION my_masks.remove_content(j jsonb)
RETURNS jsonb
AS $func$
  SELECT j - ARRAY['content']
$func$
LANGUAGE sql ;
  • Super-utilisateurs seulement !

Créer en premier lieu un schéma dédié, ici my_masks, et le déclarer en trusted (« de confiance »). Cela signifie que l’extension anon va considérer les fonctions de ce schéma comme des fonctions de masquage valides.

Seul un super-utilisateur devrait être capable d’ajouter des fonctions dans ce schéma !

Cette fonction de masquage se contente de supprimer du JSON le champ avec le message :

CREATE OR REPLACE FUNCTION my_masks.remove_content(j jsonb)
RETURNS jsonb
AS $func$
  SELECT j - ARRAY['content']
$func$
LANGUAGE sql ;

Exécuter la fonction :

SELECT my_masks.remove_content(message)
FROM website_comment ;
 {"meta": {"name": "Lee Perry", "ip_addr": "40.87.29.113"}}
 {"meta": {"name": "", "email": "biz@bizmarkie.com"}}
 {"meta": {"name": "Jimmy"}}

La fonction va pouvoir ensuite être utilisée dans une règle de masquage.


Utilisation de la fonction de masquage personnalisée

SECURITY LABEL FOR anon ON COLUMN website_comment.message
IS 'MASKED WITH FUNCTION my_masks.remove_content(message)';

Sauvegarde anonymisée (1/2)

Créer un rôle dédié pour les exports anonymisés :

CREATE ROLE anon_dumper LOGIN PASSWORD 'CHANGEME';

ALTER ROLE anon_dumper
SET anon.transparent_dynamic_masking TO TRUE;

SECURITY LABEL FOR anon ON ROLE anon_dumper IS 'MASKED';

GRANT pg_read_all_data TO anon_dumper;

Sauvegarde anonymisée (2/2)

export PATH=$PATH:$(pg_config --bindir)
export PGHOST=localhost
pg_dump -U anon_dumper boutique --table=website_comment > /tmp/anon_boutique.sql

On utilise l’outil habituel de sauvegarde pg_dump mais avec un rôle masqué.

Il est possible d’obtenir les données au format plain ou binaire.


Exercices

E301 - Exporter les données anonymisées dans une nouvelle base de données

Créer une base de données nommée boutique_anon. y insérer les données anonymisées provenant de la base de données boutique.

E302 - Pseudonymiser les métadonnées du commentaire

Pierre compte extraire des informations générales depuis les métadonnées. Par exemple, il souhaite calculer le nombre de visiteurs uniques sur la base des adresses IP des visiteurs, mais une adresse IP est un identifiant indirect.

Paul doit donc anonymiser cette colonne tout en conservant la possibilité de faire le lien entre les enregistrements provenant de la même adresse.

Remplacer la fonction remove_content par la fonction clean_comment (ci-dessous), qui : - supprime la clé JSON content ; - remplace la valeur dans la colonne name par un faux nom ; - remplace l’adresse IP dans la colonne ip_address par sa somme de contrôle md5 ; - met à NULL la clé email.
CREATE OR REPLACE FUNCTION my_masks.clean_comment(message jsonb)
RETURNS jsonb
VOLATILE
LANGUAGE SQL
AS $func$
SELECT
  jsonb_set(
    message,
    ARRAY['meta'],
    jsonb_build_object(
        'name',anon.fake_last_name(),
        'ip_address', md5((message->'meta'->'ip_addr')::TEXT),
        'email', NULL
    )
  ) - ARRAY['content'];
$func$;

Solutions

S301

Créer une base de données nommée boutique_anon. y insérer les données anonymisées provenant de la base de données boutique.

export PATH=$PATH:$(pg_config --bindir)
export PGHOST=localhost
dropdb -U paul --if-exists boutique_anon
createdb -U paul -d boutique_anon --owner paul
pg_dump -U anon_dumper -d boutique | psql --quiet boutique_anon
export PGHOST=localhost
psql -U paul -d boutique_anon -c 'SELECT COUNT(*) FROM company'
     count
    -------
         3

S302

Remplacer la fonction remove_content par la fonction clean_comment (ci-dessous), qui : - supprime la clé JSON content ; - remplace la valeur dans la colonne name par un faux nom ; - remplace l’adresse IP dans la colonne ip_address par sa somme de contrôle md5 ; - met à NULL la clé email.

Suite à utilisation de la fonction personnalisée clean_comment, les données n’ont plus rien à voir :

SELECT my_masks.clean_comment(message)
FROM website_comment;
                                         clean_comment
-----------------------------------------------------------------------------------------------
 {"meta": {"name": "Heller", "email": null, "ip_address": "1d8cbcdef988d55982af1536922ddcd1"}}
 {"meta": {"name": "Christiansen", "email": null, "ip_address": null}}
 {"meta": {"name": "Frami", "email": null, "ip_address": null}}
(3 lignes)

On applique le masquage comme à l’habitude :

SECURITY LABEL FOR anon ON COLUMN website_comment.message
IS 'MASKED WITH FUNCTION my_masks.clean_comment(message)';

Vues de masquage


Principe

  • Les vues de masquage permettent de modifier le type de données
  • Similaire au masquage dynamique
  • Très utiles pour mettre en place de la généralisation

Généralisation

  • Flouter les données
  • Par ex : 25 juillet 1989 années 1980

L’idée derrière la généralisation est de pouvoir flouter une donnée originale.

Par exemple, au lieu de dire « Monsieur X est né le 25 juillet 1989 », on peut dire « Monsieur X est né dans les années 1980 ». L’information reste vraie, bien que moins précise, et elle rend plus difficile l’identification de la personne.


L’histoire

  • Paul a embauché des dizaines de salariés au fil du temps.

  • Il conserve une trace sur la couleur de leurs cheveux, leurs tailles, et leurs conditions médicales.

  • Paul souhaite extraire des statistiques depuis ces détails.

  • Il fournit des vues généralisées à Pierre.


Comment ça marche ?


Objectifs

Nous allons voir :

  • la différence entre le masquage dynamique et les vues de masquage
  • un exemple de généralisation
  • le concept de « k-anonymat »

Table « employee »

DROP TABLE IF EXISTS employee CASCADE;
CREATE TABLE employee (
  id INT PRIMARY KEY,
  full_name  TEXT,
  first_day DATE, last_day DATE,
  height INT,
  hair TEXT, eyes TEXT, size TEXT,
  asthma BOOLEAN,
  CHECK(hair = ANY(ARRAY['bald','blond','dark','red'])),
  CHECK(eyes = ANY(ARRAY['blue','green','brown'])) ,
  CHECK(size = ANY(ARRAY['S','M','L','XL','XXL']))
);
  • Légal ?

Bien sûr, stocker les caractéristiques physiques d’employés est généralement illégal. Quoi qu’il en soit, il sera impératif de les masquer.


Quelques données

curl -Ls https://dali.bo/employee -o /tmp/employee.tsv
\c boutique paul
\COPY employee FROM '/tmp/employee.tsv'

Ce fichier charge 16 lignes, dont :

SELECT full_name,first_day, hair, size, asthma
FROM employee
LIMIT 3 ;
  full_name   | first_day  | hair  | size | asthma
--------------+------------+-------+------+--------
 Luna Dickens | 2018-07-22 | blond | L    | t
 Paul Wolf    | 2020-01-15 | bald  | M    | f
 Rowan Hoeger | 2018-12-01 | dark  | XXL  | t

Suppression de données

Pierre peut trouver un lien entre asthme et yeux verts :

\c boutique paul
DROP MATERIALIZED VIEW IF EXISTS v_asthma_eyes ;

CREATE MATERIALIZED VIEW v_asthma_eyes AS
SELECT eyes, asthma
FROM employee ;

Paul souhaite savoir s’il y a une corrélation entre l’asthme et la couleur des yeux.

Il fournit à Pierre la vue ci-dessus, qui peut désormais écrire des requêtes sur cette vue :

SELECT *
FROM v_asthma_eyes
LIMIT 3;
 eyes  | asthma
-------+--------
 blue  | t
 brown | f
 blue  | t
SELECT
  eyes,
  100*COUNT(1) FILTER (WHERE asthma) / COUNT(1) AS asthma_rate
FROM v_asthma_eyes
GROUP BY eyes ;
 eyes  | asthma_rate
-------+-------------
 green |         100
 brown |          37
 blue  |          33

Paul vient de prouver que l’asthme est favorisé par les yeux verts, et surtout de trouver une corrélation entre deux champs.


Calculer le k-anonymat

  • Les colonnes asthma et eyes sont considérés comme des identifiants indirects.
\c boutique paul
SECURITY LABEL FOR k_anonymity ON COLUMN v_asthma_eyes.eyes
IS 'INDIRECT IDENTIFIER';

SECURITY LABEL FOR k_anonymity ON COLUMN v_asthma_eyes.asthma
IS 'INDIRECT IDENTIFIER';
SELECT anon.k_anonymity('v_asthma_eyes');
SELECT anon.k_anonymity('v_asthma_eyes');
 k_anonymity
-------------
           2

La vue v_asthma_eyes a le niveau « 2-anonymity ». Cela signifie que chaque combinaison de quasi-identifiants (eyes-asthma) apparaît au moins 2 fois dans le jeu de données.

En d’autres termes, cela veut dire qu’un individu ne peut pas être distingué d’au moins un autre individu (k-1) dans cette vue.

Pour les détails sur le K-anonymat, voir cet article sur Wikipédia.

Le fournisseur est ici k-anonymity et plus anon !


Fonctions d’intervalle et de généralisation

\c boutique paul
DROP MATERIALIZED VIEW IF EXISTS v_staff_per_month ;

CREATE MATERIALIZED VIEW v_staff_per_month AS
SELECT
    anon.generalize_daterange(first_day,'month') AS first_day,
    anon.generalize_daterange(last_day, 'month') AS last_day
FROM employee ;

GRANT SELECT ON v_staff_per_month TO pierre ;
\c boutique pierre
SELECT *
FROM v_staff_per_month
LIMIT 3;
        first_day        |        last_day
-------------------------+-------------------------
 [2018-07-01,2018-08-01) | [2018-12-01,2019-01-01)
 [2020-01-01,2020-02-01) | (,)
 [2018-12-01,2019-01-01) | [2018-12-01,2019-01-01)

Pierre peut écrire une requête pour trouver le nombre d’employés embauchés en novembre 2021 :

SELECT COUNT(1)
       FILTER (
            WHERE make_date(2019,11,1)
            BETWEEN lower(first_day)
            AND COALESCE(upper(last_day),now())
       )
FROM v_staff_per_month ;
 count
-------
     4

Déclarer les identifiants indirects

Calculer le facteur de k-anonymat de cette vue :

\c boutique paul
SECURITY LABEL FOR k_anonymity ON COLUMN v_staff_per_month.first_day
IS 'INDIRECT IDENTIFIER';
SECURITY LABEL FOR k_anonymity ON COLUMN v_staff_per_month.last_day
IS 'INDIRECT IDENTIFIER';
SELECT anon.k_anonymity('v_staff_per_month');

Dans ce cas, le résultat est 1, ce qui veut dire qu’au moins une personne peut être directement identifiée par les dates de ses premier et dernier jour en poste.

Dans ce cas, la généralisation est insuffisante.


Exercices

E401 - Simplifier la vue v_staff_per_month pour en réduire la granularité.

Généraliser les dates en mois n’est pas suffisant. > Écrire une autre vue > v_staff_per_year qui va généraliser les dates en années. > Simplifier également la vue en utilisant un intervalle de int pour stocker > l’année, plutôt qu’un intervalle de date.

E402 - Progression du personnel au fil des années

Combien de personnes ont travaillé pour Paul chaque année entre 2018 et 2021 ?

E403 - Atteindre le facteur 2-anonymity sur la vue v_staff_per_year

Quel est le facteur k-anonymat de la vue v_staff_per_year ?

Solutions

S401

Écrire une autre vue v_staff_per_year qui va généraliser les dates en années. Simplifier également la vue en utilisant un intervalle de int pour stocker l’année, plutôt qu’un intervalle de date.

Cette vue généralise les dates en années :

\c boutique paul
DROP MATERIALIZED VIEW IF EXISTS v_staff_per_year;
CREATE MATERIALIZED VIEW v_staff_per_year AS
SELECT
  int4range(
    extract(year from first_day)::INT,
    extract(year from last_day)::INT,
    '[]' -- include upper bound
  ) AS period
FROM employee;
SELECT *
FROM v_staff_per_year
LIMIT 3;
   period
-------------
 [2018,2019)
 [2020,)
 [2018,2019)

S402

Combien de personnes ont travaillé pour Paul chaque année entre 2018 et 2021 ?

Les personnes ayant travaillé pour Paul entre 2018 et 2021 sont :

SELECT
  year,
  COUNT(1) FILTER (
      WHERE year <@ period
  )
FROM
    generate_series(2018,2021) year,
    v_staff_per_year
GROUP BY year
ORDER BY year ASC;
 year | count
------+-------
 2018 |     4
 2019 |     6
 2020 |     9
 2021 |    10

S403

Quel est le facteur k-anonymat de la vue v_staff_per_year ?

Le k-anonymat de cette vue est meilleur :

SECURITY LABEL FOR k_anonymity ON COLUMN v_staff_per_year.period
IS 'INDIRECT IDENTIFIER';

SELECT anon.k_anonymity('v_staff_per_year');
 k_anonymity
-------------
           2

Conclusion sur postgresql_anonymizer


Beaucoup de stratégies de masquage


Beaucoup de fonctions de masquage

  • Destruction partielle ou totale
  • Ajout de bruit
  • Randomisation
  • Falsification et falsification avancée
  • Pseudonymisation
  • Hachage générique
  • Masquage personnalisé

RTFM -> Fonctions de masquage


Avantages

  • Règles de masquage en SQL
  • Règles de masquage stockées dans le schéma de la base
  • Pas besoin d’un ETL
  • Fonctionne avec toutes les versions actuelles de PostgreSQL
  • Multiples stratégies, multiples fonctions.

Limites

  • Ne fonctionne pas avec d’autres SGBD (comme le nom l’indique)
  • Peu de retour d’expérience sur de gros volumes (> 10 To)

Échantillonage

En complément du masquage, il est souvent intéressant de travailler uniquement sur un sous-ensemble des données, plutot que la base entière.

L’outil pg_sample permet d’extraire un petit jeu de données d’une base de données volumineuse en conservant l’intégrité référentielle.


Contribuez !

C’est un projet libre !

https://labs.dalibo.com/postgresql_anonymizer

Merci de vos retours sur la manière dont vous l’utilisez, comment il répond ou non à vos attentes, etc.


Questions

N’hésitez pas, c’est le moment !