From fe76c8c5728c3bbb5bd26d44e1567879212b39cd Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Thu, 30 Oct 2025 01:47:51 +0000 Subject: [PATCH] feat: Implement comprehensive ban enforcement --- src/hooks/useAuth.tsx | 13 ++ src/hooks/useBanCheck.ts | 82 +++++++++ src/integrations/supabase/types.ts | 1 + src/lib/companyHelpers.ts | 22 +++ src/lib/entitySubmissionHelpers.ts | 66 +++++++ supabase/functions/_shared/banCheck.ts | 47 +++++ .../process-selective-approval/index.ts | 54 ++++++ ...1_907579ae-3007-45db-8270-bc2910e53762.sql | 167 ++++++++++++++++++ 8 files changed, 452 insertions(+) create mode 100644 src/hooks/useBanCheck.ts create mode 100644 supabase/functions/_shared/banCheck.ts create mode 100644 supabase/migrations/20251030014451_907579ae-3007-45db-8270-bc2910e53762.sql diff --git a/src/hooks/useAuth.tsx b/src/hooks/useAuth.tsx index b68075e1..49a113cc 100644 --- a/src/hooks/useAuth.tsx +++ b/src/hooks/useAuth.tsx @@ -110,6 +110,19 @@ function AuthProviderComponent({ children }: { children: React.ReactNode }) { const currentAal = await getSessionAal(session); setAal(currentAal); authLog('[Auth] Current AAL:', currentAal); + + // Check if user is banned + const { data: profile } = await supabase + .from('profiles') + .select('banned') + .eq('user_id', session.user.id) + .maybeSingle(); + + if (profile?.banned) { + authWarn('[Auth] Banned user detected, signing out'); + await supabase.auth.signOut(); + return; + } } else { setAal(null); } diff --git a/src/hooks/useBanCheck.ts b/src/hooks/useBanCheck.ts new file mode 100644 index 00000000..7cc90887 --- /dev/null +++ b/src/hooks/useBanCheck.ts @@ -0,0 +1,82 @@ +import { useEffect, useState } from 'react'; +import { useAuth } from '@/hooks/useAuth'; +import { supabase } from '@/integrations/supabase/client'; +import { useNavigate } from 'react-router-dom'; +import { toast } from '@/hooks/use-toast'; + +export function useBanCheck() { + const { user } = useAuth(); + const navigate = useNavigate(); + const [isBanned, setIsBanned] = useState(false); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!user) { + setIsBanned(false); + setLoading(false); + return; + } + + const checkBan = async () => { + try { + const { data: profile } = await supabase + .from('profiles') + .select('banned') + .eq('user_id', user.id) + .single(); + + if (profile?.banned) { + setIsBanned(true); + toast({ + title: 'Account Suspended', + description: 'Your account has been suspended. Contact support for assistance.', + variant: 'destructive', + duration: Infinity // Don't auto-dismiss + }); + // Sign out banned user + await supabase.auth.signOut(); + navigate('/'); + } + } catch (error) { + console.error('Ban check error:', error); + } finally { + setLoading(false); + } + }; + + checkBan(); + + // Subscribe to profile changes (real-time ban detection) + const channel = supabase + .channel('ban-check') + .on( + 'postgres_changes', + { + event: 'UPDATE', + schema: 'public', + table: 'profiles', + filter: `user_id=eq.${user.id}` + }, + (payload) => { + if (payload.new && (payload.new as { banned: boolean }).banned) { + setIsBanned(true); + toast({ + title: 'Account Suspended', + description: 'Your account has been suspended. Contact support for assistance.', + variant: 'destructive', + duration: Infinity + }); + supabase.auth.signOut(); + navigate('/'); + } + } + ) + .subscribe(); + + return () => { + supabase.removeChannel(channel); + }; + }, [user, navigate]); + + return { isBanned, loading }; +} diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index a474f3ba..20a9cc88 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -4099,6 +4099,7 @@ export type Database = { } is_moderator: { Args: { _user_id: string }; Returns: boolean } is_superuser: { Args: { _user_id: string }; Returns: boolean } + is_user_banned: { Args: { _user_id: string }; Returns: boolean } log_admin_action: { Args: { _action: string diff --git a/src/lib/companyHelpers.ts b/src/lib/companyHelpers.ts index 425c5165..d1154bfb 100644 --- a/src/lib/companyHelpers.ts +++ b/src/lib/companyHelpers.ts @@ -12,6 +12,17 @@ export async function submitCompanyCreation( companyType: 'manufacturer' | 'designer' | 'operator' | 'property_owner', userId: string ) { + // Check if user is banned + const { data: profile } = await supabase + .from('profiles') + .select('banned') + .eq('user_id', userId) + .single(); + + if (profile?.banned) { + throw new Error('Account suspended. Contact support for assistance.'); + } + // Upload any pending local images first let processedImages = data.images; if (data.images?.uploaded && data.images.uploaded.length > 0) { @@ -78,6 +89,17 @@ export async function submitCompanyUpdate( data: CompanyFormData, userId: string ) { + // Check if user is banned + const { data: profile } = await supabase + .from('profiles') + .select('banned') + .eq('user_id', userId) + .single(); + + if (profile?.banned) { + throw new Error('Account suspended. Contact support for assistance.'); + } + // Fetch existing company data (all fields for original_data) const { data: existingCompany, error: fetchError } = await supabase .from('companies') diff --git a/src/lib/entitySubmissionHelpers.ts b/src/lib/entitySubmissionHelpers.ts index 761b63ea..6ce2888e 100644 --- a/src/lib/entitySubmissionHelpers.ts +++ b/src/lib/entitySubmissionHelpers.ts @@ -177,6 +177,17 @@ export async function submitParkCreation( data: ParkFormData, userId: string ): Promise<{ submitted: boolean; submissionId: string }> { + // Check if user is banned + const { data: profile } = await supabase + .from('profiles') + .select('banned') + .eq('user_id', userId) + .single(); + + if (profile?.banned) { + throw new Error('Account suspended. Contact support for assistance.'); + } + // Upload any pending local images first let processedImages = data.images; if (data.images?.uploaded && data.images.uploaded.length > 0) { @@ -257,6 +268,17 @@ export async function submitParkUpdate( data: ParkFormData, userId: string ): Promise<{ submitted: boolean; submissionId: string }> { + // Check if user is banned + const { data: profile } = await supabase + .from('profiles') + .select('banned') + .eq('user_id', userId) + .single(); + + if (profile?.banned) { + throw new Error('Account suspended. Contact support for assistance.'); + } + // Fetch existing park data first const { data: existingPark, error: fetchError } = await supabase .from('parks') @@ -349,6 +371,17 @@ export async function submitRideCreation( data: RideFormData, userId: string ): Promise<{ submitted: boolean; submissionId: string }> { + // Check if user is banned + const { data: profile } = await supabase + .from('profiles') + .select('banned') + .eq('user_id', userId) + .single(); + + if (profile?.banned) { + throw new Error('Account suspended. Contact support for assistance.'); + } + // Upload any pending local images first let processedImages = data.images; if (data.images?.uploaded && data.images.uploaded.length > 0) { @@ -429,6 +462,17 @@ export async function submitRideUpdate( data: RideFormData, userId: string ): Promise<{ submitted: boolean; submissionId: string }> { + // Check if user is banned + const { data: profile } = await supabase + .from('profiles') + .select('banned') + .eq('user_id', userId) + .single(); + + if (profile?.banned) { + throw new Error('Account suspended. Contact support for assistance.'); + } + // Fetch existing ride data first const { data: existingRide, error: fetchError } = await supabase .from('rides') @@ -518,6 +562,17 @@ export async function submitRideModelCreation( data: RideModelFormData, userId: string ): Promise<{ submitted: boolean; submissionId: string }> { + // Check if user is banned + const { data: profile } = await supabase + .from('profiles') + .select('banned') + .eq('user_id', userId) + .single(); + + if (profile?.banned) { + throw new Error('Account suspended. Contact support for assistance.'); + } + // Upload any pending local images first let processedImages = data.images; if (data.images?.uploaded && data.images.uploaded.length > 0) { @@ -584,6 +639,17 @@ export async function submitRideModelUpdate( data: RideModelFormData, userId: string ): Promise<{ submitted: boolean; submissionId: string }> { + // Check if user is banned + const { data: profile } = await supabase + .from('profiles') + .select('banned') + .eq('user_id', userId) + .single(); + + if (profile?.banned) { + throw new Error('Account suspended. Contact support for assistance.'); + } + // Fetch existing ride model const { data: existingModel, error: fetchError } = await supabase .from('ride_models') diff --git a/supabase/functions/_shared/banCheck.ts b/supabase/functions/_shared/banCheck.ts new file mode 100644 index 00000000..e707b63d --- /dev/null +++ b/supabase/functions/_shared/banCheck.ts @@ -0,0 +1,47 @@ +import { createClient, SupabaseClient } from "https://esm.sh/@supabase/supabase-js@2.57.4"; +import { edgeLogger } from "./logger.ts"; + +export async function checkUserBanned( + userId: string, + supabase: SupabaseClient +): Promise<{ banned: boolean; error?: string }> { + try { + const { data: profile, error } = await supabase + .from('profiles') + .select('banned') + .eq('user_id', userId) + .single(); + + if (error) { + edgeLogger.error('Ban check failed', { userId, error: error.message }); + return { banned: false, error: 'Unable to verify account status' }; + } + + if (!profile) { + return { banned: false, error: 'Profile not found' }; + } + + return { banned: profile.banned }; + } catch (error) { + edgeLogger.error('Ban check exception', { userId, error }); + return { banned: false, error: 'Internal error checking account status' }; + } +} + +export function createBannedResponse(requestId: string, corsHeaders: Record) { + return new Response( + JSON.stringify({ + error: 'Account suspended', + message: 'Your account has been suspended. Contact support for assistance.', + requestId + }), + { + status: 403, + headers: { + ...corsHeaders, + 'Content-Type': 'application/json', + 'X-Request-ID': requestId + } + } + ); +} diff --git a/supabase/functions/process-selective-approval/index.ts b/supabase/functions/process-selective-approval/index.ts index a886ffdd..452a6d16 100644 --- a/supabase/functions/process-selective-approval/index.ts +++ b/supabase/functions/process-selective-approval/index.ts @@ -116,6 +116,60 @@ serve(async (req) => { edgeLogger.info('Authentication successful', { action: 'approval_auth_success', userId: user.id }); + // Check if user is banned + const { data: profile, error: profileError } = await supabaseAuth + .from('profiles') + .select('banned') + .eq('user_id', user.id) + .single(); + + if (profileError || !profile) { + edgeLogger.error('Profile check failed', { + action: 'approval_profile_check', + error: profileError?.message, + requestId: tracking.requestId + }); + const duration = endRequest(tracking); + return new Response( + JSON.stringify({ + error: 'Unable to verify user profile', + requestId: tracking.requestId + }), + { + status: 403, + headers: { + ...corsHeaders, + 'Content-Type': 'application/json', + 'X-Request-ID': tracking.requestId + } + } + ); + } + + if (profile.banned) { + edgeLogger.warn('Banned user attempted approval', { + action: 'approval_banned_user', + userId: user.id, + requestId: tracking.requestId + }); + const duration = endRequest(tracking); + return new Response( + JSON.stringify({ + error: 'Account suspended', + message: 'Your account has been suspended. Contact support for assistance.', + requestId: tracking.requestId + }), + { + status: 403, + headers: { + ...corsHeaders, + 'Content-Type': 'application/json', + 'X-Request-ID': tracking.requestId + } + } + ); + } + // SECURITY NOTE: Service role key used later in this function // Reason: Need to bypass RLS to write approved changes to entity tables // (parks, rides, companies, ride_models) which have RLS policies diff --git a/supabase/migrations/20251030014451_907579ae-3007-45db-8270-bc2910e53762.sql b/supabase/migrations/20251030014451_907579ae-3007-45db-8270-bc2910e53762.sql new file mode 100644 index 00000000..8401cbf9 --- /dev/null +++ b/supabase/migrations/20251030014451_907579ae-3007-45db-8270-bc2910e53762.sql @@ -0,0 +1,167 @@ +-- Phase 1 & 4: Comprehensive Ban Enforcement (Corrected) +-- Create security definer function to check if user is banned +CREATE OR REPLACE FUNCTION public.is_user_banned(_user_id uuid) +RETURNS boolean +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path = public +AS $$ + SELECT COALESCE( + (SELECT banned FROM public.profiles WHERE user_id = _user_id), + false + ) +$$; + +-- Update RLS policies for content_submissions +DROP POLICY IF EXISTS "Users can create submissions" ON content_submissions; +CREATE POLICY "Users can create submissions" +ON content_submissions +FOR INSERT +TO authenticated +WITH CHECK ( + (auth.uid() = user_id) + AND NOT is_user_banned(auth.uid()) +); + +-- Update RLS policies for park_submissions +DROP POLICY IF EXISTS "Users can insert their own park submissions" ON park_submissions; +CREATE POLICY "Users can insert their own park submissions" +ON park_submissions +FOR INSERT +TO authenticated +WITH CHECK ( + EXISTS (SELECT 1 FROM content_submissions cs WHERE cs.id = park_submissions.submission_id AND cs.user_id = auth.uid()) + AND NOT is_user_banned(auth.uid()) +); + +-- Update RLS policies for ride_submissions +DROP POLICY IF EXISTS "Users can insert their own ride submissions" ON ride_submissions; +CREATE POLICY "Users can insert their own ride submissions" +ON ride_submissions +FOR INSERT +TO authenticated +WITH CHECK ( + EXISTS (SELECT 1 FROM content_submissions cs WHERE cs.id = ride_submissions.submission_id AND cs.user_id = auth.uid()) + AND NOT is_user_banned(auth.uid()) +); + +-- Update RLS policies for company_submissions +DROP POLICY IF EXISTS "Users can insert their own company submissions" ON company_submissions; +CREATE POLICY "Users can insert their own company submissions" +ON company_submissions +FOR INSERT +TO authenticated +WITH CHECK ( + EXISTS (SELECT 1 FROM content_submissions cs WHERE cs.id = company_submissions.submission_id AND cs.user_id = auth.uid()) + AND NOT is_user_banned(auth.uid()) +); + +-- Update RLS policies for ride_model_submissions +DROP POLICY IF EXISTS "Users can insert their own ride model submissions" ON ride_model_submissions; +CREATE POLICY "Users can insert their own ride model submissions" +ON ride_model_submissions +FOR INSERT +TO authenticated +WITH CHECK ( + EXISTS (SELECT 1 FROM content_submissions cs WHERE cs.id = ride_model_submissions.submission_id AND cs.user_id = auth.uid()) + AND NOT is_user_banned(auth.uid()) +); + +-- Update RLS policies for photo_submissions +DROP POLICY IF EXISTS "Users can insert their own photo submissions" ON photo_submissions; +CREATE POLICY "Users can insert their own photo submissions" +ON photo_submissions +FOR INSERT +TO authenticated +WITH CHECK ( + EXISTS (SELECT 1 FROM content_submissions cs WHERE cs.id = photo_submissions.submission_id AND cs.user_id = auth.uid()) + AND NOT is_user_banned(auth.uid()) +); + +-- Update RLS policies for submission_items +DROP POLICY IF EXISTS "Users can insert their own submission items" ON submission_items; +CREATE POLICY "Users can insert their own submission items" +ON submission_items +FOR INSERT +TO authenticated +WITH CHECK ( + EXISTS (SELECT 1 FROM content_submissions cs WHERE cs.id = submission_items.submission_id AND cs.user_id = auth.uid()) + AND NOT is_user_banned(auth.uid()) +); + +-- Update RLS policies for reviews (using user_id) +DROP POLICY IF EXISTS "Users can create reviews" ON reviews; +CREATE POLICY "Users can create reviews" +ON reviews +FOR INSERT +TO authenticated +WITH CHECK ( + user_id = auth.uid() + AND NOT is_user_banned(auth.uid()) +); + +-- Update RLS policies for photos (using submitted_by) +DROP POLICY IF EXISTS "Users can insert their own photos" ON photos; +CREATE POLICY "Users can insert their own photos" +ON photos +FOR INSERT +TO authenticated +WITH CHECK ( + submitted_by = auth.uid() + AND NOT is_user_banned(auth.uid()) +); + +-- Update RLS policies for user_blocks +DROP POLICY IF EXISTS "Users can block other users" ON user_blocks; +CREATE POLICY "Users can block other users" +ON user_blocks +FOR INSERT +TO authenticated +WITH CHECK ( + blocker_id = auth.uid() + AND NOT is_user_banned(auth.uid()) +); + +-- Update RLS policies for contact_submissions +DROP POLICY IF EXISTS "Anyone can submit contact form" ON contact_submissions; +CREATE POLICY "Anyone can submit contact form" +ON contact_submissions +FOR INSERT +TO authenticated +WITH CHECK ( + (user_id = auth.uid() OR user_id IS NULL) + AND NOT is_user_banned(auth.uid()) +); + +-- Phase 4: Create trigger to invalidate sessions when user is banned +CREATE OR REPLACE FUNCTION public.invalidate_banned_user_sessions() +RETURNS TRIGGER +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +BEGIN + -- If user was just banned (banned changed from false to true) + IF NEW.banned = true AND OLD.banned = false THEN + -- Notify application layer about the ban + PERFORM pg_notify( + 'user_banned', + json_build_object( + 'user_id', NEW.user_id, + 'username', NEW.username, + 'banned_at', NOW() + )::text + ); + END IF; + + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS enforce_ban_on_profile_update ON public.profiles; +CREATE TRIGGER enforce_ban_on_profile_update + AFTER UPDATE ON public.profiles + FOR EACH ROW + WHEN (NEW.banned IS DISTINCT FROM OLD.banned) + EXECUTE FUNCTION invalidate_banned_user_sessions(); \ No newline at end of file