Add complete project structure and all files
This commit is contained in:
78
backend/app/rbac/__init__.py
Normal file
78
backend/app/rbac/__init__.py
Normal 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
267
backend/app/rbac/audit.py
Normal 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()
|
||||
163
backend/app/rbac/decorators.py
Normal file
163
backend/app/rbac/decorators.py
Normal 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
245
backend/app/rbac/engine.py
Normal 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)
|
||||
230
backend/app/rbac/middleware.py
Normal file
230
backend/app/rbac/middleware.py
Normal 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)
|
||||
424
backend/app/rbac/migration.py
Normal file
424
backend/app/rbac/migration.py
Normal 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")
|
||||
652
backend/app/rbac/permissions.py
Normal file
652
backend/app/rbac/permissions.py
Normal 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)
|
||||
Reference in New Issue
Block a user