mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 15:51:12 -05:00
563 lines
19 KiB
TypeScript
563 lines
19 KiB
TypeScript
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.57.4';
|
|
import { edgeLogger, startRequest, endRequest } from '../_shared/logger.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: error instanceof Error ? error.message : String(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: error instanceof Error ? error.message : String(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: error instanceof Error ? error.message : String(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: error instanceof Error ? error.message : String(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' } }
|
|
);
|
|
}
|
|
});
|