Files
accounted/supabase/migrations/20260901120000_sandbox_cleanup_completion.sql
T
Jakob WennbergandClaude Opus 5 2c46d69d21 fix(sandbox): complete the teardown for the four FK and guard blockers added since August (#2112)
cleanup_sandbox_user deletes supplier_invoices and journal_entries without
first clearing four blockers added or missed since it was last fixed:
supplier_payment_batch_items (RESTRICT FK, that table was created 2026-08-10),
fiscal_periods.opening_balance_entry_id (NO ACTION FK whose NULLing is itself
blocked by enforce_opening_balance_immutability), terminal webhook_deliveries
rows, and payment_match_log rows whose company_id IS NULL, which fail the
shared audit bypass because it requires a non-null company.

Prod: 9 stale sandbox users, oldest 2026-07-22, retried nightly and failing
forever, so anonymous demo tenants accumulate indefinitely.

The bypass stays scoped to sandbox teardown. The audit-log immutability and the
webhook terminal-delete guard are not weakened for normal tenants, and the pg
test asserts they still bite for a non-sandbox tenant.


Claude-Session: https://claude.ai/code/session_016ifKg6Ec67A39oxfGPU1yc

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 14:14:45 +02:00

378 lines
16 KiB
PL/PgSQL

-- Four more sandbox-teardown blockers, all added or missed since
-- 20260807170000. Nine sandbox tenants are stuck on prod (oldest settings row
-- 2026-07-22), retried by the nightly cron and failing forever, so anonymous
-- demo tenants accumulate instead of being torn down:
--
-- 1. supplier_payment_batch_items references supplier_invoices through the
-- composite FK fk_supplier_payment_batch_items_invoice with ON DELETE
-- RESTRICT. The table shipped 2026-08-10, after the last cleanup fix, so
-- any sandbox visitor who generated a betalfil blocks the
-- supplier_invoices delete. The batch header cascades to its items
-- (fk_supplier_payment_batch_items_batch), so deleting the batches is
-- enough and no guard trigger stands in the way.
-- 2. fiscal_periods.opening_balance_entry_id and .closing_entry_id are both
-- plain NO ACTION FKs into journal_entries, so the journal delete fails
-- while a period still points at an IB or bokslut voucher. NULLing them
-- is itself blocked by enforce_opening_balance_immutability, which has
-- no sandbox bypass.
-- 3. webhook_deliveries rows in terminal status ('delivered', 'dead') are
-- blocked by block_webhook_delivery_terminal_delete, which raises
-- unconditionally. Those rows reach the trigger through the
-- auth.users -> companies -> webhook_deliveries cascade.
-- 4. payment_match_log rows whose company_id IS NULL fail the shared
-- audit_log_immutable() bypass, because that bypass requires
-- OLD.company_id IS NOT NULL. lib/invoices/match-log.ts has written
-- company_id-less rows since the table shipped (7103 of them on prod),
-- and payment_match_log_user_id_fkey ON DELETE CASCADE drags them into
-- the auth.users delete. The table is also reached as an UPDATE:
-- payment_match_log_supplier_invoice_id_fkey is ON DELETE SET NULL, so
-- the supplier_invoices delete rewrites any row that points at one, and
-- the UPDATE branch of the guard stays unconditional. The purge
-- therefore runs before the supplier_invoices delete, not just before
-- the auth.users one.
--
-- Three decisions worth writing down, because the obvious reading of each
-- goes the other way:
--
-- * closing_entry_id is cleared too, not just opening_balance_entry_id, and
-- the sandbox bypass covers the whole trigger rather than the
-- opening-balance clause alone. No stale sandbox has closing_entry_id set
-- today, so a narrower fix would also pass, and would then break again
-- the first time a sandbox visitor runs a bokslut:
-- fiscal_periods_closing_entry_id_fkey is NO ACTION as well. The year-end
-- immutability the clause protects guards a ledger that ceases to exist a
-- few statements later.
-- * the opening-balance RAISE loses its em dash (repo rule: no em or en
-- dashes) and gains the colon its sibling clause already uses. Both
-- assertions in the tree match the tail of the sentence
-- (lib/bookkeeping/__tests__/engine.pg.test.ts and
-- tests/pg/closing-entry-detach.pg.test.ts) and no runtime code maps the
-- text, so no caller notices.
-- * payment_match_log gets its own guard function instead of a relaxed
-- audit_log_immutable(). That function also guards audit_log, event_log
-- and processing_history; processing_history has no user_id column at
-- all, so a bypass branch reading OLD.user_id could not live there.
--
-- Every bypass follows the 20260807 chain: a transaction-local flag that only
-- cleanup_sandbox_user sets, after its all-rows is_sandbox check, re-verified
-- per row against company_settings so the flag alone can never unlock a real
-- tenant. UPDATE stays forbidden on both audit-shaped logs.
--
-- Known residual risk, deliberately not fixed here: cleanup_sandbox_user
-- enumerates blockers one table at a time, and prod still has unhandled
-- RESTRICT / NO ACTION FKs into the tables it deletes (accrual_schedules,
-- accrual_schedule_installments, assets.disposal_journal_entry_id,
-- depreciation_schedules, peppol_deliveries, rot_rut_payout_request_items,
-- agi_declarations, stripe_payment_events, stripe_payouts,
-- vacation_year_closures, webshop_orders), and it never deletes
-- public.invoices at all. A read-only sweep of every one of those tables
-- against the nine stuck sandboxes returns zero rows today, so this does
-- clear the whole current backlog; a sandbox that exercises one of those
-- surfaces will need the next entry in this chain.
-- =============================================================================
-- 1. enforce_opening_balance_immutability: allow sandbox-teardown link clearing
-- =============================================================================
-- Body otherwise identical to 20260720140000, minus the em dash.
CREATE OR REPLACE FUNCTION public.enforce_opening_balance_immutability()
RETURNS trigger
LANGUAGE plpgsql
SET search_path TO 'public'
AS $$
BEGIN
-- Sandbox teardown clears the period's voucher links so the journal delete
-- can proceed; the period itself dies with the tenant moments later. Flag
-- first so a normal tenant never pays for the company_settings lookup, and
-- per-row re-verification so the flag alone unlocks nothing.
IF 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 NEW;
END IF;
-- Only check if opening_balance_entry_id is being changed
IF OLD.opening_balance_entry_id IS NOT NULL
AND OLD.opening_balances_set = true
AND NEW.opening_balance_entry_id IS DISTINCT FROM OLD.opening_balance_entry_id THEN
RAISE EXCEPTION 'Cannot modify opening_balance_entry_id on period "%": opening balances are immutable once set',
OLD.name;
END IF;
-- Block changing closing_entry_id once set, UNLESS the referenced closing
-- entry has been reversed by a real storno (administrative year-end undo).
IF OLD.closing_entry_id IS NOT NULL
AND NEW.closing_entry_id IS DISTINCT FROM OLD.closing_entry_id THEN
IF NOT EXISTS (
SELECT 1
FROM journal_entries je
JOIN journal_entries storno
ON storno.reverses_id = je.id
AND storno.source_type = 'storno'
AND storno.status = 'posted'
AND storno.company_id = OLD.company_id
WHERE je.id = OLD.closing_entry_id
AND je.company_id = OLD.company_id
AND je.status = 'reversed'
) THEN
RAISE EXCEPTION 'Cannot modify closing_entry_id on period "%": year-end closing is immutable',
OLD.name;
END IF;
IF NEW.closing_entry_id IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM journal_entries ne
WHERE ne.id = NEW.closing_entry_id
AND ne.company_id = NEW.company_id
AND ne.fiscal_period_id = NEW.id
AND ne.source_type = 'year_end'
AND ne.status = 'posted'
) THEN
RAISE EXCEPTION 'closing_entry_id on period "%" must reference a posted year_end entry in the same period',
OLD.name;
END IF;
END IF;
RETURN NEW;
END;
$$;
-- =============================================================================
-- 2. block_webhook_delivery_terminal_delete: allow sandbox-teardown DELETE
-- =============================================================================
-- Body otherwise identical to 20260515190000. webhook_deliveries carries its
-- own company_id (NOT NULL, asserted against the parent webhook at INSERT),
-- so the per-row sandbox re-check needs no join through webhooks, which is
-- what makes it safe here: webhook_id is nullable and ON DELETE SET NULL.
CREATE OR REPLACE FUNCTION public.block_webhook_delivery_terminal_delete()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
IF 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;
IF OLD.status IN ('delivered', 'dead') THEN
RAISE EXCEPTION
'webhook_deliveries row in terminal status (%) cannot be deleted (audit-log integrity policy; accounting-event rows additionally fall under BFL 7 kap 1 § retention)',
OLD.status
USING ERRCODE = 'check_violation';
END IF;
RETURN OLD;
END;
$$;
-- =============================================================================
-- 3. payment_match_log gets its own immutability guard
-- =============================================================================
CREATE OR REPLACE FUNCTION public.payment_match_log_immutable()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $$
BEGIN
-- Sandbox teardown removes the whole demo tenant; its match log goes with
-- it. Two accepted row shapes: a company_id that provably belongs to a
-- sandbox company, or a NULL company_id whose user is provably a sandbox
-- user. The second branch is the whole point of splitting this off
-- audit_log_immutable(), and it only works while the company_settings row
-- still exists, which is why cleanup_sandbox_user deletes these rows
-- explicitly rather than letting the auth.users cascade reach them.
-- UPDATE stays forbidden even during teardown.
IF TG_OP = 'DELETE'
AND current_setting('gnubok.sandbox_cleanup', true) = 'true'
AND (
(OLD.company_id IS NOT NULL AND EXISTS (
SELECT 1 FROM public.company_settings cs
WHERE cs.company_id = OLD.company_id AND cs.is_sandbox = true
))
OR
(OLD.company_id IS NULL AND EXISTS (
SELECT 1 FROM public.company_settings cs
WHERE cs.user_id = OLD.user_id AND cs.is_sandbox = true
))
) THEN
RETURN OLD;
END IF;
-- Same wording as audit_log_immutable(): app/api/transactions/[id]/route.ts
-- matches this text to turn the cascade refusal into a Swedish 409.
RAISE EXCEPTION 'Audit log entries cannot be modified or deleted';
END;
$$;
DROP TRIGGER IF EXISTS payment_match_log_no_update ON public.payment_match_log;
CREATE TRIGGER payment_match_log_no_update
BEFORE UPDATE ON public.payment_match_log
FOR EACH ROW EXECUTE FUNCTION public.payment_match_log_immutable();
DROP TRIGGER IF EXISTS payment_match_log_no_delete ON public.payment_match_log;
CREATE TRIGGER payment_match_log_no_delete
BEFORE DELETE ON public.payment_match_log
FOR EACH ROW EXECUTE FUNCTION public.payment_match_log_immutable();
-- =============================================================================
-- 4. cleanup_sandbox_user: clear the four blockers before the deletes
-- =============================================================================
-- Body otherwise identical to 20260807170000.
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
);
-- Match log: append-only, user-scoped on purpose, and purged this early for
-- two reasons. lib/invoices/match-log.ts writes rows with no company_id, and
-- the guard can only recognise those as sandbox rows while the
-- company_settings row still exists, which the auth.users cascade cannot
-- promise (company_settings.user_id cascades from the same delete, and
-- sibling cascade order is undefined). And payment_match_log.supplier_invoice_id
-- is ON DELETE SET NULL, so the supplier_invoices delete further down turns
-- into an UPDATE on any row that references one, which the guard still
-- refuses even during teardown.
DELETE FROM public.payment_match_log
WHERE user_id = p_user_id
OR 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;
-- fiscal_periods points at its IB and bokslut vouchers with plain NO ACTION
-- FKs, and previous_period_id chains periods to each other the same way.
-- Clearing all three under the teardown bypass is what lets the journal
-- delete below run at all.
UPDATE public.fiscal_periods
SET opening_balance_entry_id = NULL,
closing_entry_id = NULL,
previous_period_id = NULL
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.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;
-- Betalfil batches: their items reference supplier_invoices with
-- ON DELETE RESTRICT, so the batch headers must go first (the items
-- cascade off the header, and neither table has a delete guard).
DELETE FROM public.supplier_payment_batches
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.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
);
-- Terminal webhook deliveries: guarded against DELETE, and reached by the
-- auth.users -> companies cascade unless purged here under the bypass.
DELETE FROM public.webhook_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;
NOTIFY pgrst, 'reload schema';