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
This commit is contained in:
2026-07-09 15:57:23 +02:00
parent 571d1513e7
commit 5302a8bc82
2 changed files with 31 additions and 6 deletions
+23 -2
View File
@@ -261,6 +261,13 @@ def flow_int(value: object, default: int = 0) -> int:
return default 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: def subnet_label_for_ip(subnets: list[Subnet], value: str) -> str | None:
try: try:
address = ip_address(value) 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) @api_router.post("/policies", response_model=PolicyRead)
def create_policy(payload: PolicyCreate, user: CurrentUser, db: Session = Depends(get_db)) -> Policy: 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) db.add(policy)
commit_or_400(db) commit_or_400(db)
db.refresh(policy) 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.project_id = payload.project_id
policy.name = payload.name policy.name = payload.name
policy.enabled = payload.enabled policy.enabled = payload.enabled
policy.definition = payload.definition policy.definition = normalized_policy_definition(payload.definition)
policy.version += 1 policy.version += 1
commit_or_400(db) commit_or_400(db)
db.refresh(policy) db.refresh(policy)
@@ -1410,16 +1417,27 @@ 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 = resolve_firewall_preview(db, cluster, await FirewallOrchestrator().preview(cluster, policy)) 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: if payload.dry_run:
result = { result = {
"applied": False, "applied": False,
"dry_run": True, "dry_run": True,
"policy_mode": policy_mode,
"reason": "Dry run completed. No firewall rules were applied.", "reason": "Dry run completed. No firewall rules were applied.",
"rules": preview.generated_rules, "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: elif preview.conflicts:
result = { result = {
"applied": False, "applied": False,
"policy_mode": policy_mode,
"reason": "Live apply stopped because the preview has unresolved conflicts.", "reason": "Live apply stopped because the preview has unresolved conflicts.",
"conflicts": preview.conflicts, "conflicts": preview.conflicts,
"rules": preview.generated_rules, "rules": preview.generated_rules,
@@ -1439,9 +1457,12 @@ async def firewall_apply(payload: FirewallApplyRequest, user: CurrentUser, db: S
except Exception as exc: except Exception as exc:
result = { result = {
"applied": False, "applied": False,
"policy_mode": policy_mode,
"reason": f"Provider apply failed: {exc}", "reason": f"Provider apply failed: {exc}",
"rules": preview.generated_rules, "rules": preview.generated_rules,
} }
else:
result["policy_mode"] = policy_mode
applied = bool(result.get("applied")) applied = bool(result.get("applied"))
operation_success = applied or payload.dry_run operation_success = applied or payload.dry_run
job = Job( job = Job(
+8 -4
View File
@@ -29,6 +29,8 @@ export function FirewallPreview() {
}), }),
}); });
const selectedPolicyId = policyId || policies.data?.[0]?.id || ""; 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 const busyMessage = preview.isPending
? "Generating firewall preview..." ? "Generating firewall preview..."
: apply.isPending : apply.isPending
@@ -46,7 +48,7 @@ export function FirewallPreview() {
<div className="grid gap-3"> <div className="grid gap-3">
<Field label="Policy"> <Field label="Policy">
<select className={selectClass} value={selectedPolicyId} onChange={(event) => setPolicyId(event.target.value)}> <select className={selectClass} value={selectedPolicyId} onChange={(event) => setPolicyId(event.target.value)}>
{(policies.data ?? []).map((policy) => <option key={policy.id} value={policy.id}>{policy.name}</option>)} {(policies.data ?? []).map((policy) => <option key={policy.id} value={policy.id}>{policy.name} · {policy.enforcement_mode} · v{policy.version}</option>)}
</select> </select>
</Field> </Field>
<Field label="Cluster"> <Field label="Cluster">
@@ -59,7 +61,9 @@ export function FirewallPreview() {
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"> <div className="rounded-md border border-border bg-canvas p-3 text-xs text-slate-500 dark:text-slate-400">
{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." ? "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."} : "Live apply. NexaFabric will send the generated rules to the selected write-enabled cluster."}
</div> </div>
@@ -67,9 +71,9 @@ export function FirewallPreview() {
<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 || (auditMode && !dryRun)} onClick={() => apply.mutate()}>
<ShieldCheck size={18} /> <ShieldCheck size={18} />
{dryRun ? "Run Dry Apply" : "Apply Confirmed"} {dryRun ? "Run Dry Apply" : auditMode ? "Audit Mode Only" : "Apply Confirmed"}
</button> </button>
</div> </div>
</section> </section>