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 + /> + +