La eliminación de cuenta en autoservicio deja filas que aún apuntan al id de usuario eliminado

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:

  1. Registra una cuenta de prueba normal.

  2. Mientras estás conectado como esa cuenta, ejecuta una búsqueda: GET /search.json?q=<marcador único>.

  3. 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> con Referer: https://example.invalid/x.

  4. 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 un Referer externo.

  5. Como la cuenta de prueba, elimina la cuenta: DELETE /u/<cuenta-de-prueba>.json con context=/my/preferences/account. Devuelve {"success":"OK"} y la fila de users desaparece.

  6. 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 con SearchLog, 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

  1. En UserDestroyer#delete_posts, itera sobre user.posts.with_deleted (o pon en null los temas en una pasada separada Topic.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.

  2. Añade las tablas analíticas vinculadas al usuario a la ruta de destrucción de la manera en que Jobs::AnonymizeUser ya las enumera: elimina o pon en null IncomingLink.where(user_id:), IncomingLink.where(current_user_id:) y SearchLog.where(user_id:).

  3. Pon en null las columnas de atribución restantes — post_revisions.user_id, custom_emojis.user_id, topic_localizations.localizer_user_id — de manera consistente con posts.user_id.

  4. policy_users pertenece a discourse-policy; puede engancharse a user_destroyer_on_content_deletion_callbacks o añadir un dependent: :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.

2 Me gusta