From 7fa4bcaad184e4ea260b595ca576191b5d04fc1b Mon Sep 17 00:00:00 2001 From: nessi Date: Thu, 9 Jul 2026 13:32:35 +0200 Subject: [PATCH] feat: add policy deletion, improve dry run handling, and enhance policy designer UX Add DELETE /policies/{policy_id} endpoint with audit logging, improve firewall apply to handle dry run mode without calling provider and track operation success separately from applied status, update Proxmox provider error message to clarify rule-to-VM mapping requirement, add dry run explanation text to FirewallPreview with conditional button labels, enhance Policies page with expanded DataTable columns showing source --- backend/app/api/v1/router.py | 51 +++++++++++++++------- backend/app/services/providers/proxmox.py | 6 ++- frontend/src/pages/FirewallPreview.tsx | 7 ++- frontend/src/pages/Policies.tsx | 53 ++++++++++++++++++----- frontend/src/pages/PolicyDesigner.tsx | 16 +++++-- 5 files changed, 103 insertions(+), 30 deletions(-) diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index bfce30f..e68abce 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -725,6 +725,17 @@ def update_policy(policy_id: str, payload: PolicyCreate, user: CurrentUser, db: return policy +@api_router.delete("/policies/{policy_id}") +def delete_policy(policy_id: str, user: CurrentUser, db: Session = Depends(get_db)) -> dict[str, str]: + policy = db.get(Policy, policy_id) + if not policy: + raise HTTPException(status_code=404, detail="Policy not found") + db.delete(policy) + commit_or_400(db) + write_audit(db, action="policy.deleted", object_type="policy", object_id=policy_id, user_id=user.id) + return {"status": "deleted", "id": policy_id} + + @api_router.post("/policies/{policy_id}/compile", response_model=PolicyRead) def compile_policy(policy_id: str, user: CurrentUser, db: Session = Depends(get_db)) -> Policy: from app.services.policy_engine import PolicyEngine @@ -760,24 +771,34 @@ 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 = await FirewallOrchestrator().preview(cluster, policy) - provider = get_provider(cluster.provider) - result = await provider.apply_rules( - ProviderConnection( - api_url=cluster.api_url, - token=cluster.token_ref or "", - verify_tls=cluster.verify_tls, - read_only=payload.dry_run or cluster.mode == "read_only", - ), - preview.generated_rules, - ) + if payload.dry_run: + result = { + "applied": False, + "dry_run": True, + "reason": "Dry run completed. No firewall rules were applied.", + "rules": preview.generated_rules, + } + else: + provider = get_provider(cluster.provider) + result = await provider.apply_rules( + ProviderConnection( + api_url=cluster.api_url, + token=cluster.token_ref or "", + verify_tls=cluster.verify_tls, + read_only=cluster.mode == "read_only", + ), + preview.generated_rules, + ) + applied = bool(result.get("applied")) + operation_success = applied or payload.dry_run job = Job( kind="firewall.apply", - status="success" if result.get("applied") else "failed", + status="success" if operation_success else "failed", progress=100, started_at=datetime.utcnow(), finished_at=datetime.utcnow(), - logs=[f"Policy {policy.name}", f"Dry run: {payload.dry_run}", str(result)], - error=None if result.get("applied") else result.get("reason", "Provider did not apply rules"), + logs=[f"Policy {policy.name}", f"Cluster mode: {cluster.mode}", f"Dry run: {payload.dry_run}", str(result)], + error=None if operation_success else result.get("reason", "Provider did not apply rules"), ) db.add(job) commit_or_400(db) @@ -788,8 +809,8 @@ async def firewall_apply(payload: FirewallApplyRequest, user: CurrentUser, db: S object_id=policy.id, user_id=user.id, new_values={"request": payload.model_dump(), "result": result}, - result="success" if result.get("applied") else "blocked", - error_text=None if result.get("applied") else result.get("reason"), + result="success" if operation_success else "blocked", + error_text=None if operation_success else result.get("reason"), ) return {"job_id": job.id, "preview": preview.model_dump(), "provider_result": result} diff --git a/backend/app/services/providers/proxmox.py b/backend/app/services/providers/proxmox.py index e59b16c..fd53c1a 100644 --- a/backend/app/services/providers/proxmox.py +++ b/backend/app/services/providers/proxmox.py @@ -130,4 +130,8 @@ class ProxmoxProvider(Provider): async def apply_rules(self, connection: ProviderConnection, rules: list[dict[str, Any]]) -> dict[str, Any]: if connection.read_only: return {"applied": False, "reason": "Cluster is read-only", "rules": rules} - return {"applied": False, "reason": "Apply adapter intentionally requires explicit implementation", "rules": rules} + return { + "applied": False, + "reason": "Live Proxmox firewall apply needs rule-to-VM mapping before NexaFabric can safely write provider rules.", + "rules": rules, + } diff --git a/frontend/src/pages/FirewallPreview.tsx b/frontend/src/pages/FirewallPreview.tsx index d4db96a..8626bc6 100644 --- a/frontend/src/pages/FirewallPreview.tsx +++ b/frontend/src/pages/FirewallPreview.tsx @@ -49,13 +49,18 @@ export function FirewallPreview() { setDryRun(event.target.checked)} /> Dry run +
+ {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."} +
diff --git a/frontend/src/pages/Policies.tsx b/frontend/src/pages/Policies.tsx index 0cdb828..8cb2a5d 100644 --- a/frontend/src/pages/Policies.tsx +++ b/frontend/src/pages/Policies.tsx @@ -1,6 +1,6 @@ import { FormEvent, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { GitBranch, Play, Plus } from "lucide-react"; +import { GitBranch, Play, Plus, Trash2 } from "lucide-react"; import { api, Policy, Project, ServiceCatalogItem } from "../api/client"; import { DataTable } from "../components/DataTable"; @@ -8,6 +8,19 @@ import { buttonClass, Field, inputClass, secondaryButtonClass, selectClass } fro import { Modal } from "../components/Modal"; import { PageHeader } from "../components/PageHeader"; +function policyValue(policy: Policy, key: string) { + return String(policy.definition?.[key] ?? ""); +} + +function policyService(policy: Policy) { + const service = policy.definition?.service; + if (!service || typeof service !== "object") { + return ""; + } + const value = service as { protocol?: unknown; ports?: unknown }; + return `${String(value.protocol ?? "")}/${String(value.ports ?? "")}`; +} + export function Policies() { const queryClient = useQueryClient(); const policies = useQuery({ queryKey: ["policies"], queryFn: () => api("/policies") }); @@ -57,6 +70,10 @@ export function Policies() { queryClient.invalidateQueries({ queryKey: ["policies"] }); }, }); + const remove = useMutation({ + mutationFn: (policy: Policy) => api(`/policies/${policy.id}`, { method: "DELETE" }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["policies"] }), + }); async function submit(event: FormEvent) { event.preventDefault(); @@ -110,15 +127,31 @@ export function Policies() {
- []} columns={[{ key: "name", label: "Policy" }, { key: "version", label: "Version" }, { key: "enabled", label: "Enabled" }]} /> -
- {(policies.data ?? []).map((policy) => ( -
- - -
- ))} -
+ []} + columns={[ + { key: "name", label: "Policy" }, + { key: "source", label: "Source", render: (row) => policyValue(row as unknown as Policy, "source") }, + { key: "destination", label: "Destination", render: (row) => policyValue(row as unknown as Policy, "destination") }, + { key: "service", label: "Service", render: (row) => policyService(row as unknown as Policy) }, + { key: "enforcement_mode", label: "Mode" }, + { key: "version", label: "Version" }, + { + key: "actions", + label: "Actions", + render: (row) => { + const policy = row as unknown as Policy; + return ( +
+ + + +
+ ); + }, + }, + ]} + />
{preview || "No policy output yet."}
diff --git a/frontend/src/pages/PolicyDesigner.tsx b/frontend/src/pages/PolicyDesigner.tsx index 9b2c2b6..b32f855 100644 --- a/frontend/src/pages/PolicyDesigner.tsx +++ b/frontend/src/pages/PolicyDesigner.tsx @@ -19,7 +19,7 @@ export function PolicyDesigner() { const services = useQuery({ queryKey: ["service-catalog"], queryFn: () => api("/service-catalog") }); const [preview, setPreview] = useState | null>(null); const [form, setForm] = useState({ - name: "Designed Policy", + name: "", source: "any", destination: "any", service_id: "", @@ -45,7 +45,7 @@ export function PolicyDesigner() { const service = services.data?.find((item) => item.id === form.service_id); return { project_id: null, - name: form.name, + name: form.name.trim(), enabled: true, definition: { source: form.source, @@ -95,6 +95,16 @@ export function PolicyDesigner() {
+ + setForm({ ...form, name: event.target.value })} + placeholder="DNS from VMs to resolver" + required + /> + +