diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 87d8812..998deb3 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,30 +1,49 @@ -## 🔗 Related Issue -Closes #issue-number +## Summary ---- +Closes #[issue number] -## 🔖 Title - +Briefly describe what this PR does in 2-3 sentences. ---- +## This repo is for the NestJS backend API only -## 📝 Description - +Before submitting, confirm your changes belong here: ---- +- [ ] My changes are inside src/ or test/ +- [ ] I have NOT added React, React Native, + or frontend component files +- [ ] I have NOT added Rust or Soroban contract code +- [ ] This is NestJS/TypeScript backend work -## 🔄 Changes Made - -- [ ] -- [ ] -- [ ] +## Type of change ---- +- [ ] Bug fix +- [ ] New endpoint +- [ ] New service or module +- [ ] Database migration +- [ ] Background job +- [ ] Test coverage -## 📸 Screenshots (if applicable) - +## Testing ---- +- [ ] npm run build passes with zero TypeScript errors +- [ ] npm test passes — all 184+ existing tests pass +- [ ] No new `any` types introduced anywhere +- [ ] Swagger decorators added to every new endpoint +- [ ] Migration file created for any schema changes +- [ ] New unit tests written for new service methods -## 🗒️ Additional Notes - \ No newline at end of file +## Context files reviewed + +- [ ] context/architecture-context.md +- [ ] context/code-standards.md +- [ ] context/progress-tracker.md updated + +## Mandatory before requesting review + +Running these must all exit 0: +npm run build +npm test + +If either fails, fix it before opening this PR. +PRs with failing CI checks will be closed without review. +PRs that reduce the test count will be rejected. diff --git a/src/database/supabase.client.ts b/src/database/supabase.client.ts index 5497f9c..7c356e0 100644 --- a/src/database/supabase.client.ts +++ b/src/database/supabase.client.ts @@ -51,4 +51,36 @@ export class SupabaseService { getServiceRoleClient(): SupabaseClient { return this.serviceRoleClient; } + + /** + * Creates a wallet-scoped client for RLS enforcement. + * Uses anon key (non-service-role) which respects RLS policies. + * + * IMPORTANT: Before using this client, the caller must execute: + * SET LOCAL app.current_wallet = 'G...' via raw SQL + * This can be done using client.rpc() with a custom function or + * by wrapping queries in a transaction that sets the variable. + * + * Example usage: + * const client = this.getWalletScopedClient(); + * await client.rpc('set_app_current_wallet', { wallet: walletAddress }); + * // Now queries respect RLS + */ + getWalletScopedClient(): SupabaseClient { + return createClient( + this.configService.get('SUPABASE_URL'), + this.configService.get('SUPABASE_ANON_KEY'), + { + auth: { + persistSession: false, + autoRefreshToken: false, + detectSessionInUrl: false, + }, + realtime: { + transport: ws as unknown as typeof WebSocket, + }, + }, + ); + } + } diff --git a/supabase/migrations/20260718000000_rls_policies.sql b/supabase/migrations/20260718000000_rls_policies.sql new file mode 100644 index 0000000..fbdbd9d --- /dev/null +++ b/supabase/migrations/20260718000000_rls_policies.sql @@ -0,0 +1,145 @@ +-- Row Level Security Policies for StepFi Tables +-- This migration adds RLS policies to ensure users can only access their own data +-- Service role bypasses RLS automatically for admin operations +-- +-- IMPORTANT: These policies require the API layer to execute +-- SET LOCAL app.current_wallet = 'G...' before each query using a +-- non-service-role client. The current API uses service-role client which +-- bypasses RLS entirely. To enable RLS enforcement, the API layer must: +-- 1. Use anon-key client (non-service-role) for user-facing queries +-- 2. Execute SET LOCAL app.current_wallet = $wallet via raw SQL before queries + +-- ============================================================================ +-- Enable RLS on tables (ensure it's enabled) +-- ============================================================================ +ALTER TABLE public.learner_profiles ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.vouches ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.vendors ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.sponsor_pools ENABLE ROW LEVEL SECURITY; + +-- ============================================================================ +-- RPC function to set session variable for RLS +-- ============================================================================ +CREATE OR REPLACE FUNCTION public.set_app_current_wallet(wallet TEXT) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +BEGIN + PERFORM set_config('app.current_wallet', wallet, false); +END; +$$; + +-- Grant execute to authenticated users +GRANT EXECUTE ON FUNCTION public.set_app_current_wallet(TEXT) TO anon; +GRANT EXECUTE ON FUNCTION public.set_app_current_wallet(TEXT) TO authenticated; + +-- ============================================================================ +-- learner_profiles +-- ============================================================================ +-- Users can read their own profile +CREATE POLICY "Users can read own learner profile" +ON public.learner_profiles + FOR SELECT + USING (wallet_address = current_setting('app.current_wallet', true)::TEXT); + +-- Users can insert their own profile +CREATE POLICY "Users can insert own learner profile" +ON public.learner_profiles + FOR INSERT + WITH CHECK (wallet_address = current_setting('app.current_wallet', true)::TEXT); + +-- Users can update their own profile +CREATE POLICY "Users can update own learner profile" +ON public.learner_profiles + FOR UPDATE + USING (wallet_address = current_setting('app.current_wallet', true)::TEXT); + +-- ============================================================================ +-- loans +-- ============================================================================ +-- Users can read their own loans +CREATE POLICY "Users can read own loans" +ON public.loans + FOR SELECT + USING (user_wallet = current_setting('app.current_wallet', true)::TEXT); + + +-- ============================================================================ +-- vouches +-- ============================================================================ +-- Users can read vouches where they are either mentor or learner +CREATE POLICY "Users can read own vouches" +ON public.vouches + FOR SELECT + USING ( + mentor_wallet = current_setting('app.current_wallet', true)::TEXT + OR + learner_wallet = current_setting('app.current_wallet', true)::TEXT + ); + +-- Users can insert vouches where they are the mentor +CREATE POLICY "Mentors can insert vouches" +ON public.vouches + FOR INSERT + WITH CHECK (mentor_wallet = current_setting('app.current_wallet', true)::TEXT); + +-- Users can update vouches where they are the mentor +CREATE POLICY "Mentors can update own vouches" +ON public.vouches + FOR UPDATE + USING (mentor_wallet = current_setting('app.current_wallet', true)::TEXT); + +-- ============================================================================ +-- vendors (formerly merchants) +-- ============================================================================ +-- Authenticated users can read all vendors (public data) +CREATE POLICY "Authenticated users can read vendors" +ON public.vendors + FOR SELECT + USING (true); + + +-- ============================================================================ +-- liquidity_positions (pool_positions) +-- ============================================================================ +-- Users can read their own liquidity positions +CREATE POLICY "Users can read own liquidity positions" +ON public.liquidity_positions + FOR SELECT + USING (provider_wallet = current_setting('app.current_wallet', true)::TEXT); + + +-- ============================================================================ +-- sponsor_pools +-- ============================================================================ +-- Users can read their own sponsor pool +CREATE POLICY "Users can read own sponsor pool" +ON public.sponsor_pools + FOR SELECT + USING (wallet_address = current_setting('app.current_wallet', true)::TEXT); + +-- Users can insert their own sponsor pool +CREATE POLICY "Users can insert own sponsor pool" +ON public.sponsor_pools + FOR INSERT + WITH CHECK (wallet_address = current_setting('app.current_wallet', true)::TEXT); + +-- Users can update their own sponsor pool +CREATE POLICY "Users can update own sponsor pool" +ON public.sponsor_pools + FOR UPDATE + USING (wallet_address = current_setting('app.current_wallet', true)::TEXT); + +-- ============================================================================ +-- Notes +-- ============================================================================ +-- 1. Service role bypasses RLS automatically for all operations +-- 2. Non-service-role clients must execute SET LOCAL app.current_wallet = 'G...' +-- via raw SQL before each query for RLS enforcement +-- 3. No blanket-permissive policies (WITH CHECK (true)) - service role bypasses RLS +-- 4. repayment_installments table does not exist - payment_index is used instead +-- (payment_index is an indexer table managed by service role) +-- 5. pool_positions is implemented as liquidity_positions +-- 6. RLS is explicitly enabled on all target tables in this migration