Удаление аккаунта через self-service оставляет строки, ссылающиеся на удалённый ID пользователя

Резюме

Запрос DELETE /u/<username>.json выполняется успешно, и строка в таблице users удаляется, однако в нескольких таблицах остаются строки, в которых user_id по-прежнему содержит идентификатор удаленного пользователя. Ни в одной из них нет внешнего ключа, ассоциации с dependent:, задачи по очистке или задокументированной причины для сохранения данных.

Одна из этих ситуаций является прямым нарушением намерения, заявленного самим разработчиком функции удаления: метод UserDestroyer#delete_posts написан так, чтобы обнулять topics.user_id, но он итерирует user.posts, чья область действия по умолчанию (default scope) исключает удаленные (trashed) посты. В результате тема, чей первый пост уже был удален, продолжает ссылаться на удаленного пользователя.

Две другие таблицы, incoming_links и search_logs, являются таблицами, которые собственный путь анонимизации Discourse рассматривает как персональные данные, связанные с пользователем (см. Jobs::AnonymizeUser#anonymize_ips), и которые UserMerger явно переназначает, но к которым UserDestroyer вообще не обращается.

Воспроизведено на версии v2026.7.1 с настройками по умолчанию и учетной записью, не имеющей постов. Метод UserDestroyer#delete_posts не был изменен в ветке main на 25.09.2026.

Версия

Discourse v2026.7.1 (self-hosted, локальный авторизованный тестовый экземпляр). В основном ядро; одна строка плагина указана отдельно ниже.

Шаги для воспроизведения

Для минимального случая не требуется изменение настроек сайта и не нужны посты, поэтому он работает при значении по умолчанию для delete user self max post count:

1. Зарегистрируйте обычный тестовый аккаунт.

2. Во время входа в систему под этим аккаунтом выполните поиск: GET /search.json?q=<уникальный маркер>.

3. Посетите HTML-страницу какой-либо темы во время входа в систему, перейдя из внешнего реферера и с параметром шаринга другого пользователя: GET /t/<slug>/<topic_id>?u=<other-username> с заголовком Referer: https://example.invalid/x.

4. Из браузера без входа в систему посетите любую тему с параметром шаринга тестового аккаунта: GET /t/<slug>/<topic_id>?u=<test-account> с внешним заголовком Referer.

5. От имени тестового аккаунта удалите аккаунт: DELETE /u/<test-account>.json с параметром context=/my/preferences/account. Возвращается {"success":"OK"}, и строка в таблице users исчезает.

6. Выполните запрос:


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

Приложенный poc.py выполняет шаги 1–5 по HTTP и выводит точный SQL для шага 6.

Ожидаемый результат

После того как пользователь удаляет собственный аккаунт, строки, идентифицирующие этого пользователя, должны быть удалены, обнулены или переназначены — так, как UserDestroyer уже делает для posts.user_id (обнуление), categories.user_id (переназначение на системного пользователя) и topics.user_id (предполагается обнуление).

Фактический результат

Все перечисленное ниже было измерено за один запуск, после того как DELETE /u/<username>.json вернул 200, а SELECT count(*) FROM users WHERE id = <id> вернул 0.

| Таблица / колонка | Оставшиеся строки | Содержимое строки | Примечания |

|—|—|—|—|

| incoming_links.user_id | 1 | id удаленного пользователя + ip_address посетителя + post_id | клик по ссылке для шаринга, отнесенный к удаленному пользователю |

| incoming_links.current_user_id | 1 | id удаленного пользователя + post_id + реферер | собственная запись клика удаленного пользователя |

| search_logs.user_id | 1 | id удаленного пользователя + поисковый запрос, который они ввели | хранится в течение search query log max retention days, по умолчанию 365 |

| topics.user_id | 1 | id удаленного пользователя | только для темы, чей первый пост уже был помечен как удаленный — см. ниже |

| post_revisions.user_id | 2 | id удаленного пользователя + modifications['raw'], то есть их предыдущие тексты постов | |

| custom_emojis.user_id | 1 | id удаленного пользователя | атрибуция загрузчика для эмодзи, доступного на всем сайте |

| topic_localizations.localizer_user_id | 1 | id удаленного пользователя | |

| policy_users.user_id | 1 | id удаленного пользователя + accepted_at | плагин discourse-policy |

Запуск встроенных задач очистки после этого ничего не меняет: я перечитал каждую строку после выполнения Jobs::UpdateScoresForToday, Jobs::CleanUpUnusedRegisteredUserApiKeyClients и PostDestroyer.destroy_stubs, и они все еще были там.

Для сравнения, в том же запуске эти вещи действительно работали: строки в users, user_profiles и email_tokens исчезли, строки в chat_mentions, направленные на аккаунт, были удалены, а posts.user_id и большинство topics.user_id были обнулены. Таким образом, это список конкретных упущений, а не утверждение, что удаление вообще ничего не делает.

Как были созданы строки: шаги 1–5 выше создают строки в incoming_links и search_logs через обычный просмотр. Строки в topics.user_id, post_revisions.user_id и policy_users возникают из обычного создания постов, самостоятельного удаления темы и принятия политики. Строки в custom_emojis и topic_localizations были созданы непосредственно в тестовом фикстуре, поскольку загрузка пользовательского эмодзи и написание локализации темы являются административными/плагинными путями, а не тем, что делает обычный аккаунт; поведение удаления, измеренное для них, в остальном идентично.

Случай с topics.user_id является конкретным ошибкой в области действия (off-by-scope bug)

Метод UserDestroyer#delete_posts написан следующим образом:


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 использует область действия по умолчанию, которая исключает удаленные посты. Если пользователь уже удалил одну из своих тем (заглушка позже была помечена как удаленная методом PostDestroyer.destroy_stubs), этот пост не итерируется, поэтому обнуление никогда не выполняется для его темы. В моем запуске две из трех тем субъекта закончились со значением user_id IS NULL, а третья, чей первый пост был помечен как удаленный до удаления аккаунта, сохранила user_id = <deleted id>.

Post.unscoped.where(user_id: result.id).update_all(user_id: nil) несколькими строками ниже использует unscoped, поэтому сам пост корректно отсоединяется — упущена только тема.

Почему incoming_links и search_logs выделяются

Это не просто осиротевшие идентификаторы. Discourse уже классифицирует их как персональные данные, связанные с пользователем, в других местах:

  • app/jobs/regular/anonymize_user.rb:43 — IncomingLink.where(current_user_id: …).update_all(ip_address: new_ip), наряду с SearchLog, TopicLinkClick, TopicViewItem, UserProfileView.

  • app/services/user_merger.rb:348 — IncomingLink.where(user_id: …) и .where(current_user_id: …) оба переназначаются на целевого пользователя.

Анонимизация пользователя и слияние пользователя обрабатывают эти таблицы. Удаление пользователя — нет.

Влияние

Нет несанкционированного доступа: ни одна из этих строк не предоставляется анонимным или обычным пользователям, и я не утверждаю обратного. Влияние заключается в сохранении данных и ссылочной целостности — после того, как пользователь воспользовался функцией самостоятельного удаления, сайт по-прежнему хранит строки, привязанные к нему, включая то, что они искали и какие ссылки они посещали, без срока действия, привязанного к удалению, и без инструментов оператора для их очистки.

Это неудобно для любого сайта, обрабатывающего запрос на стирание данных, и это противоречит собственному обращению пути удаления с posts, categories и topics.

Предлагаемое исправление

1. В UserDestroyer#delete_posts итерируйте user.posts.with_deleted (или обнуляйте темы в отдельном проходе Topic.unscoped.where(user_id: user.id).update_all(user_id: nil)), чтобы уже удаленные первые посты не оставляли свою тему привязанной к пользователю.

2. Добавьте таблицы аналитики, связанные с пользователем, в путь удаления так, как Jobs::AnonymizeUser уже перечисляет их: удалите или обнулите IncomingLink.where(user_id:), IncomingLink.where(current_user_id:) и SearchLog.where(user_id:).

3. Обнулите оставшиеся колонки атрибуции — post_revisions.user_id, custom_emojis.user_id, topic_localizations.localizer_user_id — согласованно с posts.user_id.

4. policy_users принадлежит плагину discourse-policy; он может использовать user_destroyer_on_content_deletion_callbacks или добавить dependent: :destroy. Я с удовольствием открою это отдельно в репозитории плагина, если вы предпочитаете.

Спецификация регрессии может утверждать, что после UserDestroyer#destroy ни одна строка в этом наборе не содержит идентификатор удаленного пользователя.

2 лайка