Add policy_read_payload helper to build policy response with deployment status, implement nexafabric_rule_version to parse policy ID and version from rule comments, add policy_deployment_status to check active/stale/partial/unresolved states by comparing expected rules from preview against live firewall rules per cluster with version matching, extend PolicyRead schema with
282 lines
13 KiB
TypeScript
282 lines
13 KiB
TypeScript
import { FormEvent, useState } from "react";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { AlertTriangle, CheckCircle2, Clock3, Eye, GitBranch, Pencil, Play, Plus, Shield, Trash2 } from "lucide-react";
|
|
|
|
import { api, Policy, Project, ServiceCatalogItem } from "../api/client";
|
|
import { DataTable } from "../components/DataTable";
|
|
import { buttonClass, Field, iconButtonClass, inputClass, selectClass } from "../components/FormControls";
|
|
import { LoadingOverlay } from "../components/LoadingOverlay";
|
|
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 ?? "")}`;
|
|
}
|
|
|
|
function policyStatus(policy: Policy) {
|
|
const status = policy.deployment_status ?? {};
|
|
return {
|
|
state: String(status.state ?? "unknown"),
|
|
label: String(status.label ?? "Unknown"),
|
|
expectedRules: Number(status.expected_rules ?? 0),
|
|
activeRules: Number(status.active_rules ?? 0),
|
|
staleRules: Number(status.stale_rules ?? 0),
|
|
};
|
|
}
|
|
|
|
function statusClass(state: string) {
|
|
if (state === "active") {
|
|
return "border-accent/40 bg-accent/10 text-accent";
|
|
}
|
|
if (state === "audit") {
|
|
return "border-amber-400/40 bg-amber-400/10 text-amber-300";
|
|
}
|
|
if (["stale", "partial", "unresolved"].includes(state)) {
|
|
return "border-danger/40 bg-danger/10 text-danger";
|
|
}
|
|
return "border-border bg-canvas text-slate-500";
|
|
}
|
|
|
|
function StatusIcon({ state }: { state: string }) {
|
|
if (state === "active") {
|
|
return <CheckCircle2 size={15} />;
|
|
}
|
|
if (state === "audit") {
|
|
return <Shield size={15} />;
|
|
}
|
|
if (["stale", "partial", "unresolved"].includes(state)) {
|
|
return <AlertTriangle size={15} />;
|
|
}
|
|
return <Clock3 size={15} />;
|
|
}
|
|
|
|
function PolicyDeploymentStatus({ policy }: { policy: Policy }) {
|
|
const status = policyStatus(policy);
|
|
const detail =
|
|
status.state === "audit"
|
|
? "No live firewall write"
|
|
: `${status.activeRules}/${status.expectedRules} active${status.staleRules ? ` · ${status.staleRules} stale` : ""}`;
|
|
return (
|
|
<div className="flex flex-col gap-1">
|
|
<span className={`inline-flex w-fit items-center gap-1.5 rounded-md border px-2 py-1 text-xs ${statusClass(status.state)}`}>
|
|
<StatusIcon state={status.state} />
|
|
{status.label}
|
|
</span>
|
|
<span className="text-xs text-slate-500">{detail}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const defaultPolicyForm = {
|
|
project_id: "",
|
|
name: "Web to DB",
|
|
source: "sg:Web Tier",
|
|
destination: "sg:Database",
|
|
service_id: "",
|
|
protocol: "tcp",
|
|
ports: "5432",
|
|
action: "allow",
|
|
direction: "egress",
|
|
enforcement_mode: "enforced",
|
|
logging: true,
|
|
description: "Allow application database traffic",
|
|
};
|
|
|
|
export function Policies() {
|
|
const queryClient = useQueryClient();
|
|
const policies = useQuery({ queryKey: ["policies"], queryFn: () => api<Policy[]>("/policies") });
|
|
const projects = useQuery({ queryKey: ["projects"], queryFn: () => api<Project[]>("/projects") });
|
|
const services = useQuery({ queryKey: ["service-catalog"], queryFn: () => api<ServiceCatalogItem[]>("/service-catalog") });
|
|
const [preview, setPreview] = useState("");
|
|
const [open, setOpen] = useState(false);
|
|
const [editing, setEditing] = useState<Policy | null>(null);
|
|
const [form, setForm] = useState(defaultPolicyForm);
|
|
const [busyMessage, setBusyMessage] = useState("");
|
|
|
|
const save = useMutation({
|
|
mutationFn: () => {
|
|
const service = services.data?.find((item) => item.id === form.service_id);
|
|
return api<Policy>(editing ? `/policies/${editing.id}` : "/policies", {
|
|
method: editing ? "PATCH" : "POST",
|
|
body: JSON.stringify({
|
|
project_id: form.project_id || null,
|
|
name: form.name,
|
|
enabled: true,
|
|
definition: {
|
|
source: form.source,
|
|
destination: form.destination,
|
|
service: { protocol: service?.protocol ?? form.protocol, ports: service?.ports ?? form.ports },
|
|
action: form.action,
|
|
direction: form.direction,
|
|
enforcement_mode: form.enforcement_mode,
|
|
logging: form.logging,
|
|
description: form.description,
|
|
},
|
|
}),
|
|
});
|
|
},
|
|
onSuccess: () => {
|
|
setOpen(false);
|
|
setEditing(null);
|
|
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();
|
|
await save.mutateAsync();
|
|
}
|
|
|
|
async function compile(policy: Policy) {
|
|
setBusyMessage(`Compiling ${policy.name}...`);
|
|
try {
|
|
const data = await api<Policy>(`/policies/${policy.id}/compile`, { method: "POST" });
|
|
setPreview(JSON.stringify(data.last_compiled, null, 2));
|
|
await queryClient.invalidateQueries({ queryKey: ["policies"] });
|
|
} finally {
|
|
setBusyMessage("");
|
|
}
|
|
}
|
|
|
|
async function firewallPreview(policy: Policy) {
|
|
setBusyMessage(`Generating preview for ${policy.name}...`);
|
|
try {
|
|
const data = await api<Record<string, unknown>>(`/firewall/preview/${policy.id}`, { method: "POST" });
|
|
setPreview(JSON.stringify(data, null, 2));
|
|
} finally {
|
|
setBusyMessage("");
|
|
}
|
|
}
|
|
|
|
function addPolicy() {
|
|
setEditing(null);
|
|
setForm(defaultPolicyForm);
|
|
setOpen(true);
|
|
}
|
|
|
|
function editPolicy(policy: Policy) {
|
|
const service = policy.definition?.service as { protocol?: string; ports?: string } | undefined;
|
|
setEditing(policy);
|
|
setForm({
|
|
project_id: policy.project_id ?? "",
|
|
name: policy.name,
|
|
source: policyValue(policy, "source") || "any",
|
|
destination: policyValue(policy, "destination") || "any",
|
|
service_id: "",
|
|
protocol: service?.protocol ?? "tcp",
|
|
ports: service?.ports ?? "",
|
|
action: policyValue(policy, "action") || "allow",
|
|
direction: policyValue(policy, "direction") || "ingress",
|
|
enforcement_mode: policy.enforcement_mode || "enforced",
|
|
logging: Boolean(policy.definition?.logging),
|
|
description: policyValue(policy, "description"),
|
|
});
|
|
setOpen(true);
|
|
}
|
|
|
|
function chooseService(serviceId: string) {
|
|
const service = services.data?.find((item) => item.id === serviceId);
|
|
setForm({
|
|
...form,
|
|
service_id: serviceId,
|
|
protocol: service?.protocol ?? form.protocol,
|
|
ports: service?.ports ?? form.ports,
|
|
});
|
|
}
|
|
|
|
function deletePolicy(policy: Policy) {
|
|
if (window.confirm(`Delete policy "${policy.name}"?`)) {
|
|
remove.mutate(policy);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<PageHeader title="Policies" subtitle="Create versioned microsegmentation policies and compile firewall previews." />
|
|
<LoadingOverlay open={Boolean(busyMessage)} message={busyMessage} />
|
|
<div className="space-y-4">
|
|
<button className={buttonClass} onClick={addPolicy}><Plus size={16} /> Add Policy</button>
|
|
<Modal title={editing ? "Edit Policy" : "Add Policy"} open={open} onClose={() => setOpen(false)}>
|
|
<form onSubmit={submit}>
|
|
<div className="mb-4 flex items-center gap-2 font-medium"><GitBranch size={18} /> {editing ? "Edit Policy" : "Add Policy"}</div>
|
|
<div className="grid gap-3">
|
|
<Field label="Project"><select className={selectClass} value={form.project_id} onChange={(event) => setForm({ ...form, project_id: event.target.value })}><option value="">Global</option>{(projects.data ?? []).map((project) => <option key={project.id} value={project.id}>{project.name}</option>)}</select></Field>
|
|
<Field label="Name"><input className={inputClass} value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} /></Field>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<Field label="Source"><input className={inputClass} value={form.source} onChange={(event) => setForm({ ...form, source: event.target.value })} /></Field>
|
|
<Field label="Destination"><input className={inputClass} value={form.destination} onChange={(event) => setForm({ ...form, destination: event.target.value })} /></Field>
|
|
</div>
|
|
<Field label="Service"><select className={selectClass} value={form.service_id} onChange={(event) => chooseService(event.target.value)}><option value="">Custom</option>{(services.data ?? []).map((service) => <option key={service.id} value={service.id}>{service.name} {service.ports}</option>)}</select></Field>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<Field label="Protocol">
|
|
<select className={selectClass} value={form.protocol} onChange={(event) => setForm({ ...form, protocol: event.target.value })}>
|
|
<option value="tcp">tcp</option>
|
|
<option value="udp">udp</option>
|
|
<option value="tcp/udp">tcp & udp</option>
|
|
</select>
|
|
</Field>
|
|
<Field label="Ports"><input className={inputClass} value={form.ports} onChange={(event) => setForm({ ...form, ports: event.target.value })} /></Field>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<Field label="Action"><select className={selectClass} value={form.action} onChange={(event) => setForm({ ...form, action: event.target.value })}><option>allow</option><option>deny</option><option>reject</option></select></Field>
|
|
<Field label="Direction"><select className={selectClass} value={form.direction} onChange={(event) => setForm({ ...form, direction: event.target.value })}><option>ingress</option><option>egress</option></select></Field>
|
|
</div>
|
|
<Field label="Mode">
|
|
<select className={selectClass} value={form.enforcement_mode} onChange={(event) => setForm({ ...form, enforcement_mode: event.target.value })}>
|
|
<option value="enforced">enforced</option>
|
|
<option value="audit">audit</option>
|
|
</select>
|
|
</Field>
|
|
<Field label="Description"><input className={inputClass} value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} /></Field>
|
|
<button className={buttonClass}><Plus size={16} /> {editing ? "Update Policy" : "Save Policy"}</button>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
<section className="space-y-4">
|
|
<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: "deployment_status", label: "Status", render: (row) => <PolicyDeploymentStatus policy={row as unknown as Policy} /> },
|
|
{ key: "version", label: "Version" },
|
|
{
|
|
key: "actions",
|
|
label: "Actions",
|
|
render: (row) => {
|
|
const policy = row as unknown as Policy;
|
|
return (
|
|
<div className="flex justify-end gap-2">
|
|
<button className={iconButtonClass} title="Compile policy" aria-label={`Compile ${policy.name}`} onClick={() => compile(policy)}><Play size={16} /></button>
|
|
<button className={iconButtonClass} title="Preview policy" aria-label={`Preview ${policy.name}`} onClick={() => firewallPreview(policy)}><Eye size={16} /></button>
|
|
<button className={iconButtonClass} title="Edit policy" aria-label={`Edit ${policy.name}`} onClick={() => editPolicy(policy)}><Pencil size={16} /></button>
|
|
<button className={iconButtonClass} title="Delete policy" aria-label={`Delete ${policy.name}`} disabled={remove.isPending} onClick={() => deletePolicy(policy)}><Trash2 size={16} /></button>
|
|
</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>
|
|
</section>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|