fix(sandbox): repair the silently-failing nightly sandbox cleanup and lock down its RPCs (#1449)
* fix(sandbox): repair the silently-failing nightly sandbox cleanup and lock down its RPCs
The daily cleanup cron has deleted nothing for months: cleanup_sandbox_user
died on the journal-line immutability trigger for every user (the seed posts
vouchers since spring), and cleanup_expired_sandbox_users swallowed each
failure as a WARNING while reporting success. 658 expired sandbox users plus
21 orphaned anonymous users had accumulated in prod auth.users.
- cleanup_sandbox_user sets the sanctioned gnubok.allow_delete flag plus a
new transaction-local gnubok.sandbox_cleanup flag, only after verifying
is_sandbox; write_audit_log, audit_log_immutable (DELETE only, per-row
sandbox re-check), enforce_dimension_registry_guards (DELETE only) and
enforce_pending_operations_no_delete (DELETE only) respect it
- clears salary_runs voucher-link FKs and purges the sandbox company's
audit rows before the auth.users cascade
- cleanup_expired_sandbox_users returns {cleaned, failed, orphans_removed},
additionally sweeps expired anonymous users that never got a
company_settings row, and takes an optional p_limit for bounded batches;
the cron route logs failures at error level and accepts both return shapes
- both RPCs lose their default PUBLIC EXECUTE grant (anon and authenticated
could call them via PostgREST) and are now service_role-only
- validated by replaying the full delete chain against prod inside aborted
transactions (21 users sampled across all seed eras, zero failures) and a
committed staging run; pg-real suite + cron route unit tests added
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(sandbox): per-row sandbox re-verification in teardown guards, is_anonymous column guard
Resolution pass for PR #1449 review findings and the pg-real CI failure:
- Swedish accounting review: enforce_dimension_registry_guards and
enforce_pending_operations_no_delete now re-verify per row that
OLD.company_id belongs to a sandbox company (same pattern as
audit_log_immutable) instead of trusting the gnubok.sandbox_cleanup flag
alone. Because that re-check needs company_settings to still exist,
cleanup_sandbox_user deletes pending_operations and dimensions explicitly
before the auth.users cascade.
- pg-real CI: auth.users.is_anonymous does not exist in the CI
supabase/postgres image (or on older self-hosted stacks); the orphan sweep
in cleanup_expired_sandbox_users is now guarded on the column's existence,
and the pg test skips the orphan assertions on such stacks.
Re-validated on staging end-to-end: {cleaned: 5, failed: 0,
orphans_removed: 1}, fresh users and non-sandbox rows untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(sandbox): make company_settings.is_sandbox write-once, prove orphan sweep fails loudly
Round-2 review findings (Swedish accounting review on PR #1449):
- Every teardown bypass trusts company_settings.is_sandbox, and RLS lets an
owner update their own settings row via PostgREST, so a real company that
flipped the flag would become eligible for full deletion by the nightly
cron. New trigger makes the flag write-once (no application path updates
it; a future sandbox-to-real conversion would ship its own migration).
- New pg test pins the reviewer's remaining concern: an anonymous user who
somehow has bookkeeping but no company_settings row is NOT silently
deleted by the orphan sweep; the unbypasseed immutability triggers make
the deletion fail loudly into the summary's failed count.
Validated on staging: flip blocked in both directions, unrelated
company_settings updates unaffected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(sandbox): guard is_sandbox provenance at INSERT, make orphan sweep exclusions explicit
Round-3 review hardening, approved by Emil:
- is_sandbox = true can now only be created by an anonymous-user JWT (the
sandbox seed's actor), service_role, or a direct database session. A
regular authenticated user could previously insert their settings row
pre-flagged and have the nightly cron destroy their real books, which
BFL 7 kap. forbids even self-inflicted. Claims are read from the
request.jwt.* GUCs directly so the check behaves identically on hosted,
self-hosted, and the CI auth shim.
- The orphan sweep now explicitly excludes anonymous users attached to any
companies or company_members row, instead of relying on downstream
immutability triggers throwing (emergent safety) to protect half-seeded
users.
- pg tests updated accordingly: blocked/allowed provenance paths, and the
half-seeded user is proven unreachable rather than merely failing loudly.
Validated on staging: authed insert blocked, anonymous-claim insert
allowed, half-seeded user untouched, sweep summary failed=0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(sandbox): all-rows sandbox check, cleared bypass flags, tighter insert guard
CodeRabbit review pass on PR #1449 (its first non-rate-limited run):
- cleanup_sandbox_user now requires EVERY company_settings row of the user
to be sandbox-flagged, not an arbitrary single row: a hypothetical
mixed-company user would otherwise have their real company's rows reached
by the user-scoped deletes.
- Both bypass flags are cleared before the RPC returns, so later work in
the same transaction (the expired loop's next iterations, the orphan
sweep) never runs with them still armed.
- The is_sandbox insert guard now treats ANY PostgREST claims context
(claims json without a role claim included) as guarded, instead of
falling open when the role claim is absent.
- The flag-leak pg test now runs inside an explicit transaction (the old
version could not observe transaction-local GUCs at all), and a new test
covers the mixed sandbox/real user refusal.
Declined: replacing the em dashes inside the two replicated Swedish
exception messages; they are byte-identical copies of the strings already
deployed by migration 20260702084500 and changing them would alter live
user-facing errors out of scope.
Validated on staging: mixed user refused, flags cleared post-teardown,
role-less claims blocked.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
6458180bf5
commit
f7f3a31f8e
@@ -0,0 +1,544 @@
|
||||
-- Fix: the nightly sandbox cleanup has been a silent no-op since the sandbox
|
||||
-- seed started posting vouchers. cleanup_sandbox_user deletes
|
||||
-- journal_entry_lines without setting the gnubok.allow_delete bypass, so
|
||||
-- enforce_journal_entry_line_immutability raises "Cannot DELETE lines of a
|
||||
-- posted journal entry", cleanup_expired_sandbox_users swallows the error as
|
||||
-- a WARNING, and the cron reports success every night while expired anonymous
|
||||
-- users accumulate in auth.users (658 overdue on prod at the time of writing,
|
||||
-- oldest from 2026-03).
|
||||
--
|
||||
-- Behind that first failure hide two more, confirmed by replaying the fixed
|
||||
-- delete chain against prod inside an aborted transaction:
|
||||
--
|
||||
-- * salary_runs references its booked vouchers with plain NO ACTION FKs,
|
||||
-- so the journal entry delete fails while a booked run points at them.
|
||||
-- * the auth.users delete cascades through the sandbox company, and
|
||||
-- write_audit_log fires mid-cascade, inserting audit rows that reference
|
||||
-- the company being deleted in that same cascade: FK violation. Existing
|
||||
-- audit rows from seeding block the company delete the same way, and
|
||||
-- audit_log_no_delete forbids removing them.
|
||||
--
|
||||
-- The teardown therefore gets its own transaction-local flag,
|
||||
-- gnubok.sandbox_cleanup, set only inside cleanup_sandbox_user after its
|
||||
-- is_sandbox check:
|
||||
--
|
||||
-- 1. write_audit_log skips while the flag is set (sandbox demo data needs
|
||||
-- no audit trail, and the company the rows would reference is being
|
||||
-- deleted anyway).
|
||||
-- 2. audit_log_immutable allows DELETE only when the flag is set AND the
|
||||
-- row's company_id provably belongs to a sandbox company. UPDATE stays
|
||||
-- forbidden unconditionally. Real companies' audit rows remain WORM:
|
||||
-- the trigger re-verifies sandbox-ness per row instead of trusting the
|
||||
-- flag alone.
|
||||
-- 3. enforce_dimension_registry_guards allows the DELETE cascade through
|
||||
-- the system dimensions (every post-2026-07 sandbox has dims 1/6 via
|
||||
-- ensure_company_dimensions) while the flag is set.
|
||||
-- 4. enforce_pending_operations_no_delete allows the cascade through
|
||||
-- terminal-state demo operations while the flag is set.
|
||||
-- 4b. company_settings.is_sandbox becomes write-once and insert-guarded
|
||||
-- (trigger): every bypass above trusts that flag, so a real company
|
||||
-- must never be able to flip it, and is_sandbox = true can only be
|
||||
-- created by anonymous-user JWTs, service_role, or direct DB sessions.
|
||||
-- 5. cleanup_sandbox_user sets gnubok.allow_delete (the sanctioned
|
||||
-- delete_last_voucher flag that every delete-path trigger on
|
||||
-- journal_entries / journal_entry_lines respects) plus the new flag,
|
||||
-- clears the salary_runs voucher links, and purges the sandbox
|
||||
-- company's audit rows before deleting auth.users.
|
||||
--
|
||||
-- cleanup_expired_sandbox_users additionally returns a jsonb summary
|
||||
-- {cleaned, failed, orphans_removed} instead of a bare count so the cron can
|
||||
-- log failures instead of hiding them, sweeps expired anonymous auth users
|
||||
-- that never got a company_settings row (a seed that failed before writing
|
||||
-- settings left them invisible to the old query, leaking them forever), and
|
||||
-- takes an optional p_limit for bounded batch runs against a backlog.
|
||||
--
|
||||
-- Both RPCs also lose their default PUBLIC EXECUTE grant: on prod, anon and
|
||||
-- authenticated could call them via PostgREST.
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. write_audit_log: skip during sandbox teardown
|
||||
-- =============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.write_audit_log()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_user_id uuid;
|
||||
v_company_id uuid;
|
||||
v_action text;
|
||||
v_old_state jsonb;
|
||||
v_new_state jsonb;
|
||||
v_record_id uuid;
|
||||
v_desc text;
|
||||
BEGIN
|
||||
-- Sandbox teardown (cleanup_sandbox_user) deletes the company row itself;
|
||||
-- audit rows inserted mid-cascade would reference the vanishing company
|
||||
-- and violate audit_log_company_id_fkey. The flag is transaction-local and
|
||||
-- only set after the RPC's is_sandbox check.
|
||||
IF current_setting('gnubok.sandbox_cleanup', true) = 'true' THEN
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
v_old_state := to_jsonb(OLD);
|
||||
v_new_state := NULL;
|
||||
v_record_id := OLD.id;
|
||||
v_user_id := (v_old_state->>'user_id')::uuid;
|
||||
v_company_id := (v_old_state->>'company_id')::uuid;
|
||||
v_action := 'DELETE';
|
||||
v_desc := 'Deleted ' || TG_TABLE_NAME || ' record';
|
||||
ELSIF TG_OP = 'INSERT' THEN
|
||||
v_old_state := NULL;
|
||||
v_new_state := to_jsonb(NEW);
|
||||
v_record_id := NEW.id;
|
||||
v_user_id := (v_new_state->>'user_id')::uuid;
|
||||
v_company_id := (v_new_state->>'company_id')::uuid;
|
||||
v_action := 'INSERT';
|
||||
v_desc := 'Created ' || TG_TABLE_NAME || ' record';
|
||||
ELSIF TG_OP = 'UPDATE' THEN
|
||||
v_old_state := to_jsonb(OLD);
|
||||
v_new_state := to_jsonb(NEW);
|
||||
v_record_id := COALESCE(NEW.id, OLD.id);
|
||||
v_user_id := COALESCE((v_new_state->>'user_id')::uuid, (v_old_state->>'user_id')::uuid);
|
||||
v_company_id := COALESCE((v_new_state->>'company_id')::uuid, (v_old_state->>'company_id')::uuid);
|
||||
v_action := 'UPDATE';
|
||||
v_desc := 'Updated ' || TG_TABLE_NAME || ' record';
|
||||
|
||||
IF TG_TABLE_NAME = 'journal_entries' THEN
|
||||
IF OLD.status = 'draft' AND NEW.status = 'posted' THEN
|
||||
v_action := 'COMMIT';
|
||||
v_desc := 'Committed journal entry ' || NEW.voucher_series || NEW.voucher_number;
|
||||
ELSIF OLD.status = 'posted' AND NEW.status = 'reversed' THEN
|
||||
v_action := 'REVERSE';
|
||||
v_desc := 'Reversed journal entry ' || OLD.voucher_series || OLD.voucher_number;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
IF TG_TABLE_NAME = 'fiscal_periods' THEN
|
||||
IF (OLD.locked_at IS NULL AND NEW.locked_at IS NOT NULL) THEN
|
||||
v_action := 'LOCK_PERIOD';
|
||||
v_desc := 'Locked fiscal period "' || NEW.name || '"';
|
||||
ELSIF (NOT OLD.is_closed AND NEW.is_closed) THEN
|
||||
v_action := 'CLOSE_PERIOD';
|
||||
v_desc := 'Closed fiscal period "' || NEW.name || '"';
|
||||
END IF;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- Fall back to auth.uid() when the row does not carry user_id
|
||||
v_user_id := COALESCE(v_user_id, auth.uid());
|
||||
|
||||
INSERT INTO public.audit_log (user_id, company_id, action, table_name, record_id, actor_id, old_state, new_state, description, actor_type, actor_label)
|
||||
VALUES (
|
||||
v_user_id, v_company_id, v_action, TG_TABLE_NAME, v_record_id, v_user_id, v_old_state, v_new_state, v_desc,
|
||||
COALESCE(nullif(current_setting('gnubok.actor_type', true), ''), 'user'),
|
||||
nullif(current_setting('gnubok.actor_label', true), '')
|
||||
);
|
||||
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$function$;
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. audit_log_immutable: allow sandbox-teardown DELETE, verified per row
|
||||
-- =============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.audit_log_immutable()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SET search_path = public
|
||||
AS $function$
|
||||
BEGIN
|
||||
-- Sandbox teardown may delete audit rows, but only rows whose company is
|
||||
-- provably a sandbox: the per-row re-check means a set flag alone can
|
||||
-- never unlock real companies' audit trail. UPDATE stays forbidden even
|
||||
-- during teardown. Rows must be purged while the company_settings row
|
||||
-- still exists (cleanup_sandbox_user does this before auth.users).
|
||||
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
|
||||
) THEN
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
|
||||
RAISE EXCEPTION 'Audit log entries cannot be modified or deleted';
|
||||
END;
|
||||
$function$;
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. enforce_dimension_registry_guards: allow sandbox-teardown DELETE
|
||||
-- =============================================================================
|
||||
|
||||
-- Every sandbox seeded since the dimensions substrate (2026-07) carries the
|
||||
-- system dimensions 1/6 via ensure_company_dimensions, and this guard blocks
|
||||
-- their DELETE unconditionally, so the auth.users cascade dies on
|
||||
-- "Systemdimensionen 1 (Kostnadsställe) kan inte tas bort" (found by probing
|
||||
-- the fixed cleanup against staging's stale sandboxes). During teardown the
|
||||
-- whole company is being deleted; keeping its dimension registry rows is
|
||||
-- neither possible nor meaningful. UPDATE guards stay untouched.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.enforce_dimension_registry_guards()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'UPDATE' THEN
|
||||
IF NEW.sie_dim_no <> OLD.sie_dim_no THEN
|
||||
RAISE EXCEPTION 'Dimensionsnumret kan inte ändras (rader är taggade med numret).';
|
||||
END IF;
|
||||
IF NEW.is_system <> OLD.is_system THEN
|
||||
RAISE EXCEPTION 'is_system kan inte ändras.';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- DELETE
|
||||
-- Sandbox teardown deletes the entire company; its registry rows go with
|
||||
-- it. Transaction-local flag, only set by cleanup_sandbox_user after its
|
||||
-- is_sandbox check, plus a per-row re-verification (same pattern as
|
||||
-- audit_log_immutable) so the flag alone can never unlock a real
|
||||
-- company's registry. cleanup_sandbox_user deletes these rows explicitly
|
||||
-- while company_settings still exists; the re-check would fail mid-cascade
|
||||
-- once that row is gone.
|
||||
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.is_system THEN
|
||||
RAISE EXCEPTION 'Systemdimensionen % (%) kan inte tas bort — avaktivera den istället.',
|
||||
OLD.sie_dim_no, OLD.name;
|
||||
END IF;
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM public.journal_entries je
|
||||
JOIN public.journal_entry_lines jel ON jel.journal_entry_id = je.id
|
||||
WHERE je.company_id = OLD.company_id
|
||||
AND je.status IN ('posted', 'reversed')
|
||||
AND jel.dimensions ? OLD.sie_dim_no::text
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Dimensionen % (%) används på bokförda verifikat och kan inte tas bort — avaktivera den istället.',
|
||||
OLD.sie_dim_no, OLD.name;
|
||||
END IF;
|
||||
RETURN OLD;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- =============================================================================
|
||||
-- 4. enforce_pending_operations_no_delete: allow sandbox-teardown DELETE
|
||||
-- =============================================================================
|
||||
|
||||
-- Sandbox visitors approve/reject the pre-staged demo operations, leaving
|
||||
-- terminal-state pending_operations rows that this guard refuses to delete
|
||||
-- (BFL 7 kap. protection for real books; found by probing prod's backlog).
|
||||
-- Same teardown rule as above: the demo company is being deleted wholesale.
|
||||
-- The UPDATE immutability trigger stays untouched. Base definition:
|
||||
-- 20260722134114 (includes 'failed_partial').
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.enforce_pending_operations_no_delete()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- Sandbox teardown: transaction-local flag, only set by
|
||||
-- cleanup_sandbox_user after its is_sandbox check, plus a per-row
|
||||
-- re-verification (same pattern as audit_log_immutable) so the flag alone
|
||||
-- can never unlock a real company's rows. cleanup_sandbox_user deletes
|
||||
-- these rows explicitly while company_settings still exists.
|
||||
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 ('committed', 'rejected', 'failed_partial') THEN
|
||||
RAISE EXCEPTION
|
||||
'pending_operations row % is in terminal state % and cannot be deleted (BFL 7 kap.)',
|
||||
OLD.id, OLD.status
|
||||
USING ERRCODE = 'check_violation';
|
||||
END IF;
|
||||
RETURN OLD;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- =============================================================================
|
||||
-- 5. cleanup_sandbox_user: set bypass flags, clear blocking FKs, purge audit
|
||||
-- =============================================================================
|
||||
|
||||
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. A single-row read would pick an arbitrary
|
||||
-- row for a hypothetical multi-company user and the user-scoped deletes
|
||||
-- below would then reach the real company's rows.
|
||||
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;
|
||||
|
||||
-- Sanctioned trigger bypasses, transaction-local and only reachable after
|
||||
-- the is_sandbox check above, so real companies can never enter this path.
|
||||
-- allow_delete is the delete_last_voucher flag the journal immutability
|
||||
-- and retention triggers respect; sandbox_cleanup gates the audit-log
|
||||
-- behavior (see the trigger functions above).
|
||||
PERFORM set_config('gnubok.allow_delete', 'true', true);
|
||||
PERFORM set_config('gnubok.sandbox_cleanup', 'true', true);
|
||||
|
||||
-- Clear RESTRICT FKs on document_attachments
|
||||
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;
|
||||
|
||||
-- salary_runs references its booked vouchers with plain NO ACTION FKs
|
||||
-- (salary_entry_id, avgifter_entry_id, pension_entry_id,
|
||||
-- vacation_entry_id); the seed links one booked run, so the journal entry
|
||||
-- delete below would otherwise fail.
|
||||
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 journal entry lines (child of journal_entries)
|
||||
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 supplier invoices before suppliers cascade
|
||||
DELETE FROM public.supplier_invoices WHERE user_id = p_user_id;
|
||||
|
||||
-- Terminal pending operations and the dimension registry carry delete
|
||||
-- guards whose bypass re-verifies sandbox-ness through company_settings,
|
||||
-- so delete them explicitly while that row still exists instead of
|
||||
-- leaving them to the auth.users cascade (cascade order is unspecified
|
||||
-- and may remove company_settings first).
|
||||
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
|
||||
);
|
||||
|
||||
-- Purge the sandbox company's audit rows while company_settings still
|
||||
-- exists (audit_log_immutable re-verifies sandbox-ness through it).
|
||||
-- audit_log.company_id has a plain NO ACTION FK, so leftover rows would
|
||||
-- block the companies delete inside the auth.users cascade.
|
||||
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 cascades everything else
|
||||
DELETE FROM auth.users WHERE id = p_user_id;
|
||||
GET DIAGNOSTICS v_deleted = ROW_COUNT;
|
||||
|
||||
-- Drop the bypasses before returning so nothing later in the same
|
||||
-- transaction (the expired-users loop, the orphan sweep) runs with them
|
||||
-- still armed.
|
||||
PERFORM set_config('gnubok.allow_delete', '', true);
|
||||
PERFORM set_config('gnubok.sandbox_cleanup', '', true);
|
||||
|
||||
RETURN v_deleted;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- =============================================================================
|
||||
-- 6. cleanup_expired_sandbox_users: jsonb summary + orphaned-anonymous sweep
|
||||
-- =============================================================================
|
||||
|
||||
-- Return type changes from integer to jsonb, so CREATE OR REPLACE cannot be
|
||||
-- used; drop the old signature first.
|
||||
DROP FUNCTION IF EXISTS public.cleanup_expired_sandbox_users(int);
|
||||
|
||||
CREATE FUNCTION public.cleanup_expired_sandbox_users(
|
||||
p_max_age_hours int DEFAULT 24,
|
||||
p_limit int DEFAULT NULL
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_user_id uuid;
|
||||
v_cleaned integer := 0;
|
||||
v_failed integer := 0;
|
||||
v_orphans integer := 0;
|
||||
BEGIN
|
||||
FOR v_user_id IN
|
||||
SELECT cs.user_id
|
||||
FROM public.company_settings cs
|
||||
WHERE cs.is_sandbox = true
|
||||
AND cs.created_at < now() - interval '1 hour' * p_max_age_hours
|
||||
ORDER BY cs.created_at
|
||||
LIMIT p_limit
|
||||
LOOP
|
||||
BEGIN
|
||||
PERFORM public.cleanup_sandbox_user(v_user_id);
|
||||
v_cleaned := v_cleaned + 1;
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
v_failed := v_failed + 1;
|
||||
RAISE WARNING 'Failed to clean up sandbox user %: %', v_user_id, SQLERRM;
|
||||
END;
|
||||
END LOOP;
|
||||
|
||||
-- Anonymous users whose seed never reached the company_settings insert are
|
||||
-- invisible to the query above and would otherwise leak forever. They have
|
||||
-- no bookkeeping (the seed writes settings before any vouchers), so a
|
||||
-- plain auth.users delete cascades the little they do have. Non-anonymous
|
||||
-- users are never touched here. auth.users.is_anonymous arrived with
|
||||
-- GoTrue's anonymous sign-ins; older self-hosted stacks (and the CI
|
||||
-- supabase/postgres image) predate it, and without anonymous sign-ins no
|
||||
-- orphans can exist, so the sweep is skipped there. plpgsql resolves the
|
||||
-- loop query only when this branch executes.
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'auth' AND table_name = 'users'
|
||||
AND column_name = 'is_anonymous'
|
||||
) THEN
|
||||
FOR v_user_id IN
|
||||
SELECT u.id
|
||||
FROM auth.users u
|
||||
WHERE u.is_anonymous = true
|
||||
AND u.created_at < now() - interval '1 hour' * p_max_age_hours
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.company_settings cs WHERE cs.user_id = u.id
|
||||
)
|
||||
-- Explicit safety, not emergent: an anonymous user attached to ANY
|
||||
-- company (a seed that died between company creation and the
|
||||
-- settings insert) is out of scope for this blind delete. Such
|
||||
-- half-seeded users are rare and can be handled manually; a user
|
||||
-- with real data must never depend on a downstream trigger throwing.
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.companies c WHERE c.created_by = u.id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.company_members cm WHERE cm.user_id = u.id
|
||||
)
|
||||
ORDER BY u.created_at
|
||||
LIMIT p_limit
|
||||
LOOP
|
||||
BEGIN
|
||||
DELETE FROM auth.users WHERE id = v_user_id;
|
||||
v_orphans := v_orphans + 1;
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
v_failed := v_failed + 1;
|
||||
RAISE WARNING 'Failed to clean up orphaned anonymous user %: %', v_user_id, SQLERRM;
|
||||
END;
|
||||
END LOOP;
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'cleaned', v_cleaned,
|
||||
'failed', v_failed,
|
||||
'orphans_removed', v_orphans
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- =============================================================================
|
||||
-- 7. company_settings.is_sandbox: write-once, and true only for sandbox actors
|
||||
-- =============================================================================
|
||||
|
||||
-- Every teardown bypass above trusts company_settings.is_sandbox, so the
|
||||
-- flag needs guarded provenance in both directions:
|
||||
--
|
||||
-- * UPDATE: write-once. RLS lets an owner update their own settings row
|
||||
-- via PostgREST, and a real company that flipped is_sandbox = true
|
||||
-- would become eligible for full deletion by the nightly cron. No
|
||||
-- application path updates the flag; a future sandbox-to-real
|
||||
-- conversion ships its own migration relaxing this deliberately.
|
||||
-- * INSERT: is_sandbox = true may only be written by an anonymous-user
|
||||
-- JWT (the sandbox seed's actor), service_role, or a direct database
|
||||
-- session (no PostgREST claims at all: migrations, seeds, tests). A
|
||||
-- regular authenticated user must not be able to provision their real
|
||||
-- company as a sandbox and have the cron destroy their books, which
|
||||
-- BFL 7 kap. forbids even self-inflicted.
|
||||
--
|
||||
-- Claims are read from request.jwt.* GUCs directly (both the modern json
|
||||
-- and the legacy per-claim style) rather than auth helpers, so the check
|
||||
-- behaves identically on hosted, self-hosted, and the CI auth shim.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.enforce_company_settings_sandbox_immutable()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_claims jsonb;
|
||||
v_role text;
|
||||
BEGIN
|
||||
IF TG_OP = 'UPDATE' THEN
|
||||
IF NEW.is_sandbox IS DISTINCT FROM OLD.is_sandbox THEN
|
||||
RAISE EXCEPTION 'company_settings.is_sandbox is write-once: it is set at company creation and cannot be changed';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- INSERT
|
||||
IF NEW.is_sandbox = true THEN
|
||||
v_claims := nullif(current_setting('request.jwt.claims', true), '')::jsonb;
|
||||
v_role := coalesce(
|
||||
nullif(current_setting('request.jwt.claim.role', true), ''),
|
||||
v_claims->>'role'
|
||||
);
|
||||
-- Any PostgREST context at all (claims json or a per-claim role) must
|
||||
-- prove itself; a claims blob without a role claim is still not a
|
||||
-- direct database session.
|
||||
IF (v_claims IS NOT NULL OR v_role IS NOT NULL)
|
||||
AND coalesce(v_role, '') <> 'service_role'
|
||||
AND coalesce((v_claims->>'is_anonymous')::boolean, false) = false THEN
|
||||
RAISE EXCEPTION 'company_settings.is_sandbox = true can only be created for anonymous sandbox users';
|
||||
END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS company_settings_sandbox_immutable ON public.company_settings;
|
||||
CREATE TRIGGER company_settings_sandbox_immutable
|
||||
BEFORE INSERT OR UPDATE ON public.company_settings
|
||||
FOR EACH ROW EXECUTE FUNCTION public.enforce_company_settings_sandbox_immutable();
|
||||
|
||||
-- =============================================================================
|
||||
-- 8. Grants: service_role only (both were PUBLIC-executable on prod)
|
||||
-- =============================================================================
|
||||
|
||||
REVOKE ALL ON FUNCTION public.cleanup_sandbox_user(uuid) FROM PUBLIC, anon, authenticated;
|
||||
REVOKE ALL ON FUNCTION public.cleanup_expired_sandbox_users(int, int) FROM PUBLIC, anon, authenticated;
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.cleanup_sandbox_user(uuid) TO service_role;
|
||||
GRANT EXECUTE ON FUNCTION public.cleanup_expired_sandbox_users(int, int) TO service_role;
|
||||
Reference in New Issue
Block a user