Files
pwap/web/supabase/migrations/20260725010000_harden_financial_rls.sql
T
pezkuwichain a8f41cd47f security(p2p): fix withdrawal BOLA, financial RLS exposure, admin dispute escrow
Critical/high audit remediation on the custodial P2P ledger. Auth model is
wallet-based; identity (citizen/visa) is cryptographically bound to a wallet
(People Chain tiki.citizenNft / active p2p_visa), which enables a correct fix.

- Withdrawal BOLA (CRITICAL): process-withdraw now requires a wallet-SIGNED
  challenge; server verifies signature (raw + <Bytes> forms), asserts the signer
  OWNS the identity, consumes a single-use nonce (replay), and derives user_id
  server-side — the client-supplied user_id is ignored. process-withdrawal (batch)
  now requires the service-role key (was: any bearer, incl. public anon key).
  request_withdraw is REVOKEd from anon/authenticated + service-role guarded.
- Financial RLS (HIGH): drop the blanket USING(true) SELECT on user_internal_balances,
  p2p_balance_transactions, p2p_deposit_withdraw_requests; lock p2p_user_payment_methods
  (IBAN PII) + p2p_fiat_disputes UPDATE to service_role; legitimate reads move behind
  scoped SECURITY DEFINER RPCs.
- Deposit integrity: verify-deposit now binds the on-chain sender to identity ownership
  before crediting.
- Admin dispute (CRITICAL fund-logic): DisputeResolutionPanel relabeled trades without
  moving escrow. New admin-signed resolve-dispute function + admin_resolve_dispute RPC
  moves escrow (release/refund/split) atomically with correct accounting (avoids the
  double-count in the legacy resolve_p2p_dispute). Client isAdmin documented as cosmetic.

DEPLOY RUNBOOK (gated; owner runs): 1) apply migrations 20260225/20260725* in order;
2) deploy edge functions process-withdraw, process-withdrawal, verify-deposit, resolve-dispute;
3) set edge secrets PEOPLE_RPC_ENDPOINT (+ optional ADMIN_WALLETS); 4) ship frontend.
Migrations + functions + frontend must go together or the app breaks.

KNOWN RESIDUAL (Round 2 — as severe as the withdrawal BOLA): release_/lock_/refund_
escrow_internal still have PUBLIC EXECUTE and the client calls release_escrow_internal
directly with the anon key from confirmPaymentReceived -> an anon caller can drain any
victim's LOCKED balance. Fix = a wallet-signed confirm-payment edge function (same
pattern as withdrawals) before revoking PUBLIC execute. Not yet fixed.
2026-07-25 00:09:03 -07:00

123 lines
6.5 KiB
PL/PgSQL

-- =====================================================
-- Migration: Harden RLS on financial + PII tables
-- Date: 2026-07-25
-- =====================================================
--
-- Forward migration (does NOT rewrite history) that removes the blanket
-- `USING (true)` read policies added in 20260223160000_fix_rls_for_wallet_auth
-- on the crown-jewel financial/PII tables, so the public anon key can no longer
-- enumerate every user's balances, ledger history, deposit/withdraw requests, or
-- payment-method PII.
--
-- Reads the app legitimately needs are moved behind SECURITY DEFINER RPCs that
-- return only the requested user's rows (instead of the whole table). Writes to
-- these tables already go through service_role edge functions / SECURITY DEFINER
-- RPCs, so no client write path is affected.
--
-- NOTE (residual, tracked): user_id is a UUID v5 derived from a citizen/visa
-- number, which is not a secret. These RPCs remove full-table exfiltration but do
-- not yet cryptographically bind the reader to the row owner. Fully closing that
-- requires a signed-session read path (same mechanism as withdrawals) applied to
-- these reads — see the security report. The high-severity leak (dump ALL rows)
-- is closed here.
-- =====================================================
-- 1. user_internal_balances — no direct client reads (uses
-- get_user_internal_balance RPC which is SECURITY DEFINER and bypasses RLS)
-- =====================================================
DROP POLICY IF EXISTS "balances_anon_select" ON public.user_internal_balances;
-- No anon policy remains -> anon/authenticated cannot read the table directly.
-- SECURITY DEFINER RPCs (get_user_internal_balance) continue to work.
-- =====================================================
-- 2. p2p_balance_transactions — replace open SELECT with a scoped RPC
-- =====================================================
DROP POLICY IF EXISTS "balance_tx_anon_select" ON public.p2p_balance_transactions;
CREATE OR REPLACE FUNCTION public.get_user_balance_transactions(
p_user_id UUID,
p_limit INT DEFAULT 50
) RETURNS SETOF public.p2p_balance_transactions AS $$
SELECT *
FROM public.p2p_balance_transactions
WHERE user_id = p_user_id
ORDER BY created_at DESC
LIMIT LEAST(GREATEST(COALESCE(p_limit, 50), 1), 200);
$$ LANGUAGE sql STABLE SECURITY DEFINER;
GRANT EXECUTE ON FUNCTION public.get_user_balance_transactions(UUID, INT) TO anon, authenticated;
COMMENT ON FUNCTION public.get_user_balance_transactions IS
'Returns balance-transaction history for a single user_id only (no full-table
dump). Replaces the removed USING(true) SELECT policy.';
-- =====================================================
-- 3. p2p_deposit_withdraw_requests — replace open SELECT with a scoped RPC
-- (service_role write policy from 20260223160000 is retained)
-- =====================================================
DROP POLICY IF EXISTS "deposit_requests_anon_select" ON public.p2p_deposit_withdraw_requests;
CREATE OR REPLACE FUNCTION public.get_user_deposit_withdraw_requests(
p_user_id UUID,
p_limit INT DEFAULT 50
) RETURNS SETOF public.p2p_deposit_withdraw_requests AS $$
SELECT *
FROM public.p2p_deposit_withdraw_requests
WHERE user_id = p_user_id
ORDER BY created_at DESC
LIMIT LEAST(GREATEST(COALESCE(p_limit, 50), 1), 200);
$$ LANGUAGE sql STABLE SECURITY DEFINER;
GRANT EXECUTE ON FUNCTION public.get_user_deposit_withdraw_requests(UUID, INT) TO anon, authenticated;
COMMENT ON FUNCTION public.get_user_deposit_withdraw_requests IS
'Returns deposit/withdraw requests for a single user_id only. Replaces the
removed USING(true) SELECT policy.';
-- =====================================================
-- 4. p2p_user_payment_methods (IBAN / payment PII) — lock to service role
-- (no direct client access exists; offers embed encrypted payment details).
-- =====================================================
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'p2p_user_payment_methods') THEN
EXECUTE 'ALTER TABLE public.p2p_user_payment_methods ENABLE ROW LEVEL SECURITY';
EXECUTE 'DROP POLICY IF EXISTS "user_pm_anon_select" ON public.p2p_user_payment_methods';
EXECUTE 'DROP POLICY IF EXISTS "user_pm_anon_insert" ON public.p2p_user_payment_methods';
EXECUTE 'DROP POLICY IF EXISTS "user_pm_anon_update" ON public.p2p_user_payment_methods';
EXECUTE 'DROP POLICY IF EXISTS "user_pm_service_only" ON public.p2p_user_payment_methods';
EXECUTE 'CREATE POLICY "user_pm_service_only" ON public.p2p_user_payment_methods
FOR ALL USING (auth.role() = ''service_role'') WITH CHECK (auth.role() = ''service_role'')';
EXECUTE 'REVOKE ALL ON public.p2p_user_payment_methods FROM anon, authenticated, PUBLIC';
END IF;
END $$;
-- =====================================================
-- 5. p2p_fiat_disputes — remove world-open UPDATE.
-- Dispute state transitions (claim/resolve) now go through the service-role
-- `resolve-dispute` edge function (which verifies the admin wallet signature).
-- Regular users still INSERT disputes (disputes_anon_insert retained).
-- =====================================================
DROP POLICY IF EXISTS "disputes_anon_update" ON public.p2p_fiat_disputes;
DROP POLICY IF EXISTS "disputes_service_update" ON public.p2p_fiat_disputes;
CREATE POLICY "disputes_service_update" ON public.p2p_fiat_disputes
FOR UPDATE USING (auth.role() = 'service_role') WITH CHECK (auth.role() = 'service_role');
-- =====================================================
-- Done. Balances, ledger, deposit/withdraw requests and payment PII are no longer
-- world-readable via the anon key; dispute resolution is no longer world-writable.
--
-- RESIDUAL (documented in the security report, NOT closed here to avoid breaking
-- the live app without the signed-session refactor):
-- * p2p_fiat_offers / p2p_fiat_trades / p2p_messages still carry USING(true)
-- policies because the client reads/writes them directly with the anon key
-- and there is no server session to bind to. Marking a trade status does not
-- itself move funds.
-- * The internal-ledger escrow RPCs (lock_/release_/refund_escrow_internal)
-- are still EXECUTE-able by anon (confirmPaymentReceived / createFiatOffer
-- call them directly). This is a SEPARATE critical hole that needs the same
-- signed-challenge mechanism applied to trade actions (an authenticated
-- confirm-payment / create-offer edge function). See report.
-- =====================================================