Skip to Content
RGPDPartie 14 — Le RGPD dans le code (dev & IA)14.2 — Coder la rétention & l'effacement

Chapitre 14.2 — Coder la rétention & l’effacement

⏱️ TL;DR — « Limitation de la conservation » (art. 5) n’est pas un vœu, c’est un mécanisme. Concrètement : chaque donnée porte une échéance (un champ de date), une tâche planifiée (cron) passe purger ce qui a expiré, et l’effacement va jusqu’au bout — pas seulement la ligne en base, mais aussi l’index de recherche, les caches, les exports, les sauvegardes (par expiration) et les systèmes tiers. On distingue le soft delete (marquer supprimé, réversible) du hard delete (détruire vraiment), et on retient un cas particulier crucial : certaines données ne se suppriment pas mais s’anonymisent — typiquement une facture, qu’une obligation légale oblige à conserver mais qu’on peut détacher de l’identité. Une durée de conservation sans code pour l’appliquer n’existe pas.

🎯 Objectifs

  • Matérialiser une durée de conservation par un champ d’échéance en base.
  • Écrire une tâche de purge planifiée, idempotente et journalisée.
  • Choisir entre soft delete et hard delete selon le contexte.
  • Effacer en cascade partout où la donnée a essaimé (index, caches, exports, sauvegardes, tiers).
  • Anonymiser plutôt que supprimer quand une obligation légale impose de garder.

Une date d’échéance sur chaque donnée périssable

Le principe de limitation de la conservation (5.1.e) dit : on garde une donnée le temps de la finalité, puis on la supprime ou on l’anonymise. Traduit en schéma, ça donne un champ d’échéance calculé à la création (ou à la dernière activité), qui rend la règle explicite et interrogeable.

model StagiaireSession { id String @id @default(cuid()) userId String finFormation DateTime // Échéance de purge calculée à partir de la politique de rétention documentée. // La VALEUR de la durée vient du registre (Partie 11), pas d'un chiffre en dur ici. purgeAfter DateTime // ex. finFormation + durée de conservation retenue createdAt DateTime @default(now()) @@index([purgeAfter]) // indexé : la purge doit être rapide à requêter }

Deux réflexes :

  • On stocke l’échéance, pas seulement la date de création : la requête de purge devient triviale (purgeAfter < now()) et l’index la rend efficace.
  • La durée elle-même (combien de temps ?) n’est pas un nombre magique perdu dans le code : elle vient de la politique de rétention documentée (registre, Partie 11). Le code applique une décision prise ailleurs — c’est l’accountability.

💡 Réflexe — Pour chaque nouvelle table, réponds à deux questions avant le premier INSERT : combien de temps je garde, et qui déclenche la purge ? Une durée sans mécanisme d’effacement automatique n’est qu’un commentaire. Le RGPD se code aussi en cron jobs.

Soft delete vs hard delete

Deux façons de « supprimer », aux effets RGPD opposés :

Soft deleteHard delete
Ce qu’on faitOn pose un deletedAt, la ligne resteOn détruit la ligne (DELETE)
Réversible ?Oui (on peut « annuler »)Non
Vu du RGPDLa donnée existe toujours : ce n’est pas un effacement au sens de l’art. 17Effacement réel
Usage légitimeCorbeille temporaire, fenêtre d’annulation courteFin de rétention, demande d’effacement honorée

Le piège : croire qu’un soft delete suffit à répondre à un droit à l’effacement (art. 17). Non — tant que la donnée est là, même « masquée », elle est conservée et reste dans le champ du RGPD. Le soft delete est utile comme corbeille à durée courte ; passé ce délai, un hard delete (ou une anonymisation) doit prendre le relais.

⚠️ Piège — Le soft delete définitif : on met un deletedAt, on filtre l’affichage, et on considère le sujet clos. Mais la donnée est toujours en base, dans les sauvegardes, peut-être dans l’index de recherche. Face à une demande d’effacement, c’est un manquement : la personne a demandé la disparition, pas la mise en invisible. Le soft delete est une étape, jamais la destination.

L’effacement en cascade : la donnée a essaimé

Une donnée personnelle ne vit presque jamais à un seul endroit. Effacer, c’est effacer partout où elle a été copiée :

  • Relations en base : les tables liées (profil, résultats, consentements) — géré par onDelete: Cascade ou explicitement.
  • Index de recherche (Elastic, Meilisearch, Algolia…) : un document dupliqué qu’il faut supprimer séparément.
  • Caches (Redis, cache applicatif, CDN) : invalider les entrées qui contiennent la donnée.
  • Exports & fichiers : CSV générés, PDF, pièces jointes stockées sur un bucket.
  • Systèmes tiers / sous-traitants : CRM, e-mailing, outil de support — il faut propager la suppression (via API ou demande contractuelle).
  • Sauvegardes : on ne réécrit pas une sauvegarde, mais on s’assure qu’elles ont une durée de rétention limitée, de sorte que la donnée effacée disparaît d’elle-même à l’expiration du cycle de sauvegarde.
// Effacement complet d'un utilisateur : la base n'est que la première étape. import { prisma } from "@/lib/db" import { searchIndex } from "@/lib/search" import { cache } from "@/lib/cache" import { crm } from "@/lib/crm" export async function effacerUtilisateur(userId: string): Promise<void> { // 1) Base : la cascade Prisma emporte profile, résultats, consentements… await prisma.user.delete({ where: { id: userId } }) // 2) Index de recherche : document dupliqué, à supprimer à part await searchIndex.deleteDocument(`user:${userId}`) // 3) Caches : invalider toute entrée dérivée await cache.deletePattern(`user:${userId}:*`) // 4) Sous-traitant CRM / e-mailing : propager la suppression await crm.deleteContact(userId) // 5) Sauvegardes : rien à réécrire, mais leur rétention limitée // garantit la disparition au prochain cycle (documenté dans le registre). // 6) Tracer l'effacement lui-même (SANS re-stocker la donnée effacée) await prisma.erasureLog.create({ data: { subjectId: userId, done: true, at: new Date() }, }) }

Note la dernière étape : on journalise qu’un effacement a eu lieu (date, identifiant), sans re-stocker les données qu’on vient d’effacer. C’est la preuve d’accountability — on doit pouvoir démontrer qu’on a bien honoré la demande.

La tâche de purge planifiée

Le cœur de la rétention : un cron qui passe régulièrement supprimer ce qui a expiré. Il doit être idempotent (le relancer ne casse rien), par lots (ne pas verrouiller la base), et journalisé.

// tasks/purge.ts — exécuté par un cron (ex. quotidien, minuit). import { prisma } from "@/lib/db" import { effacerUtilisateur } from "@/lib/erasure" import { logger } from "@/lib/logger" const TAILLE_LOT = 500 export async function purgerDonneesExpirees(): Promise<void> { const maintenant = new Date() let traites = 0 // On boucle par lots tant qu'il reste des enregistrements expirés. for (;;) { const expirees = await prisma.stagiaireSession.findMany({ where: { purgeAfter: { lt: maintenant } }, select: { userId: true }, take: TAILLE_LOT, }) if (expirees.length === 0) break for (const { userId } of expirees) { // Effacement complet en cascade (base + index + caches + tiers). await effacerUtilisateur(userId) traites++ } } // On journalise un COMPTE, pas des données personnelles (voir chapitre 14.4). logger.info(`purge terminée: ${traites} enregistrement(s) traité(s)`) }

Points d’attention : la purge journalise un compte agrégé (traites), jamais la liste des e-mails supprimés (ce serait remettre de la PII dans les logs — chapitre 14.4). Et elle s’appuie sur effacerUtilisateur, donc la cascade est appliquée automatiquement à chaque purge.

💡 Réflexe — Teste ta purge sur des données de test avant de la lâcher en prod, et fais-la d’abord tourner à blanc (mode « compte seulement, ne supprime pas ») pour vérifier le volume qu’elle s’apprête à effacer. Une purge trop gourmande à cause d’un purgeAfter mal calculé peut détruire des données encore utiles — irréversiblement.

Le cas particulier : anonymiser une facture plutôt que la supprimer

Toutes les données ne se suppriment pas à la fin de la relation. Certaines doivent être conservées au titre d’une obligation légale (comptable, fiscale) : une facture en est l’exemple type. Ici, effacer serait illégal ; mais garder le lien avec la personne au-delà du nécessaire serait contraire à la minimisation.

La solution : anonymiser la partie personnelle tout en conservant le document comptable. On coupe le lien vers l’identité, on garde les montants et les mentions légales obligatoires.

// Fin de la relation client, mais obligation légale de garder la facture : // on ANONYMISE au lieu de supprimer. export async function anonymiserFacture(factureId: string): Promise<void> { await prisma.invoice.update({ where: { id: factureId }, data: { // On coupe le lien vers l'identité… userId: null, // …et on remplace les données nominatives par des valeurs neutres, // tout en gardant montants, dates et TVA (exigés par l'obligation légale). clientNom: "ANONYMISÉ", clientEmail: null, clientAdresse: null, }, }) }

Le résultat n’est plus une donnée personnelle (plus de lien raisonnable vers la personne), mais reste un document comptable exploitable. La durée de l’obligation légale de conservation vient du droit applicable (comptable/fiscal) et se documente dans le registre — on ne l’invente pas dans le code.

📚 Le texte — La limitation de la conservation est le principe de l’article 5.1.e : les données sont conservées sous une forme permettant l’identification pas plus longtemps que nécessaire. Le droit à l’effacement (« droit à l’oubli ») est l’article 17 ; il connaît des exceptions, notamment le respect d’une obligation légale de conservation — d’où l’anonymisation plutôt que la suppression pour une facture. Les durées précises relèvent des référentiels CNIL et des obligations comptables/fiscales, à vérifier à la source.

🧭 Sur FormaCampus — Un stagiaire termine sa formation. La politique de rétention (documentée au registre) fixe une durée de conservation du compte après la dernière activité ; purgeAfter est calculé en conséquence. Le cron quotidien efface les comptes expirés en cascade (base, index des recherches formateur, cache des tableaux de bord, contact dans l’outil d’e-mailing). Mais les factures de la formation, elles, sont anonymisées et non supprimées : FormaCampus doit pouvoir les présenter en cas de contrôle comptable, sans pour autant garder indéfiniment le nom et l’adresse du stagiaire. Deux règles de rétention différentes, deux bouts de code différents.

🔒 Côté personne concernée — Quand un ancien stagiaire demande l’effacement de son compte, il s’attend à disparaître : plus de mails, plus de profil, plus de trace exploitable. Si six mois plus tard il reçoit encore une relance parce que son contact traînait dans l’outil d’e-mailing, la promesse est trahie. L’effacement en cascade, c’est ce qui fait la différence entre « on a caché ton profil » et « tu n’es plus dans nos systèmes » — tout en lui expliquant honnêtement que sa facture est conservée, anonymisée, parce que la loi l’impose.

✏️ Exercices

Exercice 1 — La donnée a essaimé. Un utilisateur exerce son droit à l’effacement. Ton app stocke son profil en base (Postgres), l’indexe dans Meilisearch pour la recherche, met en cache son tableau de bord dans Redis, l’a synchronisé dans un CRM tiers, et fait des sauvegardes quotidiennes conservées un mois. Liste toutes les actions d’effacement nécessaires.

✅ Solution

(1) DELETE en base avec cascade sur les tables liées (profil, résultats, consentements). (2) Supprimer le document Meilisearch correspondant (l’index est une copie). (3) Invalider les clés Redis dérivées de cet utilisateur. (4) Propager la suppression au CRM tiers via son API (c’est un sous-traitant : la demande d’effacement doit voyager jusqu’à lui — Partie 12). (5) Pour les sauvegardes : on ne les réécrit pas, mais leur rétention d’un mois garantit que la donnée effacée en disparaît au bout d’un cycle ; on le documente dans la réponse à la personne. (6) Journaliser l’effacement (date + identifiant), sans re-stocker les données effacées, pour la preuve d’accountability.

Exercice 2 — Supprimer ou anonymiser ? Pour chacun, dis s’il faut hard delete ou anonymiser, et pourquoi : (a) le compte d’un utilisateur qui demande son effacement ; (b) une facture émise il y a huit mois pour un client parti ; (c) les logs de connexion vieux de deux ans ; (d) les résultats de quiz d’un élève dont le compte est purgé.

✅ Solution

(a) Hard delete (en cascade) : demande d’effacement, aucune obligation de conservation identifiée sur le compte lui-même. (b) Anonymiser : une obligation légale (comptable/fiscale) impose de conserver la facture ; on coupe le lien vers l’identité mais on garde le document. (c) Hard delete (purge) une fois la durée de conservation des logs atteinte — sauf obligation de sécurité spécifique documentée ; les logs ne se gardent pas « pour toujours ». (d) Hard delete en cascade avec le compte, ou anonymisation/agrégation si on veut conserver des statistiques de réussite non nominatives (taux moyen par module) — auquel cas on s’assure qu’aucun résultat n’est ré-identifiable individuellement.

Exercice 3 — Revue de la tâche de purge. On te soumet ce cron : await prisma.user.deleteMany({ where: { lastLogin: { lt: oneYearAgo } } }). Cite au moins trois problèmes.

✅ Solution

(1) Pas de cascade externe : le deleteMany efface la base, mais pas l’index de recherche, les caches, ni les sous-traitants (CRM, e-mailing) — la donnée survit ailleurs. (2) Durée en dur (oneYearAgo) : la règle de rétention doit venir de la politique documentée (registre), pas d’une constante isolée ; et « un an après la dernière connexion » doit correspondre à une finalité justifiée. (3) Pas de garde-fou : aucun mode « à blanc », aucun traitement par lots, aucun log de contrôle du volume ; un lastLogin mal renseigné (null, jamais mis à jour) pourrait purger des comptes actifs. Bonus : rien n’est journalisé pour prouver l’effacement (accountability), et un deleteMany massif peut verrouiller la base.

🧠 Quiz de révision

1. Comment matérialiser une durée de conservation en base ?

Par un champ d’échéance (ex. purgeAfter) calculé à la création ou à la dernière activité, indexé pour que la purge soit rapide à requêter (purgeAfter < now()). La valeur de la durée vient de la politique de rétention documentée (registre), pas d’un nombre magique dans le code.

2. Le soft delete suffit-il à répondre à un droit à l’effacement ?

Non. Un deletedAt masque la donnée mais la conserve : elle reste en base (et ailleurs), donc dans le champ du RGPD. Le soft delete est une corbeille temporaire à durée courte ; un hard delete (ou une anonymisation) doit ensuite réellement effacer.

3. Cite quatre endroits, hors base principale, où une donnée effacée peut survivre.

L’index de recherche (Elastic, Meilisearch…), les caches (Redis, CDN), les exports/fichiers (CSV, PDF, buckets), les systèmes tiers/sous-traitants (CRM, e-mailing) et les sauvegardes. L’effacement doit cascader partout ; les sauvegardes se gèrent par une rétention limitée qui les fait expirer.

4. Pourquoi anonymiser une facture au lieu de la supprimer ?

Parce qu’une obligation légale (comptable/fiscale) impose de conserver la facture — la supprimer serait fautif. Mais garder le lien avec la personne au-delà du nécessaire violerait la minimisation. On coupe le lien vers l’identité (nom, e-mail, adresse remplacés/neutralisés) en gardant montants et mentions légales : le document reste, la donnée personnelle disparaît.

5. Quelles qualités doit avoir une tâche de purge ?

Être planifiée (cron régulier), idempotente (relançable sans dégât), traiter par lots (ne pas verrouiller la base), appliquer la cascade complète (via une fonction d’effacement unique), et journaliser un compte agrégé — jamais la liste des données personnelles supprimées. Idéalement, un mode « à blanc » pour vérifier le volume avant de détruire.


Chapitre suivant : Consentement & droits côté code — un registre de consentement horodaté et versionné, et des route handlers pour l’accès (export JSON), la portabilité et la suppression.

Last updated on