feat: add Workspace feature — scoped server groups with user access and sidebar selector

This commit is contained in:
markmorado
2026-03-15 01:00:24 +01:00
parent 85cc0ce76d
commit bef0bcc542
18 changed files with 1675 additions and 77 deletions

View File

@@ -8,6 +8,7 @@ from . import logs
from . import settings
from . import notifications
from . import users
from . import workspaces
__all__ = [
"auth",
@@ -18,5 +19,6 @@ __all__ = [
"logs",
"settings",
"notifications",
"users"
"users",
"workspaces",
]

View File

@@ -17,18 +17,52 @@ templates = Jinja2Templates(directory="app/templates")
@router.get("/", response_class=HTMLResponse, include_in_schema=False)
@router.get("/dashboard", response_class=HTMLResponse, include_in_schema=False)
def dashboard(request: Request, db: Session = Depends(get_db)):
total_servers = db.query(ProxmoxServer).count()
online_servers = db.query(ProxmoxServer).filter(ProxmoxServer.is_online == True).count()
offline_servers = db.query(ProxmoxServer).filter(ProxmoxServer.is_online == False).count()
from .workspaces import get_workspace_server_ids
# Count distinct clusters (non-null cluster_name counts as one cluster each)
named_clusters = db.query(func.count(distinct(ProxmoxServer.cluster_name))).filter(
# Try to get current user for workspace filtering (best-effort)
try:
token = request.cookies.get("access_token") or (
request.headers.get("Authorization", "").removeprefix("Bearer ").strip() or None
)
current_user_obj = None
if token:
from ..auth import decode_access_token
from ..models import User as UserModel
try:
payload = decode_access_token(token)
username = payload.get("sub")
if username:
current_user_obj = db.query(UserModel).filter(
UserModel.username == username, UserModel.is_active == True
).first()
except Exception:
pass
server_ids = get_workspace_server_ids(request, db, current_user_obj) if current_user_obj else None
except Exception:
server_ids = None
base_q = db.query(ProxmoxServer)
if server_ids is not None:
base_q = base_q.filter(ProxmoxServer.id.in_(server_ids))
total_servers = base_q.count()
online_servers = base_q.filter(ProxmoxServer.is_online == True).count()
offline_servers = base_q.filter(ProxmoxServer.is_online == False).count()
# Count distinct clusters within the filtered servers
cluster_q = db.query(func.count(distinct(ProxmoxServer.cluster_name))).filter(
ProxmoxServer.cluster_name.isnot(None),
ProxmoxServer.cluster_name != ''
).scalar() or 0
standalone_count = db.query(ProxmoxServer).filter(
)
standalone_q = db.query(ProxmoxServer).filter(
(ProxmoxServer.cluster_name == None) | (ProxmoxServer.cluster_name == '')
).count()
)
if server_ids is not None:
cluster_q = cluster_q.filter(ProxmoxServer.id.in_(server_ids))
standalone_q = standalone_q.filter(ProxmoxServer.id.in_(server_ids))
named_clusters = cluster_q.scalar() or 0
standalone_count = standalone_q.count()
total_clusters = named_clusters + standalone_count
# Alerts in last 24 hours
@@ -47,7 +81,10 @@ def dashboard(request: Request, db: Session = Depends(get_db)):
AuditLog.created_at.desc()
).limit(5).all()
recent_servers = db.query(ProxmoxServer).order_by(ProxmoxServer.id.desc()).limit(6).all()
recent_servers_q = db.query(ProxmoxServer)
if server_ids is not None:
recent_servers_q = recent_servers_q.filter(ProxmoxServer.id.in_(server_ids))
recent_servers = recent_servers_q.order_by(ProxmoxServer.id.desc()).limit(6).all()
stats = {
"total_servers": total_servers,

View File

@@ -32,8 +32,36 @@ async def backups_page(
db: Session = Depends(get_db),
):
from ...i18n import t
from ...api.workspaces import get_workspace_server_ids
from ...auth import get_current_user_optional
lang = request.cookies.get("language", "ru")
servers = db.query(ProxmoxServer).filter(ProxmoxServer.is_online.is_(True)).all()
# Workspace filtering (best-effort, page may be loaded unauthenticated)
server_ids = None
try:
token = request.cookies.get("access_token") or (
request.headers.get("Authorization", "").removeprefix("Bearer ").strip() or None
)
if token:
from ...auth import decode_access_token
from ...models import User as UserModel
payload = decode_access_token(token)
username = payload.get("sub")
if username:
user_obj = db.query(UserModel).filter(
UserModel.username == username, UserModel.is_active == True
).first()
if user_obj:
server_ids = get_workspace_server_ids(request, db, user_obj)
except Exception:
pass
servers_q = db.query(ProxmoxServer).filter(ProxmoxServer.is_online.is_(True))
if server_ids is not None:
servers_q = servers_q.filter(ProxmoxServer.id.in_(server_ids))
servers = servers_q.all()
context = {
"request": request,
"page_title": t("nav_backups", lang),
@@ -57,7 +85,7 @@ def get_storages(
raise HTTPException(status_code=404, detail="Server not found")
try:
client = _get_proxmox_client(server)
storages = client.get_storages()
storages = client.get_cluster_storages()
return JSONResponse(content={"storages": storages})
except Exception as e:
logger.error(f"Error listing storages for server {server_id}: {e}")

View File

@@ -84,11 +84,17 @@ def instance_detail_page(request: Request, server_id: int, vmid: int, type: str
@router.get("/api/servers", response_model=List[ProxmoxServerResponse])
def list_proxmox_servers(
db: Session = Depends(get_db),
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("proxmox.view"))
):
"""Получить список всех Proxmox серверов"""
servers = db.query(ProxmoxServer).all()
"""Получить список всех Proxmox серверов (фильтруется по активному workspace)"""
from ...api.workspaces import get_workspace_server_ids
server_ids = get_workspace_server_ids(request, db, current_user)
query = db.query(ProxmoxServer)
if server_ids is not None:
query = query.filter(ProxmoxServer.id.in_(server_ids))
servers = query.all()
return servers
@@ -705,7 +711,7 @@ def get_storages(
if not client.is_connected():
raise HTTPException(status_code=503, detail="Failed to connect to Proxmox server")
storages = client.get_storages(node)
storages = client.get_node_storages(node)
# Фильтрация по типу контента если указано
if content_type and storages:

View File

@@ -77,6 +77,7 @@ def sync_vms_now(
@router.get("/api/virtual-machines")
def get_all_virtual_machines(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("vms.view"))
):
@@ -88,9 +89,16 @@ def get_all_virtual_machines(
Для пользователей с ролью 'user' возвращаются только их собственные инстансы.
"""
from ...models import VMInstance
# Get servers for name lookup
servers = db.query(ProxmoxServer).all()
from ...api.workspaces import get_workspace_server_ids
# Workspace filtering
workspace_server_ids = get_workspace_server_ids(request, db, current_user)
# Get servers for name lookup (filtered by workspace)
servers_q = db.query(ProxmoxServer)
if workspace_server_ids is not None:
servers_q = servers_q.filter(ProxmoxServer.id.in_(workspace_server_ids))
servers = servers_q.all()
server_map = {s.id: s for s in servers}
# Pre-load IPAM allocations for IP lookup
@@ -121,6 +129,10 @@ def get_all_virtual_machines(
VMInstance.deleted_at.is_(None),
VMInstance.is_template == False
)
# Workspace filter: only show VMs from servers in the active workspace
if workspace_server_ids is not None:
query = query.filter(VMInstance.server_id.in_(workspace_server_ids))
# VPS-style user isolation: users with 'user' role see only their own instances
# Check if user has 'vms.view' but not 'vms.view:all' (or is user role)

View File

@@ -0,0 +1,431 @@
"""
Workspace API
=============
Endpoints for managing workspaces — named groups of Proxmox servers
with scoped user access.
Admin-only: create, update, delete, assign servers/users.
All authenticated: list (filtered to their workspaces), get detail.
"""
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from loguru import logger
from ..db import get_db
from ..models import Workspace, WorkspaceServer, WorkspaceUser, ProxmoxServer, User
from ..schemas import WorkspaceCreate, WorkspaceUpdate, WorkspaceResponse, WorkspaceDetail
from ..auth import get_current_user, PermissionChecker
from ..template_helpers import add_i18n_context
from ..logging_service import LoggingService
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
# ---------------------------------------------------------------------------
# Helper: resolve workspace server IDs from X-Active-Workspace header
# ---------------------------------------------------------------------------
def get_workspace_server_ids(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> Optional[List[int]]:
"""
Read X-Active-Workspace header and return list[int] of server_ids
that belong to that workspace AND that the current user can access.
Returns None when:
- Header is absent → no workspace filter (show all, admin behaviour)
- workspace_id == 0 → explicit "show all" (admin only)
- workspace not found or user has no access → empty list (403-ish)
"""
raw = request.headers.get("X-Active-Workspace")
if raw is None:
return None # no filter
try:
workspace_id = int(raw)
except ValueError:
return None
if workspace_id == 0:
# "All workspaces" — only admins may use this
if current_user.is_admin:
return None
# non-admin trying to bypass → return their union of server_ids
workspace_id = None
if workspace_id is None:
# Collect all workspaces the user belongs to
wus = db.query(WorkspaceUser).filter(WorkspaceUser.user_id == current_user.id).all()
ws_ids = [wu.workspace_id for wu in wus]
if not ws_ids:
# Fall back to Default workspace
default_ws = db.query(Workspace).filter(Workspace.is_default == True).first()
if default_ws:
ws_ids = [default_ws.id]
else:
return []
rows = db.query(WorkspaceServer.server_id).filter(
WorkspaceServer.workspace_id.in_(ws_ids)
).all()
return [r.server_id for r in rows]
workspace = db.query(Workspace).filter(Workspace.id == workspace_id).first()
if not workspace:
return []
# Check access: admins always have access; regular users need WorkspaceUser entry
if not current_user.is_admin:
access = db.query(WorkspaceUser).filter(
WorkspaceUser.workspace_id == workspace_id,
WorkspaceUser.user_id == current_user.id,
).first()
if not access:
return []
rows = db.query(WorkspaceServer.server_id).filter(
WorkspaceServer.workspace_id == workspace_id
).all()
return [r.server_id for r in rows]
# ---------------------------------------------------------------------------
# Helper: build WorkspaceResponse with counters
# ---------------------------------------------------------------------------
def _build_response(ws: Workspace) -> WorkspaceResponse:
return WorkspaceResponse(
id=ws.id,
name=ws.name,
description=ws.description,
color=ws.color,
is_default=ws.is_default,
server_count=len(ws.workspace_servers),
user_count=len(ws.workspace_users),
created_at=ws.created_at,
)
def _build_detail(ws: Workspace) -> WorkspaceDetail:
from ..schemas import WorkspaceServerItem, WorkspaceUserItem
servers = [
WorkspaceServerItem(
id=wss.server.id,
name=wss.server.name,
ip_address=wss.server.ip_address,
is_online=wss.server.is_online,
cluster_name=wss.server.cluster_name,
)
for wss in ws.workspace_servers
if wss.server is not None
]
users = [
WorkspaceUserItem(
id=wsu.user.id,
username=wsu.user.username,
email=wsu.user.email,
full_name=wsu.user.full_name,
)
for wsu in ws.workspace_users
if wsu.user is not None
]
return WorkspaceDetail(
id=ws.id,
name=ws.name,
description=ws.description,
color=ws.color,
is_default=ws.is_default,
server_count=len(servers),
user_count=len(users),
created_at=ws.created_at,
servers=servers,
users=users,
)
# ===========================================================================
# HTML Page
# ===========================================================================
@router.get("/workspaces", response_class=HTMLResponse, include_in_schema=False)
def workspaces_page(
request: Request,
):
"""Admin workspace management page"""
context = {
"request": request,
"page_title": "Workspaces",
}
from ..template_helpers import add_i18n_context
context = add_i18n_context(request, context)
return templates.TemplateResponse("workspaces.html", context)
# ===========================================================================
# REST API
# ===========================================================================
@router.get("/api/workspaces", response_model=List[WorkspaceResponse])
def list_workspaces(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
List workspaces.
Admins see all; regular users see only workspaces they belong to
(plus the Default workspace).
"""
if current_user.is_admin:
workspaces = db.query(Workspace).order_by(Workspace.is_default.desc(), Workspace.name).all()
else:
wus = db.query(WorkspaceUser).filter(WorkspaceUser.user_id == current_user.id).all()
ws_ids = [wu.workspace_id for wu in wus]
# Always include default workspace
default_ws = db.query(Workspace).filter(Workspace.is_default == True).first()
if default_ws and default_ws.id not in ws_ids:
ws_ids.append(default_ws.id)
workspaces = db.query(Workspace).filter(Workspace.id.in_(ws_ids)).order_by(
Workspace.is_default.desc(), Workspace.name
).all()
return [_build_response(ws) for ws in workspaces]
@router.post("/api/workspaces", response_model=WorkspaceDetail, status_code=status.HTTP_201_CREATED)
def create_workspace(
data: WorkspaceCreate,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("settings.panel")),
):
"""Create a new workspace (admin only)."""
existing = db.query(Workspace).filter(Workspace.name == data.name).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Workspace with name '{data.name}' already exists",
)
ws = Workspace(
name=data.name,
description=data.description,
color=data.color or "#667eea",
is_default=False,
created_by=current_user.id,
)
db.add(ws)
db.flush() # get ws.id
# Assign servers
for server_id in (data.server_ids or []):
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if server:
db.add(WorkspaceServer(workspace_id=ws.id, server_id=server_id))
# Assign users
for user_id in (data.user_ids or []):
user = db.query(User).filter(User.id == user_id).first()
if user:
db.add(WorkspaceUser(workspace_id=ws.id, user_id=user_id))
db.commit()
db.refresh(ws)
LoggingService.log(
db=db,
level=LoggingService.INFO,
category=LoggingService.SYSTEM,
action="workspace_create",
message=f"Workspace '{ws.name}' created",
username=current_user.username,
user_id=current_user.id,
resource_type="workspace",
resource_id=str(ws.id),
resource_name=ws.name,
success=True,
)
logger.info(f"User {current_user.username} created workspace: {ws.name}")
return _build_detail(ws)
@router.get("/api/workspaces/{workspace_id}", response_model=WorkspaceDetail)
def get_workspace(
workspace_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get workspace detail with servers and users list."""
ws = db.query(Workspace).filter(Workspace.id == workspace_id).first()
if not ws:
raise HTTPException(status_code=404, detail="Workspace not found")
if not current_user.is_admin:
access = db.query(WorkspaceUser).filter(
WorkspaceUser.workspace_id == workspace_id,
WorkspaceUser.user_id == current_user.id,
).first()
if not access and not ws.is_default:
raise HTTPException(status_code=403, detail="Access denied")
return _build_detail(ws)
@router.put("/api/workspaces/{workspace_id}", response_model=WorkspaceDetail)
def update_workspace(
workspace_id: int,
data: WorkspaceUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("settings.panel")),
):
"""Update workspace name/description/color (admin only)."""
ws = db.query(Workspace).filter(Workspace.id == workspace_id).first()
if not ws:
raise HTTPException(status_code=404, detail="Workspace not found")
if data.name is not None:
dup = db.query(Workspace).filter(
Workspace.name == data.name, Workspace.id != workspace_id
).first()
if dup:
raise HTTPException(status_code=400, detail=f"Name '{data.name}' already taken")
ws.name = data.name
if data.description is not None:
ws.description = data.description
if data.color is not None:
ws.color = data.color
db.commit()
db.refresh(ws)
logger.info(f"User {current_user.username} updated workspace {ws.id}: {ws.name}")
return _build_detail(ws)
@router.delete("/api/workspaces/{workspace_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_workspace(
workspace_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("settings.panel")),
):
"""Delete workspace (admin only). Default workspace cannot be deleted."""
ws = db.query(Workspace).filter(Workspace.id == workspace_id).first()
if not ws:
raise HTTPException(status_code=404, detail="Workspace not found")
if ws.is_default:
raise HTTPException(status_code=400, detail="Cannot delete the Default workspace")
db.delete(ws)
db.commit()
logger.info(f"User {current_user.username} deleted workspace {workspace_id}: {ws.name}")
# ---------------------------------------------------------------------------
# Server assignment
# ---------------------------------------------------------------------------
from pydantic import BaseModel as _BaseModel
class _IdsRequest(_BaseModel):
ids: List[int]
@router.post("/api/workspaces/{workspace_id}/servers", response_model=WorkspaceDetail)
def assign_servers(
workspace_id: int,
body: _IdsRequest,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("settings.panel")),
):
"""Replace the server list of a workspace (admin only)."""
ws = db.query(Workspace).filter(Workspace.id == workspace_id).first()
if not ws:
raise HTTPException(status_code=404, detail="Workspace not found")
# Remove existing
db.query(WorkspaceServer).filter(WorkspaceServer.workspace_id == workspace_id).delete()
# Add new
for server_id in body.ids:
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if server:
db.add(WorkspaceServer(workspace_id=workspace_id, server_id=server_id))
db.commit()
db.refresh(ws)
return _build_detail(ws)
@router.delete("/api/workspaces/{workspace_id}/servers/{server_id}", response_model=WorkspaceDetail)
def remove_server(
workspace_id: int,
server_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("settings.panel")),
):
"""Remove a single server from a workspace (admin only)."""
ws = db.query(Workspace).filter(Workspace.id == workspace_id).first()
if not ws:
raise HTTPException(status_code=404, detail="Workspace not found")
db.query(WorkspaceServer).filter(
WorkspaceServer.workspace_id == workspace_id,
WorkspaceServer.server_id == server_id,
).delete()
db.commit()
db.refresh(ws)
return _build_detail(ws)
# ---------------------------------------------------------------------------
# User assignment
# ---------------------------------------------------------------------------
@router.post("/api/workspaces/{workspace_id}/users", response_model=WorkspaceDetail)
def assign_users(
workspace_id: int,
body: _IdsRequest,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("settings.panel")),
):
"""Replace the user list of a workspace (admin only)."""
ws = db.query(Workspace).filter(Workspace.id == workspace_id).first()
if not ws:
raise HTTPException(status_code=404, detail="Workspace not found")
db.query(WorkspaceUser).filter(WorkspaceUser.workspace_id == workspace_id).delete()
for user_id in body.ids:
user = db.query(User).filter(User.id == user_id).first()
if user:
db.add(WorkspaceUser(workspace_id=workspace_id, user_id=user_id))
db.commit()
db.refresh(ws)
return _build_detail(ws)
@router.delete("/api/workspaces/{workspace_id}/users/{user_id}", response_model=WorkspaceDetail)
def remove_user(
workspace_id: int,
user_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("settings.panel")),
):
"""Remove a single user from a workspace (admin only)."""
ws = db.query(Workspace).filter(Workspace.id == workspace_id).first()
if not ws:
raise HTTPException(status_code=404, detail="Workspace not found")
db.query(WorkspaceUser).filter(
WorkspaceUser.workspace_id == workspace_id,
WorkspaceUser.user_id == user_id,
).delete()
db.commit()
db.refresh(ws)
return _build_detail(ws)