Résumé
DELETE /u/<username>.json réussit et la ligne users est supprimée, mais plusieurs tables conservent des lignes dont la colonne user_id contient toujours l’identifiant de l’utilisateur détruit. Aucune de ces tables ne possède de clé étrangère, d’association dependent:, de tâche de nettoyage ni de raison de rétention documentée.
L’une d’elles est une simple erreur par rapport à l’intention déclarée du destructeur : UserDestroyer#delete_posts est conçu pour mettre à null topics.user_id, mais il itère sur user.posts, dont le scope par défaut exclut les posts mis à la corbeille — ainsi, un sujet dont le premier post a déjà été supprimé continue de pointer vers l’utilisateur détruit.
Deux autres, incoming_links et search_logs, sont des tables que le propre chemin d’anonymisation de Discourse traite comme des données personnelles liées à l’utilisateur (Jobs::AnonymizeUser#anonymize_ips), et que UserMerger redirige explicitement — mais que UserDestroyer ne touche pas du tout.
Reproduit sur v2026.7.1, avec des paramètres standard et un compte ne possédant aucun post. UserDestroyer#delete_posts n’a pas été modifié sur main à la date du 2026-09-25.
Version
Discourse v2026.7.1 (auto-hébergé, instance de test locale autorisée). Principalement le noyau ; une ligne de plugin est listée séparément ci-dessous.
Étapes pour reproduire
Le cas minimal ne nécessite aucune modification de paramètre de site ni de posts, il fonctionne donc sous le paramètre par défaut delete user self max post count :
-
Enregistrer un compte de test normal.
-
Pendant que vous êtes connecté en tant que ce compte, effectuer une recherche :
GET /search.json?q=<marqueur unique>. -
Visiter la page HTML d’un sujet pendant que vous êtes connecté, en arrivant d’un référent externe et avec le paramètre de partage d’un autre utilisateur :
GET /t/<slug>/<topic_id>?u=<autre-nom-d-utilisateur>avecReferer: https://example.invalid/x. -
Depuis un navigateur non connecté, visiter n’importe quel sujet avec le paramètre de partage du compte de test :
GET /t/<slug>/<topic_id>?u=<compte-de-test>avec unRefererexterne. -
En tant que compte de test, supprimer le compte :
DELETE /u/<compte-de-test>.jsonaveccontext=/my/preferences/account. Il renvoie{"success":"OK"}et la ligneusersdisparaît. -
Interroger :
SELECT id, user_id, current_user_id, ip_address, post_id FROM incoming_links WHERE user_id = <id> OR current_user_id = <id>; SELECT id, user_id, term FROM search_logs WHERE user_id = <id>;
Le poc.py joint pilote les étapes 1 à 5 via HTTP et affiche le SQL exact pour l’étape 6.
Attendu
Après qu’un utilisateur ait supprimé son propre compte, les lignes qui identifient cet utilisateur devraient être supprimées, mises à null ou réaffectées — comme UserDestroyer le fait déjà pour posts.user_id (mis à null), categories.user_id (réaffecté au système) et topics.user_id (censé être mis à null).
Réel
Tout ce qui suit a été mesuré lors d’une seule exécution, après que DELETE /u/<username>.json ait renvoyé 200 et que SELECT count(*) FROM users WHERE id = <id> ait renvoyé 0.
| Table / colonne | Lignes restantes | Contenu de la ligne | Notes |
|—|—|—|—|
| incoming_links.user_id | 1 | l’identifiant de l’utilisateur supprimé + l’ip_address du visiteur + post_id | clic sur lien de partage attribué à l’utilisateur supprimé |
| incoming_links.current_user_id | 1 | l’identifiant de l’utilisateur supprimé + post_id + référent | enregistrement de clic de l’utilisateur supprimé lui-même |
| search_logs.user_id | 1 | l’identifiant de l’utilisateur supprimé + le terme de recherche qu’il a tapé | conservé pour search query log max retention days, défaut 365 |
| topics.user_id | 1 | l’identifiant de l’utilisateur supprimé | uniquement pour un sujet dont le premier post a déjà été mis à la corbeille — voir ci-dessous |
| post_revisions.user_id | 2 | l’identifiant de l’utilisateur supprimé + modifications['raw'], c’est-à-dire leurs corps de posts précédents | |
| custom_emojis.user_id | 1 | l’identifiant de l’utilisateur supprimé | attribution de l’upload sur un emoji site-wide |
| topic_localizations.localizer_user_id | 1 | l’identifiant de l’utilisateur supprimé | |
| policy_users.user_id | 1 | l’identifiant de l’utilisateur supprimé + accepted_at | plugin discourse-policy |
L’exécution des tâches de nettoyage fournies ensuite ne change rien : j’ai relu chaque ligne après Jobs::UpdateScoresForToday, Jobs::CleanUpUnusedRegisteredUserApiKeyClients et PostDestroyer.destroy_stubs et elles étaient toutes encore présentes.
Pour contraste, lors de la même exécution, ces éléments ont bien fonctionné : les lignes users, user_profiles et email_tokens étaient parties, les lignes chat_mentions ciblant le compte ont été supprimées, et posts.user_id et la plupart des topics.user_id ont été mis à null. Il s’agit donc d’une liste d’erreurs spécifiques, et non d’une affirmation que la suppression ne fait rien.
Comment les lignes ont été créées : les étapes 1 à 5 ci-dessus créent les lignes incoming_links et search_logs par navigation ordinaire. Les lignes topics.user_id, post_revisions.user_id et policy_users proviennent d’une publication ordinaire, de la suppression d’un sujet par soi-même, et de l’acceptation d’une politique. Les lignes custom_emojis et topic_localizations ont été insérées directement dans le fixture de test, car l’upload d’un emoji personnalisé et l’écriture d’une localisation de sujet sont des chemins administrateur/plugin et non quelque chose qu’un compte normal fait ; le comportement de suppression mesuré pour eux est sinon identique.
Le cas topics.user_id est un bug concret de portée (off-by-scope)
UserDestroyer#delete_posts est écrit ainsi :
user.posts.find_each do |post|
...
if post.topic && post.is_first_post?
Topic.unscoped.where(id: post.topic_id).update_all(user_id: nil)
end
end
user.posts utilise le scope par défaut, qui exclut les posts mis à la corbeille. Si l’utilisateur avait déjà supprimé l’un de ses propres sujets — le stub a ensuite été mis à la corbeille par PostDestroyer.destroy_stubs — ce post n’est pas itéré, donc la mise à null ne s’exécute jamais pour son sujet. Lors de mon exécution, deux des trois sujets du sujet principal ont fini avec user_id IS NULL et le troisième, dont le premier post avait été mis à la corbeille avant la suppression du compte, a conservé user_id = <id supprimé>.
Post.unscoped.where(user_id: result.id).update_all(user_id: nil) quelques lignes plus bas utilise bien unscoped, donc le post lui-même est correctement détaché — seul le sujet est manqué.
Pourquoi incoming_links et search_logs se démarquent
Ce ne sont pas de simples identifiants orphelins. Discourse les classe déjà comme des données personnelles liées à l’utilisateur ailleurs :
-
app/jobs/regular/anonymize_user.rb:43—IncomingLink.where(current_user_id: …).update_all(ip_address: new_ip), aux côtés deSearchLog,TopicLinkClick,TopicViewItem,UserProfileView. -
app/services/user_merger.rb:348—IncomingLink.where(user_id: …)et.where(current_user_id: …)sont tous deux redirigés vers l’utilisateur cible.
L’anonymisation d’un utilisateur et la fusion d’un utilisateur gèrent toutes ces tables. La suppression d’un utilisateur ne le fait pas.
Impact
Pas d’accès non autorisé : aucune de ces lignes n’est servie aux utilisateurs anonymes ou réguliers, et je ne prétends pas le contraire. L’impact concerne la rétention des données et l’intégrité référentielle — après qu’un utilisateur ait exercé la suppression auto-service, le site conserve toujours des lignes liées à lui, y compris ce qu’il a cherché et quels liens il a suivis, sans expiration liée à la suppression et sans outil d’opérateur pour les effacer.
C’est gênant pour tout site traitant une demande d’effacement, et c’est incohérent avec le traitement par le chemin de suppression lui-même de posts, categories et topics.
Correction suggérée
-
Dans
UserDestroyer#delete_posts, itérer suruser.posts.with_deleted(ou mettre les sujets à null dans un passage séparéTopic.unscoped.where(user_id: user.id).update_all(user_id: nil)) afin que les premiers posts déjà mis à la corbeille ne laissent pas leur sujet attribué. -
Ajouter les tables analytiques liées à l’utilisateur au chemin de destruction de la même manière que
Jobs::AnonymizeUserles énumère déjà : supprimer ou mettre à nullIncomingLink.where(user_id:),IncomingLink.where(current_user_id:)etSearchLog.where(user_id:). -
Mettre à null les colonnes d’attribution restantes —
post_revisions.user_id,custom_emojis.user_id,topic_localizations.localizer_user_id— de manière cohérente avecposts.user_id. -
policy_usersappartient àdiscourse-policy; il peut accrocheruser_destroyer_on_content_deletion_callbacksou ajouter undependent: :destroy. Je suis prêt à ouvrir cela séparément dans le dépôt du plugin si vous préférez.
Une spécification de régression pourrait affirmer qu’après UserDestroyer#destroy, aucune ligne de cet ensemble ne conserve l’identifiant de l’utilisateur détruit.