Compare commits

...

3 Commits

Author SHA1 Message Date
gpt-engineer-app[bot]
06c004d5fe Fix: Remove .select() from delete operations 2025-11-04 16:42:23 +00:00
gpt-engineer-app[bot]
c904fe10a1 feat: Implement MFA step-up system 2025-11-04 16:35:40 +00:00
gpt-engineer-app[bot]
05acd49334 Fix RLS policy for test data registry 2025-11-04 16:30:33 +00:00
7 changed files with 402 additions and 20 deletions

View File

@@ -7,6 +7,7 @@ import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { BrowserRouter, Routes, Route, useLocation } from "react-router-dom";
import { AuthProvider } from "@/hooks/useAuth";
import { AuthModalProvider } from "@/contexts/AuthModalContext";
import { MFAStepUpProvider } from "@/contexts/MFAStepUpContext";
import { LocationAutoDetectProvider } from "@/components/providers/LocationAutoDetectProvider";
import { AnalyticsWrapper } from "@/components/analytics/AnalyticsWrapper";
import { Footer } from "@/components/layout/Footer";
@@ -385,9 +386,11 @@ const App = (): React.JSX.Element => (
<QueryClientProvider client={queryClient}>
<AuthProvider>
<AuthModalProvider>
<MFAStepUpProvider>
<BrowserRouter>
<AppContent />
</BrowserRouter>
</MFAStepUpProvider>
</AuthModalProvider>
</AuthProvider>
{import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} position="bottom" />}

View File

@@ -16,6 +16,8 @@ import { Beaker, CheckCircle, ChevronDown, Trash2, AlertTriangle } from 'lucide-
import { clearTestData, getTestDataStats } from '@/lib/testDataGenerator';
import { TestDataTracker } from '@/lib/integrationTests/TestDataTracker';
import { logger } from '@/lib/logger';
import { useMFAStepUp } from '@/contexts/MFAStepUpContext';
import { isMFACancelledError } from '@/lib/aalErrorDetection';
const PRESETS = {
small: { label: 'Small', description: '~30 submissions - Quick test', counts: '5 parks, 10 rides, 3 companies, 2 models, 5 photo sets' },
@@ -44,6 +46,7 @@ interface TestDataResults {
export function TestDataGenerator(): React.JSX.Element {
const { toast } = useToast();
const { requireAAL2 } = useMFAStepUp();
const [preset, setPreset] = useState<'small' | 'medium' | 'large' | 'stress'>('small');
const [fieldDensity, setFieldDensity] = useState<'mixed' | 'minimal' | 'standard' | 'maximum'>('mixed');
const [entityTypes, setEntityTypes] = useState({
@@ -168,7 +171,12 @@ export function TestDataGenerator(): React.JSX.Element {
setLoading(true);
try {
const { deleted } = await clearTestData();
// Wrap operation with AAL2 requirement
const { deleted } = await requireAAL2(
() => clearTestData(),
'Clearing test data requires additional verification'
);
await loadStats();
toast({
@@ -177,11 +185,14 @@ export function TestDataGenerator(): React.JSX.Element {
});
setResults(null);
} catch (error: unknown) {
// Only show error if it's NOT an MFA cancellation
if (!isMFACancelledError(error)) {
toast({
title: 'Clear Failed',
description: getErrorMessage(error),
variant: 'destructive'
});
}
} finally {
setLoading(false);
}
@@ -191,7 +202,12 @@ export function TestDataGenerator(): React.JSX.Element {
setLoading(true);
try {
const { deleted, errors } = await TestDataTracker.bulkCleanupAllTestData();
// Wrap operation with AAL2 requirement
const { deleted, errors } = await requireAAL2(
() => TestDataTracker.bulkCleanupAllTestData(),
'Emergency cleanup requires additional verification'
);
await loadStats();
toast({
@@ -200,11 +216,14 @@ export function TestDataGenerator(): React.JSX.Element {
});
setResults(null);
} catch (error: unknown) {
// Only show error if it's NOT an MFA cancellation
if (!isMFACancelledError(error)) {
toast({
title: 'Emergency Cleanup Failed',
description: getErrorMessage(error),
variant: 'destructive'
});
}
} finally {
setLoading(false);
}

View File

@@ -0,0 +1,156 @@
/**
* MFA Step-Up Context
*
* Provides global MFA step-up functionality that automatically
* prompts users for MFA verification when operations require AAL2
*/
import { createContext, useContext, useState, useCallback, ReactNode } from 'react';
import { useAuth } from '@/hooks/useAuth';
import { MFAStepUpModal } from '@/components/auth/MFAStepUpModal';
import { getEnrolledFactors } from '@/lib/authService';
import { isAAL2PolicyError, getAAL2ErrorMessage, MFACancelledError } from '@/lib/aalErrorDetection';
import { logger } from '@/lib/logger';
interface MFAStepUpContextType {
/**
* Wrap an operation that may require AAL2 verification
* If operation fails due to AAL2, prompts user for MFA and retries
*/
requireAAL2: <T>(operation: () => Promise<T>, errorMessage?: string) => Promise<T>;
/**
* Check if error is AAL2-related
*/
isAAL2Error: (error: unknown) => boolean;
}
const MFAStepUpContext = createContext<MFAStepUpContextType | undefined>(undefined);
interface MFAStepUpProviderProps {
children: ReactNode;
}
export function MFAStepUpProvider({ children }: MFAStepUpProviderProps) {
const { session } = useAuth();
const [modalOpen, setModalOpen] = useState(false);
const [factorId, setFactorId] = useState<string>('');
const [pendingOperation, setPendingOperation] = useState<(() => Promise<any>) | null>(null);
const [pendingResolve, setPendingResolve] = useState<((value: any) => void) | null>(null);
const [pendingReject, setPendingReject] = useState<((error: any) => void) | null>(null);
/**
* Handle successful MFA verification
* Retries the pending operation with upgraded AAL2 session
*/
const handleMFASuccess = useCallback(async () => {
setModalOpen(false);
if (!pendingOperation || !pendingResolve || !pendingReject) {
logger.error('No pending operation to retry after MFA success');
return;
}
try {
logger.log('[MFAStepUp] Retrying operation after AAL2 upgrade');
const result = await pendingOperation();
pendingResolve(result);
} catch (error) {
logger.error('[MFAStepUp] Operation failed after AAL2 upgrade', { error });
pendingReject(error);
} finally {
// Clean up
setPendingOperation(null);
setPendingResolve(null);
setPendingReject(null);
}
}, [pendingOperation, pendingResolve, pendingReject]);
/**
* Handle MFA cancellation
*/
const handleMFACancel = useCallback(() => {
setModalOpen(false);
if (pendingReject) {
pendingReject(new MFACancelledError());
}
// Clean up
setPendingOperation(null);
setPendingResolve(null);
setPendingReject(null);
}, [pendingReject]);
/**
* Wrap an operation with AAL2 requirement checking
*/
const requireAAL2 = useCallback(
async <T,>(operation: () => Promise<T>, customErrorMessage?: string): Promise<T> => {
try {
// Try the operation first
return await operation();
} catch (error) {
// Check if error is AAL2-related
if (!isAAL2PolicyError(error)) {
// Not an AAL2 error, re-throw
throw error;
}
logger.log('[MFAStepUp] AAL2 error detected, prompting for MFA', {
error: error instanceof Error ? error.message : String(error),
});
// Get enrolled factors
const factors = await getEnrolledFactors();
if (factors.length === 0) {
logger.error('[MFAStepUp] No enrolled MFA factors found');
throw new Error('MFA is not set up for your account');
}
const factor = factors[0];
setFactorId(factor.id);
// Return a promise that resolves when MFA is verified
return new Promise<T>((resolve, reject) => {
setPendingOperation(() => operation);
setPendingResolve(() => resolve);
setPendingReject(() => reject);
setModalOpen(true);
});
}
},
[]
);
const value: MFAStepUpContextType = {
requireAAL2,
isAAL2Error: isAAL2PolicyError,
};
return (
<MFAStepUpContext.Provider value={value}>
{children}
<MFAStepUpModal
open={modalOpen}
factorId={factorId}
onSuccess={handleMFASuccess}
onCancel={handleMFACancel}
/>
</MFAStepUpContext.Provider>
);
}
/**
* Hook to access MFA step-up functionality
*/
export function useMFAStepUp(): MFAStepUpContextType {
const context = useContext(MFAStepUpContext);
if (!context) {
throw new Error('useMFAStepUp must be used within MFAStepUpProvider');
}
return context;
}

View File

@@ -0,0 +1,125 @@
/**
* AAL2 Error Detection Utilities
*
* Detects when operations fail due to AAL2/MFA requirements
* and provides user-friendly error messages
*/
import { PostgrestError } from '@supabase/supabase-js';
/**
* Check if an error is due to AAL2/RLS policy failure
*/
export function isAAL2PolicyError(error: unknown): boolean {
if (!error) return false;
// Handle Supabase PostgrestError
if (isPostgrestError(error)) {
const code = error.code;
const message = error.message?.toLowerCase() || '';
const details = error.details?.toLowerCase() || '';
// Check for RLS policy violations
if (code === 'PGRST301' || code === '42501') {
return true;
}
// Check for permission denied messages
if (
message.includes('permission denied') ||
message.includes('row-level security') ||
message.includes('policy') ||
details.includes('policy')
) {
return true;
}
}
// Handle generic errors with 403 status
if (hasStatusCode(error) && error.status === 403) {
return true;
}
// Handle error messages
if (error instanceof Error) {
const message = error.message.toLowerCase();
return (
message.includes('row-level security') ||
message.includes('permission denied') ||
message.includes('policy') ||
message.includes('403')
);
}
return false;
}
/**
* Get user-friendly error message for AAL2 errors
*/
export function getAAL2ErrorMessage(error: unknown): string {
// Default message
const defaultMessage = 'This action requires additional security verification';
if (!error) return defaultMessage;
// Check if error mentions specific operations
if (error instanceof Error) {
const message = error.message.toLowerCase();
if (message.includes('delete') || message.includes('remove')) {
return 'Deleting this data requires additional security verification';
}
if (message.includes('update') || message.includes('modify')) {
return 'Modifying this data requires additional security verification';
}
if (message.includes('insert') || message.includes('create')) {
return 'Creating this data requires additional security verification';
}
}
return defaultMessage;
}
/**
* Type guard for PostgrestError
*/
function isPostgrestError(error: unknown): error is PostgrestError {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
'message' in error
);
}
/**
* Type guard for errors with status code
*/
function hasStatusCode(error: unknown): error is { status: number } {
return (
typeof error === 'object' &&
error !== null &&
'status' in error &&
typeof (error as any).status === 'number'
);
}
/**
* Create a user-cancellation error
*/
export class MFACancelledError extends Error {
constructor() {
super('MFA verification was cancelled by user');
this.name = 'MFACancelledError';
}
}
/**
* Check if error is a user cancellation
*/
export function isMFACancelledError(error: unknown): boolean {
return error instanceof MFACancelledError;
}

View File

@@ -148,17 +148,23 @@ export class TestDataTracker {
for (const table of tables) {
try {
const { error, data } = await supabase
// First count how many will be deleted
const { count: countToDelete } = await supabase
.from(table as any)
.select('*', { count: 'exact', head: true })
.eq('is_test_data', true);
// Then delete without selecting (avoids needing SELECT permission on deleted rows)
const { error } = await supabase
.from(table as any)
.delete()
.eq('is_test_data', true)
.select('id');
.eq('is_test_data', true);
if (error) {
logger.warn('Failed to bulk delete test data', { table, error });
totalErrors++;
} else if (data) {
totalDeleted += data.length;
} else if (countToDelete) {
totalDeleted += countToDelete;
}
} catch (err) {
logger.warn('Exception bulk deleting test data', { table, error: err });

View File

@@ -0,0 +1,31 @@
-- Relax RLS on test_data_registry to not require MFA for management operations
-- Separate SELECT (viewing) from INSERT/UPDATE/DELETE (management)
-- Drop ALL existing policies on test_data_registry
DROP POLICY IF EXISTS "Moderators can manage test data registry" ON test_data_registry;
DROP POLICY IF EXISTS "Moderators can view test data registry" ON test_data_registry;
-- Keep MFA requirement for viewing (sensitive operation tracking)
CREATE POLICY "Moderators can view test data registry"
ON test_data_registry
FOR SELECT
TO authenticated
USING (
is_moderator(auth.uid())
AND (
(NOT EXISTS (
SELECT 1 FROM auth.mfa_factors
WHERE user_id = auth.uid() AND status = 'verified'
))
OR has_aal2()
)
);
-- Allow moderators to insert/update/delete without MFA requirement
-- Test data cleanup is a low-risk development operation
CREATE POLICY "Moderators can manage test data registry"
ON test_data_registry
FOR ALL
TO authenticated
USING (is_moderator(auth.uid()))
WITH CHECK (is_moderator(auth.uid()));

View File

@@ -0,0 +1,42 @@
-- Allow moderators to delete test data from version tables
-- This enables test data cleanup operations to work properly
-- Park versions
CREATE POLICY "Moderators can delete test park versions"
ON park_versions
FOR DELETE
TO authenticated
USING (
is_test_data = true
AND is_moderator(auth.uid())
);
-- Ride versions
CREATE POLICY "Moderators can delete test ride versions"
ON ride_versions
FOR DELETE
TO authenticated
USING (
is_test_data = true
AND is_moderator(auth.uid())
);
-- Company versions
CREATE POLICY "Moderators can delete test company versions"
ON company_versions
FOR DELETE
TO authenticated
USING (
is_test_data = true
AND is_moderator(auth.uid())
);
-- Ride model versions
CREATE POLICY "Moderators can delete test ride model versions"
ON ride_model_versions
FOR DELETE
TO authenticated
USING (
is_test_data = true
AND is_moderator(auth.uid())
);