From 7d3eeebcb7eea8110f3a69bbec7e483f2fe8d812 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Taylan=20Bak=C4=B1rc=C4=B1o=C4=9Flu?= Date: Mon, 17 Nov 2025 13:07:55 +0300 Subject: [PATCH] fix(waf): Require frontend selection with backend + frontend validation COMPLETE UX FIX: Backend validation + Frontend required field Changes Summary: 1. Backend API validation (waf.py) 2. Frontend API validation (frontend.py) 3. Frontend UI required field (WAFManagement.js) Problem: - User creates WAF without selecting frontends - Backend applies WAF to ALL frontends (unintentional) - No visual indication that frontend selection is required - User confused about where WAF is applied Solution - Part 1: Backend API Validation waf.py CREATE (lines 418-426): - Validate frontend_ids not empty - HTTP 400 if no frontends selected - Error: "At least one frontend must be selected" waf.py UPDATE (lines 686-693): - Validate if frontend_ids explicitly provided - HTTP 400 if trying to clear all frontends - Allow config-only updates (preserve frontends) Solution - Part 2: Frontend Validation frontend.py CREATE (lines 408-428): - Validate backend has active servers - HTTP 400 if backend has no servers - Error: "Backend has no active servers. Add servers first." frontend.py UPDATE (lines 650-670): - Same validation when changing default_backend - Prevent routing to DOWN backends Solution - Part 3: Frontend UI (User Experience) WAFManagement.js (lines 1473-1508): BEFORE: - Label: "Target Frontends" - Tooltip: "Can be left empty for globally available WAF" - Placeholder: "Select frontends" - No validation - Optional field appearance AFTER: - Label: "Target Frontends" (with red asterisk) - Required validation rules: * Antd required: true * Custom validator: at least 1 frontend - Placeholder: "Select frontends (Required *)" - Tooltip: "At least one frontend is required" - Search enabled for easy filtering - Error messages: * "Please select at least one frontend" * "At least one frontend must be selected for WAF rule" User Experience Improvements: 1. Visual indication: Red asterisk on label 2. Clear placeholder text: "(Required *)" 3. Helpful tooltip: Explains requirement 4. Client-side validation: Immediate feedback 5. Server-side validation: Safety net 6. Searchable dropdown: Easy to find frontends 7. Clear error messages: User knows what to do Test Scenarios: 1. Create WAF without selecting frontend: - UI: Red error "Please select at least one frontend" - Submit blocked (client-side) 2. Bypass client-side, try API: - API: HTTP 400 "At least one frontend must be selected" 3. Create frontend with server-less backend: - UI: Can select backend - API: HTTP 400 "Backend has no active servers" 4. Update WAF remove all frontends: - UI: Red error message - API: HTTP 400 if bypassed Related: bcb8ef0 (backend without servers) Refs: #waf-validation #frontend-validation #ux-improvement --- backend/routers/frontend.py | 44 ++++++++++++++++++++++++ backend/routers/waf.py | 19 ++++++++++ frontend/src/components/WAFManagement.js | 22 ++++++++++-- 3 files changed, 83 insertions(+), 2 deletions(-) diff --git a/backend/routers/frontend.py b/backend/routers/frontend.py index e001908..f7b3b80 100644 --- a/backend/routers/frontend.py +++ b/backend/routers/frontend.py @@ -405,6 +405,28 @@ async def create_frontend(frontend: FrontendConfig, request: Request, authorizat await close_database_connection(conn) raise HTTPException(status_code=400, detail=f"Frontend '{frontend.name}' already exists") + # CRITICAL VALIDATION: Check if default_backend has active servers + # Use case: Prevent frontend from routing to DOWN backend (no servers = 503 errors) + # HAProxy allows this (syntax valid) but it's bad practice for production + if frontend.default_backend: + backend_has_servers = await conn.fetchval(""" + SELECT EXISTS( + SELECT 1 FROM backend_servers bs + JOIN backends b ON bs.backend_name = b.name AND bs.cluster_id = b.cluster_id + WHERE b.name = $1 + AND b.cluster_id = $2 + AND b.is_active = TRUE + AND bs.is_active = TRUE + ) + """, frontend.default_backend, frontend.cluster_id) + + if not backend_has_servers: + await close_database_connection(conn) + raise HTTPException( + status_code=400, + detail=f"Backend '{frontend.default_backend}' has no active servers. Please add at least one server to the backend before assigning it to a frontend." + ) + # ENTERPRISE DUAL-MODE: Save ssl_certificate_ids (NEW) and ssl_certificate_id (OLD - backward compat) # Convert ssl_certificate_ids to JSONB for database ssl_cert_ids_json = json.dumps(frontend.ssl_certificate_ids) if frontend.ssl_certificate_ids else '[]' @@ -625,6 +647,28 @@ async def update_frontend(frontend_id: int, frontend: FrontendConfig, request: R await close_database_connection(conn) raise HTTPException(status_code=400, detail=f"Frontend name '{frontend.name}' already exists") + # CRITICAL VALIDATION: Check if default_backend has active servers + # Use case: Prevent frontend from routing to DOWN backend (no servers = 503 errors) + # HAProxy allows this (syntax valid) but it's bad practice for production + if frontend.default_backend: + backend_has_servers = await conn.fetchval(""" + SELECT EXISTS( + SELECT 1 FROM backend_servers bs + JOIN backends b ON bs.backend_name = b.name AND bs.cluster_id = b.cluster_id + WHERE b.name = $1 + AND (b.cluster_id = $2 OR b.cluster_id IS NULL) + AND b.is_active = TRUE + AND bs.is_active = TRUE + ) + """, frontend.default_backend, cluster_id) + + if not backend_has_servers: + await close_database_connection(conn) + raise HTTPException( + status_code=400, + detail=f"Backend '{frontend.default_backend}' has no active servers. Please add at least one server to the backend before assigning it to a frontend." + ) + # CRITICAL FIX: Preserve SSL configuration if not explicitly changed # If SSL is currently enabled but incoming data has ssl_enabled=False or ssl_certificate_id=None, # check if this is an intentional change or just missing data from the form diff --git a/backend/routers/waf.py b/backend/routers/waf.py index ef126b7..20eae80 100644 --- a/backend/routers/waf.py +++ b/backend/routers/waf.py @@ -415,6 +415,16 @@ async def create_waf_rule(waf_rule_data: dict, cluster_id: Optional[int] = None, cluster_info = f" in cluster {cluster_id}" if cluster_id else "" raise HTTPException(status_code=400, detail=f"WAF rule '{waf_rule.name}' already exists{cluster_info}") + # CRITICAL VALIDATION: At least one frontend must be selected + # Use case: Prevent unintentional application to ALL frontends + # WAF rule without frontend = no config change = should not create pending version + if not waf_rule.frontend_ids or len(waf_rule.frontend_ids) == 0: + await close_database_connection(conn) + raise HTTPException( + status_code=400, + detail="At least one frontend must be selected for WAF rule. Please select target frontend(s) where this WAF rule should be applied." + ) + async with conn.transaction(): rule_id = await conn.fetchval(""" INSERT INTO waf_rules (name, rule_type, config, action, priority, description, enabled, is_active, cluster_id) @@ -673,6 +683,15 @@ async def update_waf_rule(rule_id: int, waf_rule_data: dict, request: Request, a frontend_ids_in_payload = 'frontend_ids' in waf_rule_data if frontend_ids_in_payload: + # CRITICAL VALIDATION: If frontend_ids explicitly provided, must have at least one + # Use case: Prevent clearing all frontends (WAF rule would apply to nothing) + if not waf_rule.frontend_ids or len(waf_rule.frontend_ids) == 0: + await close_database_connection(conn) + raise HTTPException( + status_code=400, + detail="At least one frontend must be selected for WAF rule. Cannot remove all frontend assignments. Please select target frontend(s)." + ) + # Frontend IDs were explicitly provided in the request (could be [] or [1,2,3]) await conn.execute("DELETE FROM frontend_waf_rules WHERE waf_rule_id = $1", rule_id) frontend_assignments, cluster_ids = await assign_frontends_and_get_clusters(conn, rule_id, waf_rule.frontend_ids or [], existing_rule.get("cluster_id")) diff --git a/frontend/src/components/WAFManagement.js b/frontend/src/components/WAFManagement.js index addc85d..cac084a 100644 --- a/frontend/src/components/WAFManagement.js +++ b/frontend/src/components/WAFManagement.js @@ -1473,13 +1473,31 @@ const WAFManagement = () => { { + if (!value || value.length === 0) { + return Promise.reject('At least one frontend must be selected for WAF rule'); + } + return Promise.resolve(); + } + } + ]} + tooltip="Select which frontend(s) this WAF rule should be applied to. At least one frontend is required." >