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:
Mattsson
2026-08-07 13:51:01 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 6458180bf5
commit f7f3a31f8e
5 changed files with 1052 additions and 3 deletions
@@ -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;