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
+1
View File
@@ -821,3 +821,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-06] Bucket A defaults pass commits the /pending Godkänn pill directly for low/medium risk and keeps the ConfirmationDialog only for high risk: the Granskning row already states source, title, risk and offers Detaljer, so the dialog's second Godkänn restated the row (the audit's expert lens called double-Godkänn the thing professionals do not tolerate). The chat-side "Godkänn alla N" was DEFERRED, not built: ApprovalCard owns its whole state machine internally (commit fetch, account-activation retry, typed high-risk confirm) and a bulk commit from AgentChat would leave committed cards rendering as pending; that is assistant-redesign seam 8.8 (approval batching) and needs the state lifted, not a button.
[2026-08-06] SIE-export period default left unchanged despite the choice-audit finding: FiscalYearSelector with includeAllOption=false already auto-selects the newest started period once loaded, so the "opens with nothing selected" claim is only fetch latency. FyPicker gained preferLatestEnded for helårsmoms instead, which also skips the shared per-company localStorage scope on that surface: a filing page defaulting to the current (unfilable) year because Balansräkningen was last viewed there is the one wrong default.
[2026-08-06] Review-workflow triage on the Bucket A branch (13 confirmed findings): fixed 10, incl. the branch-killing one (setActiveCompany's cookie write throws in Server Component render, so the /select-company auto-forward silently never fired: the cookie set is now best-effort because the gnubok-company-id cookie is write-only compat nothing reads). Batch "Ingen moms" now goes over the wire as 'exempt' instead of collapsing to undefined, which had an explicit no-VAT choice booking the derived 25%; the same pre-existing collapse in QuickReviewDialog/CategoryExpandedDialog is left for a follow-up. Skipped by choice: generalizing AiFilledIndicator for history provenance (the note's copy already names the source) and converting BulkBookInboxDialog's hardcoded-Swedish option lists to i18n (whole-file migration, not this branch's divergence). Monthly momsdeklaration default is deadline-aware (M-2 until the 12th/17th, M-1 after; over-40M always M-1) mirroring deadline-config, not just calendar-ended.
[2026-08-07] Sandbox cleanup repaired via a dedicated gnubok.sandbox_cleanup transaction-local flag (respected by write_audit_log, audit_log_immutable, enforce_dimension_registry_guards, enforce_pending_operations_no_delete) instead of tombstoning sandbox users like the real account-deletion flow: sandbox data is synthetic demo content, not rakenskapsinformation, so full deletion is the correct GDPR/BFL posture and the audit-log DELETE bypass re-verifies per row that the company is a sandbox before letting anything through. Also revoked the default PUBLIC EXECUTE both cleanup RPCs had carried on prod since March (anon could call them via PostgREST), and validated the whole delete chain by replaying it against prod inside aborted transactions (21 sampled users across all seed eras) plus a committed staging run.