fix(db): lock down exchange_rates writes, drop duplicate JEL index, receipts anon read (#969)

Three Supabase-advisor findings from the 2026-07-09 production log triage:

1. exchange_rates (rls_policy_always_true): the exchange_rates_insert
   policy was WITH CHECK (true) for authenticated, letting any signed-in
   user poison the shared FX cache that feeds money math (amount_sek on
   ingested transactions, invoice SEK conversion). Migration
   20260710100000 drops the policy and revokes INSERT from
   anon/authenticated; only the service role writes the cache now (the
   05:00 enable-banking sync cron and the v1 API-key paths both use the
   service client). writeCachedRate() in lib/currency/riksbanken.ts was
   already fail-soft and never inspects the upsert result, so
   user-client paths (bank file import, refresh-exchange-rate) keep
   returning the fetched rate unchanged when the cache write is
   rejected; documented and covered by a new unit test.

2. journal_entry_lines (duplicate_index): idx_journal_entry_lines_entry
   and idx_journal_entry_lines_entry_id are byte-identical btree indexes
   on (journal_entry_id), verified via pg_indexes on prod. Migration
   20260710101000 drops idx_journal_entry_lines_entry (created outside
   the migration history); the repo-defined _entry_id stays.

3. receipts bucket (public_bucket_allows_listing): receipts_public_read
   gave anon SELECT over every object in the bucket, enabling anonymous
   listing. The bucket is unused: no code references it, public.receipts
   has 0 rows in prod, 2 orphan objects from 2026-02-26. Migration
   20260710102000 drops the anon policy; authenticated own-folder
   policies stay untouched.

New tests/pg/db-advisor-lockdowns.pg.test.ts covers all three
(authenticated INSERT rejected, SELECT still works, privilege revoked,
duplicate index gone, anon cannot list receipts).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-10 11:05:40 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 2be104ba34
commit aec81cb7ad
6 changed files with 210 additions and 2 deletions
@@ -0,0 +1,28 @@
-- Lock down writes to the shared exchange-rate cache.
-- pg-test: covered-by tests/pg/db-advisor-lockdowns.pg.test.ts
--
-- Supabase advisor (rls_policy_always_true): exchange_rates_insert allowed
-- any authenticated user to INSERT arbitrary rows (WITH CHECK (true)) into a
-- cache that feeds money math (amount_sek on ingested transactions, invoice
-- SEK conversion). Because the table is shared across all tenants and reads
-- take the first row for a (currency, rate_date) pair (UNIQUE constraint +
-- ignoreDuplicates on the writer), one malicious or buggy client could
-- poison a rate for every company.
--
-- New design: only the service role writes the cache. The 05:00
-- enable-banking sync cron (service role, the primary cache filler) bypasses
-- RLS and keeps working. User-client paths (bank file import execute,
-- refresh-exchange-rate, manual bank sync) still attempt the write; it is
-- now rejected and deliberately ignored: lib/currency/riksbanken.ts
-- writeCachedRate() is fail-soft and never inspects the upsert result, so
-- the fetched rate is returned to the caller exactly as before. Reads
-- (exchange_rates_select) are unchanged: the data is public reference data.
DROP POLICY IF EXISTS "exchange_rates_insert" ON public.exchange_rates;
-- Defense in depth: revoke the table privilege as well, so a future
-- always-true policy cannot silently reopen the hole. With RLS enabled and
-- no INSERT policy this is already denied; the REVOKE makes the intent
-- explicit and the rejection deterministic (permission denied).
REVOKE INSERT ON public.exchange_rates FROM anon, authenticated;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,18 @@
-- Drop the duplicate journal_entry_lines(journal_entry_id) index.
--
-- Supabase linter (duplicate_index): idx_journal_entry_lines_entry and
-- idx_journal_entry_lines_entry_id are identical. Verified 2026-07-09 via
-- pg_indexes on prod; both read:
-- CREATE INDEX ... ON public.journal_entry_lines USING btree (journal_entry_id)
--
-- idx_journal_entry_lines_entry_id is the one this repo defines (migration
-- 20240101000002_bookkeeping.sql), so it stays. idx_journal_entry_lines_entry
-- exists only on databases where it was created outside the migration
-- history, hence IF EXISTS: fresh-from-migrations databases (pg-real CI,
-- preview branches) never had it.
--
-- Plain DROP INDEX, not CONCURRENTLY: CONCURRENTLY cannot run inside the
-- migration transaction. The ACCESS EXCLUSIVE lock on journal_entry_lines is
-- momentary; dropping an index is a catalog-only operation with no table
-- rewrite.
DROP INDEX IF EXISTS public.idx_journal_entry_lines_entry;
@@ -0,0 +1,22 @@
-- Remove anonymous read/listing access to the receipts storage bucket.
-- pg-test: covered-by tests/pg/db-advisor-lockdowns.pg.test.ts
--
-- Supabase advisor (public_bucket_allows_listing): the receipts storage
-- bucket is public and carried an anon SELECT policy on storage.objects
-- (receipts_public_read, USING bucket_id = 'receipts'), which let anonymous
-- clients LIST every object in the bucket through the storage API.
--
-- Investigation (2026-07-09): the bucket is unused. No code references it
-- (no storage.from('receipts'), no getPublicUrl or signed URL against it),
-- the public.receipts table has 0 rows in prod, and the bucket holds 2
-- orphan objects from 2026-02-26 (early development). Nothing depends on
-- anonymous reads or listing, so the policy can go. Authenticated own-folder
-- policies (receipts_select/insert/delete) are untouched, and direct
-- public-URL object reads are served without RLS while the bucket's public
-- flag stays on; whether to flip the bucket private or delete it outright is
-- a separate product decision, documented in the PR.
--
-- The bucket and its policies were created outside the migration history
-- (dashboard), hence IF EXISTS: fresh-from-migrations databases (pg-real CI,
-- preview branches) do not have the policy.
DROP POLICY IF EXISTS "receipts_public_read" ON storage.objects;