import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.57.4'; import { corsHeaders } from '../_shared/cors.ts'; import { rateLimiters, withRateLimit } from '../_shared/rateLimiter.ts'; import { createEdgeFunction } from '../_shared/edgeFunctionWrapper.ts'; import { validateUUID } from '../_shared/typeValidation.ts'; import { addSpanEvent } from '../_shared/logger.ts'; interface DeleteUserRequest { targetUserId: string; } interface DeleteUserResponse { success: boolean; error?: string; errorCode?: 'aal2_required' | 'permission_denied' | 'invalid_request' | 'deletion_failed'; } // Apply moderate rate limiting (10 req/min) for admin user deletion const handler = createEdgeFunction( { name: 'admin-delete-user', requireAuth: true, corsHeaders: corsHeaders }, async (req, context) => { const supabaseUrl = Deno.env.get('SUPABASE_URL')!; const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!; // Create admin client for privileged operations const supabaseAdmin = createClient(supabaseUrl, supabaseServiceKey); // Create client with user's JWT for MFA checks const supabase = createClient(supabaseUrl, Deno.env.get('SUPABASE_ANON_KEY')!, { global: { headers: { Authorization: req.headers.get('Authorization')! } } }); const adminUserId = context.userId; context.span.setAttribute('action', 'admin_delete_user'); context.span.setAttribute('admin_user_id', adminUserId); // Parse request const { targetUserId }: DeleteUserRequest = await req.json(); validateUUID(targetUserId, 'targetUserId', { adminUserId, requestId: context.requestId }); context.span.setAttribute('target_user_id', targetUserId); addSpanEvent(context.span, 'delete_request_received', { targetUserId }); // SECURITY CHECK 1: Verify admin is superuser const { data: adminRoles, error: rolesError } = await supabaseAdmin .from('user_roles') .select('role') .eq('user_id', adminUserId); if (rolesError || !adminRoles) { throw new Error('Permission denied'); } const isSuperuser = adminRoles.some(r => r.role === 'superuser'); if (!isSuperuser) { addSpanEvent(context.span, 'non_superuser_attempt', { roles: adminRoles.map(r => r.role) }); return new Response( JSON.stringify({ success: false, error: 'Only superusers can delete users', errorCode: 'permission_denied' } as DeleteUserResponse), { status: 403, headers: { 'Content-Type': 'application/json' } } ); } // SECURITY CHECK 2: Verify AAL2 if MFA is enrolled (FAIL CLOSED) const { data: factorsData } = await supabase.auth.mfa.listFactors(); const hasMFAEnrolled = factorsData?.totp?.some(f => f.status === 'verified') || false; if (hasMFAEnrolled) { const token = req.headers.get('Authorization')!.replace('Bearer ', ''); const payload = JSON.parse(atob(token.split('.')[1])); const currentAal = payload.aal || 'aal1'; if (currentAal !== 'aal2') { addSpanEvent(context.span, 'aal2_required', { currentAal }); return new Response( JSON.stringify({ success: false, error: 'AAL2 verification required for this action', errorCode: 'aal2_required' } as DeleteUserResponse), { status: 403, headers: { 'Content-Type': 'application/json' } } ); } addSpanEvent(context.span, 'aal2_verified'); } // SECURITY CHECK 3: Verify target user is not a superuser const { data: targetRoles, error: targetRolesError } = await supabaseAdmin .from('user_roles') .select('role') .eq('user_id', targetUserId); if (targetRolesError) { throw new Error('Failed to verify target user'); } const targetIsSuperuser = targetRoles?.some(r => r.role === 'superuser') || false; if (targetIsSuperuser) { addSpanEvent(context.span, 'superuser_protection', { targetUserId }); return new Response( JSON.stringify({ success: false, error: 'Cannot delete other superusers', errorCode: 'permission_denied' } as DeleteUserResponse), { status: 403, headers: { 'Content-Type': 'application/json' } } ); } // SECURITY CHECK 4: Verify not deleting self if (adminUserId === targetUserId) { addSpanEvent(context.span, 'self_deletion_blocked'); return new Response( JSON.stringify({ success: false, error: 'Cannot delete your own account', errorCode: 'permission_denied' } as DeleteUserResponse), { status: 403, headers: { 'Content-Type': 'application/json' } } ); } // Get target user profile for logging and email const { data: targetProfile } = await supabaseAdmin .from('profiles') .select('username, display_name, avatar_image_id') .eq('user_id', targetUserId) .single(); // Get target user email const { data: { user: targetAuthUser } } = await supabaseAdmin.auth.admin.getUserById(targetUserId); const targetEmail = targetAuthUser?.email; addSpanEvent(context.span, 'deletion_start', { targetUsername: targetProfile?.username }); // CLEANUP STEP 1: Delete reviews (CASCADE will handle review_photos) const { error: reviewsError } = await supabaseAdmin .from('ride_reviews') .delete() .eq('user_id', targetUserId); if (reviewsError) { addSpanEvent(context.span, 'reviews_delete_failed', { error: reviewsError.message }); } else { addSpanEvent(context.span, 'reviews_deleted'); } // CLEANUP STEP 2: Anonymize submissions and photos const { error: anonymizeError } = await supabaseAdmin .rpc('anonymize_user_submissions', { target_user_id: targetUserId }); if (anonymizeError) { addSpanEvent(context.span, 'anonymize_failed', { error: anonymizeError.message }); } else { addSpanEvent(context.span, 'submissions_anonymized'); } // CLEANUP STEP 3: Delete user roles const { error: rolesDeleteError } = await supabaseAdmin .from('user_roles') .delete() .eq('user_id', targetUserId); if (rolesDeleteError) { addSpanEvent(context.span, 'roles_delete_failed', { error: rolesDeleteError.message }); } else { addSpanEvent(context.span, 'roles_deleted'); } // CLEANUP STEP 4: Delete avatar from Cloudflare Images (non-critical) if (targetProfile?.avatar_image_id) { try { const cfAccountId = Deno.env.get('CLOUDFLARE_ACCOUNT_ID'); const cfApiToken = Deno.env.get('CLOUDFLARE_API_TOKEN'); if (cfAccountId && cfApiToken) { const response = await fetch( `https://api.cloudflare.com/client/v4/accounts/${cfAccountId}/images/v1/${targetProfile.avatar_image_id}`, { method: 'DELETE', headers: { 'Authorization': `Bearer ${cfApiToken}` } } ); if (response.ok) { addSpanEvent(context.span, 'avatar_deleted_cloudflare', { imageId: targetProfile.avatar_image_id }); } } } catch (error) { // Non-critical - continue with deletion } } // CLEANUP STEP 5: Delete profile const { error: profileError } = await supabaseAdmin .from('profiles') .delete() .eq('user_id', targetUserId); if (profileError) { throw new Error('Failed to delete user profile'); } addSpanEvent(context.span, 'profile_deleted'); // CLEANUP STEP 6: Remove Novu subscriber (non-critical) try { const novuApiKey = Deno.env.get('NOVU_API_KEY'); if (novuApiKey) { await fetch( `https://api.novu.co/v1/subscribers/${targetUserId}`, { method: 'DELETE', headers: { 'Authorization': `ApiKey ${novuApiKey}`, 'Content-Type': 'application/json' } } ); addSpanEvent(context.span, 'novu_subscriber_removed'); } } catch (error) { // Non-critical } // CLEANUP STEP 7: Delete auth user (CRITICAL - must succeed) const { error: authDeleteError } = await supabaseAdmin.auth.admin.deleteUser(targetUserId); if (authDeleteError) { throw new Error('Failed to delete user account'); } addSpanEvent(context.span, 'auth_user_deleted'); // AUDIT LOG: Record admin action await supabaseAdmin .from('admin_audit_log') .insert({ admin_user_id: adminUserId, target_user_id: targetUserId, action: 'admin_delete_user', details: { target_username: targetProfile?.username, target_email: targetEmail, target_roles: targetRoles?.map(r => r.role) || [], aal_level: hasMFAEnrolled ? 'aal2' : 'aal1', timestamp: new Date().toISOString() } }); addSpanEvent(context.span, 'audit_logged'); // NOTIFICATION: Send email to deleted user (non-critical) if (targetEmail) { try { const forwardEmailKey = Deno.env.get('FORWARD_EMAIL_API_KEY'); if (forwardEmailKey) { await fetch('https://api.forwardemail.net/v1/emails', { method: 'POST', headers: { 'Authorization': `Basic ${btoa(forwardEmailKey + ':')}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ from: 'noreply@thrillwiki.com', to: targetEmail, subject: 'Your Account Has Been Deleted by an Administrator', text: `Your ThrillWiki account has been deleted by an administrator.\n\nDeletion Date: ${new Date().toLocaleString()}\n\nWhat was deleted:\n- Your profile and personal information\n- Your reviews and ratings\n- Your account preferences\n\nWhat was preserved:\n- Your content submissions (as anonymous contributions)\n- Your uploaded photos (credited as anonymous)\n\nIf you believe this was done in error, please contact support@thrillwiki.com.\n\nNo action is required from you.`, html: `
Your ThrillWiki account has been deleted by an administrator.
Deletion Date: ${new Date().toLocaleString()}
If you believe this was done in error, please contact support@thrillwiki.com.
No action is required from you.
` }) }); addSpanEvent(context.span, 'notification_email_sent'); } } catch (error) { // Non-critical } } addSpanEvent(context.span, 'deletion_complete'); return new Response( JSON.stringify({ success: true } as DeleteUserResponse), { status: 200, headers: { 'Content-Type': 'application/json' } } ); } ); export default withRateLimit(handler, rateLimiters.moderate, corsHeaders);