diff --git a/supabase/config.toml b/supabase/config.toml index dbba576f..a26a25d7 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -85,3 +85,6 @@ verify_jwt = false [functions.scheduled-maintenance] verify_jwt = false + +[functions.rate-limit-metrics] +verify_jwt = true diff --git a/supabase/functions/rate-limit-metrics/index.ts b/supabase/functions/rate-limit-metrics/index.ts new file mode 100644 index 00000000..37fcac56 --- /dev/null +++ b/supabase/functions/rate-limit-metrics/index.ts @@ -0,0 +1,200 @@ +/** + * Rate Limit Metrics API + * + * Exposes rate limiting metrics for monitoring and analysis. + * Requires admin/moderator authentication. + */ + +import { createClient } from 'jsr:@supabase/supabase-js@2'; +import { withRateLimit, rateLimiters } from '../_shared/rateLimiter.ts'; +import { + getRecentMetrics, + getMetricsStats, + getFunctionMetrics, + getUserMetrics, + getIPMetrics, + clearMetrics, +} from '../_shared/rateLimitMetrics.ts'; + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +}; + +interface QueryParams { + action?: string; + limit?: string; + timeWindow?: string; + functionName?: string; + userId?: string; + clientIP?: string; +} + +async function handler(req: Request): Promise { + // Handle CORS preflight + if (req.method === 'OPTIONS') { + return new Response(null, { headers: corsHeaders }); + } + + try { + // Verify authentication + const authHeader = req.headers.get('Authorization'); + if (!authHeader) { + return new Response( + JSON.stringify({ error: 'Authentication required' }), + { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } + + const supabaseUrl = Deno.env.get('SUPABASE_URL')!; + const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!; + const supabase = createClient(supabaseUrl, supabaseServiceKey, { + global: { + headers: { Authorization: authHeader }, + }, + }); + + // Get authenticated user + const { data: { user }, error: authError } = await supabase.auth.getUser(); + if (authError || !user) { + return new Response( + JSON.stringify({ error: 'Invalid authentication' }), + { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } + + // Check if user has admin or moderator role + const { data: roles } = await supabase + .from('user_roles') + .select('role') + .eq('user_id', user.id); + + const userRoles = roles?.map(r => r.role) || []; + const isAuthorized = userRoles.some(role => + ['admin', 'moderator', 'superuser'].includes(role) + ); + + if (!isAuthorized) { + return new Response( + JSON.stringify({ error: 'Insufficient permissions' }), + { status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } + + // Parse query parameters + const url = new URL(req.url); + const action = url.searchParams.get('action') || 'stats'; + const limit = parseInt(url.searchParams.get('limit') || '100', 10); + const timeWindow = parseInt(url.searchParams.get('timeWindow') || '60000', 10); + const functionName = url.searchParams.get('functionName'); + const userId = url.searchParams.get('userId'); + const clientIP = url.searchParams.get('clientIP'); + + let responseData: any; + + // Route to appropriate metrics handler + switch (action) { + case 'recent': + responseData = { + metrics: getRecentMetrics(limit), + count: getRecentMetrics(limit).length, + }; + break; + + case 'stats': + responseData = getMetricsStats(timeWindow); + break; + + case 'function': + if (!functionName) { + return new Response( + JSON.stringify({ error: 'functionName parameter required for function action' }), + { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } + responseData = { + functionName, + metrics: getFunctionMetrics(functionName, limit), + count: getFunctionMetrics(functionName, limit).length, + }; + break; + + case 'user': + if (!userId) { + return new Response( + JSON.stringify({ error: 'userId parameter required for user action' }), + { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } + responseData = { + userId, + metrics: getUserMetrics(userId, limit), + count: getUserMetrics(userId, limit).length, + }; + break; + + case 'ip': + if (!clientIP) { + return new Response( + JSON.stringify({ error: 'clientIP parameter required for ip action' }), + { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } + responseData = { + clientIP, + metrics: getIPMetrics(clientIP, limit), + count: getIPMetrics(clientIP, limit).length, + }; + break; + + case 'clear': + // Only superusers can clear metrics + const isSuperuser = userRoles.includes('superuser'); + if (!isSuperuser) { + return new Response( + JSON.stringify({ error: 'Only superusers can clear metrics' }), + { status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } + clearMetrics(); + responseData = { success: true, message: 'Metrics cleared' }; + break; + + default: + return new Response( + JSON.stringify({ + error: 'Invalid action', + validActions: ['recent', 'stats', 'function', 'user', 'ip', 'clear'] + }), + { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ); + } + + return new Response( + JSON.stringify(responseData), + { + status: 200, + headers: { + ...corsHeaders, + 'Content-Type': 'application/json', + } + } + ); + + } catch (error) { + console.error('Error in rate-limit-metrics function:', error); + return new Response( + JSON.stringify({ + error: 'Internal server error', + message: error instanceof Error ? error.message : 'Unknown error' + }), + { + status: 500, + headers: { ...corsHeaders, 'Content-Type': 'application/json' } + } + ); + } +} + +// Apply rate limiting (lenient tier for admin monitoring) +Deno.serve(withRateLimit(handler, rateLimiters.lenient, corsHeaders, 'rate-limit-metrics'));