feat: add automatic policy rule cleanup when disabling or switching to audit mode with provider-level rule deletion
Add cluster_provider_targets helper to build target list from all cluster workloads, implement cleanup_policy_provider_rules to delete policy rules across all clusters with per-cluster result tracking, add delete_policy_rules method to ProxmoxProvider that removes rules matching policy ID marker with error collection, extend policy_id_marker and rule_comment_matches_marker helpers for
This commit is contained in:
@@ -853,6 +853,45 @@ def resolve_firewall_preview(db: Session, cluster: Cluster, preview: FirewallPre
|
||||
)
|
||||
|
||||
|
||||
def cluster_provider_targets(db: Session, cluster: Cluster) -> list[dict]:
|
||||
targets = []
|
||||
workloads = db.scalars(select(Workload).where(Workload.cluster_id == cluster.id).order_by(Workload.name)).all()
|
||||
for workload in workloads:
|
||||
resolved = workload_provider_target(db, cluster, f"workload:{workload.id}")
|
||||
if resolved:
|
||||
target, _ = resolved
|
||||
targets.append(target)
|
||||
return targets
|
||||
|
||||
|
||||
async def cleanup_policy_provider_rules(db: Session, policy_id: str) -> dict[str, object]:
|
||||
results = []
|
||||
total_deleted = 0
|
||||
for cluster in db.scalars(select(Cluster).order_by(Cluster.name)).all():
|
||||
targets = cluster_provider_targets(db, cluster)
|
||||
if not targets:
|
||||
results.append({"cluster_id": cluster.id, "cluster_name": cluster.name, "rules_deleted": 0, "reason": "No targets"})
|
||||
continue
|
||||
provider = get_provider(cluster.provider)
|
||||
delete_rules = getattr(provider, "delete_policy_rules", None)
|
||||
if not delete_rules:
|
||||
results.append({"cluster_id": cluster.id, "cluster_name": cluster.name, "rules_deleted": 0, "reason": "Provider cannot delete policy rules"})
|
||||
continue
|
||||
result = await delete_rules(
|
||||
ProviderConnection(
|
||||
api_url=cluster.api_url,
|
||||
token=cluster.token_ref or "",
|
||||
verify_tls=cluster.verify_tls,
|
||||
read_only=cluster.mode == "read_only",
|
||||
),
|
||||
targets,
|
||||
policy_id,
|
||||
)
|
||||
total_deleted += int(result.get("rules_deleted") or 0)
|
||||
results.append({"cluster_id": cluster.id, "cluster_name": cluster.name, **result})
|
||||
return {"policy_id": policy_id, "rules_deleted": total_deleted, "clusters": results}
|
||||
|
||||
|
||||
@api_router.get("/setup/status", response_model=SetupStatus)
|
||||
def setup_status(db: Session = Depends(get_db)) -> SetupStatus:
|
||||
setting = setup_setting(db)
|
||||
@@ -1820,31 +1859,47 @@ def create_policy(payload: PolicyCreate, user: CurrentUser, db: Session = Depend
|
||||
|
||||
|
||||
@api_router.patch("/policies/{policy_id}", response_model=PolicyRead)
|
||||
def update_policy(policy_id: str, payload: PolicyCreate, user: CurrentUser, db: Session = Depends(get_db)) -> Policy:
|
||||
async def update_policy(policy_id: str, payload: PolicyCreate, user: CurrentUser, db: Session = Depends(get_db)) -> Policy:
|
||||
policy = db.get(Policy, policy_id)
|
||||
if not policy:
|
||||
raise HTTPException(status_code=404, detail="Policy not found")
|
||||
old_values = {"name": policy.name, "enabled": policy.enabled, "definition": policy.definition, "version": policy.version}
|
||||
old_mode = policy.enforcement_mode
|
||||
old_enabled = policy.enabled
|
||||
new_definition = normalized_policy_definition(payload.definition)
|
||||
new_mode = str(new_definition.get("enforcement_mode") or "enforced")
|
||||
policy.project_id = payload.project_id
|
||||
policy.name = payload.name
|
||||
policy.enabled = payload.enabled
|
||||
policy.definition = normalized_policy_definition(payload.definition)
|
||||
policy.definition = new_definition
|
||||
policy.version += 1
|
||||
commit_or_400(db)
|
||||
db.refresh(policy)
|
||||
write_audit(db, action="policy.updated", object_type="policy", object_id=policy.id, user_id=user.id, old_values=old_values, new_values=payload.model_dump())
|
||||
cleanup_result = None
|
||||
if old_enabled and old_mode == "enforced" and (not payload.enabled or new_mode == "audit"):
|
||||
cleanup_result = await cleanup_policy_provider_rules(db, policy.id)
|
||||
write_audit(
|
||||
db,
|
||||
action="policy.updated",
|
||||
object_type="policy",
|
||||
object_id=policy.id,
|
||||
user_id=user.id,
|
||||
old_values=old_values,
|
||||
new_values={**payload.model_dump(), "provider_cleanup": cleanup_result},
|
||||
)
|
||||
return policy
|
||||
|
||||
|
||||
@api_router.delete("/policies/{policy_id}")
|
||||
def delete_policy(policy_id: str, user: CurrentUser, db: Session = Depends(get_db)) -> dict[str, str]:
|
||||
async def delete_policy(policy_id: str, user: CurrentUser, db: Session = Depends(get_db)) -> dict[str, object]:
|
||||
policy = db.get(Policy, policy_id)
|
||||
if not policy:
|
||||
raise HTTPException(status_code=404, detail="Policy not found")
|
||||
cleanup_result = await cleanup_policy_provider_rules(db, policy.id)
|
||||
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}
|
||||
write_audit(db, action="policy.deleted", object_type="policy", object_id=policy_id, user_id=user.id, new_values={"provider_cleanup": cleanup_result})
|
||||
return {"status": "deleted", "id": policy_id, "provider_cleanup": cleanup_result}
|
||||
|
||||
|
||||
@api_router.post("/policies/{policy_id}/compile", response_model=PolicyRead)
|
||||
|
||||
@@ -162,6 +162,12 @@ class ProxmoxProvider(Provider):
|
||||
def policy_marker(self, rule: dict[str, Any]) -> str:
|
||||
return f"NexaFabric policy={rule.get('policy_id')}"
|
||||
|
||||
def policy_id_marker(self, policy_id: str) -> str:
|
||||
return f"NexaFabric policy={policy_id}"
|
||||
|
||||
def rule_comment_matches_marker(self, comment: str, marker: str) -> bool:
|
||||
return comment == marker or comment.startswith(f"{marker} ")
|
||||
|
||||
def network_firewall_enabled_value(self, value: str) -> str:
|
||||
parts = [part for part in value.split(",") if part]
|
||||
found = False
|
||||
@@ -190,7 +196,7 @@ class ProxmoxProvider(Provider):
|
||||
for existing_rule in sorted(existing_rules, key=lambda item: int(item.get("pos", 0)), reverse=True):
|
||||
comment = str(existing_rule.get("comment") or "")
|
||||
pos = existing_rule.get("pos")
|
||||
if marker in comment and pos is not None:
|
||||
if self.rule_comment_matches_marker(comment, marker) and pos is not None:
|
||||
delete_response = await client.delete(f"{rules_url}/{pos}", headers=headers)
|
||||
delete_response.raise_for_status()
|
||||
deletions.append({"pos": pos, "comment": comment})
|
||||
@@ -350,3 +356,33 @@ class ProxmoxProvider(Provider):
|
||||
"audit_only": audit_only_rules,
|
||||
"rules": applied_rules,
|
||||
}
|
||||
|
||||
async def delete_policy_rules(
|
||||
self,
|
||||
connection: ProviderConnection,
|
||||
targets: list[dict[str, Any]],
|
||||
policy_id: str,
|
||||
) -> dict[str, Any]:
|
||||
if connection.read_only:
|
||||
return {"applied": False, "reason": "Cluster is read-only", "rules_deleted": 0, "targets": targets}
|
||||
|
||||
headers = {"Authorization": self.auth_header(connection.token)}
|
||||
marker = self.policy_id_marker(policy_id)
|
||||
deleted_rules = []
|
||||
errors = []
|
||||
async with httpx.AsyncClient(verify=connection.verify_tls, timeout=20) as client:
|
||||
for target in targets:
|
||||
rules_url = self.firewall_rules_url(connection, target)
|
||||
try:
|
||||
deleted = await self.delete_existing_policy_rules(client, headers, rules_url, marker)
|
||||
except Exception as exc:
|
||||
errors.append({"target": target, "error": str(exc)})
|
||||
continue
|
||||
deleted_rules.extend({"target": target, **item} for item in deleted)
|
||||
|
||||
return {
|
||||
"applied": not errors,
|
||||
"rules_deleted": len(deleted_rules),
|
||||
"deleted_rules": deleted_rules,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user