betagouv / rdv-service-public

Prise de RDV pour les services publics
https://rdv.anct.gouv.fr
GNU Affero General Public License v3.0
17 stars 2 forks source link

Anonymizer : gem & extraction de l’ETL #4362

Closed adipasquale closed 2 weeks ago

adipasquale commented 3 months ago

Objectif

Automatiser la mise à jour des données dans Metabase

Objectifs secondaires

Objectifs très secondaires ou découverts en chemin :

Contexte

Le code d’anonymisation est à la fois appelé dans le script d’ETL et dans l’application Rails.

Le script d’ETL actuel utilise une clé d’API Scalingo pour télécharger un backup de la db. On n’a pas envie de stocker ça dans l’environnement de l’app scalingo d’ETL pour des raisons de sécurité.

Solution proposée

Avertissement : GROSSE PR

En déduisant les lignes des fichiers de config, cette PR fait +582 l, -327 l auquel il faut rajouter les +255 l du repo ETL.

On peut envisager d’essayer de la découper mais je vais avoir besoin d’aide !

Gem

Le code de cette nouvelle gem vit dans ce repo. Il n’y a pas besoin de publier la gem. On peut utiliser la gem depuis le repo d’ETL avec : gem "anonymizer", git: "https://github.com/betagouv/rdv-service-public.git

Règles et configuration

Actuellement, les règles d’anonymisation de RDVI et RDVSP sont committées dans ce repo. Dorénavant, on committe uniquement les règles pour RDVSP dans config/anonymizer.yml. Les règles de la base RDVI vivront dans le repository rdv-insertion cf cette PR chez RDVI.

Le format de la config passe de Ruby à YAML et change de structure :

tables:
  - table_name: rdvs
    anonymized_column_names:
      - first_name
      - last_name
    non_anonymized_column_names:
      - created_at
  - table_name: versions
    truncated: true

Le code de l’Anonymizer est amélioré pour charger et injecter une config particulière. Un objet Anonymizer::Config est extrait à cette fin. On ne passe plus en paramètre le nom du service rdvsp / rdvi mais bien une instance de Config.

Harmonisation

Aujourd’hui il y a deux méthodes d’anonymisation parallèles :

  1. anonymize_record! ou anonymize_records_in_scope!(scope)
  2. anonymize_table!

La méthode 1 déclenche une requête du type

UPDATE users SET first_name='[anonymisé]', last_name='[anonymisé]' WHERE id=30`

La méthode 2 déclenche plusieurs requêtes successives comme

UPDATE users set first_name=NULL WHERE first_name = '';
UPDATE users SET first_name='[anonymisé]' WHERE first_name IS NOT NULL;
UPDATE users set last_name=NULL WHERE last_name = '';
UPDATE users SET last_name='[anonymisé]' WHERE last_name IS NOT NULL;

Le résultat va donc être différent pour les valeurs blank comme "" qui dans la méthode 1 vaudront [anonymisé] et NULL dans la méthode 2.

Cette PR harmonise la manière de faire vers la méthode 2. Pour les appels d’anonymisation sur un seul record ou scopés, on utilise la méthode 2 avec un WHERE. Ça sera donc nettement moins performant pour les scopes avec peu de record (notamment pour un seul record).

ActiveRecord vs AREL

Le code actuel implémente la méthode 1 avec ActiveRecord:

# dans un CRON job
Anonymizer::Core.anonymize_records_in_scope!(Receipt.where("created_at < ?", 6.months.ago))

# dans l’anonymiser
def anonymize_records_in_scope!(scope)
  scope.update_all(anonymized_attributes)
end

La méthode 2 est elle implémentée via des requêtes SQL construites à la main.

Dans la nouvelle méthode 2 unifiée, on utilise dorénavant des requêtes Arel. Par exemple:

# dans le CRON job
Anonymizer.anonymize_records!("rdvs", scope: Rdv.arel_table[:created_at].lt(Date.new(2021, 1, 1)))

# dans lib/anonymizer/column.rb
def update(where:, value:)
  ActiveRecord::Base.connection.execute(
    Arel::UpdateManager
      .new(arel_table)
      .where(scope ? scope.and(where) : where)
      .set(arel_table[column.name] => value)
      .to_sql
  )
end

J’ai fait ce choix pour plusieurs raisons :

Les désavantages de ce choix :

Truncates

On interdit dorénavant d’appeler anonymize_records! sur une table avec un scope. Par exemple, si on essaie d’anonymiser les versions d’avant 2021, une exception sera levée car versions est une table tronquée. Cela était possible jusqu’à maintenant, mais à ma connaissance jamais fait.

Cela me paraît incohérent de dire qu’une table devrait être tronquée mais de permettre de ne pas la tronquer intégralement. Cela simplifie aussi le code.

Core / Table / Column / Module

La classe Anonymizer::Core devient Anonymizer::Table car elle ne dépend que du nom de la table et de la config injectée.

Une deuxième classe Anonymizer::Column est extraite de Table.

Les méthodes de classe sont extraites depuis Core vers le module Anonymizer lui même. Ces méthodes de classe sont l’API publique de la gem.

Validations d’exhaustivité

Une nouvelle méthode Anonymizer.validate_exhaustivity! s’assure que toutes les tables et les colonnes ont des règles dans le fichier de config. Auparavant on s’assurait uniquement de l’exhaustivité des colonnes dans les tables déclarées. C’était fait inline dans les méthodes d’anonymisation.

Cela m’a permis de découvrir quelques tables qui n’étaient pas déclarées explicitement et que j’ai rajoutées en tables tronquées : https://github.com/betagouv/rdv-service-public/pull/4362/commits/eba4e26e3ce1f9e22ee95b7d8162a8ca1f69aec0.

ETL - Extraction et réécriture

Le script scripts/etl.sh est supprimé et complètement réécrit dans un repository à part : https://github.com/betagouv/rdv-service-public-etl

Le script est réécrit de shell vers ruby.

ETL - Dumps

Les données sont récupérées via un dump Postgres fait à la volée depuis les bases distantes cibles RDVI, RDVSP, RDVS. Cela remplace le téléchargement actuel d’un backup Scalingo. Il n’y a donc plus besoin de clé d’API Scalingo. Cela permet aussi d’exclure les tables tronquées dès l’étape de la création du dump.

Il y a besoin d’un user Postgres read-only sur chaque base cible. Il faut stocker ces credentials dans les variables d’env des applications Scalingo d’ETL.

ETL - schémas

Chaque appli rdv_solidarites, rdv_service_public et rdv_insertion a maintenant un schéma éponyme.

Le schéma public n’est plus utilisé que transitoirement :

  1. On restore les dumps tels quels dans le schéma public ;
  2. On anonymise, puis on copie tout vers le schéma du nom souhaité ;
  3. On supprime tout de public.

Ce processus a un avantage intéressant relevé par @victormours : il n’y aura jamais de données non-anonymisées dans les schémas cibles. On fait en sorte que le user Metabase n’ait pas accès à public mais uniquement aux schémas rdv_insertion, rdv_solidarites et plus tard rdv_service_public. Cela évite d’avoir à supprimer / recréer le user Postgres Metabase à chaque processus d’import de l’ETL comme fait actuellement.

Le renommage de schema n’est plus fait via un sed s/public/rdvi/ hacky mais par un script SQL. Malheureusement ce script de clonage est lui aussi un peu hacky 🤷‍, je l’ai trouvé sur internet. Ce n’est pas une fonctionnalité native de Postgres.

Tests effectués et déploiement envisagé

J’ai configuré une nouvelle appli Scalingo rdv-service-public-etl-staging avec des variables d’environnement pointant sur les applis de demo RDVSP et RDVI. J’ai configuré une nouvelle DB source sur le Metabase existant pointant vers la DB de cette app ETL Staging. Cette source liste les schémas accessibles de manière stricte.

Les tables des deux schémas rdv_insertion et rdv_solidarites sont maintenant accessibles sur Metabase 🎉 Je n’ai pas pu tester rdv_service_public car on n’a pas de demo.

Avant le déploiement en production, il faudra s’assurer qu’on pourra déplacer les requêtes existantes sur Metabase depuis le schéma public vers le schéma rdv_solidarites.

Après le merge de cette PR et de celle sur RDVI, on pourra modifier les URLs des fichiers de config dans les variables d’environnement.

TODO

Discussions envisagées

adipasquale commented 3 months ago

@victormours si tu veux jeter un oeil à cette PR avant que je n’en parle avec RDVI

Michaelvilleneuve commented 3 months ago

Merci pour cette refacto @adipasquale ça va être plus clean comme ça !

Ma remarque principale est finalement une remarque que j'avais déjà faite à @victormours qui m'avait suggéré une refacto en Gem : Je trouve que le passage en Gem ne solutionne pas le problème principal de ce projet qui est la dépendance directe avec le code de Rdvsp en tant que "host" de cette Gem.

Plus qu'une Gem (qui doit donc être rattachée à un projet parent, ici rdvsp), je privilégierais plutôt une solution de projet détaché très basique ruby qui puisse être executé en dehors du scope de RDVSP, simplement en ayant un Gemfile avec uniquement les dépendances nécessaire à ce projet et en le lancant de manière isolée genre rake anonymize xx.

De cette façon on pourrait :

Ceci étant dit, je trouve ton travail très très cool et ça peut d'ailleurs être un premier pas vers ça ! 🙌

victormours commented 3 months ago

@Michaelvilleneuve Cette extraction en gem est la première étape pour une extraction plus large de l'etl qui va répondre aux problèmes que tu rappelles et qui est débutée dans cette pr : https://github.com/betagouv/rdv-service-public/pull/4357

victormours commented 2 months ago

@adipasquale On a un bug qui s'est glissé dans l'anonymizer qu'on a actuellement en prod : pour lister les tables dans la méthode .all_tables on fait un RULES[service]::RULES.keys à la place de ActiveRecord::Base.connection.tables, donc si on oublie de noter les règles pour une table, elle est juste ignorée. Si on fait le fix sur la prod j'ai peur qu'il soit pas intégré ici, donc c'est sans doute plus simple qu'on l'intègre directement à cette PR

adipasquale commented 2 weeks ago

On a discuté hier avec @victormours le dernier gros obstacle que je n’avais pas prévu est que les cron job de scalingo sont limités à 15 minutes 😨

Une piste est d’optimiser pour faire rentrer dans les 15 minutes. Probablement en :

On pourra aussi sinon mettre en place un processus toujours up qui s’occupe de faire le cron type GoodJob ou plus basique, mais c’est une moins bonne utilisation des ressources et ça demande aussi pas mal de code en plus.

@victormours dans une vision de petites mises en production, j’ai envie de proposer de quand même déployer en l’état l’ETL en prod en désactivant les cron jobs ! Ça permettra de :

victormours commented 2 weeks ago

dans une vision de petites mises en production, j’ai envie de proposer de quand même déployer en l’état l’ETL en prod en désactivant les cron jobs !

Grave ! Ça fait déjà un gros pas en avant qu'on puisse faire une synchro manuelle avec beaucoup moins d'étapes qu'avant. Je suis très chaud.