From c912fc563342d8ed6b9e10ba65700be12a0cee29 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:05:46 +0200 Subject: [PATCH] fix(sandbox): delete api_keys and retag log in teardown (final backlog blockers) (#1454) The last 9 backlog users decompose into exactly two classes: api_keys.sod_acknowledged_by (NO ACTION to auth.users) blocks every keyed sandbox, and the WORM dimension_retag_log blocks any sandbox where a voucher line was retagged. cleanup_sandbox_user now deletes the user's api_keys explicitly (they must die with the sandbox anyway) and the company's retag log under the standard bypass; dimension_retag_log_immutable's DELETE branch honors gnubok.sandbox_cleanup with the same per-row sandbox re-verification as every other guard, UPDATE stays blocked. Fixture seeds both row types. Validated end-to-end on staging. Co-authored-by: Claude Fable 5 --- ...000_sandbox_cleanup_api_keys_retag_log.sql | 143 ++++++++++++++++++ tests/pg/sandbox-cleanup.pg.test.ts | 21 +++ 2 files changed, 164 insertions(+) create mode 100644 supabase/migrations/20260807170000_sandbox_cleanup_api_keys_retag_log.sql diff --git a/supabase/migrations/20260807170000_sandbox_cleanup_api_keys_retag_log.sql b/supabase/migrations/20260807170000_sandbox_cleanup_api_keys_retag_log.sql new file mode 100644 index 00000000..9fe4382d --- /dev/null +++ b/supabase/migrations/20260807170000_sandbox_cleanup_api_keys_retag_log.sql @@ -0,0 +1,143 @@ +-- Final two sandbox-teardown blockers, from the last 9 users of the prod +-- backlog (every earlier class is fixed by 20260807130000/160000): +-- +-- * api_keys.sod_acknowledged_by references auth.users with NO ACTION, so +-- a sandbox whose visitor created an API key and acknowledged the +-- separation-of-duties prompt cannot be deleted (8 users). The key rows +-- must die with the sandbox anyway, so cleanup_sandbox_user deletes them +-- explicitly. +-- * dimension_retag_log is WORM (dimension_retag_log_immutable raises +-- unconditionally), so a sandbox where a voucher line was retagged +-- cannot be deleted (1 user). The trigger's DELETE branch gains the +-- same transaction-local flag plus per-row sandbox re-verification as +-- every other guard in this chain; UPDATE stays blocked. + +-- ============================================================================= +-- 1. dimension_retag_log_immutable: allow sandbox-teardown DELETE +-- ============================================================================= + +CREATE OR REPLACE FUNCTION public.dimension_retag_log_immutable() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path TO 'public' +AS $$ +BEGIN + -- Sandbox teardown removes the whole demo company; its retag log goes + -- with it. Transaction-local flag, only set by cleanup_sandbox_user after + -- its all-rows is_sandbox check, re-verified per row so the flag alone + -- can never unlock a real company's log. UPDATE stays forbidden. + IF TG_OP = 'DELETE' + AND current_setting('gnubok.sandbox_cleanup', true) = 'true' + AND EXISTS ( + SELECT 1 FROM public.company_settings cs + WHERE cs.company_id = OLD.company_id AND cs.is_sandbox = true + ) THEN + RETURN OLD; + END IF; + RAISE EXCEPTION 'dimension_retag_log är oföränderlig — rader kan inte ändras eller tas bort.'; +END; +$$; + +-- ============================================================================= +-- 2. cleanup_sandbox_user: delete api_keys and dimension_retag_log explicitly +-- ============================================================================= + +-- Body otherwise identical to 20260807160000. + +CREATE OR REPLACE FUNCTION public.cleanup_sandbox_user(p_user_id uuid) +RETURNS integer +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_deleted integer := 0; +BEGIN + -- Verify this is a sandbox user: at least one settings row, and EVERY + -- settings row flagged sandbox. + IF NOT EXISTS ( + SELECT 1 FROM public.company_settings cs WHERE cs.user_id = p_user_id + ) OR EXISTS ( + SELECT 1 FROM public.company_settings cs + WHERE cs.user_id = p_user_id AND cs.is_sandbox IS NOT TRUE + ) THEN + RAISE EXCEPTION 'User % is not a sandbox user', p_user_id; + END IF; + + PERFORM set_config('gnubok.allow_delete', 'true', true); + PERFORM set_config('gnubok.sandbox_cleanup', 'true', true); + + -- API keys must die with the sandbox, and api_keys.sod_acknowledged_by + -- (NO ACTION to auth.users) otherwise blocks the auth delete. + DELETE FROM public.api_keys WHERE user_id = p_user_id; + + -- WORM retag log: delete under the bypass while company_settings still + -- exists, and before the journal deletes whose cascade would otherwise + -- reach it. + DELETE FROM public.dimension_retag_log + WHERE company_id IN ( + SELECT cs.company_id FROM public.company_settings cs + WHERE cs.user_id = p_user_id AND cs.is_sandbox = true + ); + + UPDATE public.document_attachments + SET journal_entry_id = NULL, journal_entry_line_id = NULL + WHERE user_id = p_user_id; + + DELETE FROM public.document_attachments WHERE user_id = p_user_id; + + UPDATE public.salary_runs + SET salary_entry_id = NULL, + avgifter_entry_id = NULL, + pension_entry_id = NULL, + vacation_entry_id = NULL + WHERE user_id = p_user_id; + + DELETE FROM public.journal_entry_lines + WHERE journal_entry_id IN ( + SELECT id FROM public.journal_entries WHERE user_id = p_user_id + ); + + DELETE FROM public.journal_entries WHERE user_id = p_user_id; + + DELETE FROM public.supplier_invoices WHERE user_id = p_user_id; + + DELETE FROM public.pending_operations WHERE user_id = p_user_id; + + DELETE FROM public.dimensions + WHERE company_id IN ( + SELECT cs.company_id FROM public.company_settings cs + WHERE cs.user_id = p_user_id AND cs.is_sandbox = true + ); + + DELETE FROM public.processing_history + WHERE company_id IN ( + SELECT cs.company_id FROM public.company_settings cs + WHERE cs.user_id = p_user_id AND cs.is_sandbox = true + ); + + DELETE FROM public.invoice_deliveries + WHERE company_id IN ( + SELECT cs.company_id FROM public.company_settings cs + WHERE cs.user_id = p_user_id AND cs.is_sandbox = true + ); + + DELETE FROM public.audit_log + WHERE company_id IN ( + SELECT cs.company_id FROM public.company_settings cs + WHERE cs.user_id = p_user_id AND cs.is_sandbox = true + ); + + DELETE FROM auth.users WHERE id = p_user_id; + GET DIAGNOSTICS v_deleted = ROW_COUNT; + + PERFORM set_config('gnubok.allow_delete', '', true); + PERFORM set_config('gnubok.sandbox_cleanup', '', true); + + RETURN v_deleted; +END; +$$; + +REVOKE ALL ON FUNCTION public.cleanup_sandbox_user(uuid) FROM PUBLIC, anon, authenticated; +GRANT EXECUTE ON FUNCTION public.cleanup_sandbox_user(uuid) TO service_role; diff --git a/tests/pg/sandbox-cleanup.pg.test.ts b/tests/pg/sandbox-cleanup.pg.test.ts index f0bc8d6a..505bafbe 100644 --- a/tests/pg/sandbox-cleanup.pg.test.ts +++ b/tests/pg/sandbox-cleanup.pg.test.ts @@ -75,6 +75,27 @@ async function seedSandboxUser(settingsCreatedAt?: string): Promise<{ VALUES ($1, $2, $3, 'manual', 'marked_sent', now(), '2033-12-31')`, [companyId, userId, invoiceId], ) + // An API key with the SoD acknowledgement set: sod_acknowledged_by is a + // plain NO ACTION FK to auth.users that blocked teardown for every keyed + // sandbox until 20260807170000 deletes the keys explicitly. + await getPool().query( + `INSERT INTO public.api_keys + (user_id, company_id, key_hash, key_prefix, sod_acknowledged_by, sod_acknowledged_at) + VALUES ($1, $2, $3, 'gnubok_sk_pgtest', $1, now())`, + [userId, companyId, randomUUID()], + ) + // A WORM retag-log row (dimension_retag_log_immutable raises on DELETE + // outside the teardown bypass). + const { rows: lineRows } = await getPool().query<{ id: string }>( + `SELECT id FROM public.journal_entry_lines WHERE journal_entry_id = $1 LIMIT 1`, + [entryId], + ) + await getPool().query( + `INSERT INTO public.dimension_retag_log + (company_id, journal_entry_id, line_id, old_dimensions, new_dimensions, reason) + VALUES ($1, $2, $3, '{}', '{"1":"BUTIK"}', 'Sandbox cleanup test retag')`, + [companyId, entryId, lineRows[0]!.id], + ) return { userId, companyId, entryId } }