セルフサービスによるアカウント削除後、削除されたユーザーIDを参照するレコードが残っている

概要

DELETE /u/<username>.json は成功し、users テーブルの行は削除されますが、user_id に削除されたユーザーの ID がまだ保持されている行が複数のテーブルに残っています。これらには、外部キー、dependent: 関連付け、クリーンアップジョブ、または文書化された保持理由のいずれも存在しません。

そのうちの一つは、削除処理自体が意図した内容の単純な見落としです。UserDestroyer#delete_posts は topics.user_id を null にするように書かれていますが、user.posts を反復処理しており、そのデフォルトスコープはゴミ箱に移動された投稿(trashed posts)を除外します。そのため、最初の投稿がすでに削除済みのトピックは、削除されたユーザーを指し続けます。

もう二つ、incoming_links と search_logs は、Discourse 自身の匿名化処理がユーザー関連の個人情報として扱うテーブルです(Jobs::AnonymizeUser#anonymize_ips 参照)。また、UserMerger が明示的に再割り当てを行うテーブルでもあります。しかし、UserDestroyer はこれらに一切触れません。

v2026.7.1 で再現確認済み。標準設定で、投稿を所有しないアカウントを使用しました。UserDestroyer#delete_posts は 2026-09-25 時点の main ブランチでも変更されていません。

バージョン

Discourse v2026.7.1(セルフホスト、ローカル認証済みテストインスタンス)。主にコア機能に関わる問題ですが、プラグイン由来の行は以下に別途記載しています。

再現手順

最小限のケースでは、サイト設定の変更も投稿も不要であり、デフォルトの delete user self max post count 設定下でも動作します。

  1. 通常のテストアカウントを登録します。

  2. そのアカウントでログインした状態で、検索を実行します:GET /search.json?q=<unique marker>。

  3. ログインした状態で、外部リファラーから、他のユーザーの共有パラメータ付きでトピックの HTML ページにアクセスします:Referer: https://example.invalid/x を付けて GET /t/<slug>/<topic_id>?u=<other-username>。

  4. ログアウト状態のブラウザから、テストアカウントの共有パラメータ付きで任意のトピックにアクセスします:外部 Referer を付けて GET /t/<slug>/<topic_id>?u=<test-account>。

  5. テストアカウントとして、アカウントを削除します:context=/my/preferences/account を付けて DELETE /u/<test-account>.json。{"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 経由で実行し、ステップ 6 の正確な SQL を出力します。

期待される動作

ユーザーが自分のアカウントを削除した後、そのユーザーを特定する行は削除、null 化、または再割り当てされるべきです。UserDestroyer がすでに posts.user_id(null 化)、categories.user_id(system に再割り当て)、topics.user_id(null 化予定)に対して行っている処理と同様に。

実際の動作

以下はすべて、DELETE /u/<username>.json が 200 を返し、SELECT count(*) FROM users WHERE id = <id> が 0 を返した後の 1 回の実行で測定されたものです。

| テーブル / 列 | 残存行 | 行の内容 | 備考 |

|—|—|—|—|

| 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 は null 化されました。したがって、これは削除処理が何もしないという主張ではなく、特定の見落としのリストです。

これらの行が作成された方法:上記のステップ 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)バグ

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 によってゴミ箱に移動された)、その投稿は反復処理されず、そのトピックに対する null 化は実行されません。私の実行では、対象の 3 つのトピックのうち 2 つは user_id IS NULL になりましたが、アカウント削除前に最初の投稿がゴミ箱に移動されていた 3 番目のトピックは user_id = <deleted id> を保持していました。

数行下の Post.unscoped.where(user_id: result.id).update_all(user_id: nil) は unscoped を使用しているため、投稿自体は正しく切り離されます — 漏れているのはトピックだけです。

なぜ incoming_links と search_logs が際立っているのか

これらは単なる孤立した ID ではありません。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:) を削除または null 化します。

  3. 残りの帰属列 — post_revisions.user_id、custom_emojis.user_id、topic_localizations.localizer_user_id — を posts.user_id と整合させる形で null 化します。

  4. policy_users は discourse-policy に属します。user_destroyer_on_content_deletion_callbacks をフックするか、dependent: :destroy を追加できます。ご希望であれば、プラグインのリポジトリで別途対応します。

リグレッション仕様では、UserDestroyer#destroy 後、このセットのいずれの行にも削除されたユーザーの ID が保持されていないことをアサートできます。

「いいね!」 1