chore: initial project setup with backend, frontend, CI/CD, and documentation
Add complete NexaFabric project structure including: - FastAPI backend with SQLAlchemy models, JWT auth, RBAC, audit logging, and provider interfaces - React + TypeScript frontend with Vite, Tailwind CSS, TanStack Query, and Zustand - Docker Compose configuration for PostgreSQL, Redis, API, worker, frontend, and nginx - GitHub Actions and GitLab CI workflows for testing, linting, building, and security scanning - Environment
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
"""API v1 package."""
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import CurrentUser
|
||||
from app.core.security import create_access_token, create_refresh_token, verify_password
|
||||
from app.db.session import get_db
|
||||
from app.models.domain import User
|
||||
from app.schemas.domain import LoginRequest, TokenPair, UserRead
|
||||
from app.services.audit import write_audit
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenPair)
|
||||
def login(payload: LoginRequest, db: Session = Depends(get_db)) -> TokenPair:
|
||||
user = db.scalar(select(User).where(User.email == payload.email))
|
||||
if not user or not verify_password(payload.password, user.password_hash):
|
||||
if user:
|
||||
user.failed_login_attempts += 1
|
||||
db.commit()
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||
permissions = sorted({permission for role in user.roles for permission in role.permissions})
|
||||
user.failed_login_attempts = 0
|
||||
db.commit()
|
||||
write_audit(db, action="login", object_type="user", object_id=user.id, user_id=user.id)
|
||||
return TokenPair(
|
||||
access_token=create_access_token(user.id, permissions),
|
||||
refresh_token=create_refresh_token(user.id),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserRead)
|
||||
def me(user: CurrentUser) -> User:
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def logout(user: CurrentUser, db: Session = Depends(get_db)) -> dict[str, str]:
|
||||
write_audit(db, action="logout", object_type="user", object_id=user.id, user_id=user.id)
|
||||
return {"status": "ok", "at": datetime.utcnow().isoformat()}
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import CurrentUser
|
||||
from app.api.v1 import auth
|
||||
from app.db.session import get_db
|
||||
from app.models.domain import (
|
||||
AuditLog,
|
||||
Cluster,
|
||||
Job,
|
||||
Network,
|
||||
Node,
|
||||
Policy,
|
||||
Project,
|
||||
SecurityGroup,
|
||||
Subnet,
|
||||
Tenant,
|
||||
User,
|
||||
Workload,
|
||||
)
|
||||
from app.schemas.domain import (
|
||||
AuditLogRead,
|
||||
ClusterCreate,
|
||||
ClusterRead,
|
||||
FirewallPreview,
|
||||
IpAddressRead,
|
||||
IpReservationCreate,
|
||||
JobRead,
|
||||
NetworkRead,
|
||||
NodeRead,
|
||||
PolicyCreate,
|
||||
PolicyRead,
|
||||
ProjectRead,
|
||||
SecurityGroupCreate,
|
||||
SecurityGroupRead,
|
||||
SubnetRead,
|
||||
TenantRead,
|
||||
UserRead,
|
||||
WorkloadRead,
|
||||
)
|
||||
from app.services.audit import write_audit
|
||||
from app.services.firewall_orchestrator import FirewallOrchestrator
|
||||
from app.services.providers.base import ProviderConnection
|
||||
from app.services.providers.registry import get_provider
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(auth.router)
|
||||
|
||||
|
||||
@api_router.get("/dashboard")
|
||||
def dashboard(_: CurrentUser, db: Session = Depends(get_db)) -> dict:
|
||||
return {
|
||||
"clusters": db.scalar(select(func.count()).select_from(Cluster)),
|
||||
"nodes": db.scalar(select(func.count()).select_from(Node)),
|
||||
"workloads": db.scalar(select(func.count()).select_from(Workload)),
|
||||
"networks": db.scalar(select(func.count()).select_from(Network)),
|
||||
"open_policy_violations": 1,
|
||||
"last_syncs": db.scalars(select(Cluster).order_by(Cluster.updated_at.desc()).limit(5)).all(),
|
||||
"faulty_nodes": db.scalars(select(Node).where(Node.status != "online")).all(),
|
||||
"top_talkers": [
|
||||
{"name": "finance-app-2", "bytes": 942000000},
|
||||
{"name": "core-services-1", "bytes": 512000000},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@api_router.get("/users", response_model=list[UserRead])
|
||||
def users(_: CurrentUser, db: Session = Depends(get_db)) -> list[User]:
|
||||
return db.scalars(select(User).order_by(User.email)).all()
|
||||
|
||||
|
||||
@api_router.get("/clusters", response_model=list[ClusterRead])
|
||||
def clusters(_: CurrentUser, db: Session = Depends(get_db)) -> list[Cluster]:
|
||||
return db.scalars(select(Cluster).order_by(Cluster.name)).all()
|
||||
|
||||
|
||||
@api_router.post("/clusters", response_model=ClusterRead)
|
||||
def create_cluster(payload: ClusterCreate, user: CurrentUser, db: Session = Depends(get_db)) -> Cluster:
|
||||
cluster = Cluster(
|
||||
name=payload.name,
|
||||
api_url=payload.api_url,
|
||||
token_ref=payload.api_token,
|
||||
mode=payload.mode,
|
||||
verify_tls=payload.verify_tls,
|
||||
)
|
||||
db.add(cluster)
|
||||
db.commit()
|
||||
db.refresh(cluster)
|
||||
write_audit(db, action="cluster.created", object_type="cluster", object_id=cluster.id, user_id=user.id)
|
||||
return cluster
|
||||
|
||||
|
||||
@api_router.post("/clusters/{cluster_id}/test")
|
||||
def test_cluster(cluster_id: str, _: CurrentUser, db: Session = Depends(get_db)) -> dict:
|
||||
cluster = db.get(Cluster, cluster_id)
|
||||
if not cluster:
|
||||
raise HTTPException(status_code=404, detail="Cluster not found")
|
||||
return {"cluster_id": cluster.id, "status": "configured", "message": "Provider connection is ready for live token validation."}
|
||||
|
||||
|
||||
@api_router.post("/clusters/{cluster_id}/sync")
|
||||
async def sync_cluster(cluster_id: str, user: CurrentUser, db: Session = Depends(get_db)) -> dict:
|
||||
cluster = db.get(Cluster, cluster_id)
|
||||
if not cluster:
|
||||
raise HTTPException(status_code=404, detail="Cluster not found")
|
||||
provider = get_provider(cluster.provider)
|
||||
inventory = await provider.sync_inventory(
|
||||
ProviderConnection(
|
||||
api_url=cluster.api_url,
|
||||
token=cluster.token_ref or "",
|
||||
verify_tls=cluster.verify_tls,
|
||||
read_only=cluster.mode == "read_only",
|
||||
)
|
||||
)
|
||||
cluster.last_sync_status = "success"
|
||||
cluster.last_sync_error = None
|
||||
db.add(Job(kind="proxmox.sync", status="success", progress=100, logs=[f"Synced {cluster.name}"]))
|
||||
db.commit()
|
||||
write_audit(db, action="cluster.sync", object_type="cluster", object_id=cluster.id, user_id=user.id)
|
||||
return {"cluster_id": cluster.id, "status": "success", "inventory_counts": {key: len(value) for key, value in inventory.items()}}
|
||||
|
||||
|
||||
@api_router.get("/nodes", response_model=list[NodeRead])
|
||||
def nodes(_: CurrentUser, db: Session = Depends(get_db)) -> list[Node]:
|
||||
return db.scalars(select(Node).order_by(Node.name)).all()
|
||||
|
||||
|
||||
@api_router.get("/vms", response_model=list[WorkloadRead])
|
||||
def workloads(_: CurrentUser, db: Session = Depends(get_db)) -> list[Workload]:
|
||||
return db.scalars(select(Workload).order_by(Workload.name)).all()
|
||||
|
||||
|
||||
@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()
|
||||
|
||||
|
||||
@api_router.get("/ipam/subnets", response_model=list[SubnetRead])
|
||||
def subnets(_: CurrentUser, db: Session = Depends(get_db)) -> list[Subnet]:
|
||||
return db.scalars(select(Subnet).order_by(Subnet.cidr)).all()
|
||||
|
||||
|
||||
@api_router.get("/ipam/addresses", response_model=list[IpAddressRead])
|
||||
def ipam_addresses(_: CurrentUser, db: Session = Depends(get_db)):
|
||||
from app.models.domain import IpAddress
|
||||
|
||||
return db.scalars(select(IpAddress).order_by(IpAddress.address)).all()
|
||||
|
||||
|
||||
@api_router.post("/ipam/addresses", response_model=IpAddressRead)
|
||||
def reserve_ip(payload: IpReservationCreate, user: CurrentUser, db: Session = Depends(get_db)):
|
||||
from app.models.domain import IpAddress
|
||||
|
||||
address = IpAddress(subnet_id=payload.subnet_id, address=payload.address, status=payload.status, note=payload.note)
|
||||
db.add(address)
|
||||
db.commit()
|
||||
db.refresh(address)
|
||||
write_audit(db, action="ipam.address.created", object_type="ip_address", object_id=address.id, user_id=user.id)
|
||||
return address
|
||||
|
||||
|
||||
@api_router.get("/tenants", response_model=list[TenantRead])
|
||||
def tenants(_: CurrentUser, db: Session = Depends(get_db)) -> list[Tenant]:
|
||||
return db.scalars(select(Tenant).order_by(Tenant.name)).all()
|
||||
|
||||
|
||||
@api_router.get("/projects", response_model=list[ProjectRead])
|
||||
def projects(_: CurrentUser, db: Session = Depends(get_db)) -> list[Project]:
|
||||
return db.scalars(select(Project).order_by(Project.name)).all()
|
||||
|
||||
|
||||
@api_router.get("/security-groups", response_model=list[SecurityGroupRead])
|
||||
def security_groups(_: CurrentUser, db: Session = Depends(get_db)) -> list[SecurityGroup]:
|
||||
return db.scalars(select(SecurityGroup).order_by(SecurityGroup.name)).all()
|
||||
|
||||
|
||||
@api_router.post("/security-groups", response_model=SecurityGroupRead)
|
||||
def create_security_group(payload: SecurityGroupCreate, user: CurrentUser, db: Session = Depends(get_db)) -> SecurityGroup:
|
||||
group = SecurityGroup(project_id=payload.project_id, name=payload.name, description=payload.description)
|
||||
db.add(group)
|
||||
db.commit()
|
||||
db.refresh(group)
|
||||
write_audit(db, action="security_group.created", object_type="security_group", object_id=group.id, user_id=user.id)
|
||||
return group
|
||||
|
||||
|
||||
@api_router.get("/policies", response_model=list[PolicyRead])
|
||||
def policies(_: CurrentUser, db: Session = Depends(get_db)) -> list[Policy]:
|
||||
return db.scalars(select(Policy).order_by(Policy.name)).all()
|
||||
|
||||
|
||||
@api_router.post("/policies", response_model=PolicyRead)
|
||||
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)
|
||||
db.add(policy)
|
||||
db.commit()
|
||||
db.refresh(policy)
|
||||
write_audit(db, action="policy.created", object_type="policy", object_id=policy.id, user_id=user.id)
|
||||
return policy
|
||||
|
||||
|
||||
@api_router.post("/firewall/preview/{policy_id}", response_model=FirewallPreview)
|
||||
async def firewall_preview(policy_id: str, user: CurrentUser, db: Session = Depends(get_db)) -> FirewallPreview:
|
||||
policy = db.get(Policy, policy_id)
|
||||
cluster = db.scalar(select(Cluster).order_by(Cluster.name).limit(1))
|
||||
if not policy or not cluster:
|
||||
raise HTTPException(status_code=404, detail="Policy or cluster not found")
|
||||
preview = await FirewallOrchestrator().preview(cluster, policy)
|
||||
write_audit(db, action="firewall.preview", object_type="policy", object_id=policy.id, user_id=user.id, new_values=preview.model_dump())
|
||||
return preview
|
||||
|
||||
|
||||
@api_router.get("/jobs", response_model=list[JobRead])
|
||||
def jobs(_: CurrentUser, db: Session = Depends(get_db)) -> list[Job]:
|
||||
return db.scalars(select(Job).order_by(Job.created_at.desc())).all()
|
||||
|
||||
|
||||
@api_router.get("/audit", response_model=list[AuditLogRead])
|
||||
def audit(_: CurrentUser, db: Session = Depends(get_db)) -> list[AuditLog]:
|
||||
return db.scalars(select(AuditLog).order_by(AuditLog.created_at.desc()).limit(200)).all()
|
||||
|
||||
|
||||
@api_router.get("/settings")
|
||||
def settings(_: CurrentUser) -> dict:
|
||||
return {"product": "NexaFabric", "firewall_apply_requires_preview": True, "agent_optional": True}
|
||||
Reference in New Issue
Block a user