Row Level Security (RLS) is a PostgreSQL feature that controls which rows users can access in database tables. Sistema Financiero uses RLS to ensure users only see their own financial transactions.
RLS is enforced at the database level, making it impossible to bypass even if application-level security is compromised.
The main table storing all financial transactions:
CREATE TABLE transacciones ( -- Primary Key id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), -- Core Transaction Fields fecha TIMESTAMP NOT NULL DEFAULT NOW(), tipo TEXT CHECK (tipo IN ('ingreso', 'gasto')) NOT NULL, monto NUMERIC(10, 2) NOT NULL CHECK (monto > 0), categoria TEXT NOT NULL, -- Optional Details concepto TEXT DEFAULT 'Transacción manual', descripcion TEXT, metodo_pago TEXT CHECK (metodo_pago IN ('Efectivo', 'Tarjeta', 'Transferencia')), registrado_por TEXT, foto_url TEXT, -- User Association (Critical for RLS) usuario_id UUID REFERENCES auth.users(id), -- Metadata created_at TIMESTAMP DEFAULT NOW());
Here’s the complete SQL to set up RLS for Sistema Financiero:
-- Enable RLSALTER TABLE transacciones ENABLE ROW LEVEL SECURITY;-- Policy 1: Users can view their own transactionsCREATE POLICY "Users can view own transactions" ON transacciones FOR SELECT USING (auth.uid() = usuario_id);-- Policy 2: Users can insert their own transactionsCREATE POLICY "Users can insert own transactions" ON transacciones FOR INSERT WITH CHECK (auth.uid() = usuario_id);-- Policy 3: Users can update their own transactionsCREATE POLICY "Users can update own transactions" ON transacciones FOR UPDATE USING (auth.uid() = usuario_id) WITH CHECK (auth.uid() = usuario_id);-- Policy 4: Users can delete their own transactionsCREATE POLICY "Users can delete own transactions" ON transacciones FOR DELETE USING (auth.uid() = usuario_id);
Queries return empty results (no transactions visible)
Inserts fail unless usuario_id is explicitly set
With Service Role Key (Bypasses RLS):
// Using service role key (NOT recommended for client-facing APIs)const supabase = createClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY! // ⚠️ Bypasses RLS)
Never use the service role key in client-side code or public API routes. It bypasses all RLS policies!
// Even if application code is compromised:const { data } = await supabase .from('transacciones') .select('*'); // Still only returns current user's data ✅
RLS policies are evaluated after query parsing, protecting against injection:
-- Malicious attemptSELECT * FROM transacciones WHERE id = '1' OR '1'='1';-- Still filtered by RLS:SELECT * FROM transaccionesWHERE (id = '1' OR '1'='1') AND usuario_id = auth.uid(); -- ✅ RLS adds this
-- Test as specific userSET request.jwt.claim.sub = 'user-uuid-here';SELECT * FROM transacciones;-- Should only show that user's transactions-- Test insert permissionINSERT INTO transacciones (usuario_id, tipo, monto, categoria)VALUES ('user-uuid-here', 'gasto', 100, 'Test');-- Should succeed ✅INSERT INTO transacciones (usuario_id, tipo, monto, categoria)VALUES ('different-user-uuid', 'gasto', 100, 'Test');-- Should fail ❌ (policy violation)
// Example test using Jest + Supabaseimport { createClient } from '@supabase/supabase-js';describe('RLS Policies', () => { it('should only return user\'s own transactions', async () => { const supabase = createClient(url, anonKey); // Authenticate as User A await supabase.auth.signInWithPassword({ email: '[email protected]', password: 'password' }); const { data } = await supabase .from('transacciones') .select('*'); // Verify all transactions belong to User A expect(data.every(t => t.usuario_id === userA.id)).toBe(true); });});
-- ❌ Forgot to enable RLSCREATE TABLE transacciones (...);CREATE POLICY "Users can view own transactions" ON transacciones ...-- Policies exist but are not enforced!
RLS adds a WHERE clause to every query. Optimize with indexes:
-- Index on usuario_id for fast filteringCREATE INDEX idx_transacciones_usuario ON transacciones(usuario_id);-- Composite index for common queriesCREATE INDEX idx_transacciones_usuario_fecha ON transacciones(usuario_id, fecha DESC);
// RLS adds filter, but with index it's fastconst { data } = await supabase .from('transacciones') .select('*') .order('fecha', { ascending: false }) .limit(50);// PostgreSQL executes:// SELECT * FROM transacciones// WHERE usuario_id = auth.uid() -- ✅ Uses index// ORDER BY fecha DESC// LIMIT 50;
RLS overhead is minimal (typically < 1ms) when proper indexes exist.
-- Allow read-only access to transactions older than 1 yearCREATE POLICY "Users can view old transactions" ON transacciones FOR SELECT USING ( auth.uid() = usuario_id AND fecha < NOW() - INTERVAL '1 year' );
-- Admins can see all transactionsCREATE POLICY "Admins can view all transactions" ON transacciones FOR SELECT USING ( EXISTS ( SELECT 1 FROM auth.users WHERE auth.users.id = auth.uid() AND auth.users.role = 'admin' ) );
-- Users can see transactions from their organizationCREATE POLICY "Users can view team transactions" ON transacciones FOR SELECT USING ( organizacion_id IN ( SELECT organizacion_id FROM users WHERE users.id = auth.uid() ) );
const { data } = await supabase.from('transacciones').select('*');console.log(data); // []
Causes:
RLS enabled but no matching rows
Not authenticated
Data belongs to different user
Debugging:
-- Check if RLS is enabledSELECT tablename, rowsecurityFROM pg_tablesWHERE tablename = 'transacciones';-- Check existing policiesSELECT * FROM pg_policiesWHERE tablename = 'transacciones';-- Count total rows (requires service role)SELECT COUNT(*) FROM transacciones;