Navigation

Supabase v5.1 Security Patch (CORRECTED)

Final hardening - run AFTER v4 migration - with all corrections applied

✅ v5.1 Corrections Applied

✓ RLS validation FIXED: Uses pg_class.relrowsecurity instead of pg_tables

✓ current_user_is_admin(): Explicit COALESCE to always return false when no match

✓ Class enrollment: Teachers can now INSERT/UPDATE/DELETE enrollments

✓ Circle membership: Read access restricted to members/creators/admins only

✓ Function ownership: All SECURITY DEFINER functions owned by postgres

✓ Policy consistency: Teacher submission update includes WITH CHECK clause

🔐 Complete Security Hardening (v5.1)

✓ SECURITY DEFINER hardened: SET search_path to prevent SQL injection

✓ Function ownership: postgres owns all SECURITY DEFINER functions

✓ Complete enrollment control: Teachers manage full lifecycle (insert/update/delete)

✓ Restricted circle access: Only members, creators, and admins see memberships

✓ Tutor access: Tutors can read their own sessions

✓ Performance indexes: Email lookups, composite queries optimized

✓ Helper functions: get_user_role(), is_enrolled_in_class()

✓ Audit logging: Optional infrastructure for production monitoring

✓ Validation checks: Automated RLS coverage verification (FIXED)

Installation Steps

Prerequisites: v4 migration must be completed first

Step 1: Verify v4 is running (check for helper functions in Supabase)

Step 2: Copy the patch SQL below

Step 3: Run in Supabase SQL Editor

Step 4: Check validation messages in output - should see "✅ All tables have RLS enabled"

Step 5: Test with different user roles (anon, student, teacher, admin)

-- =====================================================
-- Snappy English - Supabase v5.1 Security Patch (CORRECTED)
-- Run AFTER v4 migration
-- Final hardening: SECURITY DEFINER safety, policy refinements, indexes
-- FIXES: RLS validation, enrollment policies, circle membership, ownership
-- =====================================================

-- =====================================================
-- 1. HARDEN SECURITY DEFINER FUNCTIONS
-- =====================================================

-- Recreate with SET search_path for security and explicit COALESCE
CREATE OR REPLACE FUNCTION public.current_user_email()
RETURNS text
LANGUAGE sql
SECURITY DEFINER
STABLE
SET search_path = public, pg_temp
AS $$
  SELECT u.email
  FROM public.users u
  WHERE u.id = auth.uid()
  LIMIT 1;
$$;

CREATE OR REPLACE FUNCTION public.current_user_is_admin()
RETURNS boolean
LANGUAGE sql
SECURITY DEFINER
STABLE
SET search_path = public, pg_temp
AS $$
  SELECT COALESCE((
    SELECT (u.role = 'admin')
    FROM public.users u
    WHERE u.id = auth.uid()
  ), false);
$$;

-- Set ownership to postgres for security
ALTER FUNCTION public.current_user_email() OWNER TO postgres;
ALTER FUNCTION public.current_user_is_admin() OWNER TO postgres;

-- Revoke and re-grant (idempotent)
REVOKE ALL ON FUNCTION public.current_user_email() FROM PUBLIC;
REVOKE ALL ON FUNCTION public.current_user_is_admin() FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.current_user_email() TO anon, authenticated;
GRANT EXECUTE ON FUNCTION public.current_user_is_admin() TO anon, authenticated;

-- =====================================================
-- 2. REFINE PUBLIC CONTENT POLICIES
-- =====================================================

-- Most public content already correctly scoped in v4
-- Verified: idioms (approved), grammar_topics, vocabulary, animations
-- leaderboard, crosswords, settings, content_pricing all use anon + authenticated

-- =====================================================
-- 3. ADD MISSING RLS POLICIES (CORRECTED)
-- =====================================================

-- Circle Membership - RESTRICT read to members/creators/admins
DROP POLICY IF EXISTS "circle_membership_read" ON public.circle_membership;
CREATE POLICY "circle_membership_read" ON public.circle_membership
  FOR SELECT TO authenticated
  USING (
    user_email = public.current_user_email()
    OR EXISTS (
      SELECT 1 FROM public.study_circles sc
      WHERE sc.id = circle_membership.circle_id
        AND (sc.creator_email = public.current_user_email() OR public.current_user_is_admin())
    )
    OR public.current_user_is_admin()
  );

-- Circle Membership - allow members to leave (delete their own membership)
DROP POLICY IF EXISTS "circle_membership_delete_self" ON public.circle_membership;
CREATE POLICY "circle_membership_delete_self" ON public.circle_membership
  FOR DELETE TO authenticated
  USING (user_email = public.current_user_email());

-- Class Enrollment - allow teachers to INSERT students
DROP POLICY IF EXISTS "enrollment_teacher_insert" ON public.class_enrollment;
CREATE POLICY "enrollment_teacher_insert" ON public.class_enrollment
  FOR INSERT TO authenticated
  WITH CHECK (
    EXISTS (
      SELECT 1 FROM public.classes c
      WHERE c.id = class_enrollment.class_id
        AND (c.teacher_email = public.current_user_email() OR public.current_user_is_admin())
    ) OR public.current_user_is_admin()
  );

-- Class Enrollment - allow teachers to UPDATE enrollment
DROP POLICY IF EXISTS "enrollment_teacher_update" ON public.class_enrollment;
CREATE POLICY "enrollment_teacher_update" ON public.class_enrollment
  FOR UPDATE TO authenticated
  USING (
    EXISTS (
      SELECT 1 FROM public.classes c
      WHERE c.id = class_enrollment.class_id
        AND (c.teacher_email = public.current_user_email() OR public.current_user_is_admin())
    )
  )
  WITH CHECK (
    EXISTS (
      SELECT 1 FROM public.classes c
      WHERE c.id = class_enrollment.class_id
        AND (c.teacher_email = public.current_user_email() OR public.current_user_is_admin())
    )
  );

-- Class Enrollment - allow teachers to DELETE enrollment
DROP POLICY IF EXISTS "enrollment_teacher_delete" ON public.class_enrollment;
CREATE POLICY "enrollment_teacher_delete" ON public.class_enrollment
  FOR DELETE TO authenticated
  USING (
    EXISTS (
      SELECT 1 FROM public.classes c
      WHERE c.id = class_enrollment.class_id
        AND (c.teacher_email = public.current_user_email() OR public.current_user_is_admin())
    )
  );

-- Assignment Submissions - teacher update WITH CHECK consistency (FIXED)
DROP POLICY IF EXISTS "submissions_teacher_update" ON public.assignment_submissions;
CREATE POLICY "submissions_teacher_update" ON public.assignment_submissions
  FOR UPDATE TO authenticated
  USING (
    EXISTS (
      SELECT 1 FROM public.teacher_assignments ta
      WHERE ta.id = assignment_submissions.assignment_id
        AND ta.teacher_email = public.current_user_email()
    )
  )
  WITH CHECK (
    EXISTS (
      SELECT 1 FROM public.teacher_assignments ta
      WHERE ta.id = assignment_submissions.assignment_id
        AND ta.teacher_email = public.current_user_email()
    )
  );

-- Tutoring Sessions - tutors can read their own sessions
DROP POLICY IF EXISTS "tutoring_tutor_read" ON public.tutoring_sessions;
CREATE POLICY "tutoring_tutor_read" ON public.tutoring_sessions
  FOR SELECT TO authenticated
  USING (
    EXISTS (
      SELECT 1 FROM public.tutors t
      WHERE t.id = tutoring_sessions.tutor_id
        AND t.email = public.current_user_email()
    ) OR public.current_user_is_admin()
  );

-- =====================================================
-- 4. ADDITIONAL PERFORMANCE INDEXES
-- =====================================================

-- Indexes for email-based RLS lookups (critical for performance)
CREATE INDEX IF NOT EXISTS idx_referrals_referrer_email ON public.referrals(referrer_email);
CREATE INDEX IF NOT EXISTS idx_tutoring_sessions_tutor_id ON public.tutoring_sessions(tutor_id);
CREATE INDEX IF NOT EXISTS idx_tutors_email ON public.tutors(email);
CREATE INDEX IF NOT EXISTS idx_tutors_status ON public.tutors(status);
CREATE INDEX IF NOT EXISTS idx_circle_membership_user_email ON public.circle_membership(user_email);
CREATE INDEX IF NOT EXISTS idx_vocab_pack_access_user_email ON public.vocabulary_pack_access(user_email);
CREATE INDEX IF NOT EXISTS idx_ai_session_access_user_email ON public.ai_session_access(user_email);
CREATE INDEX IF NOT EXISTS idx_toolkit_access_user_email ON public.toolkit_access(user_email);
CREATE INDEX IF NOT EXISTS idx_notebooklm_access_user_email ON public.notebooklm_access(user_email);
CREATE INDEX IF NOT EXISTS idx_educator_stories_status ON public.educator_stories(status);
CREATE INDEX IF NOT EXISTS idx_notebooklm_collections_active ON public.notebooklm_collections(is_active);
CREATE INDEX IF NOT EXISTS idx_notebooklm_curriculum_active ON public.notebooklm_curriculum(is_active);

-- Composite indexes for common query patterns
CREATE INDEX IF NOT EXISTS idx_user_progress_user_status ON public.user_progress(created_by, status);
CREATE INDEX IF NOT EXISTS idx_idioms_approved_category ON public.idioms(is_approved, category);
CREATE INDEX IF NOT EXISTS idx_class_enrollment_student_status ON public.class_enrollment(student_email, status);
CREATE INDEX IF NOT EXISTS idx_assignments_class_status ON public.teacher_assignments(class_id, status);

-- =====================================================
-- 5. ADD HELPFUL DATABASE FUNCTIONS
-- =====================================================

-- Helper: Get user's role (useful for app logic)
CREATE OR REPLACE FUNCTION public.get_user_role(user_id UUID)
RETURNS text
LANGUAGE sql
SECURITY DEFINER
STABLE
SET search_path = public, pg_temp
AS $$
  SELECT role
  FROM public.users
  WHERE id = user_id
  LIMIT 1;
$$;

ALTER FUNCTION public.get_user_role(UUID) OWNER TO postgres;
GRANT EXECUTE ON FUNCTION public.get_user_role(UUID) TO authenticated;

-- Helper: Check if user is enrolled in class
CREATE OR REPLACE FUNCTION public.is_enrolled_in_class(p_class_id UUID, p_user_email TEXT)
RETURNS boolean
LANGUAGE sql
SECURITY DEFINER
STABLE
SET search_path = public, pg_temp
AS $$
  SELECT EXISTS (
    SELECT 1
    FROM public.class_enrollment
    WHERE class_id = p_class_id
      AND student_email = p_user_email
      AND status = 'active'
  );
$$;

ALTER FUNCTION public.is_enrolled_in_class(UUID, TEXT) OWNER TO postgres;
GRANT EXECUTE ON FUNCTION public.is_enrolled_in_class(UUID, TEXT) TO authenticated;

-- =====================================================
-- 6. AUDIT TRIGGERS (OPTIONAL - for production monitoring)
-- =====================================================

-- Create audit log table (optional but recommended for production)
CREATE TABLE IF NOT EXISTS public.audit_log (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  created_date TIMESTAMPTZ DEFAULT NOW(),
  table_name TEXT NOT NULL,
  operation TEXT NOT NULL CHECK (operation IN ('INSERT', 'UPDATE', 'DELETE')),
  user_id UUID,
  user_email TEXT,
  record_id UUID,
  old_data JSONB,
  new_data JSONB,
  ip_address INET
);

CREATE INDEX IF NOT EXISTS idx_audit_log_created_date ON public.audit_log(created_date);
CREATE INDEX IF NOT EXISTS idx_audit_log_table_name ON public.audit_log(table_name);
CREATE INDEX IF NOT EXISTS idx_audit_log_user_email ON public.audit_log(user_email);

-- Audit function
CREATE OR REPLACE FUNCTION public.audit_trigger_function()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
BEGIN
  INSERT INTO public.audit_log (
    table_name,
    operation,
    user_id,
    user_email,
    record_id,
    old_data,
    new_data
  ) VALUES (
    TG_TABLE_NAME,
    TG_OP,
    auth.uid(),
    public.current_user_email(),
    COALESCE(NEW.id, OLD.id),
    CASE WHEN TG_OP = 'DELETE' THEN to_jsonb(OLD) ELSE NULL END,
    CASE WHEN TG_OP IN ('INSERT', 'UPDATE') THEN to_jsonb(NEW) ELSE NULL END
  );
  
  RETURN COALESCE(NEW, OLD);
END;
$$;

ALTER FUNCTION public.audit_trigger_function() OWNER TO postgres;

-- Apply audit triggers to sensitive tables (optional - enable as needed)
-- Example: Uncomment to enable audit on users table
-- DROP TRIGGER IF EXISTS audit_users ON public.users;
-- CREATE TRIGGER audit_users
--   AFTER INSERT OR UPDATE OR DELETE ON public.users
--   FOR EACH ROW EXECUTE FUNCTION public.audit_trigger_function();

-- =====================================================
-- 7. VALIDATION CHECKS (CORRECTED)
-- =====================================================

-- Verify all critical tables have RLS enabled (FIXED - use pg_class)
DO $$
DECLARE
  unprotected_tables TEXT[];
BEGIN
  SELECT array_agg(c.relname)
  INTO unprotected_tables
  FROM pg_class c
  JOIN pg_namespace n ON n.oid = c.relnamespace
  WHERE n.nspname = 'public'
    AND c.relkind = 'r'
    AND c.relrowsecurity = false;
  
  IF array_length(unprotected_tables, 1) > 0 THEN
    RAISE WARNING 'Tables without RLS: %', unprotected_tables;
  ELSE
    RAISE NOTICE '✅ All tables have RLS enabled';
  END IF;
END $$;

-- Verify helper functions exist
DO $$
BEGIN
  IF NOT EXISTS (SELECT 1 FROM pg_proc WHERE proname = 'current_user_email') THEN
    RAISE EXCEPTION 'Helper function current_user_email() not found';
  END IF;
  
  IF NOT EXISTS (SELECT 1 FROM pg_proc WHERE proname = 'current_user_is_admin') THEN
    RAISE EXCEPTION 'Helper function current_user_is_admin() not found';
  END IF;
  
  RAISE NOTICE '✅ Helper functions verified';
END $$;

-- =====================================================
-- COMPLETED - v5.1 Security Patch Applied
-- =====================================================
-- ✅ SECURITY DEFINER functions hardened with SET search_path
-- ✅ Helper functions owned by postgres
-- ✅ current_user_is_admin() uses explicit COALESCE
-- ✅ Missing RLS policies added (circle membership read/delete)
-- ✅ Teachers can INSERT/UPDATE/DELETE class enrollments
-- ✅ Teacher submission update WITH CHECK added
-- ✅ Tutor session read policy
-- ✅ Performance indexes for email lookups and common queries
-- ✅ Helper functions for app logic
-- ✅ Optional audit logging infrastructure
-- ✅ Validation checks FIXED (use pg_class.relrowsecurity)
-- =====================================================
-- Next: Test with non-admin/teacher/student users to verify access control
-- =====================================================
Post-Patch Testing Checklist

✅ Check SQL output - should see "✅ All tables have RLS enabled" and "✅ Helper functions verified"

✅ Test helper functions: SELECT public.current_user_email(); SELECT public.current_user_is_admin();

✅ Test as anon user - can only see public content (approved idioms, grammar, etc)

✅ Test as student - can't see other students' data, can view own progress/submissions

✅ Test as teacher - can manage own classes/enrollments, grade student submissions

✅ Test as tutor - can read own sessions, can't see other tutors' sessions

✅ Test study circles - members see membership, non-members don't

✅ Monitor query performance with EXPLAIN ANALYZE on key queries

✅ Optional: Enable audit triggers for sensitive tables (users, idioms, etc)

Optional: Enable Audit Logging

The patch includes audit logging infrastructure but doesn't enable it by default.

To enable audit logging on specific tables:

-- Enable audit on users table
CREATE TRIGGER audit_users
  AFTER INSERT OR UPDATE OR DELETE ON public.users
  FOR EACH ROW EXECUTE FUNCTION public.audit_trigger_function();

-- Enable audit on other sensitive tables
CREATE TRIGGER audit_idioms
  AFTER INSERT OR UPDATE OR DELETE ON public.idioms
  FOR EACH ROW EXECUTE FUNCTION public.audit_trigger_function();
  
-- Enable audit on class enrollment
CREATE TRIGGER audit_class_enrollment
  AFTER INSERT OR UPDATE OR DELETE ON public.class_enrollment
  FOR EACH ROW EXECUTE FUNCTION public.audit_trigger_function();

⚠️ Audit logging adds ~5-10% overhead per operation

Critical Security Notes

🔒 SECURITY DEFINER functions run as owner: All functions now owned by postgres - do not change ownership

🔒 Circle membership read is restricted: Only members/creators/admins see memberships (was public before)

🔒 Teachers have full enrollment control: Can add, update, and remove students from their classes

🔒 Tutors see only own sessions: Can't access other tutors' bookings or student details

🔒 Test with real users: Don't rely on admin testing alone - verify with actual student/teacher accounts