حذف الحساب ذاتياً يترك سجلات لا تزال تشير إلى معرّف المستخدم المحذوف

ملخص

يُنجح طلب DELETE /u/<username>.json وتختفي سجلات جدول users، لكن عدة جداول أخرى تحتفظ بسجلات لا يزال فيها عمود user_id يحمل معرّف المستخدم المحذوف. لا يحتوي أيٌّ من هذه الجداول على مفتاح أجنبي، أو علاقة dependent:، أو مهمة تنظيف، أو سبب موثّق للاحتفاظ بالبيانات.

أحدها خطأ واضح في نية مُنفِّذ الحذف نفسه: دالة UserDestroyer#delete_posts مكتوبة لتصفير قيمة topics.user_id، لكنها تتصفح user.posts، والنطاق الافتراضي (default scope) يستبعد المنشورات المحذوفة (trashed) — لذا يبقى الموضوع الذي كان منشوره الأول محذوفًا بالفعل يشير إلى المستخدم المحذوف.

جدولان آخران، وهما incoming_links وsearch_logs، هما جداول تعتبرها مسارات إخفاء الهوية (anonymization) الخاصة بـ Discourse نفسها بيانات شخصية مرتبطة بالمستخدم (انظر Jobs::AnonymizeUser#anonymize_ips)، وتعيد UserMerger توجيهها صراحةً — لكن UserDestroyer لا يلمسها إطلاقًا.

تمت إعادة إنتاج المشكلة على v2026.7.1، مع إعدادات قياسية وحساب لا يملك أي منشورات. لم تتغير دالة UserDestroyer#delete_posts على فرع main حتى تاريخ 2026-09-25.

الإصدار

Discourse v2026.7.1 (مُستضاف ذاتيًا، نسخة اختبار محلية مصرّح بها). معظم المشكلة في النواة (core)، مع ذكر سجل لملحق واحد بشكل منفصل أدناه.

خطوات إعادة الإنتاج

الحالة الدنيا لا تتطلب تغيير أي إعداد موقع ولا وجود منشورات، لذا تعمل ضمن الإعداد الافتراضي delete user self max post count:

1. سجّل حساب اختبار عاديًا.

2. أثناء تسجيل الدخول بهذا الحساب، نفّذ بحثًا: GET /search.json?q=<unique marker>.

3. زر صفحة HTML لموضوع ما أثناء تسجيل الدخول، قادمًا من رابط إحالة (referer) خارجي ومع معلمة مشاركة (share parameter) لشخص آخر: 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 | معرّف المستخدم المحذوف + ip_address للزائر + post_id | نقرة رابط مشاركة مُنسوبة للمستخدم المحذوف |

| incoming_links.current_user_id | 1 | معرّف المستخدم المحذوف + post_id + رابط الإحالة | سجل نقرة المستخدم المحذوف نفسه |

| search_logs.user_id | 1 | معرّف المستخدم المحذوف + مصطلح البحث الذي كتبه | يُحتفظ به لمدة search query log max retention days، الافتراضي 365 يومًا |

| topics.user_id | 1 | معرّف المستخدم المحذوف | فقط لموضوع كان منشوره الأول محذوفًا (trashed) — راجع أدناه |

| post_revisions.user_id | 2 | معرّف المستخدم المحذوف + modifications['raw']، أي نصوص منشوراته السابقة | |

| custom_emojis.user_id | 1 | معرّف المستخدم المحذوف | نسبة الرفع لإيموجي على مستوى الموقع |

| topic_localizations.localizer_user_id | 1 | معرّف المستخدم المحذوف | |

| policy_users.user_id | 1 | معرّف المستخدم المحذوف + 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 مباشرةً في نموذج الاختبار (test fixture)، لأن رفع إيموجي مخصص وكتابة تهيئة موضوع هما مسارات إدارية/ملحقات وليست شيئًا يفعله حساب عادي؛ سلوك الحذف المقاس لهما متطابق في بقية الجوانب.

حالة 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 النطاق الافتراضي، الذي يستبعد المنشورات المحذوفة (trashed). إذا كان المستخدم قد حذف أحد موضوعاته بالفعل — حيث تم لاحقًا تحويل المنشور إلى “مخلفات” (trashed) بواسطة 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: …) إلى المستخدم المستهدف.

إخفاء هوية المستخدم ودمج المستخدم يتعاملان مع هذين الجدولين. حذف المستخدم لا يتعامل معهما.

الأثر

لا يوجد وصول غير مصرّح به: لا يتم تقديم أيٍّ من هذه السجلات للمستخدمين المجهولين أو العاديين، ولا أدّعي خلاف ذلك. الأثر يتعلق بالاحتفاظ بالبيانات وسلامة المرجعيات (referential integrity) — بعد أن يمارس المستخدم حذف الخدمة الذاتية، لا يزال الموقع يحتفظ بسجلات مرتبطة به، بما في ذلك ما بحث عنه وأي روابط اتبعها، دون انتهاء صلاحية مرتبط بالحذف وأدوات مشغل لتطهيرها.

هذا أمر محرج لأي موقع يتعامل مع طلب محو (erasure request)، وهو غير متسق مع معالجة مسار الحذف الخاصة لـ 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؛ يمكنه الخطاف (hook) user_destroyer_on_content_deletion_callbacks أو إضافة dependent: :destroy. يسعدني فتح ذلك بشكل منفصل في مستودع الملحق إذا كنت تفضّل ذلك.

يمكن أن تتحقق مواصفة الانحدار (regression spec) من أنه بعد UserDestroyer#destroy، لا توجد أي سطر في هذا المجموعة لا يزال يحمل معرّف المستخدم المحذوف.

إعجاب واحد (1)