mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 08:11:13 -05:00
Implement RLS and security functions
Apply Row Level Security to orphaned_images and system_alerts tables. Create RLS policies for admin/moderator access. Replace system_health view with get_system_health() function.
This commit is contained in:
129
src/hooks/useSystemHealth.ts
Normal file
129
src/hooks/useSystemHealth.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { supabase } from '@/lib/supabaseClient';
|
||||
import { handleError } from '@/lib/errorHandler';
|
||||
|
||||
interface SystemHealthData {
|
||||
orphaned_images_count: number;
|
||||
critical_alerts_count: number;
|
||||
alerts_last_24h: number;
|
||||
checked_at: string;
|
||||
}
|
||||
|
||||
interface SystemAlert {
|
||||
id: string;
|
||||
alert_type: 'orphaned_images' | 'stale_submissions' | 'circular_dependency' | 'validation_error' | 'ban_attempt' | 'upload_timeout' | 'high_error_rate';
|
||||
severity: 'low' | 'medium' | 'high' | 'critical';
|
||||
message: string;
|
||||
metadata: Record<string, any> | null;
|
||||
resolved_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch system health metrics
|
||||
* Only accessible to moderators and admins
|
||||
*/
|
||||
export function useSystemHealth() {
|
||||
return useQuery({
|
||||
queryKey: ['system-health'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.rpc('get_system_health');
|
||||
|
||||
if (error) {
|
||||
handleError(error, {
|
||||
action: 'Fetch System Health',
|
||||
metadata: { error: error.message }
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data?.[0] as SystemHealthData | null;
|
||||
} catch (error) {
|
||||
handleError(error, {
|
||||
action: 'Fetch System Health',
|
||||
metadata: { error: String(error) }
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
refetchInterval: 60000, // Refetch every minute
|
||||
staleTime: 30000, // Consider data stale after 30 seconds
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch unresolved system alerts
|
||||
* Only accessible to moderators and admins
|
||||
*/
|
||||
export function useSystemAlerts(severity?: 'low' | 'medium' | 'high' | 'critical') {
|
||||
return useQuery({
|
||||
queryKey: ['system-alerts', severity],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
let query = supabase
|
||||
.from('system_alerts')
|
||||
.select('*')
|
||||
.is('resolved_at', null)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (severity) {
|
||||
query = query.eq('severity', severity);
|
||||
}
|
||||
|
||||
const { data, error } = await query;
|
||||
|
||||
if (error) {
|
||||
handleError(error, {
|
||||
action: 'Fetch System Alerts',
|
||||
metadata: { severity, error: error.message }
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
return (data || []) as SystemAlert[];
|
||||
} catch (error) {
|
||||
handleError(error, {
|
||||
action: 'Fetch System Alerts',
|
||||
metadata: { severity, error: String(error) }
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
refetchInterval: 30000, // Refetch every 30 seconds
|
||||
staleTime: 15000, // Consider data stale after 15 seconds
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to run system maintenance manually
|
||||
* Only accessible to admins
|
||||
*/
|
||||
export function useRunSystemMaintenance() {
|
||||
return async () => {
|
||||
try {
|
||||
const { data, error } = await supabase.rpc('run_system_maintenance');
|
||||
|
||||
if (error) {
|
||||
handleError(error, {
|
||||
action: 'Run System Maintenance',
|
||||
metadata: { error: error.message }
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data as Array<{
|
||||
task: string;
|
||||
status: 'success' | 'error';
|
||||
details: Record<string, any>;
|
||||
}>;
|
||||
} catch (error) {
|
||||
handleError(error, {
|
||||
action: 'Run System Maintenance',
|
||||
metadata: { error: String(error) }
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -5989,15 +5989,6 @@ export type Database = {
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
system_health: {
|
||||
Row: {
|
||||
alerts_last_24h: number | null
|
||||
checked_at: string | null
|
||||
critical_alerts_count: number | null
|
||||
orphaned_images_count: number | null
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
}
|
||||
Functions: {
|
||||
anonymize_user_submissions: {
|
||||
@@ -6221,6 +6212,15 @@ export type Database = {
|
||||
updated_at: string
|
||||
}[]
|
||||
}
|
||||
get_system_health: {
|
||||
Args: never
|
||||
Returns: {
|
||||
alerts_last_24h: number
|
||||
checked_at: string
|
||||
critical_alerts_count: number
|
||||
orphaned_images_count: number
|
||||
}[]
|
||||
}
|
||||
get_user_management_permissions: {
|
||||
Args: { _user_id: string }
|
||||
Returns: Json
|
||||
|
||||
@@ -16,6 +16,21 @@ interface UploadedImageWithFlag extends UploadedImage {
|
||||
wasNewlyUploaded?: boolean;
|
||||
}
|
||||
|
||||
// Upload timeout in milliseconds (30 seconds)
|
||||
const UPLOAD_TIMEOUT_MS = 30000;
|
||||
|
||||
/**
|
||||
* Creates a promise that rejects after a timeout
|
||||
*/
|
||||
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, operation: string): Promise<T> {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise<T>((_, reject) =>
|
||||
setTimeout(() => reject(new Error(`${operation} timed out after ${timeoutMs}ms`)), timeoutMs)
|
||||
)
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads pending local images to Cloudflare via Supabase Edge Function
|
||||
* @param images Array of UploadedImage objects (mix of local and already uploaded)
|
||||
@@ -27,10 +42,14 @@ export async function uploadPendingImages(images: UploadedImage[]): Promise<Uplo
|
||||
if (image.isLocal && image.file) {
|
||||
const fileName = image.file.name;
|
||||
|
||||
// Step 1: Get upload URL from our Supabase Edge Function (with tracking)
|
||||
const { data: uploadUrlData, error: urlError, requestId } = await invokeWithTracking(
|
||||
// Step 1: Get upload URL from our Supabase Edge Function (with tracking and timeout)
|
||||
const { data: uploadUrlData, error: urlError, requestId } = await withTimeout(
|
||||
invokeWithTracking(
|
||||
'upload-image',
|
||||
{ action: 'get-upload-url' }
|
||||
),
|
||||
UPLOAD_TIMEOUT_MS,
|
||||
'Get upload URL'
|
||||
);
|
||||
|
||||
if (urlError || !uploadUrlData?.uploadURL) {
|
||||
@@ -43,21 +62,25 @@ export async function uploadPendingImages(images: UploadedImage[]): Promise<Uplo
|
||||
}
|
||||
|
||||
|
||||
// Step 2: Upload file directly to Cloudflare
|
||||
// Step 2: Upload file directly to Cloudflare (with timeout)
|
||||
const formData = new FormData();
|
||||
formData.append('file', image.file);
|
||||
|
||||
const uploadResponse = await fetch(uploadUrlData.uploadURL, {
|
||||
const uploadResponse = await withTimeout(
|
||||
fetch(uploadUrlData.uploadURL, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
}),
|
||||
UPLOAD_TIMEOUT_MS,
|
||||
'Cloudflare upload'
|
||||
);
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
const errorText = await uploadResponse.text();
|
||||
const error = new Error(`Upload failed for "${fileName}" (status ${uploadResponse.status}): ${errorText}`);
|
||||
handleError(error, {
|
||||
action: 'Cloudflare Upload',
|
||||
metadata: { fileName, status: uploadResponse.status }
|
||||
metadata: { fileName, status: uploadResponse.status, timeout_ms: UPLOAD_TIMEOUT_MS }
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ export const authTestSuite: TestSuite = {
|
||||
|
||||
// Test is_superuser() database function
|
||||
const { data: isSuper, error: superError } = await supabase
|
||||
.rpc('is_superuser', { _user_id: user.id });
|
||||
.rpc('is_superuser', { p_user_id: user.id });
|
||||
|
||||
if (superError) throw new Error(`is_superuser() failed: ${superError.message}`);
|
||||
|
||||
@@ -217,7 +217,7 @@ export const authTestSuite: TestSuite = {
|
||||
|
||||
// Test is_user_banned() database function
|
||||
const { data: isBanned, error: bannedError } = await supabase
|
||||
.rpc('is_user_banned', { _user_id: user.id });
|
||||
.rpc('is_user_banned', { p_user_id: user.id });
|
||||
|
||||
if (bannedError) throw new Error(`is_user_banned() failed: ${bannedError.message}`);
|
||||
|
||||
|
||||
@@ -220,7 +220,7 @@ export const performanceTestSuite: TestSuite = {
|
||||
const banStart = Date.now();
|
||||
const { data: isBanned, error: banError } = await supabase
|
||||
.rpc('is_user_banned', {
|
||||
_user_id: userData.user.id
|
||||
p_user_id: userData.user.id
|
||||
});
|
||||
|
||||
const banDuration = Date.now() - banStart;
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
-- Phase 4: Security Fixes for New Tables
|
||||
|
||||
-- ============================================================================
|
||||
-- Enable RLS on new tables
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE orphaned_images ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE system_alerts ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- ============================================================================
|
||||
-- RLS Policies for orphaned_images (admin/moderator access only)
|
||||
-- ============================================================================
|
||||
|
||||
CREATE POLICY "Admins can view orphaned images"
|
||||
ON orphaned_images FOR SELECT
|
||||
TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM user_roles
|
||||
WHERE user_id = auth.uid()
|
||||
AND role IN ('admin', 'superuser', 'moderator')
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "Admins can manage orphaned images"
|
||||
ON orphaned_images FOR ALL
|
||||
TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM user_roles
|
||||
WHERE user_id = auth.uid()
|
||||
AND role IN ('admin', 'superuser')
|
||||
)
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- RLS Policies for system_alerts (admin access only)
|
||||
-- ============================================================================
|
||||
|
||||
CREATE POLICY "Admins can view system alerts"
|
||||
ON system_alerts FOR SELECT
|
||||
TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM user_roles
|
||||
WHERE user_id = auth.uid()
|
||||
AND role IN ('admin', 'superuser', 'moderator')
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "Admins can manage system alerts"
|
||||
ON system_alerts FOR ALL
|
||||
TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM user_roles
|
||||
WHERE user_id = auth.uid()
|
||||
AND role IN ('admin', 'superuser')
|
||||
)
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- Fix search_path for security definer view
|
||||
-- ============================================================================
|
||||
|
||||
-- Recreate system_health view with proper security
|
||||
DROP VIEW IF EXISTS system_health;
|
||||
|
||||
-- Create a function instead of a security definer view
|
||||
CREATE OR REPLACE FUNCTION get_system_health()
|
||||
RETURNS TABLE(
|
||||
orphaned_images_count BIGINT,
|
||||
critical_alerts_count BIGINT,
|
||||
alerts_last_24h BIGINT,
|
||||
checked_at TIMESTAMPTZ
|
||||
)
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM orphaned_images WHERE marked_for_deletion_at IS NOT NULL)::BIGINT as orphaned_images_count,
|
||||
(SELECT COUNT(*) FROM system_alerts WHERE resolved_at IS NULL AND severity IN ('high', 'critical'))::BIGINT as critical_alerts_count,
|
||||
(SELECT COUNT(*) FROM system_alerts WHERE resolved_at IS NULL AND created_at > now() - interval '24 hours')::BIGINT as alerts_last_24h,
|
||||
now() as checked_at;
|
||||
END;
|
||||
$$;
|
||||
|
||||
GRANT EXECUTE ON FUNCTION get_system_health() TO authenticated;
|
||||
Reference in New Issue
Block a user