feat: v1.1.8 — user-server assignment, VM/LXC ownership, smart login redirect, RBAC migrations
- Migration 20: user_servers association table (User ↔ ProxmoxServer)
- API: GET/PUT /api/users/{id}/servers — assign servers to users with workspace-conflict validation
- API: GET /api/users/{id}/server-assignments — enriched server list (assigned + compatible flags)
- Non-admin server filter: intersection of assigned_servers and workspace servers
- Migration 21: dashboard:view = false for user role (new key format)
- Migration 22: proxmox.cluster.manage → cluster:manage for admin role
- API: GET/PUT /api/{server_id}/vm/{vmid}/owner — VM/LXC owner assignment (admin-only)
- Login: smart redirect based on user permissions after authentication
- Login: returnUrl param support for post-login redirect
- Roles API: user_count field per role in GET /api/roles
- node_uptime added to server detail API response
- Remove redundant PermissionChecker from GET /users HTML route
This commit is contained in:
@@ -8,7 +8,7 @@ from datetime import datetime, timedelta
|
||||
from ..db import get_db
|
||||
from ..models import ProxmoxServer, AuditLog, User
|
||||
from ..template_helpers import add_i18n_context
|
||||
from ..auth import get_current_user, PermissionChecker
|
||||
from ..auth import PermissionChecker
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
@@ -63,7 +63,7 @@ def dashboard(request: Request, db: Session = Depends(get_db)):
|
||||
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
|
||||
total_clusters = named_clusters
|
||||
|
||||
# Alerts in last 24 hours
|
||||
since_24h = datetime.utcnow() - timedelta(hours=24)
|
||||
|
||||
@@ -92,12 +92,50 @@ def list_proxmox_servers(
|
||||
):
|
||||
"""Получить список всех 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
|
||||
from ...models import WorkspaceUser, WorkspaceServer
|
||||
|
||||
is_privileged = current_user.is_admin or (
|
||||
current_user.role and current_user.role.name in ('admin', 'moderator')
|
||||
)
|
||||
|
||||
if is_privileged:
|
||||
# Privileged: use workspace filter only
|
||||
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))
|
||||
return query.all()
|
||||
|
||||
# Non-privileged user: must be directly assigned AND server must be in one of their workspaces
|
||||
assigned_ids = {s.id for s in current_user.assigned_servers}
|
||||
|
||||
# Collect server IDs accessible through user's workspaces
|
||||
user_ws_ids = {
|
||||
r.workspace_id for r in
|
||||
db.query(WorkspaceUser.workspace_id).filter(WorkspaceUser.user_id == current_user.id).all()
|
||||
}
|
||||
if user_ws_ids:
|
||||
ws_server_ids = {
|
||||
r.server_id for r in
|
||||
db.query(WorkspaceServer.server_id).filter(
|
||||
WorkspaceServer.workspace_id.in_(user_ws_ids)
|
||||
).all()
|
||||
}
|
||||
else:
|
||||
ws_server_ids = set()
|
||||
|
||||
# Intersect: directly assigned AND within user's workspaces
|
||||
effective_ids = assigned_ids & ws_server_ids
|
||||
|
||||
# Also apply active-workspace header filter on top
|
||||
ws_filter = get_workspace_server_ids(request, db, current_user)
|
||||
if ws_filter is not None:
|
||||
effective_ids = effective_ids & set(ws_filter)
|
||||
|
||||
if not effective_ids:
|
||||
return []
|
||||
|
||||
return db.query(ProxmoxServer).filter(ProxmoxServer.id.in_(effective_ids)).all()
|
||||
|
||||
|
||||
@router.post("/api/servers", response_model=ProxmoxServerResponse, status_code=status.HTTP_201_CREATED)
|
||||
@@ -601,6 +639,22 @@ def get_server_resources(
|
||||
vms = [vm for vm in resources.get('vms', []) if not vm.get('template', 0)]
|
||||
containers = [ct for ct in resources.get('containers', []) if not ct.get('template', 0)]
|
||||
|
||||
# Получаем uptime ноды
|
||||
node_uptime = None
|
||||
try:
|
||||
client = _get_proxmox_client(server)
|
||||
nodes = client.get_nodes()
|
||||
if nodes:
|
||||
target_node = node_filter or (nodes[0].get('node') if nodes else None)
|
||||
for n in nodes:
|
||||
if target_node and n.get('node') == target_node:
|
||||
node_uptime = n.get('uptime')
|
||||
break
|
||||
if node_uptime is None and nodes:
|
||||
node_uptime = nodes[0].get('uptime')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
server.update_status(True)
|
||||
db.commit()
|
||||
|
||||
@@ -608,7 +662,8 @@ def get_server_resources(
|
||||
"server_id": server_id,
|
||||
"server_name": server.name,
|
||||
"vms": vms,
|
||||
"containers": containers
|
||||
"containers": containers,
|
||||
"node_uptime": node_uptime
|
||||
})
|
||||
except Exception as e:
|
||||
server.update_status(False, str(e))
|
||||
|
||||
@@ -45,6 +45,10 @@ class BulkOperationRequest(BaseModel):
|
||||
items: TypingList[BulkOperationItem]
|
||||
|
||||
|
||||
class VMOwnerUpdate(BaseModel):
|
||||
user_id: Optional[int] = None
|
||||
|
||||
|
||||
@router.post("/api/sync-vms")
|
||||
def sync_vms_now(
|
||||
db: Session = Depends(get_db),
|
||||
@@ -2793,3 +2797,73 @@ def create_bulk_operation(
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
# ===================== VM Owner Management =====================
|
||||
|
||||
@router.get("/api/{server_id}/vm/{vmid}/owner")
|
||||
def get_vm_owner(
|
||||
server_id: int,
|
||||
vmid: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get current owner of a VM/LXC. Admin only."""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin required")
|
||||
|
||||
instance = db.query(VMInstance).filter(
|
||||
VMInstance.server_id == server_id,
|
||||
VMInstance.vmid == vmid,
|
||||
VMInstance.deleted_at.is_(None)
|
||||
).first()
|
||||
|
||||
owner = None
|
||||
if instance and instance.owner_id:
|
||||
owner_user = db.query(User).filter(User.id == instance.owner_id).first()
|
||||
if owner_user:
|
||||
owner = {"id": owner_user.id, "username": owner_user.username, "full_name": owner_user.full_name}
|
||||
|
||||
users = (
|
||||
db.query(User)
|
||||
.filter(User.is_active == True, User.is_admin == False)
|
||||
.order_by(User.username)
|
||||
.all()
|
||||
)
|
||||
|
||||
return {
|
||||
"owner_id": instance.owner_id if instance else None,
|
||||
"owner": owner,
|
||||
"users": [{"id": u.id, "username": u.username, "full_name": u.full_name} for u in users]
|
||||
}
|
||||
|
||||
|
||||
@router.put("/api/{server_id}/vm/{vmid}/owner")
|
||||
def set_vm_owner(
|
||||
server_id: int,
|
||||
vmid: int,
|
||||
body: VMOwnerUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Set (or clear) the owner of a VM/LXC. Admin only."""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin required")
|
||||
|
||||
instance = db.query(VMInstance).filter(
|
||||
VMInstance.server_id == server_id,
|
||||
VMInstance.vmid == vmid,
|
||||
VMInstance.deleted_at.is_(None)
|
||||
).first()
|
||||
if not instance:
|
||||
raise HTTPException(status_code=404, detail="VM not found in cache")
|
||||
|
||||
if body.user_id is not None:
|
||||
target_user = db.query(User).filter(User.id == body.user_id, User.is_active == True).first()
|
||||
if not target_user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
instance.owner_id = body.user_id
|
||||
db.commit()
|
||||
logger.info(f"Admin {current_user.username} set owner of VM {vmid} (server {server_id}) to user_id={body.user_id}")
|
||||
return {"ok": True, "owner_id": body.user_id}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ from pydantic import BaseModel, Field, EmailStr
|
||||
from loguru import logger
|
||||
|
||||
from ..db import get_db
|
||||
from ..models import User, Role
|
||||
from ..models import User, Role, ProxmoxServer, Workspace, WorkspaceServer, WorkspaceUser
|
||||
from ..auth import (
|
||||
get_password_hash,
|
||||
get_current_user,
|
||||
@@ -75,6 +75,7 @@ class RoleResponse(BaseModel):
|
||||
is_system: bool
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
user_count: int = 0
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -140,7 +141,6 @@ class SecuritySettingsUpdate(BaseModel):
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def users_page(
|
||||
request: Request,
|
||||
current_user: User = Depends(PermissionChecker("users.view"))
|
||||
):
|
||||
"""Render users management page"""
|
||||
from ..i18n import t
|
||||
@@ -162,8 +162,31 @@ async def list_roles(
|
||||
current_user: User = Depends(PermissionChecker("roles.view"))
|
||||
):
|
||||
"""Get all roles"""
|
||||
from sqlalchemy import func
|
||||
roles = SecurityService.get_all_roles(db, include_inactive=True)
|
||||
return roles
|
||||
|
||||
# Count users per role
|
||||
counts = dict(
|
||||
db.query(User.role_id, func.count(User.id))
|
||||
.filter(User.role_id.isnot(None))
|
||||
.group_by(User.role_id)
|
||||
.all()
|
||||
)
|
||||
|
||||
result = []
|
||||
for role in roles:
|
||||
result.append({
|
||||
"id": role.id,
|
||||
"name": role.name,
|
||||
"display_name": role.display_name,
|
||||
"description": role.description,
|
||||
"permissions": role.permissions or {},
|
||||
"is_system": role.is_system,
|
||||
"is_active": role.is_active,
|
||||
"created_at": role.created_at,
|
||||
"user_count": counts.get(role.id, 0),
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/api/roles/{role_id}", response_model=RoleResponse)
|
||||
@@ -585,6 +608,162 @@ async def delete_user(
|
||||
return {"message": "User deleted"}
|
||||
|
||||
|
||||
# ==================== Server Assignment API ====================
|
||||
|
||||
def _get_user_workspace_ids(db: Session, user_id: int) -> set:
|
||||
"""Return set of workspace IDs the user belongs to."""
|
||||
rows = db.query(WorkspaceUser.workspace_id).filter(WorkspaceUser.user_id == user_id).all()
|
||||
return {r.workspace_id for r in rows}
|
||||
|
||||
|
||||
def _get_server_workspace_info(db: Session, server_id: int):
|
||||
"""Return list of {id, name} workspaces that own this server."""
|
||||
rows = (
|
||||
db.query(WorkspaceServer.workspace_id, Workspace.name)
|
||||
.join(Workspace, Workspace.id == WorkspaceServer.workspace_id)
|
||||
.filter(WorkspaceServer.server_id == server_id)
|
||||
.all()
|
||||
)
|
||||
return [{"id": r.workspace_id, "name": r.name} for r in rows]
|
||||
|
||||
|
||||
@router.get("/api/users/{user_id}/servers")
|
||||
async def get_user_servers(
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("users.view"))
|
||||
):
|
||||
"""Get list of Proxmox servers assigned to a specific user"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
return [
|
||||
{"id": s.id, "name": s.name, "hostname": s.hostname, "ip_address": s.ip_address, "is_online": s.is_online}
|
||||
for s in user.assigned_servers
|
||||
]
|
||||
|
||||
|
||||
@router.get("/api/users/{user_id}/server-assignments")
|
||||
async def get_user_server_assignments(
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("users.view"))
|
||||
):
|
||||
"""
|
||||
Return all Proxmox servers enriched with:
|
||||
- workspaces each server belongs to
|
||||
- whether the server is compatible with the user (user is in at least one of its workspaces)
|
||||
- whether the server is currently assigned to the user
|
||||
"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
user_ws_ids = _get_user_workspace_ids(db, user_id)
|
||||
assigned_ids = {s.id for s in user.assigned_servers}
|
||||
|
||||
# User's own workspaces info
|
||||
user_workspaces = [
|
||||
{"id": ws.id, "name": ws.name}
|
||||
for ws in db.query(Workspace).filter(Workspace.id.in_(user_ws_ids)).all()
|
||||
] if user_ws_ids else []
|
||||
|
||||
all_servers = db.query(ProxmoxServer).all()
|
||||
result = []
|
||||
for server in all_servers:
|
||||
server_workspaces = _get_server_workspace_info(db, server.id)
|
||||
server_ws_ids = {w["id"] for w in server_workspaces}
|
||||
# Compatible = user's workspaces and server's workspaces intersect
|
||||
compatible = bool(user_ws_ids & server_ws_ids) if (user_ws_ids and server_ws_ids) else False
|
||||
result.append({
|
||||
"id": server.id,
|
||||
"name": server.name,
|
||||
"ip_address": server.ip_address,
|
||||
"is_online": server.is_online,
|
||||
"workspaces": server_workspaces,
|
||||
"compatible": compatible,
|
||||
"assigned": server.id in assigned_ids,
|
||||
})
|
||||
|
||||
return {
|
||||
"servers": result,
|
||||
"user_workspaces": user_workspaces,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/api/users/{user_id}/servers")
|
||||
async def set_user_servers(
|
||||
user_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("users.edit"))
|
||||
):
|
||||
"""Assign a list of Proxmox servers to a user (replaces current assignment).
|
||||
Rejects the operation if any server's workspaces don't intersect with the user's workspaces.
|
||||
"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
data = await request.json()
|
||||
server_ids = data.get("server_ids", [])
|
||||
|
||||
if not server_ids:
|
||||
user.assigned_servers = []
|
||||
db.commit()
|
||||
return {"message": "Servers cleared", "server_ids": []}
|
||||
|
||||
servers = db.query(ProxmoxServer).filter(ProxmoxServer.id.in_(server_ids)).all()
|
||||
|
||||
# Workspace compatibility validation
|
||||
user_ws_ids = _get_user_workspace_ids(db, user_id)
|
||||
conflicts = []
|
||||
for server in servers:
|
||||
server_workspaces = _get_server_workspace_info(db, server.id)
|
||||
server_ws_ids = {w["id"] for w in server_workspaces}
|
||||
# No overlap between user workspaces and server workspaces → conflict
|
||||
if user_ws_ids and server_ws_ids and not (user_ws_ids & server_ws_ids):
|
||||
user_workspaces_info = [
|
||||
{"id": ws.id, "name": ws.name}
|
||||
for ws in db.query(Workspace).filter(Workspace.id.in_(user_ws_ids)).all()
|
||||
]
|
||||
conflicts.append({
|
||||
"server_id": server.id,
|
||||
"server_name": server.name,
|
||||
"server_workspaces": server_workspaces,
|
||||
"user_workspaces": user_workspaces_info,
|
||||
})
|
||||
|
||||
if conflicts:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"code": "workspace_conflict",
|
||||
"message": "Cannot assign: server(s) are not in any workspace the user belongs to",
|
||||
"conflicts": conflicts,
|
||||
}
|
||||
)
|
||||
|
||||
user.assigned_servers = servers
|
||||
db.commit()
|
||||
|
||||
LoggingService.log(
|
||||
db=db,
|
||||
level=LoggingService.INFO,
|
||||
category=LoggingService.AUTH,
|
||||
action="user_servers_updated",
|
||||
message=f"Servers assigned to '{user.username}' by {current_user.username}: {[s.id for s in servers]}",
|
||||
username=current_user.username,
|
||||
user_id=current_user.id,
|
||||
ip_address=get_client_ip(request),
|
||||
resource_type="user",
|
||||
resource_id=str(user.id),
|
||||
resource_name=user.username
|
||||
)
|
||||
|
||||
return {"message": "Servers assigned successfully", "server_ids": [s.id for s in servers]}
|
||||
|
||||
|
||||
@router.post("/api/users/{user_id}/reset-password")
|
||||
async def reset_user_password(
|
||||
user_id: int,
|
||||
|
||||
Reference in New Issue
Block a user