diff --git a/supabase/functions/_shared/logger.ts b/supabase/functions/_shared/logger.ts new file mode 100644 index 00000000..c4ac03f5 --- /dev/null +++ b/supabase/functions/_shared/logger.ts @@ -0,0 +1,87 @@ +/** + * Structured logging utility for edge functions + * Prevents sensitive data exposure and provides consistent log format + */ + +type LogLevel = 'info' | 'warn' | 'error' | 'debug'; + +interface LogContext { + userId?: string; + action?: string; + [key: string]: unknown; +} + +// Fields that should never be logged +const SENSITIVE_FIELDS = [ + 'password', + 'token', + 'secret', + 'api_key', + 'apikey', + 'authorization', + 'email', + 'phone', + 'ssn', + 'credit_card', + 'ip_address', + 'session_id' +]; + +/** + * Sanitize context to remove sensitive data + */ +function sanitizeContext(context: LogContext): LogContext { + const sanitized: LogContext = {}; + + for (const [key, value] of Object.entries(context)) { + const lowerKey = key.toLowerCase(); + + // Skip sensitive fields + if (SENSITIVE_FIELDS.some(field => lowerKey.includes(field))) { + sanitized[key] = '[REDACTED]'; + continue; + } + + // Recursively sanitize objects + if (value && typeof value === 'object' && !Array.isArray(value)) { + sanitized[key] = sanitizeContext(value as LogContext); + } else { + sanitized[key] = value; + } + } + + return sanitized; +} + +/** + * Format log message with context + */ +function formatLog(level: LogLevel, message: string, context?: LogContext): string { + const timestamp = new Date().toISOString(); + const sanitizedContext = context ? sanitizeContext(context) : {}; + + return JSON.stringify({ + timestamp, + level, + message, + ...sanitizedContext + }); +} + +export const edgeLogger = { + info: (message: string, context?: LogContext): void => { + console.info(formatLog('info', message, context)); + }, + + warn: (message: string, context?: LogContext): void => { + console.warn(formatLog('warn', message, context)); + }, + + error: (message: string, context?: LogContext): void => { + console.error(formatLog('error', message, context)); + }, + + debug: (message: string, context?: LogContext): void => { + console.debug(formatLog('debug', message, context)); + } +}; diff --git a/supabase/functions/cancel-account-deletion/index.ts b/supabase/functions/cancel-account-deletion/index.ts index 22badb58..bc12f9e1 100644 --- a/supabase/functions/cancel-account-deletion/index.ts +++ b/supabase/functions/cancel-account-deletion/index.ts @@ -1,5 +1,6 @@ import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'; import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'; +import { edgeLogger } from '../_shared/logger.ts'; const corsHeaders = { 'Access-Control-Allow-Origin': '*', @@ -34,7 +35,7 @@ serve(async (req) => { throw new Error('Unauthorized'); } - console.log(`Cancelling deletion request for user: ${user.id}`); + edgeLogger.info('Cancelling deletion request', { action: 'cancel_deletion', userId: user.id }); // Find pending deletion request const { data: deletionRequest, error: requestError } = await supabaseClient @@ -100,9 +101,9 @@ serve(async (req) => { `, }), }); - console.log('Cancellation confirmation email sent'); + edgeLogger.info('Cancellation confirmation email sent', { action: 'cancel_deletion_email', userId: user.id }); } catch (emailError) { - console.error('Failed to send email:', emailError); + edgeLogger.error('Failed to send email', { action: 'cancel_deletion_email', userId: user.id }); } } @@ -117,7 +118,7 @@ serve(async (req) => { } ); } catch (error) { - console.error('Error cancelling deletion:', error); + edgeLogger.error('Error cancelling deletion', { action: 'cancel_deletion_error', error: error instanceof Error ? error.message : String(error) }); return new Response( JSON.stringify({ error: error.message }), { diff --git a/supabase/functions/export-user-data/index.ts b/supabase/functions/export-user-data/index.ts index 83b33b7d..264d2b1a 100644 --- a/supabase/functions/export-user-data/index.ts +++ b/supabase/functions/export-user-data/index.ts @@ -1,6 +1,7 @@ import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'; import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.57.4'; import { sanitizeError } from '../_shared/errorSanitizer.ts'; +import { edgeLogger } from '../_shared/logger.ts'; const corsHeaders = { 'Access-Control-Allow-Origin': '*', @@ -39,14 +40,14 @@ serve(async (req) => { } = await supabaseClient.auth.getUser(); if (authError || !user) { - console.error('[Export] Authentication failed:', authError); + edgeLogger.error('Authentication failed', { action: 'export_auth' }); return new Response( JSON.stringify({ error: 'Unauthorized', success: false }), { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ); } - console.log(`[Export] Processing export request for user: ${user.id}`); + edgeLogger.info('Processing export request', { action: 'export_start', userId: user.id }); // Check rate limiting - max 1 export per hour const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString(); @@ -86,7 +87,7 @@ serve(async (req) => { format: 'json' }; - console.log('[Export] Export options:', options); + edgeLogger.info('Export options', { action: 'export_options', userId: user.id }); // Fetch profile data const { data: profile, error: profileError } = await supabaseClient @@ -96,7 +97,7 @@ serve(async (req) => { .single(); if (profileError) { - console.error('[Export] Profile fetch failed:', profileError); + edgeLogger.error('Profile fetch failed', { action: 'export_profile' }); throw new Error('Failed to fetch profile data'); } @@ -263,7 +264,7 @@ serve(async (req) => { } }]); - console.log(`[Export] Export completed successfully for user ${user.id}`); + edgeLogger.info('Export completed successfully', { action: 'export_complete', userId: user.id }); return new Response( JSON.stringify({ success: true, data: exportData }), @@ -278,7 +279,7 @@ serve(async (req) => { ); } catch (error) { - console.error('[Export] Error:', error); + edgeLogger.error('Export error', { action: 'export_error', error: error instanceof Error ? error.message : String(error) }); const sanitized = sanitizeError(error, 'export-user-data'); return new Response( JSON.stringify({ diff --git a/supabase/functions/mfa-unenroll/index.ts b/supabase/functions/mfa-unenroll/index.ts index 68c74ae8..27078e31 100644 --- a/supabase/functions/mfa-unenroll/index.ts +++ b/supabase/functions/mfa-unenroll/index.ts @@ -1,4 +1,5 @@ import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.57.4'; +import { edgeLogger } from '../_shared/logger.ts'; const corsHeaders = { 'Access-Control-Allow-Origin': '*', @@ -22,21 +23,21 @@ Deno.serve(async (req) => { // Get authenticated user const { data: { user }, error: userError } = await supabaseClient.auth.getUser(); if (userError || !user) { - console.error('Authentication failed:', userError); + edgeLogger.error('Authentication failed', { action: 'mfa_unenroll_auth' }); return new Response( JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ); } - console.log('[mfa-unenroll] Processing request for user:', user.id); + edgeLogger.info('Processing MFA unenroll', { action: 'mfa_unenroll', userId: user.id }); // Phase 1: Check AAL level const { data: { session } } = await supabaseClient.auth.getSession(); const aal = session?.aal || 'aal1'; if (aal !== 'aal2') { - console.warn('[mfa-unenroll] AAL2 required, current:', aal); + edgeLogger.warn('AAL2 required for MFA removal', { action: 'mfa_unenroll_aal', userId: user.id, aal }); return new Response( JSON.stringify({ error: 'AAL2 required to remove MFA' }), { status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } @@ -52,7 +53,7 @@ Deno.serve(async (req) => { const requiresMFA = roles?.some(r => ['admin', 'moderator', 'superuser'].includes(r.role)); if (requiresMFA) { - console.warn('[mfa-unenroll] Role requires MFA, blocking removal'); + edgeLogger.warn('Role requires MFA, blocking removal', { action: 'mfa_unenroll_role', userId: user.id }); return new Response( JSON.stringify({ error: 'Your role requires MFA and it cannot be disabled' }), { status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } @@ -68,7 +69,7 @@ Deno.serve(async (req) => { .gte('created_at', new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()); if (recentAttempts && recentAttempts.length >= 2) { - console.warn('[mfa-unenroll] Rate limit exceeded:', recentAttempts.length, 'attempts'); + edgeLogger.warn('Rate limit exceeded', { action: 'mfa_unenroll_rate', userId: user.id, attempts: recentAttempts.length }); return new Response( JSON.stringify({ error: 'Rate limit exceeded. Try again in 24 hours.' }), { status: 429, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } @@ -88,7 +89,7 @@ Deno.serve(async (req) => { const { error: unenrollError } = await supabaseClient.auth.mfa.unenroll({ factorId }); if (unenrollError) { - console.error('[mfa-unenroll] Unenroll failed:', unenrollError); + edgeLogger.error('Unenroll failed', { action: 'mfa_unenroll_fail', userId: user.id, error: unenrollError.message }); return new Response( JSON.stringify({ error: unenrollError.message }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } @@ -109,7 +110,7 @@ Deno.serve(async (req) => { }); if (auditError) { - console.error('[mfa-unenroll] Audit log failed:', auditError); + edgeLogger.error('Audit log failed', { action: 'mfa_unenroll_audit', userId: user.id }); } // Send security notification @@ -126,10 +127,10 @@ Deno.serve(async (req) => { } }); } catch (notifError) { - console.error('[mfa-unenroll] Notification failed:', notifError); + edgeLogger.error('Notification failed', { action: 'mfa_unenroll_notification', userId: user.id }); } - console.log('[mfa-unenroll] MFA successfully disabled for user:', user.id); + edgeLogger.info('MFA successfully disabled', { action: 'mfa_unenroll_success', userId: user.id }); return new Response( JSON.stringify({ success: true }), @@ -137,7 +138,7 @@ Deno.serve(async (req) => { ); } catch (error) { - console.error('[mfa-unenroll] Unexpected error:', error); + edgeLogger.error('Unexpected error', { action: 'mfa_unenroll_error', error: error instanceof Error ? error.message : String(error) }); return new Response( JSON.stringify({ error: 'Internal server error' }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } diff --git a/supabase/functions/novu-webhook/index.ts b/supabase/functions/novu-webhook/index.ts index 02216f71..bd58bdb1 100644 --- a/supabase/functions/novu-webhook/index.ts +++ b/supabase/functions/novu-webhook/index.ts @@ -1,5 +1,6 @@ import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4"; +import { edgeLogger } from '../_shared/logger.ts'; const corsHeaders = { 'Access-Control-Allow-Origin': '*', @@ -18,7 +19,7 @@ serve(async (req) => { const event = await req.json(); - console.log('Received Novu webhook event:', event.type); + edgeLogger.info('Received Novu webhook event', { action: 'novu_webhook', eventType: event.type }); // Handle different webhook events switch (event.type) { @@ -35,7 +36,7 @@ serve(async (req) => { await handleNotificationFailed(supabase, event); break; default: - console.log('Unhandled event type:', event.type); + edgeLogger.warn('Unhandled Novu event type', { action: 'novu_webhook', eventType: event.type }); } return new Response( @@ -46,7 +47,7 @@ serve(async (req) => { } ); } catch (error: any) { - console.error('Error processing webhook:', error); + edgeLogger.error('Error processing webhook', { action: 'novu_webhook', error: error?.message }); return new Response( JSON.stringify({ diff --git a/supabase/functions/process-selective-approval/index.ts b/supabase/functions/process-selective-approval/index.ts index efe2c0bd..ca0c68a8 100644 --- a/supabase/functions/process-selective-approval/index.ts +++ b/supabase/functions/process-selective-approval/index.ts @@ -3,6 +3,7 @@ import { serve } from "https://deno.land/std@0.190.0/http/server.ts"; import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4"; import { validateEntityData, validateEntityDataStrict } from "./validation.ts"; import { createErrorResponse } from "../_shared/errorSanitizer.ts"; +import { edgeLogger } from "../_shared/logger.ts"; const corsHeaders = { 'Access-Control-Allow-Origin': '*', @@ -70,16 +71,12 @@ serve(async (req) => { // Verify JWT and get authenticated user const { data: { user }, error: authError } = await supabaseAuth.auth.getUser(); - console.log('[AUTH] User auth result:', { - hasUser: !!user, - userId: user?.id, - hasError: !!authError - }); + edgeLogger.info('User auth check', { action: 'approval_auth', hasUser: !!user, userId: user?.id }); if (authError || !user) { - console.error('[AUTH] Auth verification failed:', { - error: authError?.message, - code: authError?.code + edgeLogger.error('Auth verification failed', { + action: 'approval_auth', + error: authError?.message }); return new Response( JSON.stringify({ @@ -91,7 +88,7 @@ serve(async (req) => { ); } - console.log('[AUTH] Authentication successful:', user.id); + edgeLogger.info('Authentication successful', { action: 'approval_auth_success', userId: user.id }); // SECURITY NOTE: Service role key used later in this function // Reason: Need to bypass RLS to write approved changes to entity tables @@ -112,13 +109,10 @@ serve(async (req) => { .select('role') .eq('user_id', authenticatedUserId); - console.log('[ROLE_CHECK] Query result:', { - rolesCount: roles?.length, - error: rolesError?.message - }); + edgeLogger.info('Role check query result', { action: 'approval_role_check', userId: authenticatedUserId, rolesCount: roles?.length }); if (rolesError) { - console.error('[ROLE_CHECK] Failed:', { error: rolesError.message }); + edgeLogger.error('Role check failed', { action: 'approval_role_check', error: rolesError.message }); return new Response( JSON.stringify({ error: 'Failed to verify user permissions.' }), { status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } @@ -130,17 +124,17 @@ serve(async (req) => { userRoles.includes('admin') || userRoles.includes('superuser'); - console.log('[ROLE_CHECK] Result:', { isModerator, userId: authenticatedUserId }); + edgeLogger.info('Role check result', { action: 'approval_role_result', userId: authenticatedUserId, isModerator }); if (!isModerator) { - console.error('[ROLE_CHECK] Insufficient permissions'); + edgeLogger.error('Insufficient permissions', { action: 'approval_role_insufficient', userId: authenticatedUserId }); return new Response( JSON.stringify({ error: 'Insufficient permissions. Moderator role required.' }), { status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ); } - console.log('[ROLE_CHECK] User is moderator'); + edgeLogger.info('User is moderator', { action: 'approval_role_verified', userId: authenticatedUserId }); // Phase 2: AAL2 Enforcement - Check if user has MFA enrolled and requires AAL2 // Parse JWT directly from Authorization header to get AAL level @@ -148,17 +142,17 @@ serve(async (req) => { const payload = JSON.parse(atob(jwt.split('.')[1])); const aal = payload.aal || 'aal1'; - console.log('[AAL_CHECK] Session AAL level:', { aal, userId: authenticatedUserId }); + edgeLogger.info('Session AAL level', { action: 'approval_aal_check', userId: authenticatedUserId, aal }); // Check if user has MFA enrolled const { data: factorsData } = await supabaseAuth.auth.mfa.listFactors(); const hasMFA = factorsData?.totp?.some(f => f.status === 'verified') || false; - console.log('[MFA_CHECK] MFA status:', { hasMFA, userId: authenticatedUserId }); + edgeLogger.info('MFA status', { action: 'approval_mfa_check', userId: authenticatedUserId, hasMFA }); // Enforce AAL2 if MFA is enrolled if (hasMFA && aal !== 'aal2') { - console.error('[AAL_CHECK] AAL2 required but session is at AAL1'); + edgeLogger.error('AAL2 required but session is at AAL1', { action: 'approval_aal_violation', userId: authenticatedUserId }); return new Response( JSON.stringify({ error: 'MFA verification required', @@ -169,7 +163,7 @@ serve(async (req) => { ); } - console.log('[AAL_CHECK] AAL2 check passed:', { userId: authenticatedUserId, hasMFA, aal }); + edgeLogger.info('AAL2 check passed', { action: 'approval_aal_pass', userId: authenticatedUserId, hasMFA, aal }); const { itemIds, submissionId }: ApprovalRequest = await req.json(); @@ -206,7 +200,7 @@ serve(async (req) => { ); } - console.log('[APPROVAL] Processing selective approval:', { itemIds, userId: authenticatedUserId, submissionId }); + edgeLogger.info('Processing selective approval', { action: 'approval_start', itemCount: itemIds.length, userId: authenticatedUserId, submissionId }); // Fetch all items for the submission const { data: items, error: fetchError } = await supabase @@ -237,11 +231,12 @@ serve(async (req) => { sortedItems = topologicalSort(items); } catch (sortError: unknown) { const errorMessage = sortError instanceof Error ? sortError.message : 'Failed to sort items'; - console.error('[APPROVAL ERROR] Topological sort failed:', { + edgeLogger.error('Topological sort failed', { + action: 'approval_sort_fail', submissionId, itemCount: items.length, - error: errorMessage, - userId: authenticatedUserId + userId: authenticatedUserId, + error: errorMessage }); return new Response( JSON.stringify({ @@ -266,13 +261,14 @@ serve(async (req) => { // Process items in order for (const item of sortedItems) { try { - console.log('[APPROVAL] Processing item:', { itemId: item.id, itemType: item.item_type }); + edgeLogger.info('Processing item', { action: 'approval_process_item', itemId: item.id, itemType: item.item_type }); // Validate entity data with strict validation, passing original_data for edits const validation = validateEntityDataStrict(item.item_type, item.item_data, item.original_data); if (validation.blockingErrors.length > 0) { - console.error('[APPROVAL] Blocking validation errors:', { + edgeLogger.error('Blocking validation errors', { + action: 'approval_validation_fail', itemId: item.id, errors: validation.blockingErrors }); @@ -291,7 +287,8 @@ serve(async (req) => { } if (validation.warnings.length > 0) { - console.warn('[APPROVAL] Validation warnings:', { + edgeLogger.warn('Validation warnings', { + action: 'approval_validation_warning', itemId: item.id, warnings: validation.warnings }); @@ -378,15 +375,16 @@ serve(async (req) => { success: true }); - console.log('[APPROVAL SUCCESS]', { itemId: item.id, entityId, itemType: item.item_type }); + edgeLogger.info('Item approval success', { action: 'approval_item_success', itemId: item.id, entityId, itemType: item.item_type }); } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - console.error('[APPROVAL ERROR] Item approval failed:', { + edgeLogger.error('Item approval failed', { + action: 'approval_item_fail', itemId: item.id, itemType: item.item_type, - error: errorMessage, userId: authenticatedUserId, - submissionId + submissionId, + error: errorMessage }); const isDependencyError = error instanceof Error && ( @@ -428,9 +426,10 @@ serve(async (req) => { .eq('id', update.id); if (batchApproveError) { - console.error('[APPROVAL] Failed to approve item:', { - itemId: update.id, - error: batchApproveError.message + edgeLogger.error('Failed to approve item', { + action: 'approval_batch_approve', + itemId: update.id, + error: batchApproveError.message }); } } @@ -459,9 +458,10 @@ serve(async (req) => { .eq('id', update.id); if (batchRejectError) { - console.error('[APPROVAL] Failed to reject item:', { - itemId: update.id, - error: batchRejectError.message + edgeLogger.error('Failed to reject item', { + action: 'approval_batch_reject', + itemId: update.id, + error: batchRejectError.message }); } } diff --git a/supabase/functions/upload-image/index.ts b/supabase/functions/upload-image/index.ts index 091d50e7..b21ff168 100644 --- a/supabase/functions/upload-image/index.ts +++ b/supabase/functions/upload-image/index.ts @@ -1,5 +1,6 @@ import { serve } from "https://deno.land/std@0.168.0/http/server.ts" import { createClient } from 'https://esm.sh/@supabase/supabase-js@2' +import { edgeLogger } from '../_shared/logger.ts' // Environment-aware CORS configuration const getAllowedOrigin = (requestOrigin: string | null): string | null => { @@ -72,9 +73,9 @@ serve(async (req) => { const requestOrigin = req.headers.get('origin'); const allowedOrigin = getAllowedOrigin(requestOrigin); - // Check if this is a CORS request with a disallowed origin + // Check if this is a CORS request with a disallowed origin if (requestOrigin && !allowedOrigin) { - console.error(`[CORS] Request rejected for disallowed origin: ${requestOrigin}`); + edgeLogger.warn('CORS request rejected', { action: 'cors_validation', origin: requestOrigin }); return new Response( JSON.stringify({ error: 'Origin not allowed', @@ -124,7 +125,7 @@ serve(async (req) => { const { data: { user }, error: authError } = await supabase.auth.getUser() if (authError || !user) { - console.error('Auth verification failed:', authError) + edgeLogger.error('Auth verification failed', { action: 'delete_auth', error: authError?.message }) return new Response( JSON.stringify({ error: 'Invalid authentication', @@ -145,7 +146,7 @@ serve(async (req) => { .single() if (profileError || !profile) { - console.error('Failed to fetch user profile:', profileError) + edgeLogger.error('Failed to fetch user profile', { action: 'delete_profile_check', userId: user.id }) return new Response( JSON.stringify({ error: 'User profile not found',