L'eliminazione dell'account in autonomia lascia righe che fanno ancora riferimento all'ID utente eliminato

## Riepilogo

`DELETE /u/.json` ha esito positivo e la riga in `users` scompare, ma diverse tabelle conservano righe il cui `user_id` contiene ancora l’id dell’utente eliminato. Nessuna di queste tabelle dispone di una chiave esterna, di un’associazione `dependent:`, di un job di pulizia o di una motivazione documentata per la conservazione dei dati.

Una di esse è una semplice omissione rispetto all’intento dichiarato dello stesso strumento di eliminazione: `UserDestroyer#delete_posts` è scritto per azzerare `topics.user_id`, ma itera su `user.posts`, la cui scope predefinita esclude i post nel cestino — di conseguenza, un topic il cui primo post era già stato eliminato continua a puntare all’utente eliminato.

Altre due, `incoming_links` e `search_logs`, sono tabelle che il percorso di anonimizzazione di Discourse stesso tratta come dati personali collegati all’utente (`Jobs::AnonymizeUser#anonymize_ips`), e che `UserMerger` reindirizza esplicitamente — ma che `UserDestroyer` non tocca affatto.

Riprodotta su `v2026.7.1`, con **impostazioni standard** e un account che non possiede post. `UserDestroyer#delete_posts` non è stato modificato su `main` al 2026-09-25.

## Versione

Discourse `v2026.7.1` (self-hosted, istanza di test autorizzata locale). Principalmente core; una riga di un plugin è elencata separatamente di seguito.

## Passaggi per riprodurre

Il caso minimo non richiede modifiche alle impostazioni del sito né post, quindi funziona con il valore predefinito di `delete user self max post count`:

1. Registrare un normale account di test.

2. Mentre si è collegati con quell’account, eseguire una ricerca: `GET /search.json?q=`.

3. Visitare la pagina HTML di un topic mentre si è collegati, arrivando da un referrer esterno e con il parametro di condivisione di un altro utente: `GET /t//<topic_id>?u=` con `Referer: https://example.invalid/x`.

4. Da un browser non collegato, visitare qualsiasi topic con il parametro di condivisione dell’**account di test**: `GET /t//<topic_id>?u=` con un `Referer` esterno.

5. Come account di test, eliminare l’account: `DELETE /u/.json` con `context=/my/preferences/account`. Restituisce `{“success”:“OK”}` e la riga in `users` scompare.

6. Eseguire la query:

```sql

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

```

L’allegato `poc.py` esegue i passaggi 1–5 via HTTP e stampa la SQL esatta per il passaggio 6.

## Atteso

Dopo che un utente elimina il proprio account, le righe che identificano quell’utente dovrebbero essere rimosse, azzerate o riassegnate — come `UserDestroyer` fa già per `posts.user_id` (azzerato), `categories.user_id` (riassegnato al sistema) e `topics.user_id` (destinato ad essere azzerato).

## Effettivo

Tutto quanto segue è stato misurato in un’unica esecuzione, dopo che `DELETE /u/.json` ha restituito 200 e `SELECT count(*) FROM users WHERE id = ` ha restituito 0.

| Tabella / colonna | Righe residue | Contenuto della riga | Note |

|—|—|—|—|

| `incoming_links.user_id` | 1 | l’id dell’utente eliminato + l’`ip_address` del visitatore + `post_id` | clic sul link di condivisione attribuito all’utente eliminato |

| `incoming_links.current_user_id` | 1 | l’id dell’utente eliminato + `post_id` + referrer | record di clic dell’utente eliminato stesso |

| `search_logs.user_id` | 1 | l’id dell’utente eliminato + il **termine di ricerca** digitato | conservato per `search query log max retention days`, predefinito 365 |

| `topics.user_id` | 1 | l’id dell’utente eliminato | solo per un topic il cui primo post era già nel cestino — vedere sotto |

| `post_revisions.user_id` | 2 | l’id dell’utente eliminato + `modifications[‘raw’]`, ovvero i corpi dei post precedenti | |

| `custom_emojis.user_id` | 1 | l’id dell’utente eliminato | attribuzione del caricamento di un emoji a livello di sito |

| `topic_localizations.localizer_user_id` | 1 | l’id dell’utente eliminato | |

| `policy_users.user_id` | 1 | l’id dell’utente eliminato + `accepted_at` | plugin `discourse-policy` |

L’esecuzione dei job di pulizia forniti in seguito non cambia nulla: ho riletto ogni riga dopo `Jobs::UpdateScoresForToday`, `Jobs::CleanUpUnusedRegisteredUserApiKeyClients` e `PostDestroyer.destroy_stubs` e tutte erano ancora presenti.

Per contrasto, nella stessa esecuzione questi **hanno** funzionato: le righe in `users`, `user_profiles` e `email_tokens` sono scomparse, le righe in `chat_mentions` che puntavano all’account sono state rimosse, e `posts.user_id` e la maggior parte dei `topics.user_id` sono stati azzerati. Quindi si tratta di un elenco di omissioni specifiche, non di un’affermazione secondo cui l’eliminazione non fa nulla.

Come sono state create le righe: i passaggi 1–5 sopra creano le righe in `incoming_links` e `search_logs` tramite una normale navigazione. Le righe in `topics.user_id`, `post_revisions.user_id` e `policy_users` derivano da una normale pubblicazione, dall’auto-eliminazione di un topic e dall’accettazione di una policy. Le righe in `custom_emojis` e `topic_localizations` sono state inserite direttamente nel fixture di test, poiché il caricamento di un emoji personalizzato e la scrittura di una localizzazione di un topic sono percorsi da amministratore/plugin e non qualcosa che un account normale fa; il comportamento di eliminazione misurato per esse è altrimenti identico.

### Il caso `topics.user_id` è un bug concreto di scope

`UserDestroyer#delete_posts` è scritto come:

```ruby

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 la scope predefinita, che esclude i post nel cestino. Se l’utente aveva già eliminato uno dei propri topic — lo stub era stato successivamente spostato nel cestino da `PostDestroyer.destroy_stubs` — quel post non viene iterato, quindi l’azzeramento non viene eseguito per il suo topic. Nella mia esecuzione, due dei tre topic del soggetto sono finiti con `user_id IS NULL` e il terzo, il cui primo post era stato spostato nel cestino prima dell’eliminazione dell’account, ha mantenuto `user_id = `.

`Post.unscoped.where(user_id: result.id).update_all(user_id: nil)` alcune righe dopo usa `unscoped`, quindi il post stesso viene correttamente distaccato — viene mancato solo il topic.

### Perché `incoming_links` e `search_logs` spiccano

Non si tratta di semplici id orfani. Discourse li classifica già altrove come dati personali collegati all’utente:

* `app/jobs/regular/anonymize_user.rb:43` — `IncomingLink.where(current_user_id: …).update_all(ip_address: new_ip)`, insieme a `SearchLog`, `TopicLinkClick`, `TopicViewItem`, `UserProfileView`.

* `app/services/user_merger.rb:348` — `IncomingLink.where(user_id: …)` e `.where(current_user_id: …)` vengono entrambi reindirizzati all’utente di destinazione.

L’anonimizzazione di un utente e la fusione di un utente gestiscono entrambe queste tabelle. L’eliminazione di un utente non lo fa.

## Impatto

Nessun accesso non autorizzato: nessuna di queste righe viene servita a utenti anonimi o normali, e non affermo il contrario. L’impatto riguarda la conservazione dei dati e l’integrità referenziale — dopo che un utente esegue l’eliminazione self-service, il sito conserva ancora righe chiave per lui, incluso ciò che ha cercato e quali link ha seguito, senza scadenza legata all’eliminazione e senza strumenti per gli operatori per cancellarli.

Questo è scomodo per qualsiasi sito che gestisce una richiesta di cancellazione e non è coerente con il trattamento del percorso di eliminazione stesso per `posts`, `categories` e `topics`.

## Correzione suggerita

1. In `UserDestroyer#delete_posts`, iterare `user.posts.with_deleted` (o azzerare i topic in un passaggio separato `Topic.unscoped.where(user_id: user.id).update_all(user_id: nil)`) in modo che i primi post già nel cestino non lascino il loro topic attribuito.

2. Aggiungere le tabelle analitiche collegate all’utente al percorso di distruzione nel modo in cui `Jobs::AnonymizeUser` le elenca già: eliminare o azzerare `IncomingLink.where(user_id:)`, `IncomingLink.where(current_user_id:)` e `SearchLog.where(user_id:)`.

3. Azzerare le colonne di attribuzione rimanenti — `post_revisions.user_id`, `custom_emojis.user_id`, `topic_localizations.localizer_user_id` — in modo coerente con `posts.user_id`.

4. `policy_users` appartiene a `discourse-policy`; può agganciarsi a `user_destroyer_on_content_deletion_callbacks` o aggiungere un `dependent: :destroy`. Sono felice di aprirlo separatamente nel repository del plugin se preferite.

Una specifica di regressione potrebbe affermare che dopo `UserDestroyer#destroy`, nessuna riga in questo set contiene ancora l’id dell’utente eliminato.

2 Mi Piace