From e9a6f44d6c5f3aa07709d7ffa7ac13e9bc63e92e Mon Sep 17 00:00:00 2001 From: mdenner1234 Date: Sun, 9 Aug 2026 07:25:57 -0700 Subject: [PATCH] fix(compliance): complete the right-to-erasure cascade (audit H9, PR 1/5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > ⚠️ REVIEW BEFORE MERGE — this changes what account deletion does to a live > product's data and encodes GDPR/retention **policy defaults**. Confirm the > policy calls below before merging. `handle_user_deletion()` (BEFORE DELETE ON auth.users) erased a hardcoded 15-table list; ~20 more tables clean up via ON DELETE CASCADE. Four tables carried user PII with NEITHER — they orphaned personal data after account deletion: `gumroad_sales.email`, `cookie_consents`, `support_provisioning_log`, `support_ticket_events`. This extends the function to cover them, `to_regclass`-guarded so a missing/renamed table can't abort the whole cascade, idempotent. **Policy defaults applied (please confirm):** - `gumroad_sales` — **retain the row, de-identify it** (redact email, null resolved_user_id). Rationale: financial records typically sit under a legal retention obligation (tax) that overrides erasure; we keep the transaction but drop the person link. (Alternative: hard-delete — say the word.) - `cookie_consents` — **retain as proof-of-consent, de-identify** (null user_id, user_agent, ip_country). Rationale: GDPR accountability may require keeping that consent was given. (Alternative: hard-delete.) - `support_provisioning_log` / `support_ticket_events` — **delete** the user's rows (append-only operational logs). Bypasses the append-only guard for just that erase (transactional → auto-reverts on error), mirroring support_prune_webhook_events. - `audit_log` and other tamper-evident tables are **not** touched here — they get redact-in-place via the admin-erasure PR (PR 2/5), never hard-deleted. Test: `supabase/tests/h9_erasure_cascade_test.sql` (pgTAP, 6 cases) creates a user with PII in all four orphan tables, deletes the account, and asserts each is erased/de-identified and the profile is gone. Full `supabase test db` = 105/105. Part of the H9 series: (1) this cascade, (2) admin erasure RPC + DSAR-queue wiring, (3) fix the broken/contradictory audit-log purge, (4) enforce documented retention floors, (5) complete export_my_data + DSAR SLA watchdog. Co-Authored-By: Claude Opus 4.8 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- ...0810130000_h9_complete_erasure_cascade.sql | 84 +++++++++++++++++++ supabase/tests/h9_erasure_cascade_test.sql | 67 +++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 supabase/migrations/20260810130000_h9_complete_erasure_cascade.sql create mode 100644 supabase/tests/h9_erasure_cascade_test.sql diff --git a/supabase/migrations/20260810130000_h9_complete_erasure_cascade.sql b/supabase/migrations/20260810130000_h9_complete_erasure_cascade.sql new file mode 100644 index 00000000..d4477121 --- /dev/null +++ b/supabase/migrations/20260810130000_h9_complete_erasure_cascade.sql @@ -0,0 +1,84 @@ +-- Audit H9 (right-to-erasure completeness). `handle_user_deletion()` (BEFORE +-- DELETE ON auth.users) erases a hardcoded 15-table list, and ~20 more tables +-- clean up via ON DELETE CASCADE. But four tables carry user PII with NEITHER an +-- auth FK cascade NOR a line in the function, so they ORPHAN personal data after +-- an account is deleted: +-- * gumroad_sales.email / resolved_user_id (financial ledger) +-- * cookie_consents.user_id / user_agent / ip_country (consent record) +-- * support_provisioning_log.user_id (append-only operational log) +-- * support_ticket_events.customer_user_id (append-only operational log) +-- +-- Policy defaults applied here (flagged for owner review — see PR): +-- * gumroad_sales: RETAIN the row (financial/tax record likely under a legal +-- retention obligation) but DE-IDENTIFY — null the user link + redact email. +-- * cookie_consents: RETAIN as proof-of-consent (GDPR accountability) but +-- DE-IDENTIFY — null user_id + the request metadata (user_agent, ip_country). +-- * support_*_log: DELETE the user's operational rows. These are append-only, +-- so disable the block trigger around ONLY this erase (transactional: rolls +-- back on error), mirroring support_prune_webhook_events. +-- +-- to_regclass-guarded per table so a renamed/dropped table can never abort the +-- whole cascade. Idempotent (re-running finds nothing to erase). audit_log and +-- the other tamper-evident tables are intentionally left to the redact-in-place +-- path (a later admin-erasure PR), never hard-deleted here. + +CREATE OR REPLACE FUNCTION public.handle_user_deletion() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path TO 'public', 'extensions', 'pg_temp' +AS $function$ +BEGIN + DELETE FROM public.user_quest_selections WHERE user_id = OLD.id; + DELETE FROM public.push_subscriptions WHERE user_id = OLD.id; + DELETE FROM public.chat_messages WHERE conversation_id IN ( + SELECT id FROM public.chat_conversations WHERE user_id = OLD.id + ); + DELETE FROM public.chat_conversations WHERE user_id = OLD.id; + DELETE FROM public.journey_progress WHERE user_id = OLD.id; + DELETE FROM public.announcement_reads WHERE user_id = OLD.id; + DELETE FROM public.dashboard_preferences WHERE user_id = OLD.id; + DELETE FROM public.grid_view_states WHERE user_id = OLD.id; + DELETE FROM public.project_applications WHERE user_id = OLD.id; + DELETE FROM public.general_applications WHERE user_id = OLD.id; + DELETE FROM public.admin_promotions WHERE user_id = OLD.id; + DELETE FROM public.user_roles WHERE user_id = OLD.id; + DELETE FROM public.notifications WHERE user_id = OLD.id; + DELETE FROM public.feedback WHERE user_id = OLD.id; + + -- ── H9: erase/de-identify the PII orphans (no FK cascade, not above) ───────── + -- Financial ledger: keep the transaction, drop the person link + email. + IF to_regclass('public.gumroad_sales') IS NOT NULL THEN + UPDATE public.gumroad_sales + SET email = 'redacted@deleted.invalid', + resolved_user_id = NULL + WHERE resolved_user_id = OLD.id + OR (OLD.email IS NOT NULL AND lower(email) = lower(OLD.email)); + END IF; + + -- Consent record: keep proof-of-consent, drop the identifiers. + IF to_regclass('public.cookie_consents') IS NOT NULL THEN + UPDATE public.cookie_consents + SET user_id = NULL, user_agent = NULL, ip_country = NULL + WHERE user_id = OLD.id; + END IF; + + -- Append-only operational logs: delete the user's rows, bypassing the + -- append-only guard for just this erase (DDL is transactional → auto-reverts). + IF to_regclass('public.support_provisioning_log') IS NOT NULL THEN + ALTER TABLE public.support_provisioning_log DISABLE TRIGGER trg_support_prov_log_no_update; + DELETE FROM public.support_provisioning_log WHERE user_id = OLD.id; + ALTER TABLE public.support_provisioning_log ENABLE TRIGGER trg_support_prov_log_no_update; + END IF; + IF to_regclass('public.support_ticket_events') IS NOT NULL THEN + ALTER TABLE public.support_ticket_events DISABLE TRIGGER trg_support_ticket_events_no_update; + DELETE FROM public.support_ticket_events WHERE customer_user_id = OLD.id; + ALTER TABLE public.support_ticket_events ENABLE TRIGGER trg_support_ticket_events_no_update; + END IF; + + -- audit_log intentionally retained for SOC 2 hash-chain (append-only); + -- redact-in-place is handled by the admin-erasure path, not here. + DELETE FROM public.profiles WHERE user_id = OLD.id; + RETURN OLD; +END; +$function$; diff --git a/supabase/tests/h9_erasure_cascade_test.sql b/supabase/tests/h9_erasure_cascade_test.sql new file mode 100644 index 00000000..83833915 --- /dev/null +++ b/supabase/tests/h9_erasure_cascade_test.sql @@ -0,0 +1,67 @@ +-- pgTAP: audit H9 — deleting an auth user erases/de-identifies ALL their PII, +-- including the four orphan tables that had no FK cascade and no line in +-- handle_user_deletion (gumroad_sales, cookie_consents, support_provisioning_log, +-- support_ticket_events). Proves 20260810130000_h9_complete_erasure_cascade. +-- Runs in a rolled-back transaction. +BEGIN; +SELECT plan(6); + +-- ── Fixture: an auth user (handle_new_user makes the profile) + PII everywhere ── +INSERT INTO auth.users (id, email, raw_user_meta_data) VALUES + ('0e5a5e00-0000-0000-0000-000000000001', 'ta-erase@example.com', + '{"first_name":"Era","last_name":"Sure"}'::jsonb) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO public.gumroad_sales (sale_id, email, resolved_user_id, status) +VALUES ('erase-sale-1', 'ta-erase@example.com', '0e5a5e00-0000-0000-0000-000000000001', 'applied'); + +INSERT INTO public.cookie_consents (user_id, categories, policy_version, user_agent, ip_country) +VALUES ('0e5a5e00-0000-0000-0000-000000000001', '{"analytics":true}'::jsonb, 'v1', 'Mozilla/5.0 test', 'US'); + +INSERT INTO public.support_provisioning_log (user_id, kind, status, attempts) +VALUES ('0e5a5e00-0000-0000-0000-000000000001', 'customer', 'success', 1); + +INSERT INTO public.support_ticket_events (conversation_id, customer_user_id, event_type) +VALUES (987654, '0e5a5e00-0000-0000-0000-000000000001', 'customer.reply'); + +-- ── Act: delete the auth user (fires handle_user_deletion BEFORE DELETE) ─────── +-- Isolate handle_user_deletion: the profiles→auth cascade trigger would re-enter +-- the same auth.users delete ("tuple already modified"); it's orthogonal to the +-- erasure logic under test (and is EXCEPTION-swallowed in the live GoTrue path). +ALTER TABLE public.profiles DISABLE TRIGGER trg_cascade_delete_auth_on_profile; +DELETE FROM auth.users WHERE id = '0e5a5e00-0000-0000-0000-000000000001'; +ALTER TABLE public.profiles ENABLE TRIGGER trg_cascade_delete_auth_on_profile; + +-- ── Assert ──────────────────────────────────────────────────────────────────── +-- 1. Financial ledger row is RETAINED... +SELECT is( + (SELECT count(*)::int FROM public.gumroad_sales WHERE sale_id = 'erase-sale-1'), + 1, 'gumroad_sales row is retained (financial record)'); +-- 2. ...but de-identified (email redacted, user link nulled). +SELECT is( + (SELECT count(*)::int FROM public.gumroad_sales + WHERE sale_id = 'erase-sale-1' + AND email = 'redacted@deleted.invalid' AND resolved_user_id IS NULL), + 1, 'gumroad_sales row is de-identified (email redacted, resolved_user_id null)'); +-- 3. Consent record retained as proof, but user link nulled. +SELECT is( + (SELECT count(*)::int FROM public.cookie_consents + WHERE user_id = '0e5a5e00-0000-0000-0000-000000000001'), + 0, 'cookie_consents no longer references the deleted user'); +-- 4-5. Append-only operational logs erased. +SELECT is( + (SELECT count(*)::int FROM public.support_provisioning_log + WHERE user_id = '0e5a5e00-0000-0000-0000-000000000001'), + 0, 'support_provisioning_log rows erased'); +SELECT is( + (SELECT count(*)::int FROM public.support_ticket_events + WHERE customer_user_id = '0e5a5e00-0000-0000-0000-000000000001'), + 0, 'support_ticket_events rows erased'); +-- 6. Profile itself erased (baseline cascade still works). +SELECT is( + (SELECT count(*)::int FROM public.profiles + WHERE user_id = '0e5a5e00-0000-0000-0000-000000000001'), + 0, 'profiles row erased'); + +SELECT * FROM finish(); +ROLLBACK;