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 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-07 16:05:46 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 526f0315d0
commit c912fc5633
2 changed files with 164 additions and 0 deletions
@@ -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;
+21
View File
@@ -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 }
}