feat(quotas): per-user resource limits for VM/LXC deployment
All checks were successful
Backend Tests / pytest (Python 3.12) (push) Successful in 54s
Deploy to Production / Trigger production update (push) Successful in 8s

Add per-user quotas (max instances, vCPU, RAM, disk) enforced at deploy
time and surfaced to users self-service.

Backend:
- UserQuota model (one-to-one, NULL = unlimited) + migration #28
- quota_service: usage from vm_instances cache, check_quota raises 429
- enforce in VM (templates.py) and LXC (lxc.py) deploy paths
- RBAC quota:view / quota:manage, granted to admin
- admin API GET/PUT /admin/api/users/{id}/quota
- self-service GET /settings/api/quota
- tighten VM list isolation via can_view_all_instances

Frontend:
- admin quota dialog in UsersPage with usage bars
- quota card on Settings page (own limits)
- remaining-quota hint in CreateInstanceWizard config step
- useUserQuota/useSetUserQuota, useMyQuota hooks; RU/EN locales

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 18:15:02 +05:00
parent d59d787bed
commit 35dcbfd18f
21 changed files with 707 additions and 67 deletions

View File

@@ -20,43 +20,46 @@ from ...ipam_service import IPAMService
# ==================== Helper Functions for User Isolation ====================
def can_view_all_instances(current_user: User) -> bool:
"""
Whether the user may see/manage *every* instance, not just their own.
Only administrators — or roles that explicitly grant ``vm:manage`` — get the
global view. Every other authenticated user (including users with no role,
custom roles, or the stock ``user`` role) is scoped to instances they own.
Note: ``vm:view`` is deliberately NOT treated as a global view permission.
The stock ``user`` role carries ``vm:view`` simply so the instances feature
is usable at all; granting it would let any tenant see every VM.
"""
if getattr(current_user, 'is_admin', False):
return True
role = current_user.role
if role and role.name == 'admin':
return True
perms = (role.permissions if role else None) or {}
return bool(perms.get('vm:manage', False))
def check_vm_access(db: Session, current_user: User, server_id: int, vmid: int) -> bool:
"""
Check if user has access to a specific VM instance.
Admins and users with 'vms:view' permission can access all VMs.
Users with 'user' role can only access VMs where they are the owner.
Admins (and roles granting ``vm:manage``) can access all VMs. Everyone else
can only access VMs where they are the owner.
Returns True if access is allowed, False otherwise.
"""
# Admin role has full access
if current_user.role and current_user.role.name == 'admin':
if can_view_all_instances(current_user):
return True
# Check permissions
if current_user.role and current_user.role.permissions:
perms = current_user.role.permissions
# If user has full vms:view, they can access all VMs
has_full_view = perms.get('vm:view', False) or perms.get('vms:view', False)
has_own_only = perms.get('vms:view:own', False) or perms.get('vms.view.own', False)
instance = db.query(VMInstance).filter(
VMInstance.server_id == server_id,
VMInstance.vmid == vmid,
VMInstance.deleted_at.is_(None)
).first()
if has_full_view and not has_own_only:
return True
# For limited users, check ownership
if current_user.role and current_user.role.name == 'user':
instance = db.query(VMInstance).filter(
VMInstance.server_id == server_id,
VMInstance.vmid == vmid,
VMInstance.deleted_at.is_(None)
).first()
if instance and instance.owner_id == current_user.id:
return True
return False
# Default: allow access (for backwards compatibility)
return True
return bool(instance and instance.owner_id == current_user.id)
def require_vm_access(db: Session, current_user: User, server_id: int, vmid: int):

View File

@@ -448,6 +448,10 @@ async def deploy_lxc_from_template(
raise HTTPException(status_code=404, detail="Owner user not found")
instance_owner_id = owner.id
# Enforce per-user resource quota (no-op if owner has no quota set)
from ...services.quota_service import check_quota
check_quota(db, instance_owner_id, add_cores=req.cores, add_memory_mb=req.memory, add_disk_gb=req.disk)
if req.ssh_key_ids:
from ..ssh_keys import resolve_ssh_keys_for_deploy
resolved = resolve_ssh_keys_for_deploy(db, current_user, req.ssh_key_ids, instance_owner_id)

View File

@@ -20,7 +20,8 @@ from ...logging_service import LoggingService
from ...ipam_service import IPAMService
from ._helpers import (check_vm_access, require_vm_access, _get_proxmox_client,
get_next_vmid, archive_and_delete_snapshots,
save_vm_instance, get_vm_instance, soft_delete_vm_instance)
save_vm_instance, get_vm_instance, soft_delete_vm_instance,
can_view_all_instances)
from ...services.metrics_history import query_instance_metrics, timeframe_to_range
router = APIRouter()
@@ -146,21 +147,11 @@ def get_all_virtual_machines(
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 'vm:view' but not 'vms.view:all' (or is user role)
user_role = current_user.role.name if current_user.role else None
is_limited_user = user_role == 'user'
# Also check permission-based isolation
if hasattr(current_user, 'role') and current_user.role:
perms = current_user.role.permissions or {}
# If user has vms:view:own but not vms:view (full), filter by owner
has_view_own = perms.get('vms:view:own', False) or perms.get('vms.view.own', False)
has_view_all = perms.get('vms:view', False) or perms.get('vm:view', False)
# Limited users can only see their own VMs
if is_limited_user or (has_view_own and not has_view_all):
query = query.filter(VMInstance.owner_id == current_user.id)
# VPS-style user isolation: only admins (or roles granting vm:manage) see
# every instance. Everyone else — including users with no role or a custom
# role — is restricted to instances they own. See can_view_all_instances.
if not can_view_all_instances(current_user):
query = query.filter(VMInstance.owner_id == current_user.id)
cached_vms = query.order_by(VMInstance.name).all()