[Privacy] Account deletion flow #117

Closed
opened 2026-07-14 19:53:06 +00:00 by claude-bot · 1 comment
Contributor

Context / Motivation

There is no way to delete a user account — self-service or admin. Users are keyed on (oidc_issuer, oidc_sub) (webapp/backend/app/models/user.py:26-29, unique constraint uq_users_oidc_sub_issuer). For a privacy-respecting self-hosted app this is table stakes.

Spec

Entry points

  • Self-service: Profile page → "Delete account" → confirm modal with type-to-confirm (user types their display name); calls DELETE /api/users/me.
  • Admin-initiated: Admin → Users → delete (existing admin surface in webapp/backend/app/routers/users.py); same cascade, audit-logged with the admin as actor via audit_service.log_event (services/audit_service.py:12).

Cascade — decided per table (from the User relationships at models/user.py:57-81 plus non-relationship FKs; implementer must sweep for any FK added since):

Data Action Rationale
Campaigns where user is sole GM Block deletion — "transfer GM or delete campaign first" (409 listing the campaigns) No ownerless campaigns
campaign_memberships (CampaignMember) Delete rows Cascade already configured
votes (Vote) Anonymize to tombstone user Keeps historical voting grids consistent
attendance_records (SessionAttendance) Anonymize to tombstone Attendance history stays meaningful for the group
session_notes (SessionNote) Delete private notes; anonymize shared/public ones (check the note visibility field) Private content goes; table history stays
platform_links (PlatformLink) Delete Unlinks Discord identity
transcript_feedback Delete Personal feedback
Session.created_by, Session.audio_trashed_by_id Anonymize (SET NULL or tombstone) — these are plain FKs, no cascade (models/user.py:61-63, 79-81) Sessions belong to the campaign
Lore entry ownership (lore_entry_owners / unlinked_lore_entry_owner) Detach to unlinked/tombstone owner Wiki content belongs to the campaign
audit_logs.actor_id Keep as-is (already nullable UUID with no FK constraint, models/audit_log.py:27) Audit trail integrity

Tombstone: a well-known "Deleted user" rendering — either a reserved user row or NULL FK + display fallback. Pick one, apply consistently, and make the frontend render "Deleted user" wherever a display name would appear.

OIDC: deletion is local only — the identity at the provider is untouched (document this in the modal). After deletion, logging in again with the same OIDC sub creates a new, empty user row (the old row is gone; get_or_create by (sub, issuer) naturally recreates). This is the decided behavior — test it explicitly.

Grace period: none — immediate deletion, but the confirm modal prompts "Download your data first" linking each campaign's export (GET /campaigns/{id}/export, routers/campaigns.py:2580) where the user is GM.

Audit: write user.deleted via audit_service.log_event (actor = self or admin), context includes anonymization counts.

Out of scope

  • Erasure of the user's voice from transcripts/recordings (separate issue in this milestone).
  • Provider-side deregistration.
  • Scheduled/delayed deletion queues.

Acceptance criteria

  • Sole-GM user is blocked with a clear 409 listing blocking campaigns; after transferring GM, deletion proceeds.
  • Post-deletion: fresh login with the same OIDC sub yields a new empty account with no access to old campaigns/data (explicit test).
  • No orphaned-FK errors anywhere: exercise the dashboards, session pages, voting grids, and wiki of remaining members after a deletion (integration test sweep).
  • Tombstones render as "Deleted user" in grids, attendance, and notes.
  • Exactly one audit entry per deletion.

References

  • webapp/backend/app/models/user.py:26-29 (OIDC keying), :57-81 (relationships)
  • webapp/backend/app/models/audit_log.py, services/audit_service.py:12 (log_event)
  • webapp/backend/app/routers/users.py (admin user surface + existing audit call sites at :217)
  • webapp/backend/app/routers/campaigns.py:2580 (export_campaign for the "download first" prompt)

Filed from the July 2026 full-project review.

## Context / Motivation There is no way to delete a user account — self-service or admin. Users are keyed on `(oidc_issuer, oidc_sub)` (`webapp/backend/app/models/user.py:26-29`, unique constraint `uq_users_oidc_sub_issuer`). For a privacy-respecting self-hosted app this is table stakes. ## Spec **Entry points** - Self-service: Profile page → "Delete account" → confirm modal with type-to-confirm (user types their display name); calls `DELETE /api/users/me`. - Admin-initiated: Admin → Users → delete (existing admin surface in `webapp/backend/app/routers/users.py`); same cascade, audit-logged with the admin as actor via `audit_service.log_event` (`services/audit_service.py:12`). **Cascade — decided per table** (from the `User` relationships at `models/user.py:57-81` plus non-relationship FKs; implementer must sweep for any FK added since): | Data | Action | Rationale | |---|---|---| | Campaigns where user is **sole GM** | **Block deletion** — "transfer GM or delete campaign first" (409 listing the campaigns) | No ownerless campaigns | | `campaign_memberships` (CampaignMember) | Delete rows | Cascade already configured | | `votes` (Vote) | **Anonymize** to tombstone user | Keeps historical voting grids consistent | | `attendance_records` (SessionAttendance) | **Anonymize** to tombstone | Attendance history stays meaningful for the group | | `session_notes` (SessionNote) | **Delete private notes; anonymize shared/public ones** (check the note visibility field) | Private content goes; table history stays | | `platform_links` (PlatformLink) | Delete | Unlinks Discord identity | | `transcript_feedback` | Delete | Personal feedback | | `Session.created_by`, `Session.audio_trashed_by_id` | Anonymize (SET NULL or tombstone) — these are plain FKs, no cascade (`models/user.py:61-63, 79-81`) | Sessions belong to the campaign | | Lore entry ownership (`lore_entry_owners` / `unlinked_lore_entry_owner`) | Detach to unlinked/tombstone owner | Wiki content belongs to the campaign | | `audit_logs.actor_id` | Keep as-is (already nullable UUID with no FK constraint, `models/audit_log.py:27`) | Audit trail integrity | **Tombstone**: a well-known "Deleted user" rendering — either a reserved user row or `NULL` FK + display fallback. Pick one, apply consistently, and make the frontend render "Deleted user" wherever a display name would appear. **OIDC**: deletion is **local only** — the identity at the provider is untouched (document this in the modal). After deletion, logging in again with the same OIDC sub creates a **new, empty user row** (the old row is gone; `get_or_create` by `(sub, issuer)` naturally recreates). This is the decided behavior — test it explicitly. **Grace period**: none — immediate deletion, but the confirm modal prompts "Download your data first" linking each campaign's export (`GET /campaigns/{id}/export`, `routers/campaigns.py:2580`) where the user is GM. **Audit**: write `user.deleted` via `audit_service.log_event` (actor = self or admin), context includes anonymization counts. ## Out of scope - Erasure of the user's voice from transcripts/recordings (separate issue in this milestone). - Provider-side deregistration. - Scheduled/delayed deletion queues. ## Acceptance criteria - Sole-GM user is blocked with a clear 409 listing blocking campaigns; after transferring GM, deletion proceeds. - Post-deletion: fresh login with the same OIDC sub yields a new empty account with no access to old campaigns/data (explicit test). - No orphaned-FK errors anywhere: exercise the dashboards, session pages, voting grids, and wiki of remaining members after a deletion (integration test sweep). - Tombstones render as "Deleted user" in grids, attendance, and notes. - Exactly one audit entry per deletion. ## References - `webapp/backend/app/models/user.py:26-29` (OIDC keying), `:57-81` (relationships) - `webapp/backend/app/models/audit_log.py`, `services/audit_service.py:12` (`log_event`) - `webapp/backend/app/routers/users.py` (admin user surface + existing audit call sites at `:217`) - `webapp/backend/app/routers/campaigns.py:2580` (`export_campaign` for the "download first" prompt) _Filed from the July 2026 full-project review._
Author
Contributor

Done — merged in PR #203 (backend 62f2af7 + frontend). CI green. This completes the v3.8.0 Privacy & Data Lifecycle milestone.

Shipped: reserved "Deleted user" tombstone (migration a3b4c5d6e7f8); delete_user_account blocks sole-GM deletion first (409 + campaign list, nothing mutated), then sweeps all 26 users.id FKs — deletes personal data, anonymizes authored content to the tombstone (collision-guarded), leaves audit_logs.actor_id; one actor-attributed user.deleted audit. DELETE /api/me (self) + DELETE /api/admin/users/{id} (admin; 400 on the system user). OIDC-local-only — re-login recreates a fresh empty user (tested). Frontend: Profile danger zone (type-to-confirm + export links + OIDC note → logout) and admin per-row delete.

Verification caught three real issues (fixed): a UUID-cast bug in the migration INSERT, and two ORM identity-map staleness cases from the bulk synchronize_session=False cascade.

Tests: backend 575 (+14, incl. no-orphan HTTP reads + OIDC re-login); frontend 307 (+11). All acceptance criteria met. Closing.

Done — merged in PR #203 (backend `62f2af7` + frontend). CI green. **This completes the v3.8.0 Privacy & Data Lifecycle milestone.** **Shipped:** reserved "Deleted user" tombstone (migration `a3b4c5d6e7f8`); `delete_user_account` blocks sole-GM deletion first (409 + campaign list, nothing mutated), then sweeps all 26 `users.id` FKs — deletes personal data, anonymizes authored content to the tombstone (collision-guarded), leaves `audit_logs.actor_id`; one actor-attributed `user.deleted` audit. `DELETE /api/me` (self) + `DELETE /api/admin/users/{id}` (admin; 400 on the system user). OIDC-local-only — re-login recreates a fresh empty user (tested). Frontend: Profile danger zone (type-to-confirm + export links + OIDC note → logout) and admin per-row delete. **Verification caught three real issues** (fixed): a UUID-cast bug in the migration INSERT, and two ORM identity-map staleness cases from the bulk `synchronize_session=False` cascade. **Tests:** backend 575 (+14, incl. no-orphan HTTP reads + OIDC re-login); frontend 307 (+11). All acceptance criteria met. Closing.
rbrooks referenced this issue from a commit 2026-07-18 09:02:57 +00:00
Sign in to join this conversation.
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
rbrooks/Quest-Board#117
No description provided.