-- Create a secure function to get filtered profile data -- This prevents direct SQL queries from bypassing field-level privacy controls CREATE OR REPLACE FUNCTION public.get_filtered_profile(_profile_user_id uuid, _viewer_id uuid DEFAULT NULL) RETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = public AS $$ DECLARE profile_data jsonb; profile_privacy text; is_banned boolean; viewer_is_mod boolean; filtered_profile jsonb; BEGIN -- Check if profile exists and get basic info SELECT jsonb_build_object( 'id', p.id, 'user_id', p.user_id, 'username', p.username, 'display_name', p.display_name, 'bio', p.bio, 'avatar_url', p.avatar_url, 'avatar_image_id', p.avatar_image_id, 'preferred_pronouns', p.preferred_pronouns, 'show_pronouns', p.show_pronouns, 'timezone', p.timezone, 'preferred_language', p.preferred_language, 'location_id', p.location_id, 'personal_location', p.personal_location, 'home_park_id', p.home_park_id, 'date_of_birth', p.date_of_birth, 'privacy_level', p.privacy_level, 'theme_preference', p.theme_preference, 'ride_count', p.ride_count, 'coaster_count', p.coaster_count, 'park_count', p.park_count, 'review_count', p.review_count, 'reputation_score', p.reputation_score, 'created_at', p.created_at, 'updated_at', p.updated_at ), p.privacy_level, p.banned INTO profile_data, profile_privacy, is_banned FROM public.profiles p WHERE p.user_id = _profile_user_id; -- Return null if profile doesn't exist or is banned (unless viewer is moderator) IF profile_data IS NULL THEN RETURN NULL; END IF; -- Check if viewer is a moderator viewer_is_mod := is_moderator(_viewer_id); -- If banned and viewer is not a moderator, return null IF is_banned AND NOT viewer_is_mod THEN RETURN NULL; END IF; -- If viewer is the profile owner or a moderator, return all fields IF _viewer_id = _profile_user_id OR viewer_is_mod THEN RETURN profile_data; END IF; -- Start with safe public fields filtered_profile := jsonb_build_object( 'id', profile_data->>'id', 'user_id', profile_data->>'user_id', 'username', profile_data->>'username', 'display_name', profile_data->>'display_name', 'privacy_level', profile_data->>'privacy_level', 'created_at', profile_data->>'created_at', 'updated_at', profile_data->>'updated_at' ); -- If profile is private, only return basic fields IF profile_privacy = 'private' THEN RETURN filtered_profile; END IF; -- For public profiles, check each sensitive field individually IF can_view_profile_field(_viewer_id, _profile_user_id, 'bio') THEN filtered_profile := filtered_profile || jsonb_build_object('bio', profile_data->>'bio'); END IF; IF can_view_profile_field(_viewer_id, _profile_user_id, 'avatar_url') THEN filtered_profile := filtered_profile || jsonb_build_object('avatar_url', profile_data->>'avatar_url'); filtered_profile := filtered_profile || jsonb_build_object('avatar_image_id', profile_data->>'avatar_image_id'); END IF; IF can_view_profile_field(_viewer_id, _profile_user_id, 'preferred_pronouns') THEN filtered_profile := filtered_profile || jsonb_build_object('preferred_pronouns', profile_data->>'preferred_pronouns'); filtered_profile := filtered_profile || jsonb_build_object('show_pronouns', profile_data->>'show_pronouns'); END IF; IF can_view_profile_field(_viewer_id, _profile_user_id, 'personal_location') THEN filtered_profile := filtered_profile || jsonb_build_object('personal_location', profile_data->>'personal_location'); END IF; IF can_view_profile_field(_viewer_id, _profile_user_id, 'location_id') THEN filtered_profile := filtered_profile || jsonb_build_object('location_id', profile_data->>'location_id'); END IF; IF can_view_profile_field(_viewer_id, _profile_user_id, 'home_park_id') THEN filtered_profile := filtered_profile || jsonb_build_object('home_park_id', profile_data->>'home_park_id'); END IF; IF can_view_profile_field(_viewer_id, _profile_user_id, 'date_of_birth') THEN filtered_profile := filtered_profile || jsonb_build_object('date_of_birth', profile_data->>'date_of_birth'); END IF; IF can_view_profile_field(_viewer_id, _profile_user_id, 'ride_count') THEN filtered_profile := filtered_profile || jsonb_build_object( 'ride_count', profile_data->>'ride_count', 'coaster_count', profile_data->>'coaster_count', 'park_count', profile_data->>'park_count', 'review_count', profile_data->>'review_count', 'reputation_score', profile_data->>'reputation_score' ); END IF; -- Always include safe metadata fields filtered_profile := filtered_profile || jsonb_build_object( 'timezone', profile_data->>'timezone', 'preferred_language', profile_data->>'preferred_language', 'theme_preference', profile_data->>'theme_preference' ); RETURN filtered_profile; END; $$; -- Tighten the public profile access policy to prevent field enumeration DROP POLICY IF EXISTS "Public access to non-private profiles with field restrictions" ON public.profiles; CREATE POLICY "Public can view basic profile info only" ON public.profiles FOR SELECT TO public USING ( (privacy_level = 'public'::text AND NOT banned) AND -- This policy only allows viewing basic safe fields -- For full field access, use the get_filtered_profile function TRUE ); -- Add a comment to remind developers to use the secure function COMMENT ON POLICY "Public can view basic profile info only" ON public.profiles IS 'This policy allows row access to public profiles. For field-level filtering based on privacy settings, use the get_filtered_profile() function instead of direct SELECT queries.';