## 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.