mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 10:31:13 -05:00
Migrate 8 high-priority functions (admin-delete-user, mfa-unenroll, confirm-account-deletion, request-account-deletion, send-contact-message, upload-image, validate-email-backend, process-oauth-profile) to wrapEdgeFunction pattern. Replace manual CORS/auth, add shared validations, integrate standardized error handling, and preserve existing rate limiting where applicable. Update implementations to leverage context span, requestId, and improved logging for consistent error reporting and tracing.
297 lines
11 KiB
TypeScript
297 lines
11 KiB
TypeScript
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: `<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>`
|
|
})
|
|
});
|
|
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);
|