gateway/modules/routes/routeAdminFeatures.py
2026-01-23 01:10:00 +01:00

1279 lines
46 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
from pydantic import BaseModel, Field
from modules.auth import limiter, getRequestContext, RequestContext, requireSysAdmin
from modules.datamodels.datamodelUam import User
from modules.datamodels.datamodelFeatures import Feature, FeatureInstance
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.interfaces.interfaceFeatures import getFeatureInterface
logger = logging.getLogger(__name__)
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')")
copyTemplateRoles: bool = Field(True, description="Whether to copy template roles on creation")
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")
async def listFeatures(
request: Request,
context: RequestContext = Depends(getRequestContext)
) -> List[Dict[str, Any]]:
"""
List all available features.
Returns global feature definitions that can be activated for mandates.
Any authenticated user can see available features.
"""
try:
rootInterface = getRootInterface()
featureInterface = getFeatureInterface(rootInterface.db)
features = featureInterface.getAllFeatures()
return [f.model_dump() for f in 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")
async def getMyFeatureInstances(
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
for access in featureAccesses:
if not access.enabled:
continue
instance = featureInterface.getFeatureInstance(str(access.featureInstanceId))
if not instance or not instance.enabled:
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
featureKey = f"{mandateId}_{instance.featureCode}"
if featureKey not in featuresMap:
feature = featureInterface.getFeature(instance.featureCode)
featuresMap[featureKey] = {
"code": instance.featureCode,
"label": feature.label if feature and hasattr(feature, 'label') else {"de": instance.featureCode, "en": instance.featureCode},
"icon": feature.icon if feature and hasattr(feature, 'icon') else "folder",
"instances": [],
"_mandateId": mandateId # Temporary for grouping
}
# Get user's role in this instance
userRole = _getUserRoleInInstance(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,
"userRole": userRole,
"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 _getUserRoleInInstance(rootInterface, userId: str, instanceId: str) -> str:
"""Get the user's primary role label in a feature instance."""
try:
from modules.datamodels.datamodelRbac import Role
from modules.datamodels.datamodelMembership import FeatureAccess, FeatureAccessRole
# Get FeatureAccess for this user and instance
featureAccesses = rootInterface.db.getRecordset(
FeatureAccess,
recordFilter={"userId": userId, "featureInstanceId": instanceId}
)
if featureAccesses:
featureAccessId = featureAccesses[0].get("id")
# Get role IDs via FeatureAccessRole junction table
featureAccessRoles = rootInterface.db.getRecordset(
FeatureAccessRole,
recordFilter={"featureAccessId": featureAccessId}
)
if featureAccessRoles:
roleId = featureAccessRoles[0].get("roleId")
roles = rootInterface.db.getRecordset(Role, recordFilter={"id": roleId})
if roles:
return roles[0].get("roleLabel", "user")
return "user" # Default
except Exception as e:
logger.debug(f"Error getting user role: {e}")
return "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": {}
}
try:
from modules.datamodels.datamodelRbac import AccessRule, AccessRuleContext
from modules.datamodels.datamodelMembership import FeatureAccess, FeatureAccessRole
# Get FeatureAccess for this user and instance
featureAccesses = rootInterface.db.getRecordset(
FeatureAccess,
recordFilter={"userId": userId, "featureInstanceId": instanceId}
)
if not featureAccesses:
return permissions
# Get role IDs via FeatureAccessRole junction table
featureAccessId = featureAccesses[0].get("id")
featureAccessRoles = rootInterface.db.getRecordset(
FeatureAccessRole,
recordFilter={"featureAccessId": featureAccessId}
)
roleIds = [far.get("roleId") for far in featureAccessRoles]
if not roleIds:
return permissions
# Get permissions (AccessRules) for all roles
for roleId in roleIds:
accessRules = rootInterface.db.getRecordset(
AccessRule,
recordFilter={"roleId": roleId}
)
for rule in accessRules:
context = rule.get("context", "")
item = rule.get("item", "")
# Handle DATA context (tables/fields)
if context == "DATA" or context == AccessRuleContext.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.get("view", False)
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.get("view", False)
current["read"] = _mergeAccessLevel(current["read"], rule.get("read") or "n")
current["create"] = _mergeAccessLevel(current["create"], rule.get("create") or "n")
current["update"] = _mergeAccessLevel(current["update"], rule.get("update") or "n")
current["delete"] = _mergeAccessLevel(current["delete"], rule.get("delete") or "n")
# Handle UI context (views)
elif context == "UI" or context == AccessRuleContext.UI:
if item:
permissions["views"][item] = permissions["views"].get(item, False) or rule.get("view", False)
return permissions
except Exception as e:
logger.debug(f"Error getting instance permissions: {e}")
return permissions
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")
async def createFeature(
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(requireSysAdmin)
) -> 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
existing = featureInterface.getFeature(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")
async def listFeatureInstances(
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/{instanceId}", response_model=Dict[str, Any])
@limiter.limit("60/minute")
async def getFeatureInstance(
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.isSysAdmin:
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")
async def createFeatureInstance(
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
feature = featureInterface.getFeature(data.featureCode)
if not feature:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Feature '{data.featureCode}' not found"
)
instance = featureInterface.createFeatureInstance(
featureCode=data.featureCode,
mandateId=str(context.mandateId),
label=data.label,
copyTemplateRoles=data.copyTemplateRoles
)
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")
async def deleteFeatureInstance(
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.isSysAdmin:
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.isSysAdmin:
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)}"
)
@router.post("/instances/{instanceId}/sync-roles", response_model=SyncRolesResult)
@limiter.limit("10/minute")
async def syncInstanceRoles(
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.isSysAdmin:
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.isSysAdmin:
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)
# =============================================================================
@router.get("/templates/roles", response_model=List[Dict[str, Any]])
@limiter.limit("60/minute")
async def listTemplateRoles(
request: Request,
featureCode: Optional[str] = Query(None, description="Filter by feature code"),
sysAdmin: User = Depends(requireSysAdmin)
) -> List[Dict[str, Any]]:
"""
List global template roles.
SysAdmin only - returns template roles that are copied to new feature instances.
Args:
featureCode: Optional filter by feature code
"""
try:
rootInterface = getRootInterface()
featureInterface = getFeatureInterface(rootInterface.db)
roles = featureInterface.getTemplateRoles(featureCode)
return [r.model_dump() for r in roles]
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.post("/templates/roles", response_model=Dict[str, Any])
@limiter.limit("10/minute")
async def createTemplateRole(
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(requireSysAdmin)
) -> 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
feature = featureInterface.getFeature(featureCode)
if not feature:
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
@router.get("/instances/{instanceId}/users", response_model=List[FeatureInstanceUserResponse])
@limiter.limit("60/minute")
async def listFeatureInstanceUsers(
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.isSysAdmin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this feature instance"
)
# Get all FeatureAccess records for this instance
from modules.datamodels.datamodelMembership import FeatureAccess, FeatureAccessRole
from modules.datamodels.datamodelRbac import Role
featureAccesses = rootInterface.db.getRecordset(
FeatureAccess,
recordFilter={"featureInstanceId": instanceId}
)
result = []
for fa in featureAccesses:
userId = fa.get("userId")
featureAccessId = fa.get("id")
# Get user info
users = rootInterface.db.getRecordset(UserInDB, recordFilter={"id": userId})
if not users:
continue
user = users[0]
# Get role IDs via FeatureAccessRole junction table
featureAccessRoles = rootInterface.db.getRecordset(
FeatureAccessRole,
recordFilter={"featureAccessId": featureAccessId}
)
roleIds = [far.get("roleId") for far in featureAccessRoles]
# Get role labels
roleLabels = []
for roleId in roleIds:
roles = rootInterface.db.getRecordset(Role, recordFilter={"id": roleId})
if roles:
roleLabels.append(roles[0].get("roleLabel", ""))
result.append(FeatureInstanceUserResponse(
id=featureAccessId, # FeatureAccess ID as primary key
userId=userId,
username=user.get("username", ""),
email=user.get("email"),
fullName=user.get("fullName"),
roleIds=roleIds,
roleLabels=roleLabels,
enabled=fa.get("enabled", True)
))
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.post("/instances/{instanceId}/users", response_model=Dict[str, Any])
@limiter.limit("30/minute")
async def addUserToFeatureInstance(
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.isSysAdmin:
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.isSysAdmin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin role required to add users to feature instances"
)
# Verify user exists
users = rootInterface.db.getRecordset(UserInDB, recordFilter={"id": data.userId})
if not users:
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.db.getRecordset(
FeatureAccess,
recordFilter={"userId": data.userId, "featureInstanceId": 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}"
)
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")
async def removeUserFromFeatureInstance(
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.isSysAdmin:
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.isSysAdmin:
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.db.getRecordset(
FeatureAccess,
recordFilter={"userId": userId, "featureInstanceId": instanceId}
)
if not existingAccess:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User does not have access to this feature instance"
)
featureAccessId = existingAccess[0].get("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}"
)
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")
async def updateFeatureInstanceUserRoles(
request: Request,
instanceId: str,
userId: str,
roleIds: List[str],
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""
Update a user's roles in a feature instance.
Replaces all existing FeatureAccessRole records with new ones.
Args:
instanceId: FeatureInstance ID
userId: User ID to update
roleIds: New list of role IDs
"""
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.isSysAdmin:
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.isSysAdmin:
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.db.getRecordset(
FeatureAccess,
recordFilter={"userId": userId, "featureInstanceId": instanceId}
)
if not existingAccess:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User does not have access to this feature instance"
)
featureAccessId = existingAccess[0].get("id")
# Delete existing FeatureAccessRole records
existingRoles = rootInterface.db.getRecordset(
FeatureAccessRole,
recordFilter={"featureAccessId": featureAccessId}
)
for role in existingRoles:
rootInterface.db.recordDelete(FeatureAccessRole, role.get("id"))
# Create new FeatureAccessRole records
for roleId in 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}: {roleIds}"
)
return {
"featureAccessId": featureAccessId,
"userId": userId,
"featureInstanceId": instanceId,
"roleIds": roleIds
}
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")
async def getFeatureInstanceAvailableRoles(
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.isSysAdmin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this feature instance"
)
# Get roles for this instance
from modules.datamodels.datamodelRbac import Role
instanceRoles = rootInterface.db.getRecordset(
Role,
recordFilter={"featureInstanceId": instanceId}
)
result = []
for role in instanceRoles:
result.append({
"id": role.get("id"),
"roleLabel": role.get("roleLabel"),
"description": role.get("description", {}),
"featureCode": role.get("featureCode"),
"isSystemRole": role.get("isSystemRole", False)
})
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")
async def getFeature(
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:
rootInterface = getRootInterface()
featureInterface = getFeatureInterface(rootInterface.db)
feature = featureInterface.getFeature(featureCode)
if not feature:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Feature '{featureCode}' not found"
)
return feature.model_dump()
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.isSysAdmin:
return True
if not context.roleIds:
return False
# Check if any of the user's roles is an admin role
try:
rootInterface = getRootInterface()
from modules.datamodels.datamodelRbac import Role
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 (not feature-instance 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