mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 10:11:13 -05:00
Introduce centralized rate limiting by applying defined tiers (STRICT, STANDARD, LENIENT, MODERATE) to high-risk edge functions: - export-user-data (STRICT, 5 req/min) - send-contact-message (STANDARD, 20 req/min) - validate-email-backend (LENIENT, 30 req/min) - admin-delete-user, resend-deletion-code (MODERATE) - additional standard targets identified (request-account-deletion, cancel-account-deletion) as per guidance Implements: - Wrapped handlers with withRateLimit using centralized rateLimiters - Imported from shared rate limiter module - Annotated with comments explaining tier rationale - Updated three initial functions and extended coverage to admin/account management functions - Added documentation guide for rate limiting usage This aligns with the Rate Limiting Guide and centralizes rate limit configuration for consistency.
563 lines
19 KiB
TypeScript
563 lines
19 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 { edgeLogger, startRequest, endRequest } from '../_shared/logger.ts';
|
|
import { formatEdgeError } from '../_shared/errorFormatter.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
|
|
// Prevents abuse of this sensitive administrative operation
|
|
Deno.serve(withRateLimit(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' } }
|
|
);
|
|
}
|
|
}, rateLimiters.moderate, corsHeaders));
|