Fix: Account-Löschung FK-sicher über alle Tabellen (defer_foreign_keys + Introspektion)

Der alte delete_account löschte nur ~6 Tabellen und scheiterte am finalen
DELETE FROM users, sobald der User Zeilen in Non-Cascade-Tabellen hatte
(routes, places, walks, events, forum_threads, invoices …). Jetzt:
PRAGMA defer_foreign_keys + foreign_key_list-Introspektion (CASCADE automatisch,
NO-ACTION-Eigentum löschen, Actor/SET-NULL-Spalten nullen) plus user_id/owner_id-
Scan für Tabellen ohne formale FK. Regressionstest test_account_deletion.py.

Relevant für App-Store-Gl. 4 (In-App-Konto-Löschung muss zuverlässig funktionieren).
This commit is contained in:
rene 2026-06-04 19:21:18 +02:00
parent 1448782564
commit 545b57c723
2 changed files with 125 additions and 15 deletions

View file

@ -149,25 +149,74 @@ async def put_world_config(body: WorldConfigIn, user=Depends(get_current_user)):
# ----------------------------------------------------------
# DELETE /profile/account — Konto unwiderruflich löschen
# ----------------------------------------------------------
# Spalten, die eine HANDLUNG referenzieren (Moderator/Admin/Ersteller),
# nicht Eigentum des Users. Beim Löschen auf NULL setzen statt die fremde
# Zeile (z. B. einen Partner-Code oder eine moderierte Einreichung) mitzureißen.
_ACTOR_COLUMNS = {
("wiki_foto_submissions", "reviewed_by"),
("osm_poi_edits", "mod_id"),
("partner_codes", "created_by"),
("outreach_log", "sent_by"),
("upgrade_requests", "fulfilled_by"),
}
@router.delete('/account')
async def delete_account(user=Depends(get_current_user)):
"""Löscht das Konto und alle zugehörigen Daten unwiderruflich."""
"""Löscht das Konto und ALLE zugehörigen Daten unwiderruflich (DSGVO + App-Store-Gl. 4).
FK-sicher und schema-robust: ermittelt per Introspektion alle Tabellen, die
auf users(id) verweisen. CASCADE-Tabellen werden beim users-DELETE automatisch
geleert; NO-ACTION/RESTRICT-Eigentumstabellen löschen wir explizit; Aktions-
Spalten (Moderator/Admin) setzen wir auf NULL. `defer_foreign_keys` macht die
Reihenfolge irrelevant geprüft wird erst beim Commit.
"""
uid = user['id']
with db() as conn:
# Alle Hunde-IDs des Users
dog_ids = [r['id'] for r in conn.execute(
"SELECT id FROM dogs WHERE user_id=?", (uid,)).fetchall()]
for did in dog_ids:
conn.execute("DELETE FROM diary WHERE dog_id=?", (did,))
conn.execute("DELETE FROM health WHERE dog_id=?", (did,))
conn.execute("DELETE FROM training_sessions WHERE dog_id=?", (did,))
conn.execute("DELETE FROM training_streaks WHERE dog_id=?", (did,))
conn.execute("DELETE FROM expenses WHERE dog_id=?", (did,))
conn.execute("DELETE FROM dogs WHERE user_id=?", (uid,))
conn.execute("DELETE FROM upgrade_requests WHERE user_id=?", (uid,))
conn.execute("DELETE FROM push_subscriptions WHERE user_id=?", (uid,))
conn.execute("DELETE FROM notifications WHERE user_id=?", (uid,))
conn.execute("DELETE FROM forum_posts WHERE user_id=?", (uid,))
# FK-Prüfung bis zum Commit aufschieben → Löschreihenfolge egal.
conn.execute("PRAGMA defer_foreign_keys=ON")
tables = [r['name'] for r in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
).fetchall()]
# Tabellen merken, deren User-Bezug bereits über eine FK behandelt wurde
# (gelöscht oder genullt), damit der Spalten-Scan sie nicht doppelt anfasst.
handled_fk_cols: set[tuple] = set()
# --- 1) Formale FKs auf users(id) ---
for tbl in tables:
try:
fks = conn.execute(f"PRAGMA foreign_key_list({tbl})").fetchall()
except Exception:
continue
for fk in fks:
if fk['table'] != 'users':
continue
col = fk['from']
handled_fk_cols.add((tbl, col))
on_delete = (fk['on_delete'] or '').upper()
if on_delete == 'CASCADE':
continue # wird durch den finalen users-DELETE mitgelöscht
if on_delete == 'SET NULL' or (tbl, col) in _ACTOR_COLUMNS:
conn.execute(f"UPDATE {tbl} SET {col}=NULL WHERE {col}=?", (uid,))
else:
# NO ACTION / RESTRICT auf einer Eigentums-Spalte → Zeilen löschen.
conn.execute(f"DELETE FROM {tbl} WHERE {col}=?", (uid,))
# --- 2) Eigentums-Spalten OHNE formale FK (z. B. events.user_id) ---
# Manche Tabellen tragen user_id/owner_id ohne REFERENCES-Klausel. Die fängt
# die FK-Introspektion nicht — für ein echtes „alle Daten löschen" hier nach.
for tbl in tables:
try:
cols = {r['name'] for r in conn.execute(f"PRAGMA table_info({tbl})").fetchall()}
except Exception:
continue
for col in ('user_id', 'owner_id'):
if col in cols and (tbl, col) not in handled_fk_cols and (tbl, col) not in _ACTOR_COLUMNS:
conn.execute(f"DELETE FROM {tbl} WHERE {col}=?", (uid,))
# Räumt alle verbliebenen ON-DELETE-CASCADE-Tabellen automatisch ab.
conn.execute("DELETE FROM users WHERE id=?", (uid,))
return {"status": "deleted"}