Resumen
DELETE /u/<username>.json se ejecuta correctamente y la fila de users desaparece, pero varias tablas conservan filas cuyo user_id aún contiene el id del usuario destruido. Ninguna de ellas tiene una clave foránea, una asociación dependent:, un trabajo de limpieza ni una razón documentada de retención.
Una de ellas es un simple error en la intención declarada por el propio destructor: UserDestroyer#delete_posts está escrito para poner en null el topics.user_id, pero itera sobre user.posts, cuyo alcance por defecto excluye los publicaciones en la papelera; por lo tanto, un tema cuya primera publicación ya fue eliminada sigue apuntando al usuario destruido.
Otras dos, incoming_links y search_logs, son tablas que la propia ruta de anonimización de Discourse trata como datos personales vinculados al usuario (Jobs::AnonymizeUser#anonymize_ips), y que UserMerger reasigna explícitamente, pero que UserDestroyer no toca en absoluto.
Reproducido en v2026.7.1, con configuración estándar y una cuenta que no posee publicaciones. UserDestroyer#delete_posts no ha cambiado en main a fecha de 2026-09-25.
Versión
Discourse v2026.7.1 (autoalojado, instancia de prueba autorizada local). Principalmente núcleo; una fila de un plugin se lista por separado a continuación.
Pasos para reproducir
El caso mínimo no requiere ningún cambio en la configuración del sitio ni publicaciones, por lo que funciona bajo el valor predeterminado de delete user self max post count:
-
Registra una cuenta de prueba normal.
-
Mientras estás conectado como esa cuenta, ejecuta una búsqueda:
GET /search.json?q=<marcador único>. -
Visita la página HTML de algún tema mientras estás conectado, llegando desde un referer externo y con el parámetro de compartir de otra persona:
GET /t/<slug>/<topic_id>?u=<otro-usuario>conReferer: https://example.invalid/x. -
Desde un navegador sin iniciar sesión, visita cualquier tema con el parámetro de compartir de la cuenta de prueba:
GET /t/<slug>/<topic_id>?u=<cuenta-de-prueba>con unRefererexterno. -
Como la cuenta de prueba, elimina la cuenta:
DELETE /u/<cuenta-de-prueba>.jsonconcontext=/my/preferences/account. Devuelve{"success":"OK"}y la fila deusersdesaparece. -
Consulta:
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>;
El poc.py adjunto ejecuta los pasos 1–5 mediante HTTP e imprime el SQL exacto para el paso 6.
Esperado
Después de que un usuario elimine su propia cuenta, las filas que identifican a ese usuario deberían ser eliminadas, puestas en null o reasignadas, tal como UserDestroyer ya hace para posts.user_id (puesto en null), categories.user_id (reasignado al sistema) y topics.user_id (con la intención de ponerlo en null).
Real
Todo lo siguiente se midió en una sola ejecución, después de que DELETE /u/<username>.json devolviera 200 y SELECT count(*) FROM users WHERE id = <id> devolviera 0.
| Tabla / columna | Filas restantes | Qué contiene la fila | Notas |
|---|---|---|---|
incoming_links.user_id |
1 | el id del usuario eliminado + la ip_address del visitante + post_id |
clic en enlace de compartir atribuido al usuario eliminado |
incoming_links.current_user_id |
1 | el id del usuario eliminado + post_id + referer |
registro de clic propio del usuario eliminado |
search_logs.user_id |
1 | el id del usuario eliminado + el término de búsqueda que escribieron | retenido según search query log max retention days, valor predeterminado 365 |
topics.user_id |
1 | el id del usuario eliminado | solo para un tema cuya primera publicación ya estaba en la papelera — ver abajo |
post_revisions.user_id |
2 | el id del usuario eliminado + modifications['raw'], es decir, los cuerpos de sus publicaciones anteriores |
|
custom_emojis.user_id |
1 | el id del usuario eliminado | atribución del cargador en un emoji de todo el sitio |
topic_localizations.localizer_user_id |
1 | el id del usuario eliminado | |
policy_users.user_id |
1 | el id del usuario eliminado + accepted_at |
plugin discourse-policy |
Ejecutar los trabajos de limpieza incluidos posteriormente no cambia nada: releí cada fila después de Jobs::UpdateScoresForToday, Jobs::CleanUpUnusedRegisteredUserApiKeyClients y PostDestroyer.destroy_stubs y todas seguían ahí.
Por contraste, en la misma ejecución estas sí se comportaron correctamente: las filas de users, user_profiles y email_tokens desaparecieron, las filas de chat_mentions que apuntaban a la cuenta fueron eliminadas, y posts.user_id y la mayoría de topics.user_id fueron puestos en null. Por lo tanto, esta es una lista de errores específicos, no una afirmación de que la eliminación no hace nada.
Cómo se crearon las filas: los pasos 1–5 anteriores crean las filas de incoming_links y search_logs mediante navegación ordinaria. Las filas de topics.user_id, post_revisions.user_id y policy_users provienen de publicaciones ordinarias, de autoeliminar un tema y de aceptar una política. Las filas de custom_emojis y topic_localizations se sembraron directamente en el fixture de prueba, porque cargar un emoji personalizado y escribir una localización de tema son rutas de administrador/plugin y no algo que haga una cuenta normal; el comportamiento de eliminación medido para ellas es de lo contrario idéntico.
El caso de topics.user_id es un error concreto de alcance (off-by-scope)
UserDestroyer#delete_posts está escrito 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 usa el alcance por defecto, que excluye las publicaciones en la papelera. Si el usuario ya había eliminado uno de sus propios temas — el stub fue posteriormente enviado a la papelera por PostDestroyer.destroy_stubs — esa publicación no se itera, por lo que la asignación a null nunca se ejecuta para su tema. En mi ejecución, dos de los tres temas del sujeto terminaron con user_id IS NULL y el tercero, cuya primera publicación había sido enviada a la papelera antes de la eliminación de la cuenta, conservó user_id = <id eliminado>.
Post.unscoped.where(user_id: result.id).update_all(user_id: nil) unas líneas más abajo sí usa unscoped, por lo que la publicación en sí se desvincula correctamente; solo se omite el tema.
Por qué incoming_links y search_logs destacan
No son meros ids huérfanos. Discourse ya los clasifica como datos personales vinculados al usuario en otras partes:
-
app/jobs/regular/anonymize_user.rb:43—IncomingLink.where(current_user_id: …).update_all(ip_address: new_ip), junto conSearchLog,TopicLinkClick,TopicViewItem,UserProfileView. -
app/services/user_merger.rb:348—IncomingLink.where(user_id: …)y.where(current_user_id: …)se reasignan ambos al usuario objetivo.
Anonimizar a un usuario y fusionar a un usuario manejan ambas tablas. Eliminar a un usuario no lo hace.
Impacto
Sin acceso no autorizado: ninguna de estas filas se sirve a usuarios anónimos ni regulares, y no afirmo lo contrario. El impacto es la retención de datos y la integridad referencial: después de que un usuario ejerce la eliminación de autoservicio, el sitio aún conserva filas vinculadas a ellos, incluyendo lo que buscaron y qué enlaces siguieron, sin caducidad vinculada a la eliminación y sin herramientas del operador para limpiarlas.
Esto es incómodo para cualquier sitio que maneje una solicitud de borrado, y es inconsistente con el propio tratamiento de la ruta de eliminación para `posts`, `categories` y `topics`.
Solución sugerida
-
En
UserDestroyer#delete_posts, itera sobreuser.posts.with_deleted(o pon ennulllos temas en una pasada separadaTopic.unscoped.where(user_id: user.id).update_all(user_id: nil)) para que las primeras publicaciones ya en la papelera no dejen su tema atribuido. -
Añade las tablas analíticas vinculadas al usuario a la ruta de destrucción de la manera en que
Jobs::AnonymizeUserya las enumera: elimina o pon ennullIncomingLink.where(user_id:),IncomingLink.where(current_user_id:)ySearchLog.where(user_id:). -
Pon en
nulllas columnas de atribución restantes —post_revisions.user_id,custom_emojis.user_id,topic_localizations.localizer_user_id— de manera consistente conposts.user_id. -
policy_userspertenece adiscourse-policy; puede engancharse auser_destroyer_on_content_deletion_callbackso añadir undependent: :destroy. Estoy dispuesto a abrir eso por separado en el repositorio del plugin si lo prefieren.
Una especificación de regresión podría afirmar que después de UserDestroyer#destroy, ninguna fila de este conjunto conserva el id del usuario destruido.