From 4cbf812c62bd681edf266859238e6176502a2825 Mon Sep 17 00:00:00 2001 From: Swayymalcolm99 Date: Sun, 19 Jul 2026 20:08:10 -0700 Subject: [PATCH 1/3] RLS policies implementation --- .github/PULL_REQUEST_TEMPLATE.md | 59 ++++--- .../20260718000000_rls_policies.sql | 156 ++++++++++++++++++ 2 files changed, 195 insertions(+), 20 deletions(-) create mode 100644 supabase/migrations/20260718000000_rls_policies.sql 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/supabase/migrations/20260718000000_rls_policies.sql b/supabase/migrations/20260718000000_rls_policies.sql new file mode 100644 index 0000000..5abf05e --- /dev/null +++ b/supabase/migrations/20260718000000_rls_policies.sql @@ -0,0 +1,156 @@ +-- 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 +-- +-- This app uses wallet-based authentication. The API layer sets a Postgres session +-- variable 'app.current_wallet' before making queries. RLS policies check this variable. +-- +-- To use: SET LOCAL app.current_wallet = 'G...'; before queries +-- Service role client bypasses RLS entirely for admin/indexer operations + +-- ============================================================================ +-- 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); + +-- Service role (indexer) can insert loans +CREATE POLICY "Service role can insert loans" +ON public.loans + FOR INSERT + WITH CHECK (true); + +-- Service role (indexer) can update loans +CREATE POLICY "Service role can update loans" +ON public.loans + FOR UPDATE + USING (true); + +-- ============================================================================ +-- 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); + +-- Service role can insert vendors +CREATE POLICY "Service role can insert vendors" +ON public.vendors + FOR INSERT + WITH CHECK (true); + +-- Service role can update vendors +CREATE POLICY "Service role can update vendors" +ON public.vendors + FOR UPDATE + USING (true); + +-- Service role can delete vendors +CREATE POLICY "Service role can delete vendors" +ON public.vendors + FOR DELETE + 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); + +-- Service role can insert liquidity positions +CREATE POLICY "Service role can insert liquidity positions" +ON public.liquidity_positions + FOR INSERT + WITH CHECK (true); + +-- Service role can update liquidity positions +CREATE POLICY "Service role can update liquidity positions" +ON public.liquidity_positions + FOR UPDATE + USING (true); + +-- ============================================================================ +-- 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. The API layer must SET LOCAL app.current_wallet = 'G...' before queries +-- This is done via SupabaseService.getClient() with a custom RPC or session var +-- 3. repayment_installments table does not exist - payment_index is used instead +-- (payment_index is an indexer table managed by service role) +-- 4. pool_positions is implemented as liquidity_positions +-- 5. All tables already have RLS enabled from previous migration 20260213006000 From d76323a61a525c73d5c603c24286b27c3092d15e Mon Sep 17 00:00:00 2001 From: Swayymalcolm99 Date: Tue, 21 Jul 2026 22:58:30 -0700 Subject: [PATCH 2/3] Fixed RLS policies security issues --- src/database/supabase.client.ts | 33 ++++++++ .../20260718000000_rls_policies.sql | 75 ++++++++----------- 2 files changed, 64 insertions(+), 44 deletions(-) diff --git a/src/database/supabase.client.ts b/src/database/supabase.client.ts index 5497f9c..ea26e97 100644 --- a/src/database/supabase.client.ts +++ b/src/database/supabase.client.ts @@ -51,4 +51,37 @@ export class SupabaseService { getServiceRoleClient(): SupabaseClient { return this.serviceRoleClient; } + + /** + * Creates a wallet-scoped client for RLS enforcement. + * Uses anon key (non-service-role) and sets app.current_wallet session variable. + * This client respects RLS policies, unlike serviceRoleClient which bypasses them. + * + * Note: The session variable must be set per-request via RPC or raw SQL. + * Use setWalletSessionVariable() before queries on this client. + */ + getWalletScopedClient(walletAddress: string): 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, + }, + }, + ); + } + + /** + * Sets the Postgres session variable for RLS enforcement. + * Must be called before queries on wallet-scoped client. + */ + async setWalletSessionVariable(client: SupabaseClient, walletAddress: string): Promise { + await client.rpc('set_app_current_wallet', { wallet: walletAddress }); + } } diff --git a/supabase/migrations/20260718000000_rls_policies.sql b/supabase/migrations/20260718000000_rls_policies.sql index 5abf05e..a574619 100644 --- a/supabase/migrations/20260718000000_rls_policies.sql +++ b/supabase/migrations/20260718000000_rls_policies.sql @@ -8,6 +8,31 @@ -- To use: SET LOCAL app.current_wallet = 'G...'; before queries -- Service role client bypasses RLS entirely for admin/indexer operations +-- ============================================================================ +-- 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 wallet session variable for RLS +-- ============================================================================ +CREATE OR REPLACE FUNCTION public.set_app_current_wallet(wallet TEXT) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +BEGIN + PERFORM set_config('app.current_wallet', wallet, true); +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 -- ============================================================================ @@ -38,17 +63,6 @@ ON public.loans FOR SELECT USING (user_wallet = current_setting('app.current_wallet', true)::TEXT); --- Service role (indexer) can insert loans -CREATE POLICY "Service role can insert loans" -ON public.loans - FOR INSERT - WITH CHECK (true); - --- Service role (indexer) can update loans -CREATE POLICY "Service role can update loans" -ON public.loans - FOR UPDATE - USING (true); -- ============================================================================ -- vouches @@ -84,23 +98,6 @@ ON public.vendors FOR SELECT USING (true); --- Service role can insert vendors -CREATE POLICY "Service role can insert vendors" -ON public.vendors - FOR INSERT - WITH CHECK (true); - --- Service role can update vendors -CREATE POLICY "Service role can update vendors" -ON public.vendors - FOR UPDATE - USING (true); - --- Service role can delete vendors -CREATE POLICY "Service role can delete vendors" -ON public.vendors - FOR DELETE - USING (true); -- ============================================================================ -- liquidity_positions (pool_positions) @@ -111,17 +108,6 @@ ON public.liquidity_positions FOR SELECT USING (provider_wallet = current_setting('app.current_wallet', true)::TEXT); --- Service role can insert liquidity positions -CREATE POLICY "Service role can insert liquidity positions" -ON public.liquidity_positions - FOR INSERT - WITH CHECK (true); - --- Service role can update liquidity positions -CREATE POLICY "Service role can update liquidity positions" -ON public.liquidity_positions - FOR UPDATE - USING (true); -- ============================================================================ -- sponsor_pools @@ -148,9 +134,10 @@ ON public.sponsor_pools -- Notes -- ============================================================================ -- 1. Service role bypasses RLS automatically for all operations --- 2. The API layer must SET LOCAL app.current_wallet = 'G...' before queries --- This is done via SupabaseService.getClient() with a custom RPC or session var --- 3. repayment_installments table does not exist - payment_index is used instead +-- 2. Non-service-role clients must call set_app_current_wallet(wallet) RPC before queries +-- to set the app.current_wallet session variable 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) --- 4. pool_positions is implemented as liquidity_positions --- 5. All tables already have RLS enabled from previous migration 20260213006000 +-- 5. pool_positions is implemented as liquidity_positions +-- 6. RLS is explicitly enabled on all target tables in this migration From b8fa58dc9539608a75c2b01575fc9f95680bcec2 Mon Sep 17 00:00:00 2001 From: Swayymalcolm99 Date: Thu, 23 Jul 2026 04:07:20 -0700 Subject: [PATCH 3/3] changes made --- src/database/supabase.client.ts | 23 +++++++++---------- .../20260718000000_rls_policies.sql | 20 ++++++++-------- 2 files changed, 22 insertions(+), 21 deletions(-) diff --git a/src/database/supabase.client.ts b/src/database/supabase.client.ts index ea26e97..7c356e0 100644 --- a/src/database/supabase.client.ts +++ b/src/database/supabase.client.ts @@ -54,13 +54,19 @@ export class SupabaseService { /** * Creates a wallet-scoped client for RLS enforcement. - * Uses anon key (non-service-role) and sets app.current_wallet session variable. - * This client respects RLS policies, unlike serviceRoleClient which bypasses them. + * Uses anon key (non-service-role) which respects RLS policies. * - * Note: The session variable must be set per-request via RPC or raw SQL. - * Use setWalletSessionVariable() before queries on this client. + * 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(walletAddress: string): SupabaseClient { + getWalletScopedClient(): SupabaseClient { return createClient( this.configService.get('SUPABASE_URL'), this.configService.get('SUPABASE_ANON_KEY'), @@ -77,11 +83,4 @@ export class SupabaseService { ); } - /** - * Sets the Postgres session variable for RLS enforcement. - * Must be called before queries on wallet-scoped client. - */ - async setWalletSessionVariable(client: SupabaseClient, walletAddress: string): Promise { - await client.rpc('set_app_current_wallet', { wallet: walletAddress }); - } } diff --git a/supabase/migrations/20260718000000_rls_policies.sql b/supabase/migrations/20260718000000_rls_policies.sql index a574619..fbdbd9d 100644 --- a/supabase/migrations/20260718000000_rls_policies.sql +++ b/supabase/migrations/20260718000000_rls_policies.sql @@ -2,11 +2,12 @@ -- This migration adds RLS policies to ensure users can only access their own data -- Service role bypasses RLS automatically for admin operations -- --- This app uses wallet-based authentication. The API layer sets a Postgres session --- variable 'app.current_wallet' before making queries. RLS policies check this variable. --- --- To use: SET LOCAL app.current_wallet = 'G...'; before queries --- Service role client bypasses RLS entirely for admin/indexer 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) @@ -17,15 +18,16 @@ ALTER TABLE public.vendors ENABLE ROW LEVEL SECURITY; ALTER TABLE public.sponsor_pools ENABLE ROW LEVEL SECURITY; -- ============================================================================ --- RPC function to set wallet session variable for RLS +-- 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, true); + PERFORM set_config('app.current_wallet', wallet, false); END; $$; @@ -134,8 +136,8 @@ ON public.sponsor_pools -- Notes -- ============================================================================ -- 1. Service role bypasses RLS automatically for all operations --- 2. Non-service-role clients must call set_app_current_wallet(wallet) RPC before queries --- to set the app.current_wallet session variable for RLS enforcement +-- 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)