Self-service account deletion leaves rows that still point at the deleted user id

Summary

DELETE /u/<username>.json succeeds and the users row is gone, but several tables keep rows whose user_id still holds the destroyed user’s id. None of them has a foreign key, a dependent: association, a cleanup job, or a documented retention reason.

One of them is a plain miss of the destroyer’s own stated intent: UserDestroyer#delete_posts is written to null out topics.user_id, but it iterates user.posts, whose default scope excludes trashed posts — so a topic whose first post was already deleted keeps pointing at the destroyed user.

Two others, incoming_links and search_logs, are tables that Discourse’s own anonymization path treats as user-linked personal data (Jobs::AnonymizeUser#anonymize_ips), and that UserMerger explicitly re-points — but that UserDestroyer does not touch at all.

Reproduced on v2026.7.1, with stock settings and an account that owns no posts. UserDestroyer#delete_posts is unchanged on main as of 2026-09-25.

Version

Discourse v2026.7.1 (self-hosted, local authorized test instance). Mostly core; one plugin row is listed separately below.

Steps to reproduce

The minimal case needs no site-setting change and no posts, so it works under the default delete user self max post count:

  1. Register a normal test account.

  2. While logged in as that account, run a search: GET /search.json?q=<unique marker>.

  3. Visit some topic’s HTML page while logged in, arriving from an external referer and with someone else’s share parameter: GET /t/<slug>/<topic_id>?u=<other-username> with Referer: https://example.invalid/x.

  4. From a logged-out browser, visit any topic with the test account’s share parameter: GET /t/<slug>/<topic_id>?u=<test-account> with an external Referer.

  5. As the test account, delete the account: DELETE /u/<test-account>.json with context=/my/preferences/account. It returns {"success":"OK"} and the users row disappears.

  6. Query:

    
    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>;
    
    

The attached poc.py drives steps 1–5 over HTTP and prints the exact SQL for step 6.

Expected

After a user deletes their own account, rows that identify that user should be removed, nulled, or reassigned — as UserDestroyer already does for posts.user_id (nulled), categories.user_id (reassigned to system) and topics.user_id (intended to be nulled).

Actual

Everything below was measured on one run, after DELETE /u/<username>.json returned 200 and SELECT count(\*) FROM users WHERE id = <id> returned 0.

Table / column Rows left What the row holds Notes
incoming_links.user_id 1 the deleted user’s id + the visitor’s ip_address + post_id share-link click attributed to the deleted user
incoming_links.current_user_id 1 the deleted user’s id + post_id + referer the deleted user’s own click record
search_logs.user_id 1 the deleted user’s id + the search term they typed kept for search query log max retention days, default 365
topics.user_id 1 the deleted user’s id only for a topic whose first post was already trashed — see below
post_revisions.user_id 2 the deleted user’s id + modifications['raw'], i.e. their earlier post bodies
custom_emojis.user_id 1 the deleted user’s id uploader attribution on a site-wide emoji
topic_localizations.localizer_user_id 1 the deleted user’s id
policy_users.user_id 1 the deleted user’s id + accepted_at discourse-policy plugin

Running the shipped cleanup jobs afterwards changes nothing: I re-read every row after Jobs::UpdateScoresForToday, Jobs::CleanUpUnusedRegisteredUserApiKeyClients and PostDestroyer.destroy_stubs and they were all still there.

For contrast, in the same run these did behave: the users, user_profiles and email_tokens rows were gone, chat_mentions rows targeting the account were removed, and posts.user_id and most topics.user_id were nulled. So this is a list of specific misses, not a claim that deletion does nothing.

How the rows were created: steps 1–5 above create the incoming_links and search_logs rows through ordinary browsing. The topics.user_id, post_revisions.user_id and policy_users rows come from ordinary posting, self-deleting a topic, and accepting a policy. The custom_emojis and topic_localizations rows were seeded directly in the test fixture, because uploading a custom emoji and writing a topic localization are admin/plugin paths rather than something a normal account does; the deletion behaviour measured for them is otherwise identical.

The topics.user_id case is a concrete off-by-scope bug

UserDestroyer#delete_posts is written as:


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 uses the default scope, which excludes trashed posts. If the user had already deleted one of their own topics — the stub was later trashed by PostDestroyer.destroy_stubs — that post is not iterated, so the nulling never runs for its topic. In my run, two of the subject’s three topics ended with user_id IS NULL and the third, whose first post had been trashed before the account deletion, kept user_id = <deleted id>.

Post.unscoped.where(user_id: result.id).update_all(user_id: nil) a few lines later does use unscoped, so the post itself is correctly detached — only the topic is missed.

Why incoming_links and search_logs stand out

They are not merely orphan ids. Discourse already classifies them as user-linked personal data elsewhere:

  • app/jobs/regular/anonymize_user.rb:43 — IncomingLink.where(current_user_id: …).update_all(ip_address: new_ip), alongside SearchLog, TopicLinkClick, TopicViewItem, UserProfileView.

  • app/services/user_merger.rb:348 — IncomingLink.where(user_id: …) and .where(current_user_id: …) are both re-pointed at the target user.

Anonymizing a user and merging a user both handle these tables. Deleting a user does not.

Impact

No unauthorized access: none of these rows is served to anonymous or to regular users, and I am not claiming otherwise. The impact is data retention and referential integrity — after a user exercises self-service deletion, the site still holds rows keyed to them, including what they searched for and which links they followed, with no expiry tied to the deletion and no operator tooling to clear them.

That is awkward for any site handling an erasure request, and it is inconsistent with the deletion path’s own treatment of `posts`, `categories` and `topics`.

Suggested fix

  1. In UserDestroyer#delete_posts, iterate user.posts.with_deleted (or null the topics in a separate Topic.unscoped.where(user_id: user.id).update_all(user_id: nil) pass) so already-trashed first posts do not leave their topic attributed.

  2. Add the user-linked analytics tables to the destroy path the way Jobs::AnonymizeUser already enumerates them: delete or null IncomingLink.where(user_id:), IncomingLink.where(current_user_id:) and SearchLog.where(user_id:).

  3. Null the remaining attribution columns — post_revisions.user_id, custom_emojis.user_id, topic_localizations.localizer_user_id — consistently with posts.user_id.

  4. policy_users belongs to discourse-policy; it can hook user_destroyer_on_content_deletion_callbacks or add a dependent: :destroy. I am happy to open that separately in the plugin’s repository if you prefer.

A regression spec could assert that after UserDestroyer#destroy, no row in this set still holds the destroyed user’s id.

2 לייקים