Add complete project structure and all files

This commit is contained in:
2025-12-15 13:03:29 +05:00
parent dc4c2521c7
commit f5a3903f5d
139 changed files with 72714 additions and 2 deletions

View File

@@ -0,0 +1,78 @@
"""
RBAC Module - Role-Based Access Control
Provides atomic permission model with resource:action:scope format
"""
from .permissions import (
Permission,
PermissionRegistry,
PERMISSIONS,
ScopeType,
ResourceType,
ActionType,
LEGACY_PERMISSION_MAP,
resolve_permission,
get_permission_info
)
from .engine import (
PermissionEngine,
require_permission,
check_permission,
get_user_permissions
)
from .middleware import (
PermissionChecker,
ScopedPermissionChecker,
ResourceAccessChecker
)
from .decorators import (
requires_permission,
requires_any_permission,
requires_all_permissions
)
from .audit import RBACAuditService
from .migration import (
convert_permissions_to_new_format,
convert_permissions_to_legacy_format,
get_permission_categories,
get_permission_categories_legacy,
migrate_all_roles_to_new_format,
ensure_default_roles_new_format,
NEW_DEFAULT_ROLES
)
__all__ = [
# Permissions
"Permission",
"PermissionRegistry",
"PERMISSIONS",
"ScopeType",
"ResourceType",
"ActionType",
"LEGACY_PERMISSION_MAP",
"resolve_permission",
"get_permission_info",
# Engine
"PermissionEngine",
"require_permission",
"check_permission",
"get_user_permissions",
# Middleware
"PermissionChecker",
"ScopedPermissionChecker",
"ResourceAccessChecker",
# Decorators
"requires_permission",
"requires_any_permission",
"requires_all_permissions",
# Audit
"RBACAuditService",
# Migration
"convert_permissions_to_new_format",
"convert_permissions_to_legacy_format",
"get_permission_categories",
"get_permission_categories_legacy",
"migrate_all_roles_to_new_format",
"ensure_default_roles_new_format",
"NEW_DEFAULT_ROLES",
]

267
backend/app/rbac/audit.py Normal file
View File

@@ -0,0 +1,267 @@
"""
RBAC Audit Service - Logs all permission and role changes
"""
from datetime import datetime, timezone
from typing import Optional, Dict, Any, List
from sqlalchemy.orm import Session
from sqlalchemy import desc
from loguru import logger
from .permissions import PERMISSIONS, resolve_permission
def utcnow() -> datetime:
"""Get current UTC time"""
return datetime.now(timezone.utc)
class RBACAuditService:
"""
Service for auditing RBAC changes.
Logs role creation, permission changes, role assignments, etc.
"""
AUDIT_ACTIONS = {
"role_created": "Role Created",
"role_updated": "Role Updated",
"role_deleted": "Role Deleted",
"role_assigned": "Role Assigned to User",
"role_removed": "Role Removed from User",
"permission_added": "Permission Added",
"permission_removed": "Permission Removed",
"permission_denied": "Permission Denied",
}
@staticmethod
def log_role_created(
db: Session,
role_id: int,
role_name: str,
permissions: Dict[str, bool],
created_by_id: int,
created_by_username: str,
ip_address: Optional[str] = None
) -> None:
"""Log role creation"""
from ..models import AuditLog
log_entry = AuditLog(
level="info",
category="rbac",
action="role_created",
message=f"Role '{role_name}' created by {created_by_username}",
user_id=created_by_id,
username=created_by_username,
ip_address=ip_address,
resource_type="role",
resource_id=str(role_id),
resource_name=role_name,
details={
"permissions": permissions,
"permissions_count": sum(1 for v in permissions.values() if v)
},
success=True
)
db.add(log_entry)
db.commit()
logger.info(f"RBAC Audit: Role '{role_name}' created by {created_by_username}")
@staticmethod
def log_role_updated(
db: Session,
role_id: int,
role_name: str,
old_permissions: Dict[str, bool],
new_permissions: Dict[str, bool],
updated_by_id: int,
updated_by_username: str,
ip_address: Optional[str] = None
) -> None:
"""Log role update with permission diff"""
from ..models import AuditLog
# Calculate diff
added = []
removed = []
for perm, enabled in new_permissions.items():
old_value = old_permissions.get(perm, False)
if enabled and not old_value:
added.append(perm)
elif not enabled and old_value:
removed.append(perm)
log_entry = AuditLog(
level="info",
category="rbac",
action="role_updated",
message=f"Role '{role_name}' updated by {updated_by_username}: +{len(added)} -{len(removed)} permissions",
user_id=updated_by_id,
username=updated_by_username,
ip_address=ip_address,
resource_type="role",
resource_id=str(role_id),
resource_name=role_name,
details={
"permissions_added": added,
"permissions_removed": removed,
"old_permissions_count": sum(1 for v in old_permissions.values() if v),
"new_permissions_count": sum(1 for v in new_permissions.values() if v)
},
success=True
)
db.add(log_entry)
db.commit()
logger.info(f"RBAC Audit: Role '{role_name}' updated: +{len(added)} -{len(removed)}")
@staticmethod
def log_role_deleted(
db: Session,
role_id: int,
role_name: str,
deleted_by_id: int,
deleted_by_username: str,
affected_users: int = 0,
ip_address: Optional[str] = None
) -> None:
"""Log role deletion"""
from ..models import AuditLog
log_entry = AuditLog(
level="warning",
category="rbac",
action="role_deleted",
message=f"Role '{role_name}' deleted by {deleted_by_username}, {affected_users} users affected",
user_id=deleted_by_id,
username=deleted_by_username,
ip_address=ip_address,
resource_type="role",
resource_id=str(role_id),
resource_name=role_name,
details={
"affected_users": affected_users
},
success=True
)
db.add(log_entry)
db.commit()
logger.warning(f"RBAC Audit: Role '{role_name}' deleted, {affected_users} users affected")
@staticmethod
def log_role_assigned(
db: Session,
user_id: int,
username: str,
role_id: int,
role_name: str,
old_role_name: Optional[str],
assigned_by_id: int,
assigned_by_username: str,
ip_address: Optional[str] = None
) -> None:
"""Log role assignment to user"""
from ..models import AuditLog
msg = f"Role '{role_name}' assigned to user '{username}'"
if old_role_name:
msg += f" (was: {old_role_name})"
msg += f" by {assigned_by_username}"
log_entry = AuditLog(
level="info",
category="rbac",
action="role_assigned",
message=msg,
user_id=assigned_by_id,
username=assigned_by_username,
ip_address=ip_address,
resource_type="user",
resource_id=str(user_id),
resource_name=username,
details={
"new_role_id": role_id,
"new_role_name": role_name,
"old_role_name": old_role_name,
"target_user_id": user_id,
"target_username": username
},
success=True
)
db.add(log_entry)
db.commit()
logger.info(f"RBAC Audit: {msg}")
@staticmethod
def log_permission_denied(
db: Session,
user_id: int,
username: str,
permission: str,
resource_type: Optional[str] = None,
resource_id: Optional[str] = None,
ip_address: Optional[str] = None,
request_path: Optional[str] = None
) -> None:
"""Log denied permission attempt"""
from ..models import AuditLog
log_entry = AuditLog(
level="warning",
category="rbac",
action="permission_denied",
message=f"Permission '{permission}' denied for user '{username}'",
user_id=user_id,
username=username,
ip_address=ip_address,
resource_type=resource_type,
resource_id=resource_id,
request_path=request_path,
details={
"permission": permission,
"resolved_permission": resolve_permission(permission)
},
success=False
)
db.add(log_entry)
db.commit()
logger.warning(f"RBAC Audit: Permission '{permission}' denied for '{username}'")
@staticmethod
def get_rbac_logs(
db: Session,
limit: int = 100,
offset: int = 0,
user_id: Optional[int] = None,
action: Optional[str] = None
) -> List:
"""Get RBAC audit logs"""
from ..models import AuditLog
query = db.query(AuditLog).filter(AuditLog.category == "rbac")
if user_id:
query = query.filter(AuditLog.user_id == user_id)
if action:
query = query.filter(AuditLog.action == action)
return query.order_by(desc(AuditLog.created_at)).offset(offset).limit(limit).all()
@staticmethod
def get_user_permission_history(
db: Session,
user_id: int,
limit: int = 50
) -> List:
"""Get permission change history for a user"""
from ..models import AuditLog
return db.query(AuditLog).filter(
AuditLog.category == "rbac",
AuditLog.details.contains({"target_user_id": user_id})
).order_by(desc(AuditLog.created_at)).limit(limit).all()

View File

@@ -0,0 +1,163 @@
"""
Permission Decorators - Function decorators for permission checks
"""
from functools import wraps
from typing import List, Callable, Union
from fastapi import HTTPException, status
from .engine import PermissionEngine
from .permissions import ScopeType
def requires_permission(
permission: str,
scope: ScopeType = ScopeType.GLOBAL,
user_param: str = "current_user"
):
"""
Decorator to require a specific permission.
Usage:
@requires_permission("vm:create")
async def create_vm(current_user: User, ...):
...
"""
def decorator(func: Callable):
@wraps(func)
async def async_wrapper(*args, **kwargs):
user = kwargs.get(user_param)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found in request"
)
PermissionEngine.check_permission(user, permission, scope)
return await func(*args, **kwargs)
@wraps(func)
def sync_wrapper(*args, **kwargs):
user = kwargs.get(user_param)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found in request"
)
PermissionEngine.check_permission(user, permission, scope)
return func(*args, **kwargs)
# Return appropriate wrapper based on function type
import asyncio
if asyncio.iscoroutinefunction(func):
return async_wrapper
return sync_wrapper
return decorator
def requires_any_permission(
permissions: List[str],
scope: ScopeType = ScopeType.GLOBAL,
user_param: str = "current_user"
):
"""
Decorator to require any one of the listed permissions.
Usage:
@requires_any_permission(["vm:create", "lxc:create"])
async def create_instance(current_user: User, ...):
...
"""
def decorator(func: Callable):
@wraps(func)
async def async_wrapper(*args, **kwargs):
user = kwargs.get(user_param)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found in request"
)
if not PermissionEngine.has_any_permission(user, permissions):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Permission denied: requires one of {permissions}"
)
return await func(*args, **kwargs)
@wraps(func)
def sync_wrapper(*args, **kwargs):
user = kwargs.get(user_param)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found in request"
)
if not PermissionEngine.has_any_permission(user, permissions):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Permission denied: requires one of {permissions}"
)
return func(*args, **kwargs)
import asyncio
if asyncio.iscoroutinefunction(func):
return async_wrapper
return sync_wrapper
return decorator
def requires_all_permissions(
permissions: List[str],
scope: ScopeType = ScopeType.GLOBAL,
user_param: str = "current_user"
):
"""
Decorator to require all of the listed permissions.
Usage:
@requires_all_permissions(["vm:view", "vm:update"])
async def update_vm(current_user: User, ...):
...
"""
def decorator(func: Callable):
@wraps(func)
async def async_wrapper(*args, **kwargs):
user = kwargs.get(user_param)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found in request"
)
for perm in permissions:
PermissionEngine.check_permission(user, perm, scope)
return await func(*args, **kwargs)
@wraps(func)
def sync_wrapper(*args, **kwargs):
user = kwargs.get(user_param)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found in request"
)
for perm in permissions:
PermissionEngine.check_permission(user, perm, scope)
return func(*args, **kwargs)
import asyncio
if asyncio.iscoroutinefunction(func):
return async_wrapper
return sync_wrapper
return decorator

245
backend/app/rbac/engine.py Normal file
View File

@@ -0,0 +1,245 @@
"""
Permission Engine - Central authorization logic
Handles all permission checks with scope awareness
"""
from typing import Optional, List, Set, Dict, Any
from sqlalchemy.orm import Session
from fastapi import HTTPException, status
from loguru import logger
from .permissions import (
Permission,
PermissionRegistry,
PERMISSIONS,
ScopeType,
resolve_permission,
LEGACY_PERMISSION_MAP
)
class PermissionEngine:
"""
Central permission checking engine.
All authorization decisions go through this class.
"""
@staticmethod
def get_user_permissions(user) -> Set[str]:
"""
Get all effective permissions for a user.
Returns set of permission codes.
"""
permissions = set()
# Check if user is admin (has all permissions)
if getattr(user, 'is_admin', False):
return {p.code for p in PERMISSIONS.all()}
# Get permissions from role
if user.role and user.role.permissions:
role_perms = user.role.permissions
for perm_code, enabled in role_perms.items():
if enabled:
# Resolve legacy to new format
resolved = resolve_permission(perm_code)
permissions.add(resolved)
# Also keep legacy for backwards compatibility
if perm_code != resolved:
permissions.add(perm_code)
return permissions
@staticmethod
def has_permission(
user,
permission: str,
scope: ScopeType = ScopeType.GLOBAL,
resource_id: Optional[int] = None,
organization_id: Optional[int] = None,
workspace_id: Optional[int] = None,
) -> bool:
"""
Check if user has specific permission.
Args:
user: User object
permission: Permission code (new or legacy format)
scope: Permission scope level
resource_id: Specific resource ID (for instance scope)
organization_id: Organization context
workspace_id: Workspace context
Returns:
True if user has permission, False otherwise
"""
# Admin bypass - full access
if getattr(user, 'is_admin', False):
return True
# Get user permissions
user_permissions = PermissionEngine.get_user_permissions(user)
# Resolve permission code
resolved_perm = resolve_permission(permission)
# Check direct permission
if resolved_perm in user_permissions or permission in user_permissions:
return True
# Check wildcard permissions (e.g., vm:* grants all vm permissions)
resource = resolved_perm.split(':')[0] if ':' in resolved_perm else permission.split('.')[0]
wildcard = f"{resource}:*"
if wildcard in user_permissions:
return True
# Check manage permission (implies most actions)
manage_perm = f"{resource}:manage"
if manage_perm in user_permissions:
# manage implies view, update, and some actions
action = resolved_perm.split(':')[1] if ':' in resolved_perm else None
implied_actions = {'view', 'list', 'update', 'start', 'stop', 'restart'}
if action in implied_actions:
return True
return False
@staticmethod
def check_permission(
user,
permission: str,
scope: ScopeType = ScopeType.GLOBAL,
resource_id: Optional[int] = None,
organization_id: Optional[int] = None,
workspace_id: Optional[int] = None,
) -> None:
"""
Check permission and raise 403 if denied.
"""
if not PermissionEngine.has_permission(
user, permission, scope, resource_id, organization_id, workspace_id
):
logger.warning(
f"Permission denied: user={user.username}, "
f"permission={permission}, scope={scope}"
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Permission denied: {permission}"
)
@staticmethod
def has_any_permission(user, permissions: List[str]) -> bool:
"""Check if user has any of the listed permissions"""
for perm in permissions:
if PermissionEngine.has_permission(user, perm):
return True
return False
@staticmethod
def has_all_permissions(user, permissions: List[str]) -> bool:
"""Check if user has all of the listed permissions"""
for perm in permissions:
if not PermissionEngine.has_permission(user, perm):
return False
return True
@staticmethod
def filter_permissions(user, permissions: List[str]) -> List[str]:
"""Filter list to only permissions user has"""
return [p for p in permissions if PermissionEngine.has_permission(user, p)]
@staticmethod
def get_effective_permissions(user) -> Dict[str, Any]:
"""
Get effective permissions with metadata.
Returns dict suitable for UI display.
"""
user_perms = PermissionEngine.get_user_permissions(user)
result = {}
for perm in PERMISSIONS.all():
has_perm = perm.code in user_perms
result[perm.code] = {
"code": perm.code,
"display_name": perm.display_name,
"description": perm.description,
"category": perm.category,
"granted": has_perm,
"source": "admin" if user.is_admin else ("role" if has_perm else None)
}
return result
@staticmethod
def get_permissions_by_category(user) -> Dict[str, List[Dict]]:
"""Get effective permissions grouped by category"""
effective = PermissionEngine.get_effective_permissions(user)
by_category = {}
for perm_info in effective.values():
category = perm_info["category"]
if category not in by_category:
by_category[category] = []
by_category[category].append(perm_info)
return by_category
@staticmethod
def can_assign_permission(assigner, permission: str) -> bool:
"""
Check if user can assign a specific permission to others.
Users cannot grant permissions they don't have themselves.
"""
# Must have role management permission
if not PermissionEngine.has_permission(assigner, "role:manage"):
return False
# Cannot assign permission you don't have (unless admin)
if not assigner.is_admin:
if not PermissionEngine.has_permission(assigner, permission):
return False
return True
@staticmethod
def validate_role_permissions(assigner, permissions: Dict[str, bool]) -> tuple[Dict, List[str]]:
"""
Validate permissions that user is trying to assign.
Returns (valid_permissions, denied_permissions)
"""
valid = {}
denied = []
for perm, enabled in permissions.items():
if enabled:
if PermissionEngine.can_assign_permission(assigner, perm):
valid[perm] = True
else:
denied.append(perm)
else:
valid[perm] = False
return valid, denied
# Convenience functions
def require_permission(
user,
permission: str,
scope: ScopeType = ScopeType.GLOBAL,
resource_id: Optional[int] = None,
) -> None:
"""Require permission or raise 403"""
PermissionEngine.check_permission(user, permission, scope, resource_id)
def check_permission(user, permission: str) -> bool:
"""Check if user has permission"""
return PermissionEngine.has_permission(user, permission)
def get_user_permissions(user) -> Set[str]:
"""Get all user permissions"""
return PermissionEngine.get_user_permissions(user)

View File

@@ -0,0 +1,230 @@
"""
Authorization Middleware - FastAPI dependencies for permission checks
Replaces old PermissionChecker with enhanced functionality
"""
from typing import Optional, List, Union
from fastapi import Depends, HTTPException, status, Request
from sqlalchemy.orm import Session
from .engine import PermissionEngine
from .permissions import ScopeType, resolve_permission
class PermissionChecker:
"""
FastAPI dependency for checking permissions.
Usage:
@router.get("/vms")
async def list_vms(user = Depends(PermissionChecker("vm:view"))):
...
# Multiple permissions (any)
@router.post("/vms")
async def create_vm(user = Depends(PermissionChecker(["vm:create", "lxc:create"]))):
...
"""
def __init__(
self,
permission: Union[str, List[str]],
require_all: bool = False,
scope: ScopeType = ScopeType.GLOBAL
):
"""
Args:
permission: Permission code or list of codes
require_all: If True, requires all permissions (AND), else any (OR)
scope: Permission scope level
"""
if isinstance(permission, str):
self.permissions = [permission]
else:
self.permissions = permission
self.require_all = require_all
self.scope = scope
def __call__(self, request: Request, db: Session = None):
"""Check permissions for current user"""
# Import here to avoid circular imports
from ..auth import get_current_user
from ..db import get_db
# Get user from request state if available
user = getattr(request.state, 'user', None)
if user is None:
# Fall back to standard auth
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from ..auth import decode_access_token
from ..models import User
from sqlalchemy.orm import joinedload
security = HTTPBearer()
# Get token from header
auth_header = request.headers.get("Authorization")
if not auth_header or not auth_header.startswith("Bearer "):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
token = auth_header.split(" ")[1]
payload = decode_access_token(token)
username = payload.get("sub")
if db is None:
# Get DB session
from ..db import SessionLocal
db = SessionLocal()
try:
user = db.query(User).options(joinedload(User.role)).filter(
User.username == username
).first()
finally:
db.close()
else:
user = db.query(User).options(joinedload(User.role)).filter(
User.username == username
).first()
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found",
)
# Check permissions
if self.require_all:
# Must have all permissions
for perm in self.permissions:
if not PermissionEngine.has_permission(user, perm, self.scope):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Permission denied: {perm}"
)
else:
# Must have at least one permission
has_any = False
for perm in self.permissions:
if PermissionEngine.has_permission(user, perm, self.scope):
has_any = True
break
if not has_any:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Permission denied: requires one of {self.permissions}"
)
return user
class ScopedPermissionChecker:
"""
Permission checker with resource-level scope.
Usage:
@router.get("/vms/{vm_id}")
async def get_vm(
vm_id: int,
user = Depends(ScopedPermissionChecker("vm:view", resource_param="vm_id"))
):
...
"""
def __init__(
self,
permission: str,
scope: ScopeType = ScopeType.INSTANCE,
resource_param: str = "resource_id",
organization_param: Optional[str] = None,
workspace_param: Optional[str] = None
):
self.permission = permission
self.scope = scope
self.resource_param = resource_param
self.organization_param = organization_param
self.workspace_param = workspace_param
def __call__(self, request: Request):
"""Check scoped permission"""
from ..auth import get_current_user
from ..db import SessionLocal
from ..models import User
from sqlalchemy.orm import joinedload
# Get resource ID from path params
resource_id = request.path_params.get(self.resource_param)
organization_id = request.path_params.get(self.organization_param) if self.organization_param else None
workspace_id = request.path_params.get(self.workspace_param) if self.workspace_param else None
# Get user
auth_header = request.headers.get("Authorization")
if not auth_header or not auth_header.startswith("Bearer "):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
)
token = auth_header.split(" ")[1]
from ..auth import decode_access_token
payload = decode_access_token(token)
username = payload.get("sub")
db = SessionLocal()
try:
user = db.query(User).options(joinedload(User.role)).filter(
User.username == username
).first()
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found",
)
# Check permission with scope
if not PermissionEngine.has_permission(
user,
self.permission,
self.scope,
resource_id=int(resource_id) if resource_id else None,
organization_id=int(organization_id) if organization_id else None,
workspace_id=int(workspace_id) if workspace_id else None
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Permission denied: {self.permission}"
)
return user
finally:
db.close()
class ResourceAccessChecker:
"""
Check access to specific resource based on ownership/membership.
Future use for organization/workspace scoping.
"""
def __init__(
self,
resource_type: str,
permission: str,
id_param: str = "id"
):
self.resource_type = resource_type
self.permission = permission
self.id_param = id_param
def __call__(self, request: Request):
"""Check resource access"""
# Placeholder for future organization/workspace checks
# For now, just check permission
checker = PermissionChecker(self.permission)
return checker(request)

View File

@@ -0,0 +1,424 @@
"""
RBAC Migration Utilities
Converts legacy permission format to new format
"""
from typing import Dict, Optional
from loguru import logger
from sqlalchemy import text
from .permissions import LEGACY_PERMISSION_MAP, PERMISSIONS
# New format DEFAULT_ROLES with resource:action format
NEW_DEFAULT_ROLES = [
{
"name": "admin",
"display_name": "Administrator",
"description": "Full access to all features",
"is_system": True,
"permissions": {
# Dashboard
"dashboard:view": True,
# Servers
"server:view": True,
"server:create": True,
"server:update": True,
"server:delete": True,
"server:manage": True,
# VMs
"vm:view": True,
"vm:create": True,
"vm:update": True,
"vm:delete": True,
"vm:start": True,
"vm:stop": True,
"vm:restart": True,
"vm:console": True,
"vm:migrate": True,
"vm:execute": True,
# LXC
"lxc:view": True,
"lxc:create": True,
"lxc:update": True,
"lxc:delete": True,
"lxc:start": True,
"lxc:stop": True,
"lxc:restart": True,
"lxc:console": True,
"lxc:migrate": True,
# Templates
"template:view": True,
"template:create": True,
"template:update": True,
"template:delete": True,
"template:manage": True,
# Storage
"storage:view": True,
"storage:manage": True,
# Backups
"backup:view": True,
"backup:create": True,
"backup:delete": True,
"backup:manage": True,
# IPAM
"ipam:view": True,
"ipam:manage": True,
# Users
"user:view": True,
"user:create": True,
"user:update": True,
"user:delete": True,
# Roles
"role:view": True,
"role:create": True,
"role:update": True,
"role:delete": True,
"role:manage": True,
# Logs
"log:view": True,
"log:export": True,
"log:delete": True,
# Settings
"setting:view": True,
"setting:update": True,
"setting:manage": True,
# Notifications
"notification:view": True,
"notification:manage": True,
}
},
{
"name": "moderator",
"display_name": "Moderator",
"description": "Can manage VMs and view logs",
"is_system": True,
"permissions": {
"dashboard:view": True,
"server:view": True,
"vm:view": True,
"vm:create": True,
"vm:start": True,
"vm:stop": True,
"vm:restart": True,
"vm:console": True,
"lxc:view": True,
"lxc:create": True,
"lxc:start": True,
"lxc:stop": True,
"lxc:restart": True,
"lxc:console": True,
"template:view": True,
"storage:view": True,
"backup:view": True,
"backup:create": True,
"ipam:view": True,
"user:view": True,
"log:view": True,
"log:export": True,
"setting:view": True,
"notification:view": True,
"notification:manage": True,
}
},
{
"name": "user",
"display_name": "User",
"description": "Standard user with limited access",
"is_system": True,
"permissions": {
"dashboard:view": True,
"server:view": True,
"vm:view": True,
"vm:start": True,
"vm:stop": True,
"vm:restart": True,
"vm:console": True,
"lxc:view": True,
"lxc:start": True,
"lxc:stop": True,
"lxc:restart": True,
"lxc:console": True,
"template:view": True,
"storage:view": True,
"ipam:view": True,
"setting:view": True,
"notification:view": True,
"notification:manage": True,
}
},
{
"name": "demo",
"display_name": "Demo User",
"description": "Read-only access for demonstration",
"is_system": True,
"permissions": {
"dashboard:view": True,
"server:view": True,
"vm:view": True,
"lxc:view": True,
"template:view": True,
"storage:view": True,
"ipam:view": True,
}
},
]
def convert_permissions_to_new_format(old_permissions: Dict[str, bool]) -> Dict[str, bool]:
"""
Convert legacy permission format to new format.
Args:
old_permissions: Dict with old format like {"vms.view": True, "proxmox.manage": True}
Returns:
Dict with new format like {"vm:view": True, "server:manage": True}
"""
if not old_permissions:
return {}
new_permissions = {}
for perm, value in old_permissions.items():
# Check if already in new format
if ":" in perm:
new_permissions[perm] = value
continue
# Convert using legacy map
if perm in LEGACY_PERMISSION_MAP:
new_perm = LEGACY_PERMISSION_MAP[perm]
# Only add True permissions to new format
if value:
new_permissions[new_perm] = value
else:
# Keep unknown permissions as-is for backwards compatibility
if value:
logger.warning(f"Unknown permission format: {perm}")
new_permissions[perm] = value
return new_permissions
def convert_permissions_to_legacy_format(new_permissions: Dict[str, bool]) -> Dict[str, bool]:
"""
Convert new permission format back to legacy format (for backwards compatibility).
Args:
new_permissions: Dict with new format like {"vm:view": True}
Returns:
Dict with old format like {"vms.view": True}
"""
if not new_permissions:
return {}
# Create reverse map
reverse_map = {v: k for k, v in LEGACY_PERMISSION_MAP.items()}
legacy_permissions = {}
for perm, value in new_permissions.items():
if not value:
continue
# Check if in new format
if ":" in perm and perm in reverse_map:
legacy_perm = reverse_map[perm]
legacy_permissions[legacy_perm] = value
else:
# Keep as-is
legacy_permissions[perm] = value
return legacy_permissions
def get_permission_categories() -> Dict[str, Dict[str, str]]:
"""
Get permissions grouped by category with display names.
Returns:
Dict like {"Virtual Machines": {"vm:view": "View VMs", "vm:create": "Create VMs"}}
"""
categories = {}
for perm in PERMISSIONS.all():
if perm.category not in categories:
categories[perm.category] = {}
categories[perm.category][perm.code] = perm.display_name
return categories
def get_permission_categories_legacy() -> Dict[str, Dict[str, str]]:
"""
Get permissions in legacy format grouped by category.
For backwards compatibility with existing UI.
Returns:
Dict like {"Virtual Machines": {"vms.view": "View VMs"}}
"""
# Create reverse map
reverse_map = {v: k for k, v in LEGACY_PERMISSION_MAP.items()}
categories = {}
for perm in PERMISSIONS.all():
if perm.category not in categories:
categories[perm.category] = {}
# Use legacy code if available
legacy_code = reverse_map.get(perm.code, perm.code)
categories[perm.category][legacy_code] = perm.display_name
return categories
def migrate_role_permissions(conn, role_id: int, old_permissions: dict) -> dict:
"""
Migrate a single role's permissions to new format.
Args:
conn: Database connection
role_id: Role ID
old_permissions: Old permissions dict
Returns:
New permissions dict
"""
import json
new_permissions = convert_permissions_to_new_format(old_permissions)
conn.execute(
text("UPDATE roles SET permissions = :perms WHERE id = :id"),
{"perms": json.dumps(new_permissions), "id": role_id}
)
logger.info(f"Migrated role {role_id} permissions to new format")
return new_permissions
def migrate_all_roles_to_new_format(conn) -> int:
"""
Migrate all roles to new permission format.
Args:
conn: Database connection
Returns:
Number of migrated roles
"""
import json
# Get all roles
result = conn.execute(text("SELECT id, name, permissions FROM roles"))
roles = result.fetchall()
migrated_count = 0
for role in roles:
role_id, role_name, permissions = role
if not permissions:
continue
# Parse permissions if string
if isinstance(permissions, str):
try:
permissions = json.loads(permissions)
except:
continue
# Check if already migrated (has : in keys)
has_new_format = any(":" in k for k in permissions.keys())
has_old_format = any("." in k and ":" not in k for k in permissions.keys())
if has_old_format and not has_new_format:
new_permissions = convert_permissions_to_new_format(permissions)
conn.execute(
text("UPDATE roles SET permissions = :perms WHERE id = :id"),
{"perms": json.dumps(new_permissions), "id": role_id}
)
logger.info(f"Migrated role '{role_name}' (id={role_id}) to new permission format")
migrated_count += 1
elif has_old_format and has_new_format:
# Mixed format - merge and convert
new_permissions = convert_permissions_to_new_format(permissions)
conn.execute(
text("UPDATE roles SET permissions = :perms WHERE id = :id"),
{"perms": json.dumps(new_permissions), "id": role_id}
)
logger.info(f"Merged and migrated role '{role_name}' (id={role_id})")
migrated_count += 1
return migrated_count
def ensure_default_roles_new_format(conn) -> None:
"""
Ensure default roles exist with new permission format.
Updates existing roles or creates new ones.
"""
import json
for role_data in NEW_DEFAULT_ROLES:
# Check if role exists
result = conn.execute(
text("SELECT id, permissions FROM roles WHERE name = :name"),
{"name": role_data["name"]}
)
existing = result.fetchone()
if existing:
role_id, current_perms = existing
# Parse current permissions
if isinstance(current_perms, str):
try:
current_perms = json.loads(current_perms)
except:
current_perms = {}
# Check if needs update
has_old_format = any("." in k and ":" not in k for k in (current_perms or {}).keys())
if has_old_format or not current_perms:
# Update to new format
conn.execute(
text("""
UPDATE roles
SET permissions = :perms,
display_name = :display_name,
description = :description
WHERE id = :id
"""),
{
"perms": json.dumps(role_data["permissions"]),
"display_name": role_data["display_name"],
"description": role_data["description"],
"id": role_id
}
)
logger.info(f"Updated role '{role_data['name']}' with new permission format")
else:
# Create new role
conn.execute(
text("""
INSERT INTO roles (name, display_name, description, permissions, is_system)
VALUES (:name, :display_name, :description, :permissions, :is_system)
"""),
{
"name": role_data["name"],
"display_name": role_data["display_name"],
"description": role_data["description"],
"permissions": json.dumps(role_data["permissions"]),
"is_system": role_data["is_system"]
}
)
logger.info(f"Created role '{role_data['name']}' with new permission format")

View File

@@ -0,0 +1,652 @@
"""
Permission Registry - Central definition of all permissions
Format: resource:action[:scope]
Resources: cluster, node, vm, lxc, storage, network, backup, template, user, role, log, setting, ipam
Actions: view, list, create, update, delete, start, stop, restart, console, migrate, manage
Scopes: global, organization, workspace, instance (optional, defaults to global)
"""
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Set
from enum import Enum
class ResourceType(str, Enum):
"""Resource types in the system"""
CLUSTER = "cluster"
NODE = "node"
SERVER = "server" # Proxmox server
VM = "vm"
LXC = "lxc"
STORAGE = "storage"
NETWORK = "network"
BACKUP = "backup"
TEMPLATE = "template"
USER = "user"
ROLE = "role"
LOG = "log"
SETTING = "setting"
IPAM = "ipam"
NOTIFICATION = "notification"
DASHBOARD = "dashboard"
class ActionType(str, Enum):
"""Action types"""
VIEW = "view"
LIST = "list"
CREATE = "create"
UPDATE = "update"
DELETE = "delete"
START = "start"
STOP = "stop"
RESTART = "restart"
CONSOLE = "console"
MIGRATE = "migrate"
MANAGE = "manage"
EXPORT = "export"
EXECUTE = "execute" # For running commands
class ScopeType(str, Enum):
"""Permission scope levels"""
GLOBAL = "global" # Platform-wide access
ORGANIZATION = "org" # Within organization
WORKSPACE = "workspace" # Within workspace
INSTANCE = "instance" # Specific resource only
@dataclass
class Permission:
"""
Atomic permission definition.
Format: resource:action or resource:action:scope
Examples:
- vm:view (global scope)
- vm:create:workspace (workspace scope)
- vm:delete:instance (instance scope)
"""
resource: str
action: str
scope: ScopeType = ScopeType.GLOBAL
display_name: str = ""
description: str = ""
category: str = ""
requires: List[str] = field(default_factory=list) # Dependencies
@property
def code(self) -> str:
"""Get permission code"""
if self.scope == ScopeType.GLOBAL:
return f"{self.resource}:{self.action}"
return f"{self.resource}:{self.action}:{self.scope.value}"
@property
def legacy_code(self) -> str:
"""Get legacy permission code for backwards compatibility"""
# Map to old format: resource.action
return f"{self.resource}.{self.action}"
def __hash__(self):
return hash(self.code)
def __eq__(self, other):
if isinstance(other, Permission):
return self.code == other.code
return False
class PermissionRegistry:
"""
Central registry of all permissions.
Provides validation, lookup, and category grouping.
"""
_instance = None
_permissions: Dict[str, Permission] = {}
_by_category: Dict[str, List[Permission]] = {}
_by_resource: Dict[str, List[Permission]] = {}
_legacy_map: Dict[str, str] = {} # legacy code -> new code
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._initialized = False
return cls._instance
def __init__(self):
if self._initialized:
return
self._initialized = True
self._permissions = {}
self._by_category = {}
self._by_resource = {}
self._legacy_map = {}
def register(self, permission: Permission) -> None:
"""Register a permission"""
self._permissions[permission.code] = permission
# Index by category
if permission.category not in self._by_category:
self._by_category[permission.category] = []
self._by_category[permission.category].append(permission)
# Index by resource
if permission.resource not in self._by_resource:
self._by_resource[permission.resource] = []
self._by_resource[permission.resource].append(permission)
# Legacy mapping
self._legacy_map[permission.legacy_code] = permission.code
def get(self, code: str) -> Optional[Permission]:
"""Get permission by code"""
# Try direct lookup
if code in self._permissions:
return self._permissions[code]
# Try legacy format
if code in self._legacy_map:
return self._permissions[self._legacy_map[code]]
return None
def exists(self, code: str) -> bool:
"""Check if permission exists"""
return code in self._permissions or code in self._legacy_map
def resolve(self, code: str) -> Optional[str]:
"""Resolve legacy code to new code"""
if code in self._permissions:
return code
return self._legacy_map.get(code)
def all(self) -> List[Permission]:
"""Get all permissions"""
return list(self._permissions.values())
def by_category(self, category: str) -> List[Permission]:
"""Get permissions by category"""
return self._by_category.get(category, [])
def by_resource(self, resource: str) -> List[Permission]:
"""Get permissions by resource"""
return self._by_resource.get(resource, [])
def categories(self) -> List[str]:
"""Get all categories"""
return list(self._by_category.keys())
def resources(self) -> List[str]:
"""Get all resources"""
return list(self._by_resource.keys())
def validate(self, codes: List[str]) -> tuple[List[str], List[str]]:
"""Validate permission codes, returns (valid, invalid)"""
valid = []
invalid = []
for code in codes:
if self.exists(code):
valid.append(self.resolve(code) or code)
else:
invalid.append(code)
return valid, invalid
# ==================== Permission Definitions ====================
def _create_permissions() -> PermissionRegistry:
"""Create and populate the permission registry"""
registry = PermissionRegistry()
# Dashboard permissions
registry.register(Permission(
resource="dashboard", action="view",
display_name="View Dashboard",
description="Access to dashboard and overview",
category="Dashboard"
))
# Server (Proxmox) permissions
registry.register(Permission(
resource="server", action="view",
display_name="View Servers",
description="View Proxmox server list and status",
category="Proxmox Servers"
))
registry.register(Permission(
resource="server", action="create",
display_name="Add Server",
description="Add new Proxmox servers",
category="Proxmox Servers"
))
registry.register(Permission(
resource="server", action="update",
display_name="Edit Server",
description="Edit Proxmox server settings",
category="Proxmox Servers"
))
registry.register(Permission(
resource="server", action="delete",
display_name="Delete Server",
description="Remove Proxmox servers",
category="Proxmox Servers"
))
registry.register(Permission(
resource="server", action="manage",
display_name="Manage Servers",
description="Full server management including cluster operations",
category="Proxmox Servers",
requires=["server:view"]
))
# VM permissions
registry.register(Permission(
resource="vm", action="view",
display_name="View VMs",
description="View virtual machines list and details",
category="Virtual Machines"
))
registry.register(Permission(
resource="vm", action="create",
display_name="Create VMs",
description="Create new virtual machines",
category="Virtual Machines",
requires=["vm:view", "template:view"]
))
registry.register(Permission(
resource="vm", action="update",
display_name="Update VMs",
description="Modify VM configuration",
category="Virtual Machines",
requires=["vm:view"]
))
registry.register(Permission(
resource="vm", action="delete",
display_name="Delete VMs",
description="Delete virtual machines",
category="Virtual Machines",
requires=["vm:view"]
))
registry.register(Permission(
resource="vm", action="start",
display_name="Start VMs",
description="Start virtual machines",
category="Virtual Machines",
requires=["vm:view"]
))
registry.register(Permission(
resource="vm", action="stop",
display_name="Stop VMs",
description="Stop virtual machines",
category="Virtual Machines",
requires=["vm:view"]
))
registry.register(Permission(
resource="vm", action="restart",
display_name="Restart VMs",
description="Restart virtual machines",
category="Virtual Machines",
requires=["vm:view"]
))
registry.register(Permission(
resource="vm", action="console",
display_name="VM Console",
description="Access VM console (noVNC/xterm.js)",
category="Virtual Machines",
requires=["vm:view"]
))
registry.register(Permission(
resource="vm", action="migrate",
display_name="Migrate VMs",
description="Migrate VMs between nodes",
category="Virtual Machines",
requires=["vm:view"]
))
registry.register(Permission(
resource="vm", action="execute",
display_name="Execute Commands",
description="Execute commands on VMs via QEMU agent",
category="Virtual Machines",
requires=["vm:console"]
))
# LXC (Container) permissions - same structure as VM
registry.register(Permission(
resource="lxc", action="view",
display_name="View Containers",
description="View LXC containers list and details",
category="Containers"
))
registry.register(Permission(
resource="lxc", action="create",
display_name="Create Containers",
description="Create new LXC containers",
category="Containers",
requires=["lxc:view", "template:view"]
))
registry.register(Permission(
resource="lxc", action="update",
display_name="Update Containers",
description="Modify container configuration",
category="Containers",
requires=["lxc:view"]
))
registry.register(Permission(
resource="lxc", action="delete",
display_name="Delete Containers",
description="Delete LXC containers",
category="Containers",
requires=["lxc:view"]
))
registry.register(Permission(
resource="lxc", action="start",
display_name="Start Containers",
description="Start LXC containers",
category="Containers",
requires=["lxc:view"]
))
registry.register(Permission(
resource="lxc", action="stop",
display_name="Stop Containers",
description="Stop LXC containers",
category="Containers",
requires=["lxc:view"]
))
registry.register(Permission(
resource="lxc", action="restart",
display_name="Restart Containers",
description="Restart LXC containers",
category="Containers",
requires=["lxc:view"]
))
registry.register(Permission(
resource="lxc", action="console",
display_name="Container Console",
description="Access container console",
category="Containers",
requires=["lxc:view"]
))
registry.register(Permission(
resource="lxc", action="migrate",
display_name="Migrate Containers",
description="Migrate containers between nodes",
category="Containers",
requires=["lxc:view"]
))
# Template permissions
registry.register(Permission(
resource="template", action="view",
display_name="View Templates",
description="View OS templates",
category="Templates"
))
registry.register(Permission(
resource="template", action="create",
display_name="Create Templates",
description="Create new templates",
category="Templates",
requires=["template:view"]
))
registry.register(Permission(
resource="template", action="update",
display_name="Update Templates",
description="Edit template settings",
category="Templates",
requires=["template:view"]
))
registry.register(Permission(
resource="template", action="delete",
display_name="Delete Templates",
description="Delete templates",
category="Templates",
requires=["template:view"]
))
registry.register(Permission(
resource="template", action="manage",
display_name="Manage Templates",
description="Full template management including download",
category="Templates",
requires=["template:view"]
))
# Storage permissions
registry.register(Permission(
resource="storage", action="view",
display_name="View Storage",
description="View storage pools and usage",
category="Storage"
))
registry.register(Permission(
resource="storage", action="manage",
display_name="Manage Storage",
description="Manage storage pools and volumes",
category="Storage",
requires=["storage:view"]
))
# Backup permissions
registry.register(Permission(
resource="backup", action="view",
display_name="View Backups",
description="View backup list",
category="Backups"
))
registry.register(Permission(
resource="backup", action="create",
display_name="Create Backups",
description="Create new backups",
category="Backups",
requires=["backup:view"]
))
registry.register(Permission(
resource="backup", action="delete",
display_name="Delete Backups",
description="Delete backups",
category="Backups",
requires=["backup:view"]
))
registry.register(Permission(
resource="backup", action="manage",
display_name="Restore Backups",
description="Restore from backups",
category="Backups",
requires=["backup:view", "vm:create"]
))
# IPAM permissions
registry.register(Permission(
resource="ipam", action="view",
display_name="View IPAM",
description="View IP address management",
category="IPAM"
))
registry.register(Permission(
resource="ipam", action="manage",
display_name="Manage IPAM",
description="Manage networks and IP allocations",
category="IPAM",
requires=["ipam:view"]
))
# User management permissions
registry.register(Permission(
resource="user", action="view",
display_name="View Users",
description="View user list",
category="User Management"
))
registry.register(Permission(
resource="user", action="create",
display_name="Create Users",
description="Create new users",
category="User Management",
requires=["user:view"]
))
registry.register(Permission(
resource="user", action="update",
display_name="Update Users",
description="Edit user profiles",
category="User Management",
requires=["user:view"]
))
registry.register(Permission(
resource="user", action="delete",
display_name="Delete Users",
description="Delete users",
category="User Management",
requires=["user:view"]
))
# Role management permissions
registry.register(Permission(
resource="role", action="view",
display_name="View Roles",
description="View roles and permissions",
category="Role Management"
))
registry.register(Permission(
resource="role", action="create",
display_name="Create Roles",
description="Create new roles",
category="Role Management",
requires=["role:view"]
))
registry.register(Permission(
resource="role", action="update",
display_name="Update Roles",
description="Edit role permissions",
category="Role Management",
requires=["role:view"]
))
registry.register(Permission(
resource="role", action="delete",
display_name="Delete Roles",
description="Delete roles",
category="Role Management",
requires=["role:view"]
))
registry.register(Permission(
resource="role", action="manage",
display_name="Manage Roles",
description="Full role management including assignment",
category="Role Management",
requires=["role:view", "user:view"]
))
# Log permissions
registry.register(Permission(
resource="log", action="view",
display_name="View Logs",
description="View audit logs",
category="Logs"
))
registry.register(Permission(
resource="log", action="export",
display_name="Export Logs",
description="Export log data",
category="Logs",
requires=["log:view"]
))
registry.register(Permission(
resource="log", action="delete",
display_name="Delete Logs",
description="Delete log entries",
category="Logs",
requires=["log:view"]
))
# Settings permissions
registry.register(Permission(
resource="setting", action="view",
display_name="View Settings",
description="View panel settings",
category="Settings"
))
registry.register(Permission(
resource="setting", action="update",
display_name="Update Settings",
description="Modify panel settings",
category="Settings",
requires=["setting:view"]
))
registry.register(Permission(
resource="setting", action="manage",
display_name="Manage Security Settings",
description="Manage security and advanced settings",
category="Settings",
requires=["setting:view"]
))
# Notification permissions
registry.register(Permission(
resource="notification", action="view",
display_name="View Notifications",
description="View notifications",
category="Notifications"
))
registry.register(Permission(
resource="notification", action="manage",
display_name="Manage Notifications",
description="Manage notification settings",
category="Notifications",
requires=["notification:view"]
))
return registry
# Global registry instance
PERMISSIONS = _create_permissions()
# ==================== Legacy Permission Mapping ====================
LEGACY_PERMISSION_MAP = {
# Old format -> New format
"dashboard.view": "dashboard:view",
"proxmox.view": "server:view",
"proxmox.manage": "server:manage",
"proxmox.servers.add": "server:create",
"proxmox.servers.edit": "server:update",
"proxmox.servers.delete": "server:delete",
"proxmox.vm.manage": "vm:manage",
"vms.view": "vm:view",
"vms.create": "vm:create",
"vms.start": "vm:start",
"vms.stop": "vm:stop",
"vms.restart": "vm:restart",
"vms.delete": "vm:delete",
"vms.console": "vm:console",
"vms.migrate": "vm:migrate",
"templates.view": "template:view",
"templates.manage": "template:manage",
"ipam.view": "ipam:view",
"ipam.manage": "ipam:manage",
"logs.view": "log:view",
"logs.export": "log:export",
"logs.delete": "log:delete",
"settings.view": "setting:view",
"settings.panel": "setting:update",
"settings.security": "setting:manage",
"users.view": "user:view",
"users.create": "user:create",
"users.edit": "user:update",
"users.delete": "user:delete",
"roles.view": "role:view",
"roles.manage": "role:manage",
"notifications.manage": "notification:manage",
}
def resolve_permission(code: str) -> str:
"""Resolve legacy permission code to new format"""
return LEGACY_PERMISSION_MAP.get(code, code)
def get_permission_info(code: str) -> Optional[Permission]:
"""Get permission object by code (supports legacy)"""
resolved = resolve_permission(code)
return PERMISSIONS.get(resolved)