1558 lines
59 KiB
Python
1558 lines
59 KiB
Python
# Copyright (c) 2025 Patrick Motsch
|
|
# All rights reserved.
|
|
"""
|
|
Feature management routes for the backend API.
|
|
Implements endpoints for Feature and FeatureInstance management.
|
|
|
|
Multi-Tenant Design:
|
|
- Feature definitions are global (SysAdmin can manage)
|
|
- FeatureInstances belong to mandates (Mandate Admin can manage)
|
|
- Template roles are copied on instance creation
|
|
"""
|
|
|
|
from fastapi import APIRouter, HTTPException, Depends, Request, Query
|
|
from typing import List, Dict, Any, Optional
|
|
from fastapi import status
|
|
import logging
|
|
import json
|
|
import math
|
|
from pydantic import BaseModel, Field
|
|
from modules.datamodels.datamodelPagination import PaginationParams, PaginationMetadata, normalize_pagination_dict
|
|
from modules.routes.routeDataUsers import _applyFiltersAndSort, _extractDistinctValues
|
|
|
|
from modules.auth import limiter, getRequestContext, RequestContext, requireSysAdminRole
|
|
from modules.datamodels.datamodelUam import User, UserInDB
|
|
from modules.datamodels.datamodelFeatures import Feature, FeatureInstance
|
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
|
from modules.interfaces.interfaceFeatures import getFeatureInterface
|
|
from modules.security.rbacCatalog import getCatalogService
|
|
from modules.routes.routeNotifications import create_access_change_notification
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _feature_instance_display_name(instance: Any) -> str:
|
|
if instance is None:
|
|
return ""
|
|
if isinstance(instance, dict):
|
|
return str(instance.get("label") or instance.get("uiLabel") or instance.get("id", ""))
|
|
return str(getattr(instance, "label", None) or getattr(instance, "uiLabel", None) or getattr(instance, "id", ""))
|
|
|
|
router = APIRouter(
|
|
prefix="/api/features",
|
|
tags=["Features"],
|
|
responses={404: {"description": "Not found"}}
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Request/Response Models
|
|
# =============================================================================
|
|
|
|
class FeatureInstanceCreate(BaseModel):
|
|
"""Request model for creating a feature instance"""
|
|
featureCode: str = Field(..., description="Feature code (e.g., 'trustee', 'chatbot')")
|
|
label: str = Field(..., description="Instance label (e.g., 'Buchhaltung 2025')")
|
|
enabled: bool = Field(True, description="Whether this feature instance is enabled")
|
|
copyTemplateRoles: bool = Field(True, description="Whether to copy template roles on creation")
|
|
config: Optional[Dict[str, Any]] = Field(None, description="Instance-specific configuration (JSONB). Structure depends on featureCode.")
|
|
|
|
|
|
class FeatureInstanceResponse(BaseModel):
|
|
"""Response model for feature instance"""
|
|
id: str
|
|
featureCode: str
|
|
mandateId: str
|
|
label: str
|
|
enabled: bool
|
|
|
|
|
|
class SyncRolesResult(BaseModel):
|
|
"""Response model for role synchronization"""
|
|
added: int
|
|
removed: int
|
|
unchanged: int
|
|
|
|
|
|
# =============================================================================
|
|
# Feature Endpoints (Global - mostly read-only for non-SysAdmin)
|
|
# =============================================================================
|
|
|
|
@router.get("/", response_model=List[Dict[str, Any]])
|
|
@limiter.limit("60/minute")
|
|
def list_features(
|
|
request: Request,
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> List[Dict[str, Any]]:
|
|
"""
|
|
List all available features.
|
|
|
|
Returns global feature definitions from the RBAC Catalog.
|
|
Features are automatically registered at startup from feature containers.
|
|
Any authenticated user can see available features.
|
|
"""
|
|
try:
|
|
# Features come from the RBAC Catalog (registered at startup from feature containers)
|
|
# NOT from the database - features are code-defined, not user-created
|
|
catalogService = getCatalogService()
|
|
features = catalogService.getFeatureDefinitions()
|
|
return features
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error listing features: {e}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to list features: {str(e)}"
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# My Feature Instances (No mandate context needed)
|
|
# IMPORTANT: Must be before /{featureCode} to avoid route matching conflict
|
|
# =============================================================================
|
|
|
|
class FeaturesMyResponse(BaseModel):
|
|
"""Hierarchical response for GET /features/my"""
|
|
mandates: List[Dict[str, Any]]
|
|
|
|
|
|
@router.get("/my", response_model=FeaturesMyResponse)
|
|
@limiter.limit("60/minute")
|
|
def get_my_feature_instances(
|
|
request: Request,
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> FeaturesMyResponse:
|
|
"""
|
|
Get all feature instances the current user has access to.
|
|
|
|
Returns hierarchical structure: mandates -> features -> instances -> permissions
|
|
This endpoint does not require X-Mandate-Id header.
|
|
"""
|
|
try:
|
|
rootInterface = getRootInterface()
|
|
featureInterface = getFeatureInterface(rootInterface.db)
|
|
|
|
# Get all feature accesses for this user
|
|
featureAccesses = rootInterface.getFeatureAccessesForUser(str(context.user.id))
|
|
|
|
if not featureAccesses:
|
|
return FeaturesMyResponse(mandates=[])
|
|
|
|
# Build hierarchical structure: mandate -> feature -> instances
|
|
mandatesMap: Dict[str, Dict[str, Any]] = {}
|
|
featuresMap: Dict[str, Dict[str, Any]] = {} # key: mandateId_featureCode
|
|
|
|
catalogService = getCatalogService()
|
|
for access in featureAccesses:
|
|
if not access.enabled:
|
|
continue
|
|
|
|
instance = featureInterface.getFeatureInstance(str(access.featureInstanceId))
|
|
if not instance or not instance.enabled:
|
|
continue
|
|
|
|
# Only show features that exist in this app's catalog (e.g. PowerOn vs Actan share DB)
|
|
if not catalogService.getFeatureDefinition(instance.featureCode):
|
|
continue
|
|
|
|
# Get mandate info
|
|
mandateId = str(instance.mandateId)
|
|
if mandateId not in mandatesMap:
|
|
mandate = rootInterface.getMandate(mandateId)
|
|
if mandate:
|
|
mandatesMap[mandateId] = {
|
|
"id": mandateId,
|
|
"name": mandate.name if hasattr(mandate, 'name') else mandateId,
|
|
"code": mandate.code if hasattr(mandate, 'code') else None,
|
|
"features": []
|
|
}
|
|
else:
|
|
mandatesMap[mandateId] = {
|
|
"id": mandateId,
|
|
"name": mandateId,
|
|
"code": None,
|
|
"features": []
|
|
}
|
|
|
|
# Get feature info from catalog (features are code-defined)
|
|
featureKey = f"{mandateId}_{instance.featureCode}"
|
|
if featureKey not in featuresMap:
|
|
catalogService = getCatalogService()
|
|
featureDef = catalogService.getFeatureDefinition(instance.featureCode)
|
|
featuresMap[featureKey] = {
|
|
"code": instance.featureCode,
|
|
"label": featureDef.get("label", {"de": instance.featureCode, "en": instance.featureCode}) if featureDef else {"de": instance.featureCode, "en": instance.featureCode},
|
|
"icon": featureDef.get("icon", "folder") if featureDef else "folder",
|
|
"instances": [],
|
|
"_mandateId": mandateId # Temporary for grouping
|
|
}
|
|
|
|
# Get user's roles in this instance (can have multiple)
|
|
userRoles = _getUserRolesInInstance(rootInterface, str(context.user.id), str(instance.id))
|
|
|
|
# Get permissions for this instance
|
|
permissions = _getInstancePermissions(rootInterface, str(context.user.id), str(instance.id))
|
|
|
|
# Add instance to feature
|
|
featuresMap[featureKey]["instances"].append({
|
|
"id": str(instance.id),
|
|
"featureCode": instance.featureCode,
|
|
"mandateId": mandateId,
|
|
"mandateName": mandatesMap[mandateId]["name"],
|
|
"instanceLabel": instance.label,
|
|
"userRoles": userRoles,
|
|
"permissions": permissions
|
|
})
|
|
|
|
# Build final structure
|
|
for featureKey, featureData in featuresMap.items():
|
|
mandateId = featureData.pop("_mandateId")
|
|
mandatesMap[mandateId]["features"].append(featureData)
|
|
|
|
return FeaturesMyResponse(mandates=list(mandatesMap.values()))
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting user's feature instances: {e}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to get feature instances: {str(e)}"
|
|
)
|
|
|
|
|
|
def _getUserRolesInInstance(rootInterface, userId: str, instanceId: str) -> List[str]:
|
|
"""Get all role labels for a user in a feature instance."""
|
|
try:
|
|
# Get FeatureAccess for this user and instance (Pydantic model)
|
|
featureAccess = rootInterface.getFeatureAccess(userId, instanceId)
|
|
|
|
if featureAccess:
|
|
# Get role IDs via interface method
|
|
roleIds = rootInterface.getRoleIdsForFeatureAccess(str(featureAccess.id))
|
|
|
|
if roleIds:
|
|
# Get ALL roles and extract labels
|
|
roleLabels = []
|
|
for roleId in roleIds:
|
|
role = rootInterface.getRole(roleId)
|
|
if role:
|
|
roleLabels.append(role.roleLabel)
|
|
return roleLabels if roleLabels else ["user"]
|
|
|
|
return ["user"] # Default - no access means basic user level
|
|
except Exception as e:
|
|
logger.debug(f"Error getting user roles: {e}")
|
|
return ["user"] # Fail-safe: default to basic user
|
|
|
|
|
|
def _getInstancePermissions(rootInterface, userId: str, instanceId: str) -> Dict[str, Any]:
|
|
"""Get summarized permissions for a user in an instance."""
|
|
# Default permissions structure
|
|
permissions = {
|
|
"tables": {},
|
|
"views": {},
|
|
"fields": {},
|
|
"isAdmin": False # Flag if user has admin role
|
|
}
|
|
|
|
try:
|
|
from modules.datamodels.datamodelRbac import AccessRuleContext
|
|
|
|
# Get FeatureAccess for this user and instance (Pydantic model)
|
|
featureAccess = rootInterface.getFeatureAccess(userId, instanceId)
|
|
|
|
if not featureAccess:
|
|
return permissions
|
|
|
|
# Get role IDs via interface method
|
|
roleIds = rootInterface.getRoleIdsForFeatureAccess(str(featureAccess.id))
|
|
|
|
if not roleIds:
|
|
return permissions
|
|
|
|
# Check if user has admin role
|
|
for roleId in roleIds:
|
|
role = rootInterface.getRole(roleId)
|
|
if role and "admin" in role.roleLabel.lower():
|
|
permissions["isAdmin"] = True
|
|
break
|
|
|
|
# Get permissions (AccessRules) for all roles
|
|
for roleId in roleIds:
|
|
# Get all rules for this role (returns Pydantic models)
|
|
accessRules = rootInterface.getAccessRules(roleId=roleId)
|
|
|
|
for rule in accessRules:
|
|
context = rule.context
|
|
item = rule.item or ""
|
|
|
|
# Handle DATA context (tables/fields)
|
|
if context == AccessRuleContext.DATA or context == "DATA":
|
|
if item:
|
|
# Check if it's a field (table.field) or table
|
|
if "." in item:
|
|
tableName, fieldName = item.split(".", 1)
|
|
if fieldName not in permissions["fields"]:
|
|
permissions["fields"][fieldName] = {"view": False}
|
|
permissions["fields"][fieldName]["view"] = permissions["fields"][fieldName]["view"] or rule.view
|
|
else:
|
|
tableName = item
|
|
if tableName not in permissions["tables"]:
|
|
permissions["tables"][tableName] = {
|
|
"view": False,
|
|
"read": "n",
|
|
"create": "n",
|
|
"update": "n",
|
|
"delete": "n"
|
|
}
|
|
|
|
# Merge permissions (highest wins)
|
|
current = permissions["tables"][tableName]
|
|
current["view"] = current["view"] or rule.view
|
|
current["read"] = _mergeAccessLevel(current["read"], rule.read or "n")
|
|
current["create"] = _mergeAccessLevel(current["create"], rule.create or "n")
|
|
current["update"] = _mergeAccessLevel(current["update"], rule.update or "n")
|
|
current["delete"] = _mergeAccessLevel(current["delete"], rule.delete or "n")
|
|
|
|
# Handle UI context (views)
|
|
elif context == AccessRuleContext.UI or context == "UI":
|
|
if item:
|
|
# Store with full objectKey as per Navigation-API-Konzept
|
|
permissions["views"][item] = permissions["views"].get(item, False) or rule.view
|
|
elif rule.view:
|
|
# item=None means all views - set a wildcard flag
|
|
permissions["views"]["_all"] = True
|
|
|
|
return permissions
|
|
|
|
except Exception as e:
|
|
logger.debug(f"Error getting instance permissions: {e}")
|
|
return permissions # Fail-safe: no permissions on error
|
|
|
|
|
|
def _mergeAccessLevel(current: str, new: str) -> str:
|
|
"""Merge two access levels, returning the highest."""
|
|
levels = {"n": 0, "m": 1, "g": 2, "a": 3}
|
|
currentLevel = levels.get(current, 0)
|
|
newLevel = levels.get(new, 0)
|
|
|
|
if newLevel > currentLevel:
|
|
return new
|
|
return current
|
|
|
|
|
|
@router.post("/", response_model=Dict[str, Any])
|
|
@limiter.limit("10/minute")
|
|
def create_feature(
|
|
request: Request,
|
|
code: str = Query(..., description="Unique feature code"),
|
|
label: Dict[str, str] = None,
|
|
icon: str = Query("mdi-puzzle", description="Icon identifier"),
|
|
sysAdmin: User = Depends(requireSysAdminRole)
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Create a new feature definition.
|
|
|
|
SysAdmin only - creates a global feature that can be activated for mandates.
|
|
|
|
Args:
|
|
code: Unique feature code (e.g., 'trustee')
|
|
label: I18n labels (e.g., {"en": "Trustee", "de": "Treuhand"})
|
|
icon: Icon identifier
|
|
"""
|
|
try:
|
|
rootInterface = getRootInterface()
|
|
featureInterface = getFeatureInterface(rootInterface.db)
|
|
|
|
# Check if feature already exists in catalog (features are code-defined)
|
|
catalogService = getCatalogService()
|
|
existing = catalogService.getFeatureDefinition(code)
|
|
if existing:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail=f"Feature '{code}' already exists"
|
|
)
|
|
|
|
feature = featureInterface.createFeature(
|
|
code=code,
|
|
label=label or {"en": code.title(), "de": code.title()},
|
|
icon=icon
|
|
)
|
|
|
|
logger.info(f"SysAdmin {sysAdmin.id} created feature '{code}'")
|
|
return feature.model_dump()
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error creating feature: {e}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to create feature: {str(e)}"
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Feature Instance Endpoints (Mandate-scoped)
|
|
# =============================================================================
|
|
|
|
@router.get("/instances", response_model=List[Dict[str, Any]])
|
|
@limiter.limit("60/minute")
|
|
def list_feature_instances(
|
|
request: Request,
|
|
featureCode: Optional[str] = Query(None, description="Filter by feature code"),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> List[Dict[str, Any]]:
|
|
"""
|
|
List feature instances for the current mandate.
|
|
|
|
Returns instances the user has access to within the selected mandate.
|
|
|
|
Args:
|
|
featureCode: Optional filter by feature code
|
|
"""
|
|
if not context.mandateId:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="X-Mandate-Id header is required"
|
|
)
|
|
|
|
try:
|
|
rootInterface = getRootInterface()
|
|
featureInterface = getFeatureInterface(rootInterface.db)
|
|
|
|
instances = featureInterface.getFeatureInstancesForMandate(
|
|
mandateId=str(context.mandateId),
|
|
featureCode=featureCode
|
|
)
|
|
|
|
return [inst.model_dump() for inst in instances]
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error listing feature instances: {e}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to list feature instances: {str(e)}"
|
|
)
|
|
|
|
|
|
@router.get("/instances/filter-values")
|
|
@limiter.limit("60/minute")
|
|
def get_feature_instance_filter_values(
|
|
request: Request,
|
|
column: str = Query(..., description="Column key"),
|
|
pagination: Optional[str] = Query(None, description="JSON-encoded current filters"),
|
|
featureCode: Optional[str] = Query(None, description="Filter by feature code"),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> list:
|
|
"""Return distinct filter values for a column in feature instances."""
|
|
if not context.mandateId:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="X-Mandate-Id header is required")
|
|
try:
|
|
from modules.routes.routeDataUsers import _handleFilterValuesRequest
|
|
rootInterface = getRootInterface()
|
|
featureInterface = getFeatureInterface(rootInterface.db)
|
|
instances = featureInterface.getFeatureInstancesForMandate(
|
|
mandateId=str(context.mandateId),
|
|
featureCode=featureCode
|
|
)
|
|
items = [inst.model_dump() for inst in instances]
|
|
return _handleFilterValuesRequest(items, column, pagination)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error getting filter values for feature instances: {e}")
|
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
|
|
|
|
|
|
@router.get("/instances/{instanceId}", response_model=Dict[str, Any])
|
|
@limiter.limit("60/minute")
|
|
def get_feature_instance(
|
|
request: Request,
|
|
instanceId: str,
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Get a specific feature instance.
|
|
|
|
Args:
|
|
instanceId: FeatureInstance ID
|
|
"""
|
|
try:
|
|
rootInterface = getRootInterface()
|
|
featureInterface = getFeatureInterface(rootInterface.db)
|
|
|
|
instance = featureInterface.getFeatureInstance(instanceId)
|
|
if not instance:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Feature instance '{instanceId}' not found"
|
|
)
|
|
|
|
# Verify mandate access (unless SysAdmin)
|
|
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
|
|
if not context.hasSysAdminRole:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Access denied to this feature instance"
|
|
)
|
|
|
|
return instance.model_dump()
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error getting feature instance {instanceId}: {e}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to get feature instance: {str(e)}"
|
|
)
|
|
|
|
|
|
@router.post("/instances", response_model=Dict[str, Any])
|
|
@limiter.limit("10/minute")
|
|
def create_feature_instance(
|
|
request: Request,
|
|
data: FeatureInstanceCreate,
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Create a new feature instance for the current mandate.
|
|
|
|
Requires Mandate-Admin role. Template roles are optionally copied.
|
|
|
|
Args:
|
|
data: Feature instance creation data
|
|
"""
|
|
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 create feature instances"
|
|
)
|
|
|
|
try:
|
|
rootInterface = getRootInterface()
|
|
featureInterface = getFeatureInterface(rootInterface.db)
|
|
|
|
# Verify feature exists in catalog (features are code-defined, not DB-stored)
|
|
catalogService = getCatalogService()
|
|
featureDef = catalogService.getFeatureDefinition(data.featureCode)
|
|
if not featureDef:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Feature '{data.featureCode}' not found"
|
|
)
|
|
|
|
# Subscription capacity check
|
|
mandateIdStr = str(context.mandateId)
|
|
try:
|
|
from modules.interfaces.interfaceDbSubscription import getInterface as _getSubIf
|
|
from modules.security.rootAccess import getRootUser
|
|
_subIf = _getSubIf(getRootUser(), mandateIdStr)
|
|
_subIf.assertCapacity(mandateIdStr, "featureInstances", delta=1)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as capErr:
|
|
if "SubscriptionCapacityException" in type(capErr).__name__:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail=str(capErr),
|
|
)
|
|
|
|
instance = featureInterface.createFeatureInstance(
|
|
featureCode=data.featureCode,
|
|
mandateId=mandateIdStr,
|
|
label=data.label,
|
|
enabled=data.enabled,
|
|
copyTemplateRoles=data.copyTemplateRoles,
|
|
config=data.config
|
|
)
|
|
|
|
# Sync Stripe quantity after successful creation
|
|
try:
|
|
from modules.interfaces.interfaceDbSubscription import getInterface as _getSubIf2
|
|
from modules.security.rootAccess import getRootUser as _getRU
|
|
_subIf2 = _getSubIf2(_getRU(), mandateIdStr)
|
|
_subIf2.syncQuantityToStripe(mandateIdStr)
|
|
except Exception:
|
|
pass
|
|
|
|
logger.info(
|
|
f"User {context.user.id} created feature instance '{data.label}' "
|
|
f"for feature '{data.featureCode}' in mandate {context.mandateId}"
|
|
)
|
|
|
|
return instance.model_dump()
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error creating feature instance: {e}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to create feature instance: {str(e)}"
|
|
)
|
|
|
|
|
|
@router.delete("/instances/{instanceId}", response_model=Dict[str, str])
|
|
@limiter.limit("10/minute")
|
|
def delete_feature_instance(
|
|
request: Request,
|
|
instanceId: str,
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> Dict[str, str]:
|
|
"""
|
|
Delete a feature instance.
|
|
|
|
Requires Mandate-Admin role. CASCADE will delete associated roles and access records.
|
|
|
|
Args:
|
|
instanceId: FeatureInstance ID
|
|
"""
|
|
try:
|
|
rootInterface = getRootInterface()
|
|
featureInterface = getFeatureInterface(rootInterface.db)
|
|
|
|
# Get instance to verify access
|
|
instance = featureInterface.getFeatureInstance(instanceId)
|
|
if not instance:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Feature instance '{instanceId}' not found"
|
|
)
|
|
|
|
# Verify mandate access
|
|
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
|
|
if not context.hasSysAdminRole:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Access denied to this feature instance"
|
|
)
|
|
|
|
# Check mandate admin permission
|
|
if not _hasMandateAdminRole(context) and not context.hasSysAdminRole:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Mandate-Admin role required to delete feature instances"
|
|
)
|
|
|
|
featureInterface.deleteFeatureInstance(instanceId)
|
|
|
|
logger.info(f"User {context.user.id} deleted feature instance {instanceId}")
|
|
|
|
return {"message": "Feature instance deleted", "instanceId": instanceId}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error deleting feature instance {instanceId}: {e}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to delete feature instance: {str(e)}"
|
|
)
|
|
|
|
|
|
class FeatureInstanceUpdate(BaseModel):
|
|
"""Request model for updating a feature instance."""
|
|
label: Optional[str] = Field(None, description="New label for the instance")
|
|
enabled: Optional[bool] = Field(None, description="Enable/disable the instance")
|
|
config: Optional[Dict[str, Any]] = Field(None, description="Instance-specific configuration (JSONB). Structure depends on featureCode.")
|
|
|
|
|
|
@router.put("/instances/{instanceId}", response_model=Dict[str, Any])
|
|
@limiter.limit("30/minute")
|
|
def updateFeatureInstance(
|
|
request: Request,
|
|
instanceId: str,
|
|
data: FeatureInstanceUpdate,
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Update a feature instance (label, enabled).
|
|
|
|
Requires Mandate-Admin role.
|
|
|
|
Args:
|
|
instanceId: FeatureInstance ID
|
|
data: Fields to update (label, enabled)
|
|
"""
|
|
try:
|
|
rootInterface = getRootInterface()
|
|
featureInterface = getFeatureInterface(rootInterface.db)
|
|
|
|
# Verify instance exists
|
|
instance = featureInterface.getFeatureInstance(instanceId)
|
|
if not instance:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Feature instance '{instanceId}' not found"
|
|
)
|
|
|
|
# Verify mandate access
|
|
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
|
|
if not context.hasSysAdminRole:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Access denied to this feature instance"
|
|
)
|
|
|
|
# Check mandate admin permission
|
|
if not _hasMandateAdminRole(context) and not context.hasSysAdminRole:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Mandate-Admin role required to update feature instances"
|
|
)
|
|
|
|
# Build update data (only non-None values)
|
|
updateData = {}
|
|
if data.label is not None:
|
|
updateData["label"] = data.label
|
|
if data.enabled is not None:
|
|
updateData["enabled"] = data.enabled
|
|
if data.config is not None:
|
|
updateData["config"] = data.config
|
|
|
|
if not updateData:
|
|
return instance.model_dump()
|
|
|
|
updated = featureInterface.updateFeatureInstance(instanceId, updateData)
|
|
if not updated:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Failed to update feature instance"
|
|
)
|
|
|
|
# Clear chatbot config cache when config was updated for chatbot instances
|
|
if "config" in updateData and instance.featureCode == "chatbot":
|
|
from modules.features.chatbot.config import clear_config_cache
|
|
clear_config_cache(instanceId)
|
|
|
|
logger.info(f"User {context.user.id} updated feature instance {instanceId}: {updateData}")
|
|
|
|
return updated.model_dump()
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error updating feature instance {instanceId}: {e}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to update feature instance: {str(e)}"
|
|
)
|
|
|
|
|
|
@router.post("/instances/{instanceId}/sync-roles", response_model=SyncRolesResult)
|
|
@limiter.limit("10/minute")
|
|
def sync_instance_roles(
|
|
request: Request,
|
|
instanceId: str,
|
|
addOnly: bool = Query(True, description="Only add missing roles, don't remove extras"),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> SyncRolesResult:
|
|
"""
|
|
Synchronize roles of a feature instance with current templates.
|
|
|
|
IMPORTANT: Templates are only copied when a FeatureInstance is created.
|
|
This sync function is for manual re-synchronization, not automatic propagation.
|
|
|
|
Args:
|
|
instanceId: FeatureInstance ID
|
|
addOnly: If True, only add missing roles. If False, also remove extras.
|
|
"""
|
|
try:
|
|
rootInterface = getRootInterface()
|
|
featureInterface = getFeatureInterface(rootInterface.db)
|
|
|
|
# Get instance to verify access
|
|
instance = featureInterface.getFeatureInstance(instanceId)
|
|
if not instance:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Feature instance '{instanceId}' not found"
|
|
)
|
|
|
|
# Verify mandate access
|
|
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
|
|
if not context.hasSysAdminRole:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Access denied to this feature instance"
|
|
)
|
|
|
|
# Check admin permission (Mandate-Admin or Feature-Admin)
|
|
if not _hasMandateAdminRole(context) and not context.hasSysAdminRole:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Admin role required to sync roles"
|
|
)
|
|
|
|
result = featureInterface.syncRolesFromTemplate(instanceId, addOnly)
|
|
|
|
logger.info(
|
|
f"User {context.user.id} synced roles for instance {instanceId}: {result}"
|
|
)
|
|
|
|
return SyncRolesResult(**result)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error syncing roles for instance {instanceId}: {e}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to sync roles: {str(e)}"
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Template Role Endpoints (SysAdmin only)
|
|
# =============================================================================
|
|
|
|
def _buildTemplateRolesList(featureCode: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
"""Build the full template roles list."""
|
|
rootInterface = getRootInterface()
|
|
featureInterface = getFeatureInterface(rootInterface.db)
|
|
roles = featureInterface.getTemplateRoles(featureCode)
|
|
return [r.model_dump() for r in roles]
|
|
|
|
|
|
@router.get("/templates/roles")
|
|
@limiter.limit("60/minute")
|
|
def list_template_roles(
|
|
request: Request,
|
|
featureCode: Optional[str] = Query(None, description="Filter by feature code"),
|
|
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams"),
|
|
sysAdmin: User = Depends(requireSysAdminRole),
|
|
):
|
|
"""List global template roles with pagination support."""
|
|
try:
|
|
paginationParams: Optional[PaginationParams] = None
|
|
if pagination:
|
|
try:
|
|
paginationDict = json.loads(pagination)
|
|
if paginationDict:
|
|
paginationDict = normalize_pagination_dict(paginationDict)
|
|
paginationParams = PaginationParams(**paginationDict)
|
|
except (json.JSONDecodeError, ValueError) as e:
|
|
raise HTTPException(status_code=400, detail=f"Invalid pagination parameter: {str(e)}")
|
|
|
|
enriched = _buildTemplateRolesList(featureCode)
|
|
filtered = _applyFiltersAndSort(enriched, paginationParams)
|
|
|
|
if paginationParams:
|
|
totalItems = len(filtered)
|
|
totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0
|
|
startIdx = (paginationParams.page - 1) * paginationParams.pageSize
|
|
endIdx = startIdx + paginationParams.pageSize
|
|
return {
|
|
"items": filtered[startIdx:endIdx],
|
|
"pagination": PaginationMetadata(
|
|
currentPage=paginationParams.page,
|
|
pageSize=paginationParams.pageSize,
|
|
totalItems=totalItems,
|
|
totalPages=totalPages,
|
|
sort=paginationParams.sort,
|
|
filters=paginationParams.filters,
|
|
).model_dump(),
|
|
}
|
|
|
|
return {"items": enriched, "pagination": None}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error listing template roles: {e}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to list template roles: {str(e)}"
|
|
)
|
|
|
|
|
|
@router.get("/templates/roles/filter-values")
|
|
@limiter.limit("60/minute")
|
|
def get_template_role_filter_values(
|
|
request: Request,
|
|
column: str = Query(..., description="Column key"),
|
|
featureCode: Optional[str] = Query(None, description="Filter by feature code"),
|
|
pagination: Optional[str] = Query(None, description="JSON-encoded current filters"),
|
|
sysAdmin: User = Depends(requireSysAdminRole),
|
|
):
|
|
"""Return distinct filter values for a column in template roles."""
|
|
try:
|
|
crossFilterParams: Optional[PaginationParams] = None
|
|
if pagination:
|
|
try:
|
|
paginationDict = json.loads(pagination)
|
|
if paginationDict:
|
|
paginationDict = normalize_pagination_dict(paginationDict)
|
|
filters = paginationDict.get("filters", {})
|
|
filters.pop(column, None)
|
|
paginationDict["filters"] = filters
|
|
paginationDict.pop("sort", None)
|
|
crossFilterParams = PaginationParams(**paginationDict)
|
|
except (json.JSONDecodeError, ValueError):
|
|
pass
|
|
|
|
enriched = _buildTemplateRolesList(featureCode)
|
|
crossFiltered = _applyFiltersAndSort(enriched, crossFilterParams)
|
|
return _extractDistinctValues(crossFiltered, column)
|
|
except Exception as e:
|
|
logger.error(f"Error getting filter values: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.post("/templates/roles", response_model=Dict[str, Any])
|
|
@limiter.limit("10/minute")
|
|
def create_template_role(
|
|
request: Request,
|
|
roleLabel: str = Query(..., description="Role label (e.g., 'admin', 'viewer')"),
|
|
featureCode: str = Query(..., description="Feature code this role belongs to"),
|
|
description: Dict[str, str] = None,
|
|
sysAdmin: User = Depends(requireSysAdminRole)
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Create a global template role for a feature.
|
|
|
|
SysAdmin only - new template roles are NOT automatically propagated to existing instances.
|
|
Use the sync-roles endpoint to manually synchronize.
|
|
|
|
Args:
|
|
roleLabel: Role label
|
|
featureCode: Feature code
|
|
description: I18n descriptions
|
|
"""
|
|
try:
|
|
rootInterface = getRootInterface()
|
|
featureInterface = getFeatureInterface(rootInterface.db)
|
|
|
|
# Verify feature exists in catalog (features are code-defined)
|
|
catalogService = getCatalogService()
|
|
featureDef = catalogService.getFeatureDefinition(featureCode)
|
|
if not featureDef:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Feature '{featureCode}' not found"
|
|
)
|
|
|
|
role = featureInterface.createTemplateRole(
|
|
roleLabel=roleLabel,
|
|
featureCode=featureCode,
|
|
description=description
|
|
)
|
|
|
|
logger.info(
|
|
f"SysAdmin {sysAdmin.id} created template role '{roleLabel}' "
|
|
f"for feature '{featureCode}'"
|
|
)
|
|
|
|
return role.model_dump()
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error creating template role: {e}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to create template role: {str(e)}"
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Feature Instance Users Endpoints
|
|
# Manage which users have access to a specific feature instance
|
|
# =============================================================================
|
|
|
|
class FeatureInstanceUserCreate(BaseModel):
|
|
"""Request model for adding a user to a feature instance"""
|
|
userId: str = Field(..., description="User ID to add")
|
|
roleIds: List[str] = Field(default_factory=list, description="Role IDs to assign")
|
|
|
|
|
|
class FeatureInstanceUserResponse(BaseModel):
|
|
"""Response model for a user in a feature instance"""
|
|
id: str # Use the FeatureAccess ID as primary key
|
|
userId: str
|
|
username: str
|
|
email: Optional[str]
|
|
fullName: Optional[str]
|
|
roleIds: List[str]
|
|
roleLabels: List[str]
|
|
enabled: bool
|
|
|
|
|
|
class FeatureInstanceUserUpdate(BaseModel):
|
|
"""Request model for updating a feature instance user (roles and active flag)"""
|
|
roleIds: List[str] = Field(..., description="Role IDs to assign")
|
|
enabled: Optional[bool] = Field(None, description="Whether this user's access is active (omit to leave unchanged)")
|
|
|
|
|
|
@router.get("/instances/{instanceId}/users", response_model=List[FeatureInstanceUserResponse])
|
|
@limiter.limit("60/minute")
|
|
def list_feature_instance_users(
|
|
request: Request,
|
|
instanceId: str,
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> List[FeatureInstanceUserResponse]:
|
|
"""
|
|
List all users with access to a specific feature instance.
|
|
|
|
Returns users and their roles for the given instance.
|
|
|
|
Args:
|
|
instanceId: FeatureInstance ID
|
|
"""
|
|
try:
|
|
rootInterface = getRootInterface()
|
|
featureInterface = getFeatureInterface(rootInterface.db)
|
|
|
|
# Verify instance exists
|
|
instance = featureInterface.getFeatureInstance(instanceId)
|
|
if not instance:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Feature instance '{instanceId}' not found"
|
|
)
|
|
|
|
# Verify mandate access (unless SysAdmin)
|
|
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
|
|
if not context.hasSysAdminRole:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Access denied to this feature instance"
|
|
)
|
|
|
|
# Get all FeatureAccess records for this instance (Pydantic models)
|
|
featureAccesses = rootInterface.getFeatureAccessesByInstance(instanceId)
|
|
|
|
result = []
|
|
for fa in featureAccesses:
|
|
# Get user info (Pydantic model)
|
|
user = rootInterface.getUser(str(fa.userId))
|
|
if not user:
|
|
continue
|
|
|
|
# Get role IDs via interface method
|
|
roleIds = rootInterface.getRoleIdsForFeatureAccess(str(fa.id))
|
|
|
|
# Get role labels
|
|
roleLabels = []
|
|
for roleId in roleIds:
|
|
role = rootInterface.getRole(roleId)
|
|
if role:
|
|
roleLabels.append(role.roleLabel)
|
|
|
|
result.append(FeatureInstanceUserResponse(
|
|
id=str(fa.id), # FeatureAccess ID as primary key
|
|
userId=str(fa.userId),
|
|
username=user.username,
|
|
email=user.email,
|
|
fullName=user.fullName,
|
|
roleIds=roleIds,
|
|
roleLabels=roleLabels,
|
|
enabled=fa.enabled
|
|
))
|
|
|
|
return result
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error listing feature instance users: {e}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to list feature instance users: {str(e)}"
|
|
)
|
|
|
|
|
|
@router.get("/instances/{instanceId}/users/filter-values")
|
|
@limiter.limit("60/minute")
|
|
def get_feature_instance_users_filter_values(
|
|
request: Request,
|
|
instanceId: str,
|
|
column: str = Query(..., description="Column key"),
|
|
pagination: Optional[str] = Query(None, description="JSON-encoded current filters"),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> list:
|
|
"""Return distinct filter values for a column in feature instance users."""
|
|
try:
|
|
from modules.routes.routeDataUsers import _handleFilterValuesRequest
|
|
rootInterface = getRootInterface()
|
|
featureInterface = getFeatureInterface(rootInterface.db)
|
|
instance = featureInterface.getFeatureInstance(instanceId)
|
|
if not instance:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Feature instance '{instanceId}' not found")
|
|
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
|
|
if not context.hasSysAdminRole:
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to this feature instance")
|
|
featureAccesses = rootInterface.getFeatureAccessesByInstance(instanceId)
|
|
result = []
|
|
for fa in featureAccesses:
|
|
user = rootInterface.getUser(str(fa.userId))
|
|
if not user:
|
|
continue
|
|
roleIds = rootInterface.getRoleIdsForFeatureAccess(str(fa.id))
|
|
roleLabels = []
|
|
for roleId in roleIds:
|
|
role = rootInterface.getRole(roleId)
|
|
if role:
|
|
roleLabels.append(role.roleLabel)
|
|
result.append({
|
|
"id": str(fa.id),
|
|
"userId": str(fa.userId),
|
|
"username": user.username,
|
|
"email": user.email,
|
|
"fullName": user.fullName,
|
|
"roleIds": roleIds,
|
|
"roleLabels": roleLabels,
|
|
"enabled": fa.enabled
|
|
})
|
|
return _handleFilterValuesRequest(result, column, pagination)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error getting filter values for feature instance users: {e}")
|
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
|
|
|
|
|
|
@router.post("/instances/{instanceId}/users", response_model=Dict[str, Any])
|
|
@limiter.limit("30/minute")
|
|
def add_user_to_feature_instance(
|
|
request: Request,
|
|
instanceId: str,
|
|
data: FeatureInstanceUserCreate,
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Add a user to a feature instance with specified roles.
|
|
|
|
Creates a FeatureAccess record and associated FeatureAccessRole records.
|
|
|
|
Args:
|
|
instanceId: FeatureInstance ID
|
|
data: User and role data
|
|
"""
|
|
try:
|
|
rootInterface = getRootInterface()
|
|
featureInterface = getFeatureInterface(rootInterface.db)
|
|
|
|
# Verify instance exists
|
|
instance = featureInterface.getFeatureInstance(instanceId)
|
|
if not instance:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Feature instance '{instanceId}' not found"
|
|
)
|
|
|
|
# Verify mandate access
|
|
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
|
|
if not context.hasSysAdminRole:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Access denied to this feature instance"
|
|
)
|
|
|
|
# Check admin permission
|
|
if not _hasMandateAdminRole(context) and not context.hasSysAdminRole:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Admin role required to add users to feature instances"
|
|
)
|
|
|
|
# Verify user exists
|
|
user = rootInterface.getUser(data.userId)
|
|
if not user:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"User '{data.userId}' not found"
|
|
)
|
|
|
|
# Check if user already has access
|
|
from modules.datamodels.datamodelMembership import FeatureAccess, FeatureAccessRole
|
|
existingAccess = rootInterface.getFeatureAccess(data.userId, instanceId)
|
|
if existingAccess:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail="User already has access to this feature instance"
|
|
)
|
|
|
|
# Create FeatureAccess record
|
|
featureAccess = FeatureAccess(
|
|
userId=data.userId,
|
|
featureInstanceId=instanceId,
|
|
enabled=True
|
|
)
|
|
createdAccess = rootInterface.db.recordCreate(FeatureAccess, featureAccess.model_dump())
|
|
featureAccessId = createdAccess.get("id")
|
|
|
|
# Create FeatureAccessRole records for each role
|
|
for roleId in data.roleIds:
|
|
featureAccessRole = FeatureAccessRole(
|
|
featureAccessId=featureAccessId,
|
|
roleId=roleId
|
|
)
|
|
rootInterface.db.recordCreate(FeatureAccessRole, featureAccessRole.model_dump())
|
|
|
|
logger.info(
|
|
f"User {context.user.id} added user {data.userId} to feature instance {instanceId} "
|
|
f"with roles {data.roleIds}"
|
|
)
|
|
|
|
iname = _feature_instance_display_name(instance)
|
|
create_access_change_notification(
|
|
data.userId,
|
|
"Feature-Zugriff",
|
|
f"Sie haben Zugriff auf die Feature-Instanz «{iname}» erhalten.",
|
|
"feature_access",
|
|
instanceId,
|
|
)
|
|
|
|
return {
|
|
"featureAccessId": featureAccessId,
|
|
"userId": data.userId,
|
|
"featureInstanceId": instanceId,
|
|
"roleIds": data.roleIds,
|
|
"enabled": True
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error adding user to feature instance: {e}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to add user to feature instance: {str(e)}"
|
|
)
|
|
|
|
|
|
@router.delete("/instances/{instanceId}/users/{userId}", response_model=Dict[str, str])
|
|
@limiter.limit("30/minute")
|
|
def remove_user_from_feature_instance(
|
|
request: Request,
|
|
instanceId: str,
|
|
userId: str,
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> Dict[str, str]:
|
|
"""
|
|
Remove a user's access from a feature instance.
|
|
|
|
Deletes the FeatureAccess record (CASCADE will delete FeatureAccessRole records).
|
|
|
|
Args:
|
|
instanceId: FeatureInstance ID
|
|
userId: User ID to remove
|
|
"""
|
|
try:
|
|
rootInterface = getRootInterface()
|
|
featureInterface = getFeatureInterface(rootInterface.db)
|
|
|
|
# Verify instance exists
|
|
instance = featureInterface.getFeatureInstance(instanceId)
|
|
if not instance:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Feature instance '{instanceId}' not found"
|
|
)
|
|
|
|
# Verify mandate access
|
|
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
|
|
if not context.hasSysAdminRole:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Access denied to this feature instance"
|
|
)
|
|
|
|
# Check admin permission
|
|
if not _hasMandateAdminRole(context) and not context.hasSysAdminRole:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Admin role required to remove users from feature instances"
|
|
)
|
|
|
|
# Find FeatureAccess record
|
|
from modules.datamodels.datamodelMembership import FeatureAccess
|
|
existingAccess = rootInterface.getFeatureAccess(userId, instanceId)
|
|
if not existingAccess:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="User does not have access to this feature instance"
|
|
)
|
|
|
|
featureAccessId = str(existingAccess.id)
|
|
|
|
# Delete FeatureAccess (CASCADE will delete FeatureAccessRole records)
|
|
rootInterface.db.recordDelete(FeatureAccess, featureAccessId)
|
|
|
|
logger.info(
|
|
f"User {context.user.id} removed user {userId} from feature instance {instanceId}"
|
|
)
|
|
|
|
iname = _feature_instance_display_name(instance)
|
|
create_access_change_notification(
|
|
userId,
|
|
"Feature-Zugriff",
|
|
f"Ihr Zugriff auf die Feature-Instanz «{iname}» wurde entfernt.",
|
|
"feature_access",
|
|
instanceId,
|
|
)
|
|
|
|
return {
|
|
"message": "User access removed",
|
|
"userId": userId,
|
|
"featureInstanceId": instanceId
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error removing user from feature instance: {e}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to remove user from feature instance: {str(e)}"
|
|
)
|
|
|
|
|
|
@router.put("/instances/{instanceId}/users/{userId}/roles", response_model=Dict[str, Any])
|
|
@limiter.limit("30/minute")
|
|
def update_feature_instance_user_roles(
|
|
request: Request,
|
|
instanceId: str,
|
|
userId: str,
|
|
data: FeatureInstanceUserUpdate,
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Update a user's roles and active flag in a feature instance.
|
|
|
|
Replaces all existing FeatureAccessRole records with new ones.
|
|
If enabled is provided, updates the FeatureAccess.enabled flag.
|
|
|
|
Args:
|
|
instanceId: FeatureInstance ID
|
|
userId: User ID to update
|
|
data: roleIds and optional enabled
|
|
"""
|
|
try:
|
|
rootInterface = getRootInterface()
|
|
featureInterface = getFeatureInterface(rootInterface.db)
|
|
|
|
# Verify instance exists
|
|
instance = featureInterface.getFeatureInstance(instanceId)
|
|
if not instance:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Feature instance '{instanceId}' not found"
|
|
)
|
|
|
|
# Verify mandate access
|
|
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
|
|
if not context.hasSysAdminRole:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Access denied to this feature instance"
|
|
)
|
|
|
|
# Check admin permission
|
|
if not _hasMandateAdminRole(context) and not context.hasSysAdminRole:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Admin role required to update user roles"
|
|
)
|
|
|
|
# Find FeatureAccess record
|
|
from modules.datamodels.datamodelMembership import FeatureAccess, FeatureAccessRole
|
|
existingAccess = rootInterface.getFeatureAccess(userId, instanceId)
|
|
if not existingAccess:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="User does not have access to this feature instance"
|
|
)
|
|
|
|
featureAccessId = str(existingAccess.id)
|
|
|
|
# Update enabled flag if provided
|
|
if data.enabled is not None:
|
|
rootInterface.db.recordModify(FeatureAccess, featureAccessId, {"enabled": data.enabled})
|
|
|
|
# Delete existing FeatureAccessRole records via interface method
|
|
rootInterface.deleteFeatureAccessRoles(featureAccessId)
|
|
|
|
# Create new FeatureAccessRole records
|
|
for roleId in data.roleIds:
|
|
featureAccessRole = FeatureAccessRole(
|
|
featureAccessId=featureAccessId,
|
|
roleId=roleId
|
|
)
|
|
rootInterface.db.recordCreate(FeatureAccessRole, featureAccessRole.model_dump())
|
|
|
|
logger.info(
|
|
f"User {context.user.id} updated roles for user {userId} in feature instance {instanceId}: {data.roleIds}"
|
|
)
|
|
|
|
iname = _feature_instance_display_name(instance)
|
|
create_access_change_notification(
|
|
userId,
|
|
"Feature-Rollen geändert",
|
|
f"Ihre Rollen in der Feature-Instanz «{iname}» wurden angepasst.",
|
|
"feature_access",
|
|
instanceId,
|
|
)
|
|
|
|
return {
|
|
"featureAccessId": featureAccessId,
|
|
"userId": userId,
|
|
"featureInstanceId": instanceId,
|
|
"roleIds": data.roleIds,
|
|
"enabled": data.enabled if data.enabled is not None else bool(existingAccess.enabled),
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error updating user roles in feature instance: {e}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to update user roles: {str(e)}"
|
|
)
|
|
|
|
|
|
@router.get("/instances/{instanceId}/available-roles", response_model=List[Dict[str, Any]])
|
|
@limiter.limit("60/minute")
|
|
def get_feature_instance_available_roles(
|
|
request: Request,
|
|
instanceId: str,
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> List[Dict[str, Any]]:
|
|
"""
|
|
Get available roles for a feature instance.
|
|
|
|
Returns instance-specific roles (copied from templates) that can be assigned to users.
|
|
|
|
Args:
|
|
instanceId: FeatureInstance ID
|
|
"""
|
|
try:
|
|
rootInterface = getRootInterface()
|
|
featureInterface = getFeatureInterface(rootInterface.db)
|
|
|
|
# Verify instance exists
|
|
instance = featureInterface.getFeatureInstance(instanceId)
|
|
if not instance:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Feature instance '{instanceId}' not found"
|
|
)
|
|
|
|
# Verify mandate access
|
|
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
|
|
if not context.hasSysAdminRole:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Access denied to this feature instance"
|
|
)
|
|
|
|
# Get roles for this instance using interface method
|
|
instanceRoles = rootInterface.getRolesByFeatureInstance(instanceId)
|
|
|
|
result = []
|
|
for role in instanceRoles:
|
|
result.append({
|
|
"id": role.id,
|
|
"roleLabel": role.roleLabel,
|
|
"description": role.description or {},
|
|
"featureCode": role.featureCode,
|
|
"isSystemRole": role.isSystemRole
|
|
})
|
|
|
|
return result
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error getting available roles for feature instance: {e}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to get available roles: {str(e)}"
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Dynamic Feature Route (MUST be last to avoid catching /instances, /my, etc.)
|
|
# =============================================================================
|
|
|
|
@router.get("/{featureCode}", response_model=Dict[str, Any])
|
|
@limiter.limit("60/minute")
|
|
def get_feature(
|
|
request: Request,
|
|
featureCode: str,
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Get a specific feature by code.
|
|
|
|
IMPORTANT: This route must be defined LAST to avoid catching paths like
|
|
/instances, /my, /templates, etc.
|
|
|
|
Args:
|
|
featureCode: Feature code (e.g., 'trustee', 'chatbot')
|
|
"""
|
|
try:
|
|
# Features come from the RBAC Catalog (code-defined, not DB-stored)
|
|
catalogService = getCatalogService()
|
|
featureDef = catalogService.getFeatureDefinition(featureCode)
|
|
if not featureDef:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Feature '{featureCode}' not found"
|
|
)
|
|
|
|
return featureDef
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error getting feature {featureCode}: {e}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to get feature: {str(e)}"
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Helper Functions
|
|
# =============================================================================
|
|
|
|
def _hasMandateAdminRole(context: RequestContext) -> bool:
|
|
"""
|
|
Check if the user has mandate admin role in the current context.
|
|
|
|
A user is mandate admin if they have the 'admin' role at mandate level.
|
|
"""
|
|
if context.hasSysAdminRole:
|
|
return True
|
|
|
|
if not context.roleIds:
|
|
return False
|
|
|
|
# Check if any of the user's roles is an admin role
|
|
try:
|
|
rootInterface = getRootInterface()
|
|
|
|
for roleId in context.roleIds:
|
|
role = rootInterface.getRole(roleId)
|
|
if role:
|
|
roleLabel = role.roleLabel
|
|
# Admin role at mandate level (not feature-instance level)
|
|
if roleLabel == "admin" and not role.featureInstanceId:
|
|
return True
|
|
|
|
return False
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error checking mandate admin role: {e}")
|
|
return False
|