feat: add policy deletion, improve dry run handling, and enhance policy designer UX
CI / backend (push) Failing after 3s
CI / frontend (push) Failing after 32s

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
This commit is contained in:
2026-07-09 13:32:35 +02:00
parent b382d4362c
commit 7fa4bcaad1
5 changed files with 103 additions and 30 deletions
+27 -6
View File
@@ -725,6 +725,17 @@ def update_policy(policy_id: str, payload: PolicyCreate, user: CurrentUser, db:
return policy 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) @api_router.post("/policies/{policy_id}/compile", response_model=PolicyRead)
def compile_policy(policy_id: str, user: CurrentUser, db: Session = Depends(get_db)) -> Policy: def compile_policy(policy_id: str, user: CurrentUser, db: Session = Depends(get_db)) -> Policy:
from app.services.policy_engine import PolicyEngine 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: if not policy or not cluster:
raise HTTPException(status_code=404, detail="Policy or cluster not found") raise HTTPException(status_code=404, detail="Policy or cluster not found")
preview = await FirewallOrchestrator().preview(cluster, policy) preview = await FirewallOrchestrator().preview(cluster, policy)
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) provider = get_provider(cluster.provider)
result = await provider.apply_rules( result = await provider.apply_rules(
ProviderConnection( ProviderConnection(
api_url=cluster.api_url, api_url=cluster.api_url,
token=cluster.token_ref or "", token=cluster.token_ref or "",
verify_tls=cluster.verify_tls, verify_tls=cluster.verify_tls,
read_only=payload.dry_run or cluster.mode == "read_only", read_only=cluster.mode == "read_only",
), ),
preview.generated_rules, preview.generated_rules,
) )
applied = bool(result.get("applied"))
operation_success = applied or payload.dry_run
job = Job( job = Job(
kind="firewall.apply", kind="firewall.apply",
status="success" if result.get("applied") else "failed", status="success" if operation_success else "failed",
progress=100, progress=100,
started_at=datetime.utcnow(), started_at=datetime.utcnow(),
finished_at=datetime.utcnow(), finished_at=datetime.utcnow(),
logs=[f"Policy {policy.name}", f"Dry run: {payload.dry_run}", str(result)], logs=[f"Policy {policy.name}", f"Cluster mode: {cluster.mode}", f"Dry run: {payload.dry_run}", str(result)],
error=None if result.get("applied") else result.get("reason", "Provider did not apply rules"), error=None if operation_success else result.get("reason", "Provider did not apply rules"),
) )
db.add(job) db.add(job)
commit_or_400(db) commit_or_400(db)
@@ -788,8 +809,8 @@ async def firewall_apply(payload: FirewallApplyRequest, user: CurrentUser, db: S
object_id=policy.id, object_id=policy.id,
user_id=user.id, user_id=user.id,
new_values={"request": payload.model_dump(), "result": result}, new_values={"request": payload.model_dump(), "result": result},
result="success" if result.get("applied") else "blocked", result="success" if operation_success else "blocked",
error_text=None if result.get("applied") else result.get("reason"), error_text=None if operation_success else result.get("reason"),
) )
return {"job_id": job.id, "preview": preview.model_dump(), "provider_result": result} return {"job_id": job.id, "preview": preview.model_dump(), "provider_result": result}
+5 -1
View File
@@ -130,4 +130,8 @@ class ProxmoxProvider(Provider):
async def apply_rules(self, connection: ProviderConnection, rules: list[dict[str, Any]]) -> dict[str, Any]: async def apply_rules(self, connection: ProviderConnection, rules: list[dict[str, Any]]) -> dict[str, Any]:
if connection.read_only: if connection.read_only:
return {"applied": False, "reason": "Cluster is read-only", "rules": rules} 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,
}
+6 -1
View File
@@ -49,13 +49,18 @@ export function FirewallPreview() {
<input type="checkbox" checked={dryRun} onChange={(event) => setDryRun(event.target.checked)} /> <input type="checkbox" checked={dryRun} onChange={(event) => setDryRun(event.target.checked)} />
Dry run Dry run
</label> </label>
<div className="rounded-md border border-border bg-canvas p-3 text-xs text-slate-500 dark:text-slate-400">
{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."}
</div>
<button className={secondaryButtonClass} disabled={!selectedPolicyId} onClick={() => preview.mutate(selectedPolicyId)}> <button className={secondaryButtonClass} disabled={!selectedPolicyId} onClick={() => preview.mutate(selectedPolicyId)}>
<Play size={18} /> <Play size={18} />
Generate Preview Generate Preview
</button> </button>
<button className={buttonClass} disabled={!selectedPolicyId || apply.isPending} onClick={() => apply.mutate()}> <button className={buttonClass} disabled={!selectedPolicyId || apply.isPending} onClick={() => apply.mutate()}>
<ShieldCheck size={18} /> <ShieldCheck size={18} />
Apply Confirmed {dryRun ? "Run Dry Apply" : "Apply Confirmed"}
</button> </button>
</div> </div>
</section> </section>
+40 -7
View File
@@ -1,6 +1,6 @@
import { FormEvent, useState } from "react"; import { FormEvent, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 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 { api, Policy, Project, ServiceCatalogItem } from "../api/client";
import { DataTable } from "../components/DataTable"; import { DataTable } from "../components/DataTable";
@@ -8,6 +8,19 @@ import { buttonClass, Field, inputClass, secondaryButtonClass, selectClass } fro
import { Modal } from "../components/Modal"; import { Modal } from "../components/Modal";
import { PageHeader } from "../components/PageHeader"; 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() { export function Policies() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const policies = useQuery({ queryKey: ["policies"], queryFn: () => api<Policy[]>("/policies") }); const policies = useQuery({ queryKey: ["policies"], queryFn: () => api<Policy[]>("/policies") });
@@ -57,6 +70,10 @@ export function Policies() {
queryClient.invalidateQueries({ queryKey: ["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) { async function submit(event: FormEvent) {
event.preventDefault(); event.preventDefault();
@@ -110,15 +127,31 @@ export function Policies() {
</form> </form>
</Modal> </Modal>
<section className="space-y-4"> <section className="space-y-4">
<DataTable rows={(policies.data ?? []) as unknown as Record<string, unknown>[]} columns={[{ key: "name", label: "Policy" }, { key: "version", label: "Version" }, { key: "enabled", label: "Enabled" }]} /> <DataTable
rows={(policies.data ?? []) as unknown as Record<string, unknown>[]}
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 (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{(policies.data ?? []).map((policy) => ( <button className={secondaryButtonClass} onClick={() => compile(policy)}><Play size={16} /> Compile</button>
<div key={policy.id} className="flex gap-2">
<button className={secondaryButtonClass} onClick={() => compile(policy)}><Play size={16} /> Compile {policy.name}</button>
<button className={secondaryButtonClass} onClick={() => firewallPreview(policy)}><Play size={16} /> Preview</button> <button className={secondaryButtonClass} onClick={() => firewallPreview(policy)}><Play size={16} /> Preview</button>
<button className={secondaryButtonClass} disabled={remove.isPending} onClick={() => remove.mutate(policy)}><Trash2 size={16} /> Delete</button>
</div> </div>
))} );
</div> },
},
]}
/>
<pre className="min-h-40 overflow-auto rounded-md border border-border bg-panel p-3 text-xs">{preview || "No policy output yet."}</pre> <pre className="min-h-40 overflow-auto rounded-md border border-border bg-panel p-3 text-xs">{preview || "No policy output yet."}</pre>
</section> </section>
</div> </div>
+13 -3
View File
@@ -19,7 +19,7 @@ export function PolicyDesigner() {
const services = useQuery({ queryKey: ["service-catalog"], queryFn: () => api<ServiceCatalogItem[]>("/service-catalog") }); const services = useQuery({ queryKey: ["service-catalog"], queryFn: () => api<ServiceCatalogItem[]>("/service-catalog") });
const [preview, setPreview] = useState<Record<string, unknown> | null>(null); const [preview, setPreview] = useState<Record<string, unknown> | null>(null);
const [form, setForm] = useState({ const [form, setForm] = useState({
name: "Designed Policy", name: "",
source: "any", source: "any",
destination: "any", destination: "any",
service_id: "", service_id: "",
@@ -45,7 +45,7 @@ export function PolicyDesigner() {
const service = services.data?.find((item) => item.id === form.service_id); const service = services.data?.find((item) => item.id === form.service_id);
return { return {
project_id: null, project_id: null,
name: form.name, name: form.name.trim(),
enabled: true, enabled: true,
definition: { definition: {
source: form.source, source: form.source,
@@ -95,6 +95,16 @@ export function PolicyDesigner() {
<div className="grid gap-4 lg:grid-cols-[1fr_420px]"> <div className="grid gap-4 lg:grid-cols-[1fr_420px]">
<form onSubmit={submit} className="rounded-md border border-border bg-panel p-4"> <form onSubmit={submit} className="rounded-md border border-border bg-panel p-4">
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<Field label="Policy Name">
<input
className={inputClass}
value={form.name}
onChange={(event) => setForm({ ...form, name: event.target.value })}
placeholder="DNS from VMs to resolver"
required
/>
</Field>
<div />
<Field label="Source"> <Field label="Source">
<select className={selectClass} value={form.source} onChange={(event) => setForm({ ...form, source: event.target.value })}> <select className={selectClass} value={form.source} onChange={(event) => setForm({ ...form, source: event.target.value })}>
{targets.map((target) => <option key={target.value} value={target.value}>{target.label}</option>)} {targets.map((target) => <option key={target.value} value={target.value}>{target.label}</option>)}
@@ -145,7 +155,7 @@ export function PolicyDesigner() {
<Wand2 size={18} /> <Wand2 size={18} />
Dry Run Dry Run
</button> </button>
<button className={buttonClass}> <button className={buttonClass} disabled={!form.name.trim() || save.isPending}>
<Save size={18} /> <Save size={18} />
Save Save
</button> </button>