Files
thrilltrack-explorer/supabase/functions/admin-delete-user/index.ts
gpt-engineer-app[bot] 2d65f13b85 Connect to Lovable Cloud
Add centralized errorFormatter to convert various error types into readable messages, and apply it across edge functions. Replace String(error) usage with formatEdgeError, update relevant imports, fix a throw to use toError, and enhance logger to log formatted errors. Includes new errorFormatter.ts and widespread updates to 18+ edge functions plus logger integration.
2025-11-10 18:09:15 +00:00

564 lines
19 KiB
TypeScript

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: `<p>Your ThrillWiki account has been deleted by an administrator.</p><p><strong>Deletion Date:</strong> ${new Date().toLocaleString()}</p><h3>What was deleted:</h3><ul><li>Your profile and personal information</li><li>Your reviews and ratings</li><li>Your account preferences</li></ul><h3>What was preserved:</h3><ul><li>Your content submissions (as anonymous contributions)</li><li>Your uploaded photos (credited as anonymous)</li></ul><p>If you believe this was done in error, please contact <a href="mailto:support@thrillwiki.com">support@thrillwiki.com</a>.</p><p>No action is required from you.</p>`
})
});
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' } }
);
}
});