mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 11:31:11 -05:00
Fix: Implement field-level privacy for profiles
This commit is contained in:
@@ -1450,6 +1450,13 @@ export type Database = {
|
|||||||
referencedRelation: "rides"
|
referencedRelation: "rides"
|
||||||
referencedColumns: ["id"]
|
referencedColumns: ["id"]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "reviews_user_id_fkey"
|
||||||
|
columns: ["user_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "filtered_profiles"
|
||||||
|
referencedColumns: ["user_id"]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
foreignKeyName: "reviews_user_id_fkey"
|
foreignKeyName: "reviews_user_id_fkey"
|
||||||
columns: ["user_id"]
|
columns: ["user_id"]
|
||||||
@@ -2534,6 +2541,90 @@ export type Database = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Views: {
|
Views: {
|
||||||
|
filtered_profiles: {
|
||||||
|
Row: {
|
||||||
|
avatar_image_id: string | null
|
||||||
|
avatar_url: string | null
|
||||||
|
banned: boolean | null
|
||||||
|
bio: string | null
|
||||||
|
coaster_count: number | null
|
||||||
|
created_at: string | null
|
||||||
|
date_of_birth: string | null
|
||||||
|
display_name: string | null
|
||||||
|
home_park_id: string | null
|
||||||
|
id: string | null
|
||||||
|
location_id: string | null
|
||||||
|
park_count: number | null
|
||||||
|
personal_location: string | null
|
||||||
|
preferred_language: string | null
|
||||||
|
preferred_pronouns: string | null
|
||||||
|
privacy_level: string | null
|
||||||
|
reputation_score: number | null
|
||||||
|
review_count: number | null
|
||||||
|
ride_count: number | null
|
||||||
|
show_pronouns: boolean | null
|
||||||
|
theme_preference: string | null
|
||||||
|
timezone: string | null
|
||||||
|
updated_at: string | null
|
||||||
|
user_id: string | null
|
||||||
|
username: string | null
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
avatar_image_id?: never
|
||||||
|
avatar_url?: never
|
||||||
|
banned?: never
|
||||||
|
bio?: never
|
||||||
|
coaster_count?: never
|
||||||
|
created_at?: string | null
|
||||||
|
date_of_birth?: never
|
||||||
|
display_name?: string | null
|
||||||
|
home_park_id?: never
|
||||||
|
id?: string | null
|
||||||
|
location_id?: never
|
||||||
|
park_count?: never
|
||||||
|
personal_location?: never
|
||||||
|
preferred_language?: string | null
|
||||||
|
preferred_pronouns?: never
|
||||||
|
privacy_level?: string | null
|
||||||
|
reputation_score?: never
|
||||||
|
review_count?: never
|
||||||
|
ride_count?: never
|
||||||
|
show_pronouns?: never
|
||||||
|
theme_preference?: string | null
|
||||||
|
timezone?: string | null
|
||||||
|
updated_at?: string | null
|
||||||
|
user_id?: string | null
|
||||||
|
username?: string | null
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
avatar_image_id?: never
|
||||||
|
avatar_url?: never
|
||||||
|
banned?: never
|
||||||
|
bio?: never
|
||||||
|
coaster_count?: never
|
||||||
|
created_at?: string | null
|
||||||
|
date_of_birth?: never
|
||||||
|
display_name?: string | null
|
||||||
|
home_park_id?: never
|
||||||
|
id?: string | null
|
||||||
|
location_id?: never
|
||||||
|
park_count?: never
|
||||||
|
personal_location?: never
|
||||||
|
preferred_language?: string | null
|
||||||
|
preferred_pronouns?: never
|
||||||
|
privacy_level?: string | null
|
||||||
|
reputation_score?: never
|
||||||
|
review_count?: never
|
||||||
|
ride_count?: never
|
||||||
|
show_pronouns?: never
|
||||||
|
theme_preference?: string | null
|
||||||
|
timezone?: string | null
|
||||||
|
updated_at?: string | null
|
||||||
|
user_id?: string | null
|
||||||
|
username?: string | null
|
||||||
|
}
|
||||||
|
Relationships: []
|
||||||
|
}
|
||||||
moderation_sla_metrics: {
|
moderation_sla_metrics: {
|
||||||
Row: {
|
Row: {
|
||||||
avg_resolution_hours: number | null
|
avg_resolution_hours: number | null
|
||||||
|
|||||||
@@ -234,16 +234,34 @@ export default function Profile() {
|
|||||||
};
|
};
|
||||||
const fetchProfile = async (profileUsername: string) => {
|
const fetchProfile = async (profileUsername: string) => {
|
||||||
try {
|
try {
|
||||||
|
// Use filtered_profiles view for privacy-respecting queries
|
||||||
|
// This view enforces field-level privacy based on user settings
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('profiles')
|
.from('filtered_profiles')
|
||||||
.select(`*, location:locations(*)`)
|
.select(`*`)
|
||||||
.eq('username', profileUsername)
|
.eq('username', profileUsername)
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
if (data) {
|
if (data) {
|
||||||
setProfile(data as ProfileType);
|
// Fetch location separately if location_id is visible
|
||||||
|
let locationData = null;
|
||||||
|
if (data.location_id) {
|
||||||
|
const { data: location } = await supabase
|
||||||
|
.from('locations')
|
||||||
|
.select('*')
|
||||||
|
.eq('id', data.location_id)
|
||||||
|
.single();
|
||||||
|
locationData = location;
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileWithLocation = {
|
||||||
|
...data,
|
||||||
|
location: locationData
|
||||||
|
};
|
||||||
|
|
||||||
|
setProfile(profileWithLocation as ProfileType);
|
||||||
setEditForm({
|
setEditForm({
|
||||||
username: data.username || '',
|
username: data.username || '',
|
||||||
display_name: data.display_name || '',
|
display_name: data.display_name || '',
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
-- Fix: User Personal Information Could Be Stolen by Anyone
|
||||||
|
-- Create a secure view that enforces field-level privacy using get_filtered_profile function
|
||||||
|
|
||||||
|
-- Step 1: Create a secure view that uses get_filtered_profile for field-level privacy
|
||||||
|
CREATE OR REPLACE VIEW public.filtered_profiles
|
||||||
|
WITH (security_invoker = true)
|
||||||
|
AS
|
||||||
|
SELECT
|
||||||
|
p.id,
|
||||||
|
p.user_id,
|
||||||
|
p.username,
|
||||||
|
p.display_name,
|
||||||
|
p.privacy_level,
|
||||||
|
p.created_at,
|
||||||
|
p.updated_at,
|
||||||
|
-- Conditionally include sensitive fields based on privacy settings
|
||||||
|
CASE WHEN can_view_profile_field(
|
||||||
|
COALESCE(auth.uid(), '00000000-0000-0000-0000-000000000000'::uuid),
|
||||||
|
p.user_id,
|
||||||
|
'bio'
|
||||||
|
) THEN p.bio ELSE NULL END as bio,
|
||||||
|
|
||||||
|
CASE WHEN can_view_profile_field(
|
||||||
|
COALESCE(auth.uid(), '00000000-0000-0000-0000-000000000000'::uuid),
|
||||||
|
p.user_id,
|
||||||
|
'avatar_url'
|
||||||
|
) THEN p.avatar_url ELSE NULL END as avatar_url,
|
||||||
|
|
||||||
|
CASE WHEN can_view_profile_field(
|
||||||
|
COALESCE(auth.uid(), '00000000-0000-0000-0000-000000000000'::uuid),
|
||||||
|
p.user_id,
|
||||||
|
'avatar_image_id'
|
||||||
|
) THEN p.avatar_image_id ELSE NULL END as avatar_image_id,
|
||||||
|
|
||||||
|
CASE WHEN can_view_profile_field(
|
||||||
|
COALESCE(auth.uid(), '00000000-0000-0000-0000-000000000000'::uuid),
|
||||||
|
p.user_id,
|
||||||
|
'preferred_pronouns'
|
||||||
|
) THEN p.preferred_pronouns ELSE NULL END as preferred_pronouns,
|
||||||
|
|
||||||
|
CASE WHEN can_view_profile_field(
|
||||||
|
COALESCE(auth.uid(), '00000000-0000-0000-0000-000000000000'::uuid),
|
||||||
|
p.user_id,
|
||||||
|
'preferred_pronouns'
|
||||||
|
) THEN p.show_pronouns ELSE false END as show_pronouns,
|
||||||
|
|
||||||
|
CASE WHEN can_view_profile_field(
|
||||||
|
COALESCE(auth.uid(), '00000000-0000-0000-0000-000000000000'::uuid),
|
||||||
|
p.user_id,
|
||||||
|
'personal_location'
|
||||||
|
) THEN p.personal_location ELSE NULL END as personal_location,
|
||||||
|
|
||||||
|
CASE WHEN can_view_profile_field(
|
||||||
|
COALESCE(auth.uid(), '00000000-0000-0000-0000-000000000000'::uuid),
|
||||||
|
p.user_id,
|
||||||
|
'location_id'
|
||||||
|
) THEN p.location_id ELSE NULL END as location_id,
|
||||||
|
|
||||||
|
CASE WHEN can_view_profile_field(
|
||||||
|
COALESCE(auth.uid(), '00000000-0000-0000-0000-000000000000'::uuid),
|
||||||
|
p.user_id,
|
||||||
|
'home_park_id'
|
||||||
|
) THEN p.home_park_id ELSE NULL END as home_park_id,
|
||||||
|
|
||||||
|
CASE WHEN can_view_profile_field(
|
||||||
|
COALESCE(auth.uid(), '00000000-0000-0000-0000-000000000000'::uuid),
|
||||||
|
p.user_id,
|
||||||
|
'date_of_birth'
|
||||||
|
) THEN p.date_of_birth ELSE NULL END as date_of_birth,
|
||||||
|
|
||||||
|
-- Always include safe metadata fields
|
||||||
|
p.timezone,
|
||||||
|
p.preferred_language,
|
||||||
|
p.theme_preference,
|
||||||
|
|
||||||
|
-- Include activity stats based on privacy settings
|
||||||
|
CASE WHEN can_view_profile_field(
|
||||||
|
COALESCE(auth.uid(), '00000000-0000-0000-0000-000000000000'::uuid),
|
||||||
|
p.user_id,
|
||||||
|
'ride_count'
|
||||||
|
) THEN p.ride_count ELSE 0 END as ride_count,
|
||||||
|
|
||||||
|
CASE WHEN can_view_profile_field(
|
||||||
|
COALESCE(auth.uid(), '00000000-0000-0000-0000-000000000000'::uuid),
|
||||||
|
p.user_id,
|
||||||
|
'ride_count'
|
||||||
|
) THEN p.coaster_count ELSE 0 END as coaster_count,
|
||||||
|
|
||||||
|
CASE WHEN can_view_profile_field(
|
||||||
|
COALESCE(auth.uid(), '00000000-0000-0000-0000-000000000000'::uuid),
|
||||||
|
p.user_id,
|
||||||
|
'ride_count'
|
||||||
|
) THEN p.park_count ELSE 0 END as park_count,
|
||||||
|
|
||||||
|
CASE WHEN can_view_profile_field(
|
||||||
|
COALESCE(auth.uid(), '00000000-0000-0000-0000-000000000000'::uuid),
|
||||||
|
p.user_id,
|
||||||
|
'ride_count'
|
||||||
|
) THEN p.review_count ELSE 0 END as review_count,
|
||||||
|
|
||||||
|
CASE WHEN can_view_profile_field(
|
||||||
|
COALESCE(auth.uid(), '00000000-0000-0000-0000-000000000000'::uuid),
|
||||||
|
p.user_id,
|
||||||
|
'ride_count'
|
||||||
|
) THEN p.reputation_score ELSE 0 END as reputation_score,
|
||||||
|
|
||||||
|
-- Never expose banned status except to moderators and the user themselves
|
||||||
|
CASE WHEN (auth.uid() = p.user_id OR is_moderator(COALESCE(auth.uid(), '00000000-0000-0000-0000-000000000000'::uuid)))
|
||||||
|
THEN p.banned ELSE false END as banned
|
||||||
|
FROM public.profiles p
|
||||||
|
WHERE
|
||||||
|
-- Only show non-banned profiles to non-moderators
|
||||||
|
(NOT p.banned OR auth.uid() = p.user_id OR is_moderator(COALESCE(auth.uid(), '00000000-0000-0000-0000-000000000000'::uuid)))
|
||||||
|
AND
|
||||||
|
-- Only show public or own profiles
|
||||||
|
(p.privacy_level = 'public' OR auth.uid() = p.user_id OR is_moderator(COALESCE(auth.uid(), '00000000-0000-0000-0000-000000000000'::uuid)));
|
||||||
|
|
||||||
|
-- Add comment explaining the view
|
||||||
|
COMMENT ON VIEW public.filtered_profiles IS
|
||||||
|
'Secure view that enforces field-level privacy for profile data. Uses can_view_profile_field function to filter sensitive fields based on user privacy settings. This view should be used for all profile queries where the viewer may not have full access rights.';
|
||||||
|
|
||||||
|
-- Grant access to the view
|
||||||
|
GRANT SELECT ON public.filtered_profiles TO authenticated;
|
||||||
|
GRANT SELECT ON public.filtered_profiles TO anon;
|
||||||
|
GRANT SELECT ON public.filtered_profiles TO service_role;
|
||||||
|
|
||||||
|
-- Step 2: Tighten the base profiles table RLS policies
|
||||||
|
-- Drop the overly permissive "Authenticated users can view public profiles" policy
|
||||||
|
DROP POLICY IF EXISTS "Authenticated users can view public profiles" ON public.profiles;
|
||||||
|
|
||||||
|
-- Create a more restrictive policy: users can only view their own full profile via direct table access
|
||||||
|
-- Other users should use the filtered_profiles view
|
||||||
|
CREATE POLICY "Users view own profile or use filtered view"
|
||||||
|
ON public.profiles
|
||||||
|
FOR SELECT
|
||||||
|
TO authenticated
|
||||||
|
USING (
|
||||||
|
-- Users can see their own complete profile
|
||||||
|
auth.uid() = user_id
|
||||||
|
-- Moderators can see all profiles for moderation purposes
|
||||||
|
OR is_moderator(auth.uid())
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Keep the existing policies for users viewing their own profiles
|
||||||
|
-- The "Users can view their own complete profile" policy may be redundant now, but we'll keep it for clarity
|
||||||
|
|
||||||
|
-- Step 3: Add helpful documentation
|
||||||
|
COMMENT ON TABLE public.profiles IS
|
||||||
|
'User profiles table. Direct SELECT access is restricted - use the filtered_profiles view for privacy-respecting queries when viewing other users'' profiles. Only profile owners and moderators have direct table access.';
|
||||||
Reference in New Issue
Block a user