608 lines
22 KiB
Python
608 lines
22 KiB
Python
# Copyright (c) 2025 Patrick Motsch
|
|
# All rights reserved.
|
|
"""
|
|
RBAC export/import routes for the backend API.
|
|
Implements endpoints for exporting and importing RBAC configurations.
|
|
|
|
Multi-Tenant Design:
|
|
- Global templates: SysAdmin can export/import
|
|
- Mandate-scoped RBAC: Mandate Admin can export/import
|
|
- Feature instance roles: Included in mandate export
|
|
"""
|
|
|
|
from fastapi import APIRouter, HTTPException, Depends, Request, UploadFile, File
|
|
from fastapi.responses import JSONResponse
|
|
from typing import List, Dict, Any, Optional
|
|
from fastapi import status
|
|
import logging
|
|
import json
|
|
from pydantic import BaseModel, Field
|
|
|
|
from modules.auth import limiter, getRequestContext, RequestContext, requireSysAdmin
|
|
from modules.datamodels.datamodelUam import User
|
|
from modules.datamodels.datamodelRbac import Role, AccessRule
|
|
from modules.interfaces.interfaceDbAppObjects import getRootInterface
|
|
from modules.shared.timeUtils import getUtcTimestamp
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(
|
|
prefix="/api/rbac",
|
|
tags=["RBAC Export/Import"],
|
|
responses={404: {"description": "Not found"}}
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Request/Response Models
|
|
# =============================================================================
|
|
|
|
class RoleExport(BaseModel):
|
|
"""Export model for a role with its access rules"""
|
|
roleLabel: str
|
|
description: Dict[str, str]
|
|
featureCode: Optional[str]
|
|
isSystemRole: bool
|
|
accessRules: List[Dict[str, Any]]
|
|
|
|
|
|
class RbacExportData(BaseModel):
|
|
"""Complete RBAC export data"""
|
|
exportVersion: str = "1.0"
|
|
exportedAt: float
|
|
exportedBy: str
|
|
scope: str # "global" or "mandate"
|
|
mandateId: Optional[str]
|
|
roles: List[RoleExport]
|
|
|
|
|
|
class RbacImportResult(BaseModel):
|
|
"""Result of RBAC import operation"""
|
|
rolesCreated: int
|
|
rolesUpdated: int
|
|
rolesSkipped: int
|
|
rulesCreated: int
|
|
rulesUpdated: int
|
|
errors: List[str]
|
|
|
|
|
|
# =============================================================================
|
|
# Global RBAC Export/Import (SysAdmin only)
|
|
# =============================================================================
|
|
|
|
@router.get("/export/global", response_model=RbacExportData)
|
|
@limiter.limit("10/minute")
|
|
async def exportGlobalRbac(
|
|
request: Request,
|
|
sysAdmin: User = Depends(requireSysAdmin)
|
|
) -> RbacExportData:
|
|
"""
|
|
Export global (template) RBAC rules.
|
|
|
|
SysAdmin only - exports template roles that are copied to new feature instances.
|
|
These are roles with mandateId=NULL.
|
|
"""
|
|
try:
|
|
rootInterface = getRootInterface()
|
|
|
|
# Get all global template roles (mandateId is NULL)
|
|
allRoles = rootInterface.db.getRecordset(Role)
|
|
globalRoles = [r for r in allRoles if r.get("mandateId") is None]
|
|
|
|
exportRoles = []
|
|
for role in globalRoles:
|
|
roleId = role.get("id")
|
|
|
|
# Get access rules for this role
|
|
accessRules = rootInterface.db.getRecordset(
|
|
AccessRule,
|
|
recordFilter={"roleId": roleId}
|
|
)
|
|
|
|
exportRoles.append(RoleExport(
|
|
roleLabel=role.get("roleLabel"),
|
|
description=role.get("description", {}),
|
|
featureCode=role.get("featureCode"),
|
|
isSystemRole=role.get("isSystemRole", False),
|
|
accessRules=[
|
|
{
|
|
"context": r.get("context"),
|
|
"item": r.get("item"),
|
|
"view": r.get("view", False),
|
|
"read": r.get("read"),
|
|
"create": r.get("create"),
|
|
"update": r.get("update"),
|
|
"delete": r.get("delete")
|
|
}
|
|
for r in accessRules
|
|
]
|
|
))
|
|
|
|
logger.info(f"SysAdmin {sysAdmin.id} exported global RBAC ({len(exportRoles)} roles)")
|
|
|
|
return RbacExportData(
|
|
exportedAt=getUtcTimestamp(),
|
|
exportedBy=str(sysAdmin.id),
|
|
scope="global",
|
|
mandateId=None,
|
|
roles=exportRoles
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error exporting global RBAC: {e}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to export RBAC: {str(e)}"
|
|
)
|
|
|
|
|
|
@router.post("/import/global", response_model=RbacImportResult)
|
|
@limiter.limit("5/minute")
|
|
async def importGlobalRbac(
|
|
request: Request,
|
|
file: UploadFile = File(..., description="JSON file with RBAC export data"),
|
|
updateExisting: bool = False,
|
|
sysAdmin: User = Depends(requireSysAdmin)
|
|
) -> RbacImportResult:
|
|
"""
|
|
Import global (template) RBAC rules.
|
|
|
|
SysAdmin only - imports template roles and their access rules.
|
|
|
|
Args:
|
|
file: JSON file containing RbacExportData
|
|
updateExisting: If True, update existing roles. If False, skip them.
|
|
"""
|
|
try:
|
|
# Read and parse file
|
|
content = await file.read()
|
|
try:
|
|
data = json.loads(content.decode("utf-8"))
|
|
except json.JSONDecodeError as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Invalid JSON: {str(e)}"
|
|
)
|
|
|
|
# Validate structure
|
|
if "roles" not in data:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Missing 'roles' field in import data"
|
|
)
|
|
|
|
rootInterface = getRootInterface()
|
|
result = RbacImportResult(
|
|
rolesCreated=0,
|
|
rolesUpdated=0,
|
|
rolesSkipped=0,
|
|
rulesCreated=0,
|
|
rulesUpdated=0,
|
|
errors=[]
|
|
)
|
|
|
|
for roleData in data.get("roles", []):
|
|
try:
|
|
roleLabel = roleData.get("roleLabel")
|
|
featureCode = roleData.get("featureCode")
|
|
|
|
if not roleLabel:
|
|
result.errors.append(f"Role without label skipped")
|
|
result.rolesSkipped += 1
|
|
continue
|
|
|
|
# Check if role exists (global role with same label and featureCode)
|
|
existingRoles = rootInterface.db.getRecordset(
|
|
Role,
|
|
recordFilter={
|
|
"roleLabel": roleLabel,
|
|
"mandateId": None,
|
|
"featureCode": featureCode
|
|
}
|
|
)
|
|
|
|
if existingRoles:
|
|
if updateExisting:
|
|
# Update existing role
|
|
existingRole = existingRoles[0]
|
|
roleId = existingRole.get("id")
|
|
|
|
rootInterface.db.recordUpdate(
|
|
Role,
|
|
roleId,
|
|
{
|
|
"description": roleData.get("description", {}),
|
|
"isSystemRole": roleData.get("isSystemRole", False)
|
|
}
|
|
)
|
|
|
|
# Update access rules
|
|
result.rulesUpdated += _updateAccessRules(
|
|
rootInterface,
|
|
roleId,
|
|
roleData.get("accessRules", [])
|
|
)
|
|
|
|
result.rolesUpdated += 1
|
|
else:
|
|
result.rolesSkipped += 1
|
|
continue
|
|
else:
|
|
# Create new role
|
|
newRole = Role(
|
|
roleLabel=roleLabel,
|
|
description=roleData.get("description", {}),
|
|
featureCode=featureCode,
|
|
mandateId=None,
|
|
featureInstanceId=None,
|
|
isSystemRole=roleData.get("isSystemRole", False)
|
|
)
|
|
|
|
createdRole = rootInterface.db.recordCreate(Role, newRole.model_dump())
|
|
roleId = createdRole.get("id")
|
|
|
|
# Create access rules
|
|
for ruleData in roleData.get("accessRules", []):
|
|
newRule = AccessRule(
|
|
roleId=roleId,
|
|
context=ruleData.get("context"),
|
|
item=ruleData.get("item"),
|
|
view=ruleData.get("view", False),
|
|
read=ruleData.get("read"),
|
|
create=ruleData.get("create"),
|
|
update=ruleData.get("update"),
|
|
delete=ruleData.get("delete")
|
|
)
|
|
rootInterface.db.recordCreate(AccessRule, newRule.model_dump())
|
|
result.rulesCreated += 1
|
|
|
|
result.rolesCreated += 1
|
|
|
|
except Exception as e:
|
|
result.errors.append(f"Error processing role '{roleData.get('roleLabel', 'unknown')}': {str(e)}")
|
|
|
|
logger.info(
|
|
f"SysAdmin {sysAdmin.id} imported global RBAC: "
|
|
f"{result.rolesCreated} created, {result.rolesUpdated} updated, "
|
|
f"{result.rolesSkipped} skipped"
|
|
)
|
|
|
|
return result
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error importing global RBAC: {e}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to import RBAC: {str(e)}"
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Mandate RBAC Export/Import (Mandate Admin)
|
|
# =============================================================================
|
|
|
|
@router.get("/export/mandate", response_model=RbacExportData)
|
|
@limiter.limit("10/minute")
|
|
async def exportMandateRbac(
|
|
request: Request,
|
|
includeFeatureInstances: bool = True,
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> RbacExportData:
|
|
"""
|
|
Export RBAC rules for the current mandate.
|
|
|
|
Requires Mandate-Admin role. Exports mandate-level roles and optionally
|
|
feature instance roles.
|
|
|
|
Args:
|
|
includeFeatureInstances: Include feature instance roles in export
|
|
"""
|
|
if not context.mandateId:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="X-Mandate-Id header is required"
|
|
)
|
|
|
|
# Check mandate admin permission
|
|
if not _hasMandateAdminRole(context):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Mandate-Admin role required to export RBAC"
|
|
)
|
|
|
|
try:
|
|
rootInterface = getRootInterface()
|
|
|
|
# Get mandate-level roles
|
|
allRoles = rootInterface.db.getRecordset(Role)
|
|
mandateRoles = [
|
|
r for r in allRoles
|
|
if str(r.get("mandateId")) == str(context.mandateId)
|
|
]
|
|
|
|
# Filter by feature instance if not including them
|
|
if not includeFeatureInstances:
|
|
mandateRoles = [r for r in mandateRoles if not r.get("featureInstanceId")]
|
|
|
|
exportRoles = []
|
|
for role in mandateRoles:
|
|
roleId = role.get("id")
|
|
|
|
# Get access rules for this role
|
|
accessRules = rootInterface.db.getRecordset(
|
|
AccessRule,
|
|
recordFilter={"roleId": roleId}
|
|
)
|
|
|
|
exportRoles.append(RoleExport(
|
|
roleLabel=role.get("roleLabel"),
|
|
description=role.get("description", {}),
|
|
featureCode=role.get("featureCode"),
|
|
isSystemRole=role.get("isSystemRole", False),
|
|
accessRules=[
|
|
{
|
|
"context": r.get("context"),
|
|
"item": r.get("item"),
|
|
"view": r.get("view", False),
|
|
"read": r.get("read"),
|
|
"create": r.get("create"),
|
|
"update": r.get("update"),
|
|
"delete": r.get("delete")
|
|
}
|
|
for r in accessRules
|
|
]
|
|
))
|
|
|
|
logger.info(
|
|
f"User {context.user.id} exported mandate {context.mandateId} RBAC "
|
|
f"({len(exportRoles)} roles)"
|
|
)
|
|
|
|
return RbacExportData(
|
|
exportedAt=getUtcTimestamp(),
|
|
exportedBy=str(context.user.id),
|
|
scope="mandate",
|
|
mandateId=str(context.mandateId),
|
|
roles=exportRoles
|
|
)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error exporting mandate RBAC: {e}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to export RBAC: {str(e)}"
|
|
)
|
|
|
|
|
|
@router.post("/import/mandate", response_model=RbacImportResult)
|
|
@limiter.limit("5/minute")
|
|
async def importMandateRbac(
|
|
request: Request,
|
|
file: UploadFile = File(..., description="JSON file with RBAC export data"),
|
|
updateExisting: bool = False,
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> RbacImportResult:
|
|
"""
|
|
Import RBAC rules for the current mandate.
|
|
|
|
Requires Mandate-Admin role. Imports roles as mandate-level roles
|
|
(not feature instance roles - those are created via template copying).
|
|
|
|
Args:
|
|
file: JSON file containing RbacExportData
|
|
updateExisting: If True, update existing roles. If False, skip them.
|
|
"""
|
|
if not context.mandateId:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="X-Mandate-Id header is required"
|
|
)
|
|
|
|
# Check mandate admin permission
|
|
if not _hasMandateAdminRole(context):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Mandate-Admin role required to import RBAC"
|
|
)
|
|
|
|
try:
|
|
# Read and parse file
|
|
content = await file.read()
|
|
try:
|
|
data = json.loads(content.decode("utf-8"))
|
|
except json.JSONDecodeError as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Invalid JSON: {str(e)}"
|
|
)
|
|
|
|
# Validate structure
|
|
if "roles" not in data:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Missing 'roles' field in import data"
|
|
)
|
|
|
|
rootInterface = getRootInterface()
|
|
result = RbacImportResult(
|
|
rolesCreated=0,
|
|
rolesUpdated=0,
|
|
rolesSkipped=0,
|
|
rulesCreated=0,
|
|
rulesUpdated=0,
|
|
errors=[]
|
|
)
|
|
|
|
for roleData in data.get("roles", []):
|
|
try:
|
|
roleLabel = roleData.get("roleLabel")
|
|
featureCode = roleData.get("featureCode")
|
|
|
|
if not roleLabel:
|
|
result.errors.append(f"Role without label skipped")
|
|
result.rolesSkipped += 1
|
|
continue
|
|
|
|
# System roles cannot be imported at mandate level
|
|
if roleData.get("isSystemRole", False):
|
|
result.errors.append(f"System role '{roleLabel}' skipped (SysAdmin only)")
|
|
result.rolesSkipped += 1
|
|
continue
|
|
|
|
# Check if role exists (mandate role with same label)
|
|
existingRoles = rootInterface.db.getRecordset(
|
|
Role,
|
|
recordFilter={
|
|
"roleLabel": roleLabel,
|
|
"mandateId": str(context.mandateId),
|
|
"featureInstanceId": None # Only mandate-level roles
|
|
}
|
|
)
|
|
|
|
if existingRoles:
|
|
if updateExisting:
|
|
# Update existing role
|
|
existingRole = existingRoles[0]
|
|
roleId = existingRole.get("id")
|
|
|
|
rootInterface.db.recordUpdate(
|
|
Role,
|
|
roleId,
|
|
{"description": roleData.get("description", {})}
|
|
)
|
|
|
|
# Update access rules
|
|
result.rulesUpdated += _updateAccessRules(
|
|
rootInterface,
|
|
roleId,
|
|
roleData.get("accessRules", [])
|
|
)
|
|
|
|
result.rolesUpdated += 1
|
|
else:
|
|
result.rolesSkipped += 1
|
|
continue
|
|
else:
|
|
# Create new role at mandate level
|
|
newRole = Role(
|
|
roleLabel=roleLabel,
|
|
description=roleData.get("description", {}),
|
|
featureCode=featureCode,
|
|
mandateId=str(context.mandateId),
|
|
featureInstanceId=None,
|
|
isSystemRole=False # Never create system roles via import
|
|
)
|
|
|
|
createdRole = rootInterface.db.recordCreate(Role, newRole.model_dump())
|
|
roleId = createdRole.get("id")
|
|
|
|
# Create access rules
|
|
for ruleData in roleData.get("accessRules", []):
|
|
newRule = AccessRule(
|
|
roleId=roleId,
|
|
context=ruleData.get("context"),
|
|
item=ruleData.get("item"),
|
|
view=ruleData.get("view", False),
|
|
read=ruleData.get("read"),
|
|
create=ruleData.get("create"),
|
|
update=ruleData.get("update"),
|
|
delete=ruleData.get("delete")
|
|
)
|
|
rootInterface.db.recordCreate(AccessRule, newRule.model_dump())
|
|
result.rulesCreated += 1
|
|
|
|
result.rolesCreated += 1
|
|
|
|
except Exception as e:
|
|
result.errors.append(f"Error processing role '{roleData.get('roleLabel', 'unknown')}': {str(e)}")
|
|
|
|
logger.info(
|
|
f"User {context.user.id} imported mandate {context.mandateId} RBAC: "
|
|
f"{result.rolesCreated} created, {result.rolesUpdated} updated, "
|
|
f"{result.rolesSkipped} skipped"
|
|
)
|
|
|
|
return result
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error importing mandate RBAC: {e}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to import RBAC: {str(e)}"
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Helper Functions
|
|
# =============================================================================
|
|
|
|
def _hasMandateAdminRole(context: RequestContext) -> bool:
|
|
"""
|
|
Check if the user has mandate admin role in the current context.
|
|
"""
|
|
if context.isSysAdmin:
|
|
return True
|
|
|
|
if not context.roleIds:
|
|
return False
|
|
|
|
try:
|
|
rootInterface = getRootInterface()
|
|
|
|
for roleId in context.roleIds:
|
|
roleRecords = rootInterface.db.getRecordset(Role, recordFilter={"id": roleId})
|
|
if roleRecords:
|
|
role = roleRecords[0]
|
|
roleLabel = role.get("roleLabel", "")
|
|
# Admin role at mandate level
|
|
if roleLabel == "admin" and role.get("mandateId") and not role.get("featureInstanceId"):
|
|
return True
|
|
|
|
return False
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error checking mandate admin role: {e}")
|
|
return False
|
|
|
|
|
|
def _updateAccessRules(interface, roleId: str, newRules: List[Dict[str, Any]]) -> int:
|
|
"""
|
|
Update access rules for a role.
|
|
Replaces existing rules with new ones.
|
|
|
|
Returns:
|
|
Number of rules created/updated
|
|
"""
|
|
try:
|
|
# Delete existing rules for this role
|
|
existingRules = interface.db.getRecordset(AccessRule, recordFilter={"roleId": roleId})
|
|
for rule in existingRules:
|
|
interface.db.recordDelete(AccessRule, rule.get("id"))
|
|
|
|
# Create new rules
|
|
count = 0
|
|
for ruleData in newRules:
|
|
newRule = AccessRule(
|
|
roleId=roleId,
|
|
context=ruleData.get("context"),
|
|
item=ruleData.get("item"),
|
|
view=ruleData.get("view", False),
|
|
read=ruleData.get("read"),
|
|
create=ruleData.get("create"),
|
|
update=ruleData.get("update"),
|
|
delete=ruleData.get("delete")
|
|
)
|
|
interface.db.recordCreate(AccessRule, newRule.model_dump())
|
|
count += 1
|
|
|
|
return count
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error updating access rules: {e}")
|
|
return 0
|