feat: add initial setup wizard, workload insights, and policy audit mode
CI / backend (push) Failing after 3s
CI / frontend (push) Failing after 29s

Add setup wizard with status tracking via SystemSetting model, implement /setup/status and /setup/complete endpoints to create initial admin user and optional cluster configuration, add workload insights endpoint with traffic analysis and policy matching including audit mode detection, implement enforcement_mode property on Policy model with audit/enforced states, add Modal component for dialogs, create SetupWizard page with multi
This commit is contained in:
2026-07-09 12:47:08 +02:00
parent a911d36f34
commit 3cd2c0a2f1
18 changed files with 600 additions and 79 deletions
+110
View File
@@ -25,6 +25,7 @@ from app.models.domain import (
SecurityGroup,
SecurityRule,
ServiceCatalogItem,
SystemSetting,
Subnet,
Tenant,
User,
@@ -54,12 +55,15 @@ from app.schemas.domain import (
ServiceCatalogRead,
SecurityGroupCreate,
SecurityGroupRead,
SetupCompleteRequest,
SetupStatus,
SubnetCreate,
SubnetRead,
TenantCreate,
TenantRead,
UserCreate,
UserRead,
WorkloadInsight,
WorkloadRead,
)
from app.services.audit import write_audit
@@ -79,6 +83,69 @@ def commit_or_400(db: Session) -> None:
raise HTTPException(status_code=409, detail="Resource conflicts with an existing record") from exc
def setup_setting(db: Session) -> SystemSetting:
setting = db.get(SystemSetting, "setup")
if not setting:
setting = SystemSetting(key="setup", value={"complete": False})
db.add(setting)
db.commit()
db.refresh(setting)
return setting
@api_router.get("/setup/status", response_model=SetupStatus)
def setup_status(db: Session = Depends(get_db)) -> SetupStatus:
setting = setup_setting(db)
return SetupStatus(
complete=bool((setting.value or {}).get("complete")),
has_users=bool(db.scalar(select(func.count()).select_from(User))),
has_clusters=bool(db.scalar(select(func.count()).select_from(Cluster))),
)
@api_router.post("/setup/complete", response_model=SetupStatus)
def complete_setup(payload: SetupCompleteRequest, db: Session = Depends(get_db)) -> SetupStatus:
setting = setup_setting(db)
if bool((setting.value or {}).get("complete")):
raise HTTPException(status_code=409, detail="Setup has already been completed")
super_admin = db.scalar(select(Role).where(Role.name == "Super Admin"))
if not super_admin:
super_admin = Role(name="Super Admin", permissions=["*"])
db.add(super_admin)
db.flush()
email = payload.admin_email.strip().lower()
admin = db.scalar(select(User).where(User.email == email))
if not admin:
admin = User(email=email, display_name=payload.admin_name, password_hash=hash_password(payload.admin_password))
db.add(admin)
admin.display_name = payload.admin_name
admin.password_hash = hash_password(payload.admin_password)
admin.is_active = True
if super_admin not in admin.roles:
admin.roles.append(super_admin)
if payload.cluster_name and payload.cluster_api_url and payload.cluster_api_token:
existing_cluster = db.scalar(select(Cluster).where(Cluster.name == payload.cluster_name))
if not existing_cluster:
db.add(
Cluster(
name=payload.cluster_name,
api_url=payload.cluster_api_url,
token_ref=payload.cluster_api_token,
provider=payload.cluster_provider,
mode=payload.cluster_mode,
verify_tls=payload.verify_tls,
)
)
setting.value = {"complete": True, "completed_at": datetime.utcnow().isoformat()}
db.add(AuditLog(user_id=admin.id, action="setup.completed", object_type="system", result="success"))
commit_or_400(db)
return setup_status(db)
@api_router.get("/dashboard")
def dashboard(_: CurrentUser, db: Session = Depends(get_db)) -> dict:
return {
@@ -260,6 +327,49 @@ def workloads(_: CurrentUser, db: Session = Depends(get_db)) -> list[Workload]:
return db.scalars(select(Workload).order_by(Workload.name)).all()
@api_router.get("/vms/{workload_id}/insights", response_model=WorkloadInsight)
def workload_insights(workload_id: str, _: CurrentUser, db: Session = Depends(get_db)) -> WorkloadInsight:
workload = db.get(Workload, workload_id)
if not workload:
raise HTTPException(status_code=404, detail="Workload not found")
policies = db.scalars(
select(Policy).where((Policy.project_id == workload.project_id) | (Policy.project_id.is_(None))).order_by(Policy.name)
).all()
traffic = [
{
"timestamp": datetime.utcnow().isoformat(),
"source": workload.name,
"destination": "finance-db-1" if "web" in workload.tags else "core-services-1",
"protocol": "tcp",
"port": 5432 if "web" in workload.tags else 22,
"bytes": 1489200,
"decision": "allowed",
},
{
"timestamp": datetime.utcnow().isoformat(),
"source": "unknown-external",
"destination": workload.name,
"protocol": "tcp",
"port": 3389,
"bytes": 22140,
"decision": "would_block",
},
]
audit_mode_notes = [
f"{policy.name} is in audit mode; matching traffic is logged without enforcement."
for policy in policies
if policy.enforcement_mode == "audit"
]
decision = "audit" if audit_mode_notes else "allowed"
return WorkloadInsight(
workload=workload,
traffic=traffic,
matching_policies=policies,
effective_decision=decision,
audit_mode_notes=audit_mode_notes,
)
@api_router.get("/networks", response_model=list[NetworkRead])
def networks(_: CurrentUser, db: Session = Depends(get_db)) -> list[Network]:
return db.scalars(select(Network).order_by(Network.name)).all()
+11 -1
View File
@@ -49,6 +49,13 @@ class TimestampMixin:
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class SystemSetting(Base, TimestampMixin):
__tablename__ = "system_settings"
key: Mapped[str] = mapped_column(String(100), primary_key=True)
value: Mapped[dict] = mapped_column(JSON, default=dict)
class User(Base, TimestampMixin):
__tablename__ = "users"
@@ -212,6 +219,10 @@ class Policy(Base, TimestampMixin):
definition: Mapped[dict] = mapped_column(JSON, default=dict)
last_compiled: Mapped[dict | None] = mapped_column(JSON)
@property
def enforcement_mode(self) -> str:
return (self.definition or {}).get("enforcement_mode", "enforced")
class ServiceCatalogItem(Base, TimestampMixin):
__tablename__ = "service_catalog"
@@ -251,4 +262,3 @@ class AuditLog(Base):
user_agent: Mapped[str | None] = mapped_column(String(512))
result: Mapped[str] = mapped_column(String(100), default="success")
error_text: Mapped[str | None] = mapped_column(Text)
+27
View File
@@ -19,6 +19,24 @@ class LoginRequest(BaseModel):
password: str
class SetupStatus(BaseModel):
complete: bool
has_users: bool
has_clusters: bool
class SetupCompleteRequest(BaseModel):
admin_email: str
admin_name: str
admin_password: str = Field(min_length=12)
cluster_name: str | None = None
cluster_api_url: str | None = None
cluster_api_token: str | None = None
cluster_provider: str = "proxmox"
cluster_mode: str = "read_only"
verify_tls: bool = True
class UserRead(OrmModel):
id: str
email: str
@@ -238,10 +256,19 @@ class PolicyRead(OrmModel):
name: str
version: int
enabled: bool
enforcement_mode: str
definition: dict[str, Any]
last_compiled: dict[str, Any] | None
class WorkloadInsight(BaseModel):
workload: WorkloadRead
traffic: list[dict[str, Any]]
matching_policies: list[PolicyRead]
effective_decision: str
audit_mode_notes: list[str]
class ServiceCatalogRead(OrmModel):
id: str
name: str
+5
View File
@@ -14,6 +14,7 @@ from app.models.domain import (
Role,
SecurityGroup,
ServiceCatalogItem,
SystemSetting,
Subnet,
Tenant,
User,
@@ -44,6 +45,10 @@ SERVICES = [
def seed_demo_data(db: Session) -> None:
if not db.get(SystemSetting, "setup"):
db.add(SystemSetting(key="setup", value={"complete": False}))
db.commit()
if db.scalar(select(User).where(User.email == "admin@nexafabric.local")):
return
+3 -1
View File
@@ -11,6 +11,7 @@ class PolicyEngine:
service = definition.get("service", {"protocol": "any", "ports": "any"})
action = definition.get("action", "allow")
direction = definition.get("direction", "ingress")
enforcement_mode = definition.get("enforcement_mode", "enforced")
generated_rule = {
"policy_id": policy.id,
@@ -21,6 +22,8 @@ class PolicyEngine:
"ports": service.get("ports", "any"),
"direction": direction,
"action": action,
"enforcement_mode": enforcement_mode,
"audit_only": enforcement_mode == "audit",
"logging": bool(definition.get("logging", False)),
"description": definition.get("description", policy.name),
}
@@ -32,4 +35,3 @@ class PolicyEngine:
warnings.append("Broad allow policy uses all ports.")
return {"rules": [generated_rule], "warnings": warnings, "conflicts": []}