Compare commits

...

2 Commits

Author SHA1 Message Date
gpt-engineer-app[bot]
e747e1f881 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.
2025-11-07 01:02:58 +00:00
gpt-engineer-app[bot]
6bc5343256 Apply database hardening migrations
Approve and apply the latest set of database migrations for Phase 4: Application Boundary Hardening. These migrations include orphan image cleanup, slug validation triggers, monitoring and alerting infrastructure, and scheduled maintenance functions.
2025-11-07 00:59:49 +00:00
7 changed files with 586 additions and 14 deletions

View 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;
}
};
}

View File

@@ -1997,6 +1997,30 @@ export type Database = {
} }
Relationships: [] Relationships: []
} }
orphaned_images: {
Row: {
cloudflare_id: string
created_at: string
id: string
image_url: string
marked_for_deletion_at: string | null
}
Insert: {
cloudflare_id: string
created_at?: string
id?: string
image_url: string
marked_for_deletion_at?: string | null
}
Update: {
cloudflare_id?: string
created_at?: string
id?: string
image_url?: string
marked_for_deletion_at?: string | null
}
Relationships: []
}
orphaned_images_log: { orphaned_images_log: {
Row: { Row: {
cleaned_up: boolean | null cleaned_up: boolean | null
@@ -5304,6 +5328,36 @@ export type Database = {
}, },
] ]
} }
system_alerts: {
Row: {
alert_type: string
created_at: string
id: string
message: string
metadata: Json | null
resolved_at: string | null
severity: string
}
Insert: {
alert_type: string
created_at?: string
id?: string
message: string
metadata?: Json | null
resolved_at?: string | null
severity: string
}
Update: {
alert_type?: string
created_at?: string
id?: string
message?: string
metadata?: Json | null
resolved_at?: string | null
severity?: string
}
Relationships: []
}
test_data_registry: { test_data_registry: {
Row: { Row: {
created_at: string created_at: string
@@ -6041,6 +6095,15 @@ export type Database = {
} }
Returns: string Returns: string
} }
create_system_alert: {
Args: {
p_alert_type: string
p_message: string
p_metadata?: Json
p_severity: string
}
Returns: string
}
delete_entity_from_submission: { delete_entity_from_submission: {
Args: { Args: {
p_deleted_by: string p_deleted_by: string
@@ -6149,6 +6212,15 @@ export type Database = {
updated_at: string 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: { get_user_management_permissions: {
Args: { _user_id: string } Args: { _user_id: string }
Returns: Json Returns: Json
@@ -6195,7 +6267,7 @@ export type Database = {
is_auth0_user: { Args: never; Returns: boolean } is_auth0_user: { Args: never; Returns: boolean }
is_moderator: { Args: { _user_id: string }; Returns: boolean } is_moderator: { Args: { _user_id: string }; Returns: boolean }
is_superuser: { Args: { _user_id: string }; Returns: boolean } is_superuser: { Args: { _user_id: string }; Returns: boolean }
is_user_banned: { Args: { _user_id: string }; Returns: boolean } is_user_banned: { Args: { p_user_id: string }; Returns: boolean }
log_admin_action: { log_admin_action: {
Args: { Args: {
_action: string _action: string
@@ -6239,6 +6311,7 @@ export type Database = {
} }
Returns: undefined Returns: undefined
} }
mark_orphaned_images: { Args: never; Returns: undefined }
migrate_ride_technical_data: { Args: never; Returns: undefined } migrate_ride_technical_data: { Args: never; Returns: undefined }
migrate_user_list_items: { Args: never; Returns: undefined } migrate_user_list_items: { Args: never; Returns: undefined }
process_approval_transaction: { process_approval_transaction: {
@@ -6276,6 +6349,14 @@ export type Database = {
} }
Returns: string Returns: string
} }
run_system_maintenance: {
Args: never
Returns: {
details: Json
status: string
task: string
}[]
}
set_config_value: { set_config_value: {
Args: { Args: {
is_local?: boolean is_local?: boolean

View File

@@ -16,6 +16,21 @@ interface UploadedImageWithFlag extends UploadedImage {
wasNewlyUploaded?: boolean; 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 * Uploads pending local images to Cloudflare via Supabase Edge Function
* @param images Array of UploadedImage objects (mix of local and already uploaded) * @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) { if (image.isLocal && image.file) {
const fileName = image.file.name; const fileName = image.file.name;
// Step 1: Get upload URL from our Supabase Edge Function (with tracking) // Step 1: Get upload URL from our Supabase Edge Function (with tracking and timeout)
const { data: uploadUrlData, error: urlError, requestId } = await invokeWithTracking( const { data: uploadUrlData, error: urlError, requestId } = await withTimeout(
invokeWithTracking(
'upload-image', 'upload-image',
{ action: 'get-upload-url' } { action: 'get-upload-url' }
),
UPLOAD_TIMEOUT_MS,
'Get upload URL'
); );
if (urlError || !uploadUrlData?.uploadURL) { 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(); const formData = new FormData();
formData.append('file', image.file); formData.append('file', image.file);
const uploadResponse = await fetch(uploadUrlData.uploadURL, { const uploadResponse = await withTimeout(
fetch(uploadUrlData.uploadURL, {
method: 'POST', method: 'POST',
body: formData, body: formData,
}); }),
UPLOAD_TIMEOUT_MS,
'Cloudflare upload'
);
if (!uploadResponse.ok) { if (!uploadResponse.ok) {
const errorText = await uploadResponse.text(); const errorText = await uploadResponse.text();
const error = new Error(`Upload failed for "${fileName}" (status ${uploadResponse.status}): ${errorText}`); const error = new Error(`Upload failed for "${fileName}" (status ${uploadResponse.status}): ${errorText}`);
handleError(error, { handleError(error, {
action: 'Cloudflare Upload', action: 'Cloudflare Upload',
metadata: { fileName, status: uploadResponse.status } metadata: { fileName, status: uploadResponse.status, timeout_ms: UPLOAD_TIMEOUT_MS }
}); });
throw error; throw error;
} }

View File

@@ -98,7 +98,7 @@ export const authTestSuite: TestSuite = {
// Test is_superuser() database function // Test is_superuser() database function
const { data: isSuper, error: superError } = await supabase 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}`); if (superError) throw new Error(`is_superuser() failed: ${superError.message}`);
@@ -217,7 +217,7 @@ export const authTestSuite: TestSuite = {
// Test is_user_banned() database function // Test is_user_banned() database function
const { data: isBanned, error: bannedError } = await supabase 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}`); if (bannedError) throw new Error(`is_user_banned() failed: ${bannedError.message}`);

View File

@@ -220,7 +220,7 @@ export const performanceTestSuite: TestSuite = {
const banStart = Date.now(); const banStart = Date.now();
const { data: isBanned, error: banError } = await supabase const { data: isBanned, error: banError } = await supabase
.rpc('is_user_banned', { .rpc('is_user_banned', {
_user_id: userData.user.id p_user_id: userData.user.id
}); });
const banDuration = Date.now() - banStart; const banDuration = Date.now() - banStart;

View File

@@ -0,0 +1,248 @@
-- Phase 4: Application Boundary Hardening (Simplified)
-- ============================================================================
-- 1. IMAGE UPLOAD ORPHAN CLEANUP
-- ============================================================================
-- Track image uploads that haven't been associated with submissions after 24 hours
CREATE TABLE IF NOT EXISTS orphaned_images (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
image_url TEXT NOT NULL,
cloudflare_id TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
marked_for_deletion_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_orphaned_images_marked ON orphaned_images(marked_for_deletion_at) WHERE marked_for_deletion_at IS NOT NULL;
-- Function to mark orphaned images (images uploaded but not in any submission after 24h)
CREATE OR REPLACE FUNCTION mark_orphaned_images()
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
-- Mark images that haven't been used in submissions within 24 hours
INSERT INTO orphaned_images (image_url, cloudflare_id, marked_for_deletion_at)
SELECT DISTINCT
si.url,
si.cloudflare_id,
now()
FROM submission_images si
WHERE si.created_at < now() - interval '24 hours'
AND NOT EXISTS (
SELECT 1 FROM content_submissions cs
WHERE cs.id = si.submission_id
)
AND NOT EXISTS (
SELECT 1 FROM orphaned_images oi
WHERE oi.cloudflare_id = si.cloudflare_id
);
END;
$$;
-- ============================================================================
-- 2. SLUG VALIDATION TRIGGERS
-- ============================================================================
-- Function to validate slug format (lowercase, alphanumeric with hyphens only)
CREATE OR REPLACE FUNCTION validate_slug_format()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
IF NEW.slug IS NOT NULL THEN
-- Check format: lowercase letters, numbers, hyphens only
IF NEW.slug !~ '^[a-z0-9]+(-[a-z0-9]+)*$' THEN
RAISE EXCEPTION 'Invalid slug format: %. Slugs must be lowercase alphanumeric with hyphens only.', NEW.slug;
END IF;
-- Check length constraints
IF length(NEW.slug) < 2 THEN
RAISE EXCEPTION 'Slug too short: %. Minimum length is 2 characters.', NEW.slug;
END IF;
IF length(NEW.slug) > 100 THEN
RAISE EXCEPTION 'Slug too long: %. Maximum length is 100 characters.', NEW.slug;
END IF;
-- Prevent reserved slugs
IF NEW.slug IN ('admin', 'api', 'auth', 'new', 'edit', 'delete', 'create', 'update', 'null', 'undefined') THEN
RAISE EXCEPTION 'Reserved slug: %. This slug cannot be used.', NEW.slug;
END IF;
END IF;
RETURN NEW;
END;
$$;
-- Apply slug validation to parks
DROP TRIGGER IF EXISTS validate_parks_slug ON parks;
CREATE TRIGGER validate_parks_slug
BEFORE INSERT OR UPDATE OF slug ON parks
FOR EACH ROW
EXECUTE FUNCTION validate_slug_format();
-- Apply slug validation to rides
DROP TRIGGER IF EXISTS validate_rides_slug ON rides;
CREATE TRIGGER validate_rides_slug
BEFORE INSERT OR UPDATE OF slug ON rides
FOR EACH ROW
EXECUTE FUNCTION validate_slug_format();
-- Apply slug validation to companies
DROP TRIGGER IF EXISTS validate_companies_slug ON companies;
CREATE TRIGGER validate_companies_slug
BEFORE INSERT OR UPDATE OF slug ON companies
FOR EACH ROW
EXECUTE FUNCTION validate_slug_format();
-- ============================================================================
-- 3. MONITORING & ALERTING INFRASTRUCTURE
-- ============================================================================
-- Critical alerts table for monitoring
CREATE TABLE IF NOT EXISTS system_alerts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
alert_type TEXT NOT NULL CHECK (alert_type IN (
'orphaned_images',
'stale_submissions',
'circular_dependency',
'validation_error',
'ban_attempt',
'upload_timeout',
'high_error_rate'
)),
severity TEXT NOT NULL CHECK (severity IN ('low', 'medium', 'high', 'critical')),
message TEXT NOT NULL,
metadata JSONB,
resolved_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_system_alerts_unresolved ON system_alerts(created_at DESC) WHERE resolved_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_system_alerts_type ON system_alerts(alert_type, created_at DESC);
-- Function to create system alert
CREATE OR REPLACE FUNCTION create_system_alert(
p_alert_type TEXT,
p_severity TEXT,
p_message TEXT,
p_metadata JSONB DEFAULT NULL
)
RETURNS UUID
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_alert_id UUID;
BEGIN
INSERT INTO system_alerts (alert_type, severity, message, metadata)
VALUES (p_alert_type, p_severity, p_message, p_metadata)
RETURNING id INTO v_alert_id;
RETURN v_alert_id;
END;
$$;
-- Enhanced ban attempt logging with alert
CREATE OR REPLACE FUNCTION prevent_banned_user_submissions()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_user_banned BOOLEAN;
v_ban_reason TEXT;
BEGIN
-- Check if user is banned
SELECT is_banned, ban_reason INTO v_user_banned, v_ban_reason
FROM profiles
WHERE id = NEW.submitted_by;
IF v_user_banned THEN
-- Create alert for banned user attempt
PERFORM create_system_alert(
'ban_attempt',
'medium',
format('Banned user %s attempted to submit content', NEW.submitted_by),
jsonb_build_object(
'user_id', NEW.submitted_by,
'ban_reason', v_ban_reason,
'submission_type', NEW.entity_type,
'attempted_at', now()
)
);
RAISE EXCEPTION 'Submission blocked: User account is banned. Reason: %', v_ban_reason;
END IF;
RETURN NEW;
END;
$$;
-- ============================================================================
-- 4. MAINTENANCE FUNCTION
-- ============================================================================
-- Main maintenance function to run periodically
CREATE OR REPLACE FUNCTION run_system_maintenance()
RETURNS TABLE(
task TEXT,
status TEXT,
details JSONB
)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_orphaned_count INTEGER;
BEGIN
-- Mark orphaned images
BEGIN
PERFORM mark_orphaned_images();
SELECT COUNT(*) INTO v_orphaned_count
FROM orphaned_images
WHERE marked_for_deletion_at IS NOT NULL
AND marked_for_deletion_at > now() - interval '1 hour';
RETURN QUERY SELECT
'mark_orphaned_images'::TEXT,
'success'::TEXT,
jsonb_build_object('count', v_orphaned_count);
IF v_orphaned_count > 100 THEN
PERFORM create_system_alert(
'orphaned_images',
'medium',
format('High number of orphaned images detected: %s', v_orphaned_count),
jsonb_build_object('count', v_orphaned_count)
);
END IF;
EXCEPTION WHEN OTHERS THEN
RETURN QUERY SELECT
'mark_orphaned_images'::TEXT,
'error'::TEXT,
jsonb_build_object('error', SQLERRM);
END;
RETURN;
END;
$$;
-- Grant necessary permissions
GRANT EXECUTE ON FUNCTION mark_orphaned_images() TO authenticated;
GRANT EXECUTE ON FUNCTION run_system_maintenance() TO authenticated;
GRANT EXECUTE ON FUNCTION create_system_alert(TEXT, TEXT, TEXT, JSONB) TO authenticated;
-- Create view for monitoring dashboard
CREATE OR REPLACE VIEW system_health AS
SELECT
(SELECT COUNT(*) FROM orphaned_images WHERE marked_for_deletion_at IS NOT NULL) as orphaned_images_count,
(SELECT COUNT(*) FROM system_alerts WHERE resolved_at IS NULL AND severity IN ('high', 'critical')) as critical_alerts_count,
(SELECT COUNT(*) FROM system_alerts WHERE resolved_at IS NULL AND created_at > now() - interval '24 hours') as alerts_last_24h,
now() as checked_at;

View File

@@ -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;