import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.57.4'; import { edgeLogger, startRequest, endRequest } from '../_shared/logger.ts'; import { formatEdgeError } from '../_shared/errorFormatter.ts'; const corsHeaders = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', }; interface DeleteUserRequest { targetUserId: string; } interface DeleteUserResponse { success: boolean; error?: string; errorCode?: 'aal2_required' | 'permission_denied' | 'invalid_request' | 'deletion_failed'; } Deno.serve(async (req) => { if (req.method === 'OPTIONS') { return new Response(null, { headers: corsHeaders }); } const tracking = startRequest(); const supabaseUrl = Deno.env.get('SUPABASE_URL')!; const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!; try { // Get authorization header const authHeader = req.headers.get('authorization'); if (!authHeader) { edgeLogger.warn('Missing authorization header', { requestId: tracking.requestId, action: 'admin_delete_user' }); return new Response( JSON.stringify({ success: false, error: 'Unauthorized', errorCode: 'permission_denied' } as DeleteUserResponse), { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ); } // Create admin client for privileged operations const supabaseAdmin = createClient(supabaseUrl, supabaseServiceKey); // Get current user - extract token and verify const token = authHeader.replace('Bearer ', ''); const { data: { user }, error: userError } = await supabaseAdmin.auth.getUser(token); if (userError || !user) { edgeLogger.warn('Failed to get user', { requestId: tracking.requestId, error: userError?.message, action: 'admin_delete_user' }); return new Response( JSON.stringify({ success: false, error: 'Unauthorized', errorCode: 'permission_denied' } as DeleteUserResponse), { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ); } // Create client with user's JWT for MFA checks const supabase = createClient(supabaseUrl, Deno.env.get('SUPABASE_ANON_KEY')!, { global: { headers: { Authorization: authHeader } } }); const adminUserId = user.id; // Parse request const { targetUserId }: DeleteUserRequest = await req.json(); if (!targetUserId) { edgeLogger.warn('Missing targetUserId', { requestId: tracking.requestId, adminUserId, action: 'admin_delete_user' }); return new Response( JSON.stringify({ success: false, error: 'Target user ID is required', errorCode: 'invalid_request' } as DeleteUserResponse), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ); } edgeLogger.info('Admin delete user request', { requestId: tracking.requestId, adminUserId, targetUserId, action: 'admin_delete_user' }); // 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) { edgeLogger.error('Failed to fetch admin roles', { requestId: tracking.requestId, adminUserId, error: rolesError?.message, action: 'admin_delete_user' }); return new Response( JSON.stringify({ success: false, error: 'Permission denied', errorCode: 'permission_denied' } as DeleteUserResponse), { status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ); } const isSuperuser = adminRoles.some(r => r.role === 'superuser'); if (!isSuperuser) { edgeLogger.warn('Non-superuser attempted admin deletion', { requestId: tracking.requestId, adminUserId, targetUserId, roles: adminRoles.map(r => r.role), action: 'admin_delete_user' }); return new Response( JSON.stringify({ success: false, error: 'Only superusers can delete users', errorCode: 'permission_denied' } as DeleteUserResponse), { status: 403, headers: { ...corsHeaders, '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) { // Extract AAL from JWT const token = authHeader.replace('Bearer ', ''); const payload = JSON.parse(atob(token.split('.')[1])); const currentAal = payload.aal || 'aal1'; if (currentAal !== 'aal2') { edgeLogger.warn('AAL2 required for superuser action', { requestId: tracking.requestId, adminUserId, currentAal, action: 'admin_delete_user' }); return new Response( JSON.stringify({ success: false, error: 'AAL2 verification required for this action', errorCode: 'aal2_required' } as DeleteUserResponse), { status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ); } edgeLogger.info('AAL2 verified for superuser action', { requestId: tracking.requestId, adminUserId, action: 'admin_delete_user' }); } // 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) { edgeLogger.error('Failed to fetch target user roles', { requestId: tracking.requestId, targetUserId, error: targetRolesError.message, action: 'admin_delete_user' }); return new Response( JSON.stringify({ success: false, error: 'Failed to verify target user', errorCode: 'deletion_failed' } as DeleteUserResponse), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ); } const targetIsSuperuser = targetRoles?.some(r => r.role === 'superuser') || false; if (targetIsSuperuser) { edgeLogger.warn('Attempted to delete superuser', { requestId: tracking.requestId, adminUserId, targetUserId, action: 'admin_delete_user' }); return new Response( JSON.stringify({ success: false, error: 'Cannot delete other superusers', errorCode: 'permission_denied' } as DeleteUserResponse), { status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ); } // SECURITY CHECK 4: Verify not deleting self if (adminUserId === targetUserId) { edgeLogger.warn('Attempted self-deletion', { requestId: tracking.requestId, adminUserId, action: 'admin_delete_user' }); return new Response( JSON.stringify({ success: false, error: 'Cannot delete your own account', errorCode: 'permission_denied' } as DeleteUserResponse), { status: 403, headers: { ...corsHeaders, '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; edgeLogger.info('Starting user deletion', { requestId: tracking.requestId, adminUserId, targetUserId, targetUsername: targetProfile?.username, action: 'admin_delete_user' }); // 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) { edgeLogger.error('Failed to delete reviews', { requestId: tracking.requestId, targetUserId, error: reviewsError.message, action: 'admin_delete_user' }); } else { edgeLogger.info('Deleted user reviews', { requestId: tracking.requestId, targetUserId, action: 'admin_delete_user' }); } // CLEANUP STEP 2: Anonymize submissions and photos const { error: anonymizeError } = await supabaseAdmin .rpc('anonymize_user_submissions', { target_user_id: targetUserId }); if (anonymizeError) { edgeLogger.error('Failed to anonymize submissions', { requestId: tracking.requestId, targetUserId, error: anonymizeError.message, action: 'admin_delete_user' }); } else { edgeLogger.info('Anonymized user submissions', { requestId: tracking.requestId, targetUserId, action: 'admin_delete_user' }); } // CLEANUP STEP 3: Delete user roles const { error: rolesDeleteError } = await supabaseAdmin .from('user_roles') .delete() .eq('user_id', targetUserId); if (rolesDeleteError) { edgeLogger.error('Failed to delete user roles', { requestId: tracking.requestId, targetUserId, error: rolesDeleteError.message, action: 'admin_delete_user' }); } else { edgeLogger.info('Deleted user roles', { requestId: tracking.requestId, targetUserId, action: 'admin_delete_user' }); } // 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) { edgeLogger.info('Deleted avatar from Cloudflare', { requestId: tracking.requestId, targetUserId, imageId: targetProfile.avatar_image_id, action: 'admin_delete_user' }); } else { edgeLogger.warn('Failed to delete avatar from Cloudflare', { requestId: tracking.requestId, targetUserId, imageId: targetProfile.avatar_image_id, status: response.status, action: 'admin_delete_user' }); } } } catch (error) { edgeLogger.warn('Error deleting avatar from Cloudflare', { requestId: tracking.requestId, targetUserId, error: formatEdgeError(error), action: 'admin_delete_user' }); } } // CLEANUP STEP 5: Delete profile const { error: profileError } = await supabaseAdmin .from('profiles') .delete() .eq('user_id', targetUserId); if (profileError) { edgeLogger.error('Failed to delete profile', { requestId: tracking.requestId, targetUserId, error: profileError.message, action: 'admin_delete_user' }); return new Response( JSON.stringify({ success: false, error: 'Failed to delete user profile', errorCode: 'deletion_failed' } as DeleteUserResponse), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ); } edgeLogger.info('Deleted user profile', { requestId: tracking.requestId, targetUserId, action: 'admin_delete_user' }); // CLEANUP STEP 6: Remove Novu subscriber (non-critical) try { const novuApiKey = Deno.env.get('NOVU_API_KEY'); if (novuApiKey) { const novuResponse = await fetch( `https://api.novu.co/v1/subscribers/${targetUserId}`, { method: 'DELETE', headers: { 'Authorization': `ApiKey ${novuApiKey}`, 'Content-Type': 'application/json' } } ); if (novuResponse.ok) { edgeLogger.info('Removed Novu subscriber', { requestId: tracking.requestId, targetUserId, action: 'admin_delete_user' }); } else { edgeLogger.warn('Failed to remove Novu subscriber', { requestId: tracking.requestId, targetUserId, status: novuResponse.status, action: 'admin_delete_user' }); } } } catch (error) { edgeLogger.warn('Error removing Novu subscriber', { requestId: tracking.requestId, targetUserId, error: formatEdgeError(error), action: 'admin_delete_user' }); } // CLEANUP STEP 7: Delete auth user (CRITICAL - must succeed) const { error: authDeleteError } = await supabaseAdmin.auth.admin.deleteUser(targetUserId); if (authDeleteError) { edgeLogger.error('Failed to delete auth user', { requestId: tracking.requestId, targetUserId, error: authDeleteError.message, action: 'admin_delete_user' }); return new Response( JSON.stringify({ success: false, error: 'Failed to delete user account', errorCode: 'deletion_failed' } as DeleteUserResponse), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ); } edgeLogger.info('Deleted auth user', { requestId: tracking.requestId, targetUserId, action: 'admin_delete_user' }); // AUDIT LOG: Record admin action const { error: auditError } = 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() } }); if (auditError) { edgeLogger.error('Failed to log admin action', { requestId: tracking.requestId, adminUserId, targetUserId, error: auditError.message, action: 'admin_delete_user' }); } else { edgeLogger.info('Logged admin action', { requestId: tracking.requestId, adminUserId, targetUserId, action: 'admin_delete_user' }); } // NOTIFICATION: Send email to deleted user (non-critical) if (targetEmail) { try { const forwardEmailKey = Deno.env.get('FORWARD_EMAIL_API_KEY'); if (forwardEmailKey) { const emailResponse = 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()}

What was deleted:

What was preserved:

If you believe this was done in error, please contact support@thrillwiki.com.

No action is required from you.

` }) }); if (emailResponse.ok) { edgeLogger.info('Sent deletion notification email', { requestId: tracking.requestId, targetUserId, targetEmail, action: 'admin_delete_user' }); } else { edgeLogger.warn('Failed to send deletion notification email', { requestId: tracking.requestId, targetUserId, status: emailResponse.status, action: 'admin_delete_user' }); } } } catch (error) { edgeLogger.warn('Error sending deletion notification email', { requestId: tracking.requestId, targetUserId, error: formatEdgeError(error), action: 'admin_delete_user' }); } } const duration = endRequest(tracking); edgeLogger.info('User deletion completed', { requestId: tracking.requestId, adminUserId, targetUserId, duration, action: 'admin_delete_user' }); return new Response( JSON.stringify({ success: true } as DeleteUserResponse), { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ); } catch (error) { const duration = endRequest(tracking); edgeLogger.error('Unexpected error in admin delete user', { requestId: tracking.requestId, duration, error: formatEdgeError(error), action: 'admin_delete_user' }); return new Response( JSON.stringify({ success: false, error: 'An unexpected error occurred', errorCode: 'deletion_failed' } as DeleteUserResponse), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ); } });