From 5302a8bc82e830a2413c7dd458c744df2ac210ec Mon Sep 17 00:00:00 2001 From: nessi Date: Thu, 9 Jul 2026 15:57:23 +0200 Subject: [PATCH] feat: add policy enforcement mode normalization and audit mode protection for firewall apply operations Add normalized_policy_definition helper to validate and default enforcement_mode to "enforced" or "audit" when creating/updating policies, extend firewall_apply to block live apply when policy is in audit mode with explanatory message, add policy_mode field to all firewall apply response paths, update FirewallPreview UI to show enforcement mode in policy dropdown with version number, display audit mode warning --- backend/app/api/v1/router.py | 25 +++++++++++++++++++++++-- frontend/src/pages/FirewallPreview.tsx | 12 ++++++++---- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index a35dae8..a14e066 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -261,6 +261,13 @@ def flow_int(value: object, default: int = 0) -> int: return default +def normalized_policy_definition(definition: dict) -> dict: + normalized = dict(definition or {}) + mode = str(normalized.get("enforcement_mode") or "enforced").lower() + normalized["enforcement_mode"] = mode if mode in {"enforced", "audit"} else "enforced" + return normalized + + def subnet_label_for_ip(subnets: list[Subnet], value: str) -> str | None: try: address = ip_address(value) @@ -1339,7 +1346,7 @@ def policies(_: CurrentUser, db: Session = Depends(get_db)) -> list[Policy]: @api_router.post("/policies", response_model=PolicyRead) def create_policy(payload: PolicyCreate, user: CurrentUser, db: Session = Depends(get_db)) -> Policy: - policy = Policy(project_id=payload.project_id, name=payload.name, enabled=payload.enabled, definition=payload.definition) + policy = Policy(project_id=payload.project_id, name=payload.name, enabled=payload.enabled, definition=normalized_policy_definition(payload.definition)) db.add(policy) commit_or_400(db) db.refresh(policy) @@ -1356,7 +1363,7 @@ def update_policy(policy_id: str, payload: PolicyCreate, user: CurrentUser, db: policy.project_id = payload.project_id policy.name = payload.name policy.enabled = payload.enabled - policy.definition = payload.definition + policy.definition = normalized_policy_definition(payload.definition) policy.version += 1 commit_or_400(db) db.refresh(policy) @@ -1410,16 +1417,27 @@ async def firewall_apply(payload: FirewallApplyRequest, user: CurrentUser, db: S if not policy or not cluster: raise HTTPException(status_code=404, detail="Policy or cluster not found") preview = resolve_firewall_preview(db, cluster, await FirewallOrchestrator().preview(cluster, policy)) + writable_rules = [rule for rule in preview.generated_rules if not rule.get("audit_only")] + policy_mode = policy.enforcement_mode if payload.dry_run: result = { "applied": False, "dry_run": True, + "policy_mode": policy_mode, "reason": "Dry run completed. No firewall rules were applied.", "rules": preview.generated_rules, } + elif preview.generated_rules and not writable_rules: + result = { + "applied": False, + "policy_mode": policy_mode, + "reason": "Policy is in audit mode. Audit policies do not write Proxmox firewall rules. Change the policy mode to enforced before live apply.", + "rules": preview.generated_rules, + } elif preview.conflicts: result = { "applied": False, + "policy_mode": policy_mode, "reason": "Live apply stopped because the preview has unresolved conflicts.", "conflicts": preview.conflicts, "rules": preview.generated_rules, @@ -1439,9 +1457,12 @@ async def firewall_apply(payload: FirewallApplyRequest, user: CurrentUser, db: S except Exception as exc: result = { "applied": False, + "policy_mode": policy_mode, "reason": f"Provider apply failed: {exc}", "rules": preview.generated_rules, } + else: + result["policy_mode"] = policy_mode applied = bool(result.get("applied")) operation_success = applied or payload.dry_run job = Job( diff --git a/frontend/src/pages/FirewallPreview.tsx b/frontend/src/pages/FirewallPreview.tsx index 69bac45..15fbb73 100644 --- a/frontend/src/pages/FirewallPreview.tsx +++ b/frontend/src/pages/FirewallPreview.tsx @@ -29,6 +29,8 @@ export function FirewallPreview() { }), }); const selectedPolicyId = policyId || policies.data?.[0]?.id || ""; + const selectedPolicy = (policies.data ?? []).find((policy) => policy.id === selectedPolicyId); + const auditMode = selectedPolicy?.enforcement_mode === "audit"; const busyMessage = preview.isPending ? "Generating firewall preview..." : apply.isPending @@ -46,7 +48,7 @@ export function FirewallPreview() {
@@ -59,7 +61,9 @@ export function FirewallPreview() { Dry run
- {dryRun + {auditMode + ? "This policy is in audit mode. Preview and dry apply are allowed, but live apply will not write Proxmox firewall rules." + : dryRun ? "Simulation only. NexaFabric will generate the same provider rules, but nothing is written to Proxmox." : "Live apply. NexaFabric will send the generated rules to the selected write-enabled cluster."}
@@ -67,9 +71,9 @@ export function FirewallPreview() { Generate Preview -