Aller au contenu
Sylvain Joffraud l4w
Retour

Suppression de compte RGPD : le piège ON DELETE CASCADE

Une demande de suppression de compte RGPD sur une billetterie événementielle cachait trois défauts : la suppression programmée à J+30 n’était jamais exécutée, la suppression immédiate laissait l’utilisateur se reconnecter, et la vraie suppression aurait effacé les données d’autres personnes via des clés étrangères en ON DELETE CASCADE. Le correctif ne supprime plus aucune ligne : il anonymise, depuis un cron NestJS quotidien. Contexte : NestJS 10.4, Prisma 5.22, PostgreSQL chez Supabase, supabase-js 2.116, Node 22. Ce qui suit est un retour d’architecture, pas un avis juridique.

Défaut n° 1 : une date de suppression que personne ne relit

L’endpoint de suppression différée écrivait une date d’échéance à J+30 sur le profil. Aucune tâche planifiée ne relisait cette colonne. L’application promettait une suppression qui n’arrivait jamais.

Défaut n° 2 : la suppression de compte en ON DELETE CASCADE

ON DELETE CASCADE est une option de clé étrangère PostgreSQL : quand la ligne référencée est supprimée, les lignes qui la référencent sont supprimées avec elle. Dans le schéma, trois tables pointaient vers auth.users, la table d’authentification de Supabase, avec cette option :

-- schéma simplifié
events.organizer_id      REFERENCES auth.users(id) ON DELETE CASCADE
tickets.buyer_id         REFERENCES auth.users(id) ON DELETE CASCADE
promotions.promoter_id   REFERENCES auth.users(id) ON DELETE CASCADE

Supprimer le compte d’un organisateur aurait emporté ses événements, les billets de ses participants et les commissions de ses promoteurs. Soit les données d’autres personnes, et une partie de la comptabilité.

Un ON DELETE CASCADE vers la table des utilisateurs transforme « supprimer mon compte » en « supprimer tout ce que d’autres ont construit avec moi ».

Défaut n° 3 : un profil effacé, un compte d’auth toujours actif

La suppression immédiate effaçait le profil applicatif, mais pas l’entrée auth.users. L’e-mail et le mot de passe restaient valides, et l’utilisateur pouvait se reconnecter à un compte censé ne plus exister.

ApprocheDonnées des autresAccès de l’utilisateur
Supprimer auth.userseffacées en cascadecoupé
Supprimer le profil seulintactestoujours actif
Anonymiser (retenu)intactescoupé

Anonymiser plutôt que supprimer : un service et un cron NestJS

L’anonymisation consiste à vider les champs nominatifs en gardant la ligne, et à neutraliser l’identité d’authentification. Pour Supabase Auth, updateUserById remplace l’e-mail par une adresse unique sur un domaine .invalid (réservé, non routable) et pose un mot de passe aléatoire. Les commissions non versées sont gelées : le montant et le statut ne bougent pas, la somme reste due.

async anonymizeAccount(userId: string) {
  const profile = await this.prisma.profile.findUnique({ where: { id: userId } });
  if (!profile) throw new NotFoundException();
  if (profile.anonymizedAt) return; // idempotent

  // L'accès d'abord : un profil vidé avec un login actif serait le pire cas.
  const revoked = await this.authAdmin.updateUserById(userId, {
    email: `deleted-${userId}@app.invalid`,
    password: randomBytes(32).toString('hex'),
    user_metadata: {},
  });
  if (!revoked) throw new InternalServerErrorException(); // repris demain

  const now = new Date();
  await this.prisma.$transaction([
    this.prisma.commission.updateMany({
      where: { promoterId: userId, status: { in: ['pending', 'payable'] }, frozenAt: null },
      data: { frozenAt: now, frozenReason: 'account_anonymized' },
    }),
    this.prisma.profile.update({ where: { id: userId }, data: anonymizedFields(now) }),
  ]);
}

Un @Cron(CronExpression.EVERY_DAY_AT_3AM) de @nestjs/schedule sélectionne les profils échus et non anonymisés, et traite chacun dans son propre try/catch : un compte en échec ne bloque pas les suivants. L’anonymisation remet la date d’échéance à null, sinon le cron reprendrait le même profil chaque nuit. Parmi les 27 tests ajoutés : un promoteur porteur de commissions non versées les conserve, gelées.

Pourquoi le délai retombe-t-il sur la valeur la plus longue ?

Le délai est lu dans une variable d’environnement (ici DELETION_DELAY_DAYS), car sa durée relève du juriste. Une valeur absente, non numérique ou négative retombe sur 30 jours :

get deletionDelayDays(): number {
  const parsed = Number(this.config.get('DELETION_DELAY_DAYS'));
  return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 30;
}

Se tromper vers le délai le plus long est sans danger ; supprimer trop tôt ne se rattrape pas. C’est le même raisonnement que pour choisir entre fail-open et fail-closed selon le coût de l’erreur : on échoue du côté le moins coûteux.

⚠️ Le lendemain, un autre piège est apparu : l’anonymisation échouait systématiquement sur l’environnement déployé. La clé service role de Supabase manquait dans la configuration de déploiement. Le service se déclarait Supabase Admin not configured, renvoyait false, et chaque compte échu échouait, journalisé mais invisible côté utilisateur.

Lessons learned

Questions fréquentes

Faut-il supprimer ou anonymiser un utilisateur qui demande la suppression de son compte ?
Anonymiser est souvent plus sûr quand d’autres lignes référencent l’utilisateur : billets, commandes, événements, écritures comptables. On vide les champs nominatifs, on neutralise l’accès et on garde la ligne, ce qui préserve les données des autres personnes et l’historique. Le choix exact et les données à conserver relèvent d’un juriste : c’est un choix d’architecture, pas un avis juridique.
Comment neutraliser un utilisateur Supabase Auth sans le supprimer ?
Avec le client admin de supabase-js, auth.admin.updateUserById() remplace l’e-mail par une adresse unique sur un domaine .invalid, non routable, et pose un mot de passe aléatoire. L’utilisateur ne peut plus se connecter, aucun mail ne peut partir, et la ligne de auth.users reste en place, donc aucune clé étrangère en ON DELETE CASCADE ne se déclenche.
Comment exécuter une suppression de compte différée à J+30 dans NestJS ?
Stocker la date d’échéance sur le profil ne suffit pas : il faut un job qui la relit. Avec @nestjs/schedule, un @Cron(CronExpression.EVERY_DAY_AT_3AM) sélectionne les comptes échus et non encore anonymisés, puis les traite un par un dans un try/catch, pour qu’un compte en échec ne bloque pas les suivants et soit repris la nuit d’après.
Pourquoi supprimer la ligne du profil ne suffit-il pas à supprimer un compte Supabase ?
La table des profils applicative et la table auth.users de Supabase sont distinctes. Effacer le profil laisse l’identité d’authentification intacte : l’e-mail et le mot de passe fonctionnent toujours, et l’utilisateur peut se reconnecter à un compte censé ne plus exister. Il faut agir explicitement sur auth.users via l’API admin.

Partager cet article :

Article précédent
Supabase self-hosted : le Studio exposé via l’API Kong
Article suivant
`_acme-challenge` oublié : le wildcard DNS ne répond plus