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:
co-authored by
Claude Fable 5
parent
2be104ba34
commit
aec81cb7ad
@@ -128,6 +128,7 @@ describe('fetchExchangeRate', () => {
|
||||
exactHit?: CacheRow | null
|
||||
latestHit?: CacheRow | null
|
||||
onUpsert?: (row: Record<string, unknown>) => void
|
||||
upsertError?: { code: string; message: string }
|
||||
}) {
|
||||
// .maybeSingle() terminates both the exact lookup and the latest
|
||||
// lookup. Shared across from() calls so the once-queue holds: the
|
||||
@@ -147,7 +148,7 @@ describe('fetchExchangeRate', () => {
|
||||
maybeSingle,
|
||||
upsert: vi.fn((row: Record<string, unknown>) => {
|
||||
opts.onUpsert?.(row)
|
||||
return Promise.resolve({ data: null, error: null })
|
||||
return Promise.resolve({ data: null, error: opts.upsertError ?? null })
|
||||
}),
|
||||
})),
|
||||
} as never
|
||||
@@ -182,6 +183,22 @@ describe('fetchExchangeRate', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('still returns the fetched rate when the cache write is rejected by RLS', async () => {
|
||||
// Since migration 20260710100000, INSERT on exchange_rates is
|
||||
// service-role only. supabase-js reports the RLS rejection as a
|
||||
// resolved { error }, not a throw: the rate must come back anyway.
|
||||
vi.spyOn(global, 'fetch').mockResolvedValueOnce(
|
||||
new Response(JSON.stringify([{ value: '11.42', date: '2025-01-15' }]), { status: 200 })
|
||||
)
|
||||
const supabase = makeSupabase({
|
||||
upsertError: { code: '42501', message: 'permission denied for table exchange_rates' },
|
||||
})
|
||||
|
||||
const result = await fetchExchangeRate('EUR', new Date('2025-01-15'), supabase)
|
||||
|
||||
expect(result).toEqual({ currency: 'EUR', rate: 11.42, date: '2025-01-15' })
|
||||
})
|
||||
|
||||
it('falls back to the most recent cached observation when Riksbanken is down', async () => {
|
||||
vi.spyOn(global, 'fetch').mockRejectedValue(new Error('Network error'))
|
||||
const supabase = makeSupabase({
|
||||
|
||||
@@ -52,6 +52,16 @@ async function readCachedRate(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort cache write. INSERT on exchange_rates is service-role only
|
||||
* (migration 20260710100000): the shared cache feeds money math, so a user
|
||||
* client must not be able to poison a (currency, rate_date) pair for every
|
||||
* tenant. When called with an authenticated client (bank file import,
|
||||
* refresh-exchange-rate, manual bank sync) the upsert is rejected by RLS;
|
||||
* supabase-js reports that as a resolved { error }, not a throw, and the
|
||||
* result is deliberately not inspected: the fetched rate is returned to the
|
||||
* caller regardless, and the service-role sync cron fills the cache instead.
|
||||
*/
|
||||
async function writeCachedRate(
|
||||
supabase: SupabaseClient,
|
||||
currency: Currency,
|
||||
@@ -70,7 +80,7 @@ async function writeCachedRate(
|
||||
{ onConflict: 'currency,rate_date', ignoreDuplicates: true },
|
||||
)
|
||||
} catch {
|
||||
// best-effort
|
||||
// best-effort: a cache write failure must never block the rate
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user