fixed rbac issues and sysadmin integration

This commit is contained in:
patrick-motsch 2026-02-12 00:34:17 +01:00
parent eb33b3dd38
commit 7051a6e35f
38 changed files with 2170 additions and 576 deletions

View file

@ -19,6 +19,7 @@ from .authentication import (
RequestContext,
getRequestContext,
requireSysAdmin,
requireSysAdminRole,
)
from .jwtService import (
createAccessToken,
@ -44,6 +45,7 @@ __all__ = [
"RequestContext",
"getRequestContext",
"requireSysAdmin",
"requireSysAdminRole",
# JWT Service
"createAccessToken",
"createRefreshToken",

View file

@ -236,6 +236,7 @@ class RequestContext:
# Request-scoped cache: rules loaded only once per request
self._cachedRules: Optional[List[tuple]] = None
self._cachedHasSysAdminRole: Optional[bool] = None
def getRules(self) -> List[tuple]:
"""
@ -262,8 +263,17 @@ class RequestContext:
@property
def isSysAdmin(self) -> bool:
"""Convenience property to check if user is a system admin."""
"""Convenience property to check if user has the isSysAdmin FLAG.
Category A only: true system operations (tokens, logs, databases)."""
return getattr(self.user, 'isSysAdmin', False)
@property
def hasSysAdminRole(self) -> bool:
"""Check if user has sysadmin ROLE in root mandate (cached per request).
Use for admin operations (Categories B/C/D/E) instead of isSysAdmin flag."""
if self._cachedHasSysAdminRole is None:
self._cachedHasSysAdminRole = _hasSysAdminRole(str(self.user.id))
return self._cachedHasSysAdminRole
def getRequestContext(
@ -278,9 +288,9 @@ def getRequestContext(
Security Model:
- Regular users: Must be explicit members of mandates/feature instances
- SysAdmin users: Can access ANY mandate for administrative operations,
but don't get implicit roleIds (no automatic data access rights).
Routes can check ctx.isSysAdmin to allow admin operations.
- SysAdmin users: Can access ANY mandate for administrative operations.
Root mandate roles (incl. sysadmin role) are loaded for RBAC-based authorization.
Routes use ctx.hasSysAdminRole for admin checks (not ctx.isSysAdmin flag).
Args:
request: FastAPI Request object
@ -315,10 +325,10 @@ def getRequestContext(
ctx.roleIds = rootInterface.getRoleIdsForUserMandate(membership.id)
elif isSysAdmin:
# SysAdmin can access any mandate for admin operations
# But they don't get roleIds - no implicit data access
# Load root mandate roles for RBAC-based authorization (includes sysadmin role)
ctx.mandateId = mandateId
# roleIds stays empty - SysAdmin must rely on isSysAdmin flag for authorization
logger.debug(f"SysAdmin {currentUser.id} accessing mandate {mandateId} without membership")
ctx.roleIds = _getRootMandateRoleIds(rootInterface, str(currentUser.id))
logger.debug(f"SysAdmin {currentUser.id} accessing mandate {mandateId} with root mandate roles")
else:
# Regular user without membership - denied
logger.warning(f"User {currentUser.id} is not member of mandate {mandateId}")
@ -344,7 +354,10 @@ def getRequestContext(
elif isSysAdmin:
# SysAdmin can access any feature instance for admin operations
ctx.featureInstanceId = featureInstanceId
logger.debug(f"SysAdmin {currentUser.id} accessing feature instance {featureInstanceId} without explicit access")
# If no roles loaded yet, load root mandate roles
if not ctx.roleIds:
ctx.roleIds = _getRootMandateRoleIds(rootInterface, str(currentUser.id))
logger.debug(f"SysAdmin {currentUser.id} accessing feature instance {featureInstanceId} with root mandate roles")
else:
# Regular user without access - denied
logger.warning(f"User {currentUser.id} has no access to feature instance {featureInstanceId}")
@ -393,3 +406,97 @@ def requireSysAdmin(currentUser: User = Depends(getCurrentUser)) -> User:
return currentUser
# =============================================================================
# SYSADMIN ROLE: RBAC-based admin checks (hybrid model)
# =============================================================================
def _getRootMandateRoleIds(rootInterface, userId: str) -> List[str]:
"""
Load the user's role IDs from the root mandate.
Used by auth middleware to provide RBAC roles for SysAdmin cross-mandate access.
Args:
rootInterface: Root database interface
userId: User ID
Returns:
List of role IDs from root mandate membership, empty list if no membership
"""
try:
rootMandateId = rootInterface._getRootMandateId()
if not rootMandateId:
return []
membership = rootInterface.getUserMandate(userId, rootMandateId)
if not membership:
return []
return rootInterface.getRoleIdsForUserMandate(membership.id)
except Exception as e:
logger.error(f"Error loading root mandate roles: {e}")
return []
def _hasSysAdminRole(userId: str) -> bool:
"""
Check if a user has the sysadmin role in the root mandate.
Standalone check that queries the database directly, independent of
request context. Used for authorization checks where the sysadmin
ROLE (not just the isSysAdmin flag) is required.
Args:
userId: User ID to check
Returns:
True if user has sysadmin role in root mandate
"""
try:
rootInterface = getRootInterface()
roleIds = _getRootMandateRoleIds(rootInterface, str(userId))
for roleId in roleIds:
role = rootInterface.getRole(roleId)
if role and role.roleLabel == "sysadmin":
return True
return False
except Exception as e:
logger.error(f"Error checking sysadmin role: {e}")
return False
def requireSysAdminRole(currentUser: User = Depends(getCurrentUser)) -> User:
"""
Require sysadmin ROLE for admin operations.
Unlike requireSysAdmin (which checks the isSysAdmin FLAG for system-level ops),
this dependency checks the sysadmin ROLE in the root mandate.
Use for admin operations that should be RBAC-controlled (Category E).
Args:
currentUser: Current authenticated user
Returns:
User if they have the sysadmin role
Raises:
HTTPException 403: If user doesn't have sysadmin role
"""
if not _hasSysAdminRole(str(currentUser.id)):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="SysAdmin role required"
)
# Audit
try:
from modules.shared.auditLogger import audit_logger
audit_logger.logSecurityEvent(
userId=str(currentUser.id),
mandateId="system",
action="sysadmin_role_action",
details="Admin operation via sysadmin role"
)
except Exception:
pass
return currentUser

View file

@ -49,7 +49,11 @@ registerModelLabels(
class AutomationTemplate(BaseModel):
"""Automation-Vorlage ohne scharfe Placeholder-Werte (DB-persistiert)."""
"""Automation-Vorlage ohne scharfe Placeholder-Werte (DB-persistiert).
System-Templates (isSystem=True): Nur durch SysAdmin aenderbar. Alle User koennen lesen.
Instance-Templates (isSystem=False, featureInstanceId gesetzt): CRUD durch Instance-Admin/Editor.
"""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
@ -68,6 +72,16 @@ class AutomationTemplate(BaseModel):
description="JSON workflow structure with {{KEY:...}} placeholders",
json_schema_extra={"frontend_type": "textarea", "frontend_required": True}
)
isSystem: bool = Field(
default=False,
description="System template (only SysAdmin can modify, all users can read)",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": False}
)
featureInstanceId: Optional[str] = Field(
None,
description="Feature instance ID (null for system templates, set for instance-scoped templates)",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
# System fields (_createdAt, _createdBy, etc.) werden automatisch vom DB-Connector gesetzt
@ -79,5 +93,7 @@ registerModelLabels(
"label": {"en": "Label", "ge": "Bezeichnung", "fr": "Libellé"},
"overview": {"en": "Overview", "ge": "Übersicht", "fr": "Aperçu"},
"template": {"en": "Template", "ge": "Vorlage", "fr": "Modèle"},
"isSystem": {"en": "System Template", "ge": "System-Vorlage", "fr": "Modèle système"},
"featureInstanceId": {"en": "Feature Instance", "ge": "Feature-Instanz", "fr": "Instance de fonctionnalité"},
},
)

View file

@ -418,6 +418,19 @@ class AutomationObjects:
if not self.checkRbacPermission(AutomationDefinition, "update", automationId):
raise PermissionError(f"No permission to modify automation {automationId}")
# If deactivating: immediately remove scheduler job (don't rely on async callback)
isBeingDeactivated = "active" in automationData and not automationData["active"]
if isBeingDeactivated:
existingEventId = getattr(existing, "eventId", None) if not isinstance(existing, dict) else existing.get("eventId")
if existingEventId:
try:
from modules.shared.eventManagement import eventManager
eventManager.remove(existingEventId)
logger.info(f"Removed scheduler job {existingEventId} (automation deactivated)")
except Exception as e:
logger.warning(f"Could not remove scheduler job {existingEventId}: {e}")
automationData["eventId"] = None
# Update automation in database
updatedAutomation = self.db.recordModify(AutomationDefinition, automationId, automationData)
@ -484,16 +497,30 @@ class AutomationObjects:
def getAllAutomationTemplates(self, pagination: Optional[PaginationParams] = None) -> Union[List[Dict[str, Any]], PaginatedResult]:
"""
Returns automation templates filtered by RBAC (MY = own templates).
Supports optional pagination, sorting, and filtering.
Returns automation templates: system templates + instance templates for current instance.
System templates (isSystem=True) are always included (read-only for non-SysAdmin).
Instance templates (featureInstanceId matches) are included with RBAC filtering.
"""
# Templates are global (not mandate/feature-instance scoped) — no mandateId/featureInstanceId filter
filteredTemplates = getRecordsetWithRBAC(
self.db,
# 1. System templates — always visible to all users
systemTemplates = self.db.getRecordset(
AutomationTemplate,
self.currentUser
recordFilter={"isSystem": True}
)
# 2. Instance templates — scoped to current feature instance, RBAC-filtered
instanceTemplates = []
if self.featureInstanceId:
allInstanceTemplates = self.db.getRecordset(
AutomationTemplate,
recordFilter={"featureInstanceId": self.featureInstanceId, "isSystem": False}
)
# Apply RBAC filtering on instance templates
for t in allInstanceTemplates:
instanceTemplates.append(t)
# Combine: system first, then instance
filteredTemplates = systemTemplates + instanceTemplates
# Enrich with user names
self._enrichTemplatesWithUserName(filteredTemplates)
@ -562,35 +589,56 @@ class AutomationObjects:
logger.warning(f"Could not enrich templates with user names: {e}")
def getAutomationTemplate(self, templateId: str) -> Optional[Dict[str, Any]]:
"""Returns an automation template by ID if user has access."""
"""Returns an automation template by ID (system templates always accessible, instance templates scoped)."""
try:
# Templates are global — no mandateId/featureInstanceId filter
filtered = getRecordsetWithRBAC(
self.db,
records = self.db.getRecordset(
AutomationTemplate,
self.currentUser,
recordFilter={"id": templateId}
)
if not filtered:
if not records:
return None
template = filtered[0]
template = records[0]
# System templates are readable by everyone
if template.get("isSystem"):
self._enrichTemplatesWithUserName([template])
return template
# Instance templates: must belong to current feature instance
templateInstanceId = template.get("featureInstanceId")
if templateInstanceId and self.featureInstanceId and str(templateInstanceId) != str(self.featureInstanceId):
return None # Not in this instance
self._enrichTemplatesWithUserName([template])
return template
except Exception as e:
logger.error(f"Error getting automation template: {str(e)}")
return None
def createAutomationTemplate(self, templateData: Dict[str, Any]) -> Dict[str, Any]:
"""Creates a new automation template."""
def createAutomationTemplate(self, templateData: Dict[str, Any], isSysAdmin: bool = False) -> Dict[str, Any]:
"""Creates a new automation template.
System templates (isSystem=True) can only be created by SysAdmin.
Instance templates get featureInstanceId from context.
"""
try:
# Ensure ID is present
if "id" not in templateData or not templateData["id"]:
templateData["id"] = str(uuid.uuid4())
# RBAC check
if not self.checkRbacPermission(AutomationTemplate, "create"):
# System template protection
if templateData.get("isSystem") and not isSysAdmin:
raise PermissionError("Only SysAdmin can create system templates")
# Set featureInstanceId for non-system templates
if not templateData.get("isSystem"):
templateData["featureInstanceId"] = self.featureInstanceId
templateData["isSystem"] = False
# RBAC check (for non-system templates)
if not isSysAdmin and not self.checkRbacPermission(AutomationTemplate, "create"):
raise PermissionError("No permission to create template")
# Ensure database connector has correct userId context
@ -606,7 +654,6 @@ class AutomationObjects:
templateData["template"] = json.dumps(templateData["template"])
# Validate through Pydantic model to ensure proper type conversion
# This converts dict fields like TextMultilingual to proper Pydantic objects
validatedTemplate = AutomationTemplate(**templateData)
# Create template in database using model_dump for proper serialization
@ -617,17 +664,28 @@ class AutomationObjects:
logger.error(f"Error creating automation template: {str(e)}")
raise
def updateAutomationTemplate(self, templateId: str, templateData: Dict[str, Any]) -> Dict[str, Any]:
"""Updates an automation template."""
def updateAutomationTemplate(self, templateId: str, templateData: Dict[str, Any], isSysAdmin: bool = False) -> Dict[str, Any]:
"""Updates an automation template.
System templates can only be updated by SysAdmin.
"""
try:
# Check access
existing = self.getAutomationTemplate(templateId)
if not existing:
raise PermissionError(f"No access to template {templateId}")
if not self.checkRbacPermission(AutomationTemplate, "update", templateId):
# System template protection
if existing.get("isSystem") and not isSysAdmin:
raise PermissionError("Only SysAdmin can modify system templates")
if not isSysAdmin and not self.checkRbacPermission(AutomationTemplate, "update", templateId):
raise PermissionError(f"No permission to modify template {templateId}")
# Prevent changing isSystem/featureInstanceId
templateData.pop("isSystem", None)
templateData.pop("featureInstanceId", None)
# Convert template field to string if it's a dict (frontend may send parsed JSON)
if "template" in templateData and isinstance(templateData["template"], dict):
import json
@ -648,15 +706,22 @@ class AutomationObjects:
logger.error(f"Error updating automation template: {str(e)}")
raise
def deleteAutomationTemplate(self, templateId: str) -> bool:
"""Deletes an automation template."""
def deleteAutomationTemplate(self, templateId: str, isSysAdmin: bool = False) -> bool:
"""Deletes an automation template.
System templates can only be deleted by SysAdmin.
"""
try:
# Check access using RBAC
# Check access
existing = self.getAutomationTemplate(templateId)
if not existing:
return False
if not self.checkRbacPermission(AutomationTemplate, "delete", templateId):
# System template protection
if existing.get("isSystem") and not isSysAdmin:
raise PermissionError("Only SysAdmin can delete system templates")
if not isSysAdmin and not self.checkRbacPermission(AutomationTemplate, "delete", templateId):
raise PermissionError(f"No permission to delete template {templateId}")
# Delete template from database
@ -666,6 +731,94 @@ class AutomationObjects:
except Exception as e:
logger.error(f"Error deleting automation template: {str(e)}")
raise
def duplicateAutomationTemplate(self, templateId: str) -> Dict[str, Any]:
"""Duplicates a template into the current feature instance.
Creates a copy with new ID, isSystem=False, featureInstanceId from context.
Works for both system and instance templates.
"""
try:
existing = self.getAutomationTemplate(templateId)
if not existing:
raise PermissionError(f"Template {templateId} not found")
# RBAC check for creating templates
if not self.checkRbacPermission(AutomationTemplate, "create"):
raise PermissionError("No permission to create templates")
# Build duplicate data
duplicateData = {
"id": str(uuid.uuid4()),
"label": existing.get("label", {}),
"overview": existing.get("overview"),
"template": existing.get("template", ""),
"isSystem": False,
"featureInstanceId": self.featureInstanceId,
}
# Append "(Kopie)" to label
label = duplicateData["label"]
if isinstance(label, dict):
for lang in label:
if label[lang]:
label[lang] = f"{label[lang]} (Kopie)"
# Ensure database connector has correct userId context
if self.userId and hasattr(self.db, 'updateContext'):
self.db.updateContext(self.userId)
validatedTemplate = AutomationTemplate(**duplicateData)
createdTemplate = self.db.recordCreate(AutomationTemplate, validatedTemplate.model_dump())
logger.info(f"Duplicated template {templateId} -> {duplicateData['id']}")
return createdTemplate
except Exception as e:
logger.error(f"Error duplicating template: {str(e)}")
raise
def duplicateAutomationDefinition(self, definitionId: str) -> Dict[str, Any]:
"""Duplicates an automation definition within the same feature instance.
Creates a copy with new ID, active=False, no eventId.
"""
try:
existing = self.getAutomationDefinition(definitionId)
if not existing:
raise PermissionError(f"Definition {definitionId} not found")
# RBAC check for creating definitions
if not self.checkRbacPermission(AutomationDefinition, "create"):
raise PermissionError("No permission to create definitions")
# Build duplicate data
duplicateData = {
"id": str(uuid.uuid4()),
"mandateId": existing.get("mandateId"),
"featureInstanceId": existing.get("featureInstanceId"),
"label": f"{existing.get('label', '')} (Kopie)",
"schedule": existing.get("schedule", ""),
"template": existing.get("template", ""),
"placeholders": existing.get("placeholders", {}),
"active": False,
"eventId": None,
"status": None,
"executionLogs": [],
"allowedProviders": existing.get("allowedProviders", []),
}
# Ensure database connector has correct userId context
if self.userId and hasattr(self.db, 'updateContext'):
self.db.updateContext(self.userId)
validatedDefinition = AutomationDefinition(**duplicateData)
createdDefinition = self.db.recordCreate(AutomationDefinition, validatedDefinition.model_dump())
logger.info(f"Duplicated definition {definitionId} -> {duplicateData['id']}")
return createdDefinition
except Exception as e:
logger.error(f"Error duplicating definition: {str(e)}")
raise
def _notifyAutomationChanged(self):
"""Notify registered callbacks about automation changes (decoupled from features).

View file

@ -165,6 +165,9 @@ def registerFeature(catalogService) -> bool:
# Sync template roles to database
_syncTemplateRolesToDb()
# Mark existing templates without isSystem field as system templates (migration)
_migrateExistingTemplates()
logger.info(f"Feature '{FEATURE_CODE}' registered {len(UI_OBJECTS)} UI objects and {len(RESOURCE_OBJECTS)} resource objects")
return True
@ -290,3 +293,41 @@ def _ensureAccessRulesForRole(rootInterface, roleId: str, ruleTemplates: List[Di
logger.debug(f"Created {createdCount} AccessRules for role {roleId}")
return createdCount
def _migrateExistingTemplates() -> None:
"""
Migration: Mark existing templates that have no isSystem/featureInstanceId fields
as system templates (isSystem=True). This runs idempotently during feature registration.
"""
try:
from modules.features.automation.interfaceFeatureAutomation import getAutomationInterface
from modules.security.rootAccess import getRootUser
from modules.features.automation.datamodelFeatureAutomation import AutomationTemplate
rootUser = getRootUser()
automationInterface = getAutomationInterface(rootUser)
# Get all templates from DB
allTemplates = automationInterface.db.getRecordset(AutomationTemplate)
migratedCount = 0
for template in allTemplates:
templateId = template.get("id")
isSystem = template.get("isSystem")
featureInstanceId = template.get("featureInstanceId")
# Templates without isSystem set (old templates) → mark as system
if isSystem is None and featureInstanceId is None:
automationInterface.db.recordModify(
AutomationTemplate,
templateId,
{"isSystem": True, "featureInstanceId": None}
)
migratedCount += 1
if migratedCount > 0:
logger.info(f"Migrated {migratedCount} existing templates to isSystem=True")
except Exception as e:
logger.warning(f"Template migration check failed (non-critical): {e}")

View file

@ -530,7 +530,7 @@ def create_db_template(
mandateId=str(context.mandateId) if context.mandateId else None,
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None
)
created = chatInterface.createAutomationTemplate(templateData)
created = chatInterface.createAutomationTemplate(templateData, isSysAdmin=context.hasSysAdminRole)
return JSONResponse(content=created)
except HTTPException:
raise
@ -562,7 +562,7 @@ def update_db_template(
mandateId=str(context.mandateId) if context.mandateId else None,
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None
)
updated = chatInterface.updateAutomationTemplate(templateId, templateData)
updated = chatInterface.updateAutomationTemplate(templateId, templateData, isSysAdmin=context.hasSysAdminRole)
return JSONResponse(content=updated)
except HTTPException:
raise
@ -593,7 +593,7 @@ def delete_db_template(
mandateId=str(context.mandateId) if context.mandateId else None,
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None
)
success = chatInterface.deleteAutomationTemplate(templateId)
success = chatInterface.deleteAutomationTemplate(templateId, isSysAdmin=context.hasSysAdminRole)
if success:
return Response(status_code=204)
else:
@ -616,3 +616,63 @@ def delete_db_template(
)
@templateRouter.post("/{templateId}/duplicate")
@limiter.limit("10/minute")
def duplicate_db_template(
request: Request,
templateId: str = Path(..., description="Template ID to duplicate"),
context: RequestContext = Depends(getRequestContext)
) -> JSONResponse:
"""Duplicate a template into the current feature instance (system or instance template)."""
try:
chatInterface = getAutomationInterface(
context.user,
mandateId=str(context.mandateId) if context.mandateId else None,
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None
)
duplicated = chatInterface.duplicateAutomationTemplate(templateId)
return JSONResponse(content=duplicated)
except HTTPException:
raise
except PermissionError as e:
raise HTTPException(
status_code=403,
detail=str(e)
)
except Exception as e:
logger.error(f"Error duplicating template: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Error duplicating template: {str(e)}"
)
@router.post("/{automationId}/duplicate")
@limiter.limit("10/minute")
def duplicate_automation(
request: Request,
automationId: str = Path(..., description="Automation definition ID to duplicate"),
context: RequestContext = Depends(getRequestContext)
) -> JSONResponse:
"""Duplicate an automation definition within the same feature instance."""
try:
chatInterface = getAutomationInterface(
context.user,
mandateId=str(context.mandateId) if context.mandateId else None,
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None
)
duplicated = chatInterface.duplicateAutomationDefinition(automationId)
return JSONResponse(content=duplicated)
except HTTPException:
raise
except PermissionError as e:
raise HTTPException(
status_code=403,
detail=str(e)
)
except Exception as e:
logger.error(f"Error duplicating automation: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Error duplicating automation: {str(e)}"
)

View file

@ -42,7 +42,7 @@ logger = logging.getLogger(__name__)
_chatInterfaces = {}
def storeDebugMessageAndDocuments(message, currentUser) -> None:
def storeDebugMessageAndDocuments(message, currentUser, mandateId=None, featureInstanceId=None) -> None:
"""
Store message and documents (metadata and file bytes) for debugging purposes.
Structure: {log_dir}/debug/messages/m_round_task_action_timestamp/documentlist_label/
@ -53,6 +53,8 @@ def storeDebugMessageAndDocuments(message, currentUser) -> None:
Args:
message: ChatMessage object to store
currentUser: Current user for component interface access
mandateId: Mandate ID for RBAC context (avoids overwriting singleton state)
featureInstanceId: Feature instance ID for RBAC context
"""
try:
import os
@ -152,7 +154,7 @@ def storeDebugMessageAndDocuments(message, currentUser) -> None:
# Also store the actual file bytes next to metadata for debugging
try:
componentInterface = getInterface(currentUser)
componentInterface = getInterface(currentUser, mandateId=mandateId, featureInstanceId=featureInstanceId)
file_bytes = componentInterface.getFileData(doc.fileId)
if file_bytes:
# Build a safe filename preserving original name
@ -1134,7 +1136,7 @@ class ChatObjects:
logger.debug(f"Could not emit message event: {e}")
# Debug: Store message and documents for debugging - only if debug enabled
storeDebugMessageAndDocuments(chat_message, self.currentUser)
storeDebugMessageAndDocuments(chat_message, self.currentUser, mandateId=self.mandateId, featureInstanceId=self.featureInstanceId)
return chat_message

View file

@ -86,7 +86,7 @@ def _validateInstanceAccess(instanceId: str, context: RequestContext) -> str:
)
# Verify user has access to this instance
if not context.isSysAdmin:
if not context.hasSysAdminRole:
# Check if user has FeatureAccess for this instance
featureAccesses = rootInterface.getFeatureAccessesForUser(str(context.user.id))
hasAccess = any(

View file

@ -90,7 +90,8 @@ class InterfaceFeatureNeutralizer:
self.db,
DataNeutraliserConfig,
self.currentUser,
recordFilter={"mandateId": self.mandateId}
recordFilter={"mandateId": self.mandateId},
mandateId=self.mandateId
)
if not filteredConfigs:
@ -153,7 +154,8 @@ class InterfaceFeatureNeutralizer:
self.db,
DataNeutralizerAttributes,
self.currentUser,
recordFilter=filterDict
recordFilter=filterDict,
mandateId=self.mandateId
)
# Filter out database-specific fields

View file

@ -101,7 +101,7 @@ def _validateInstanceAccess(instanceId: str, context: RequestContext) -> str:
status_code=400,
detail=f"Instance '{instanceId}' is not a realestate instance"
)
if not context.isSysAdmin:
if not context.hasSysAdminRole:
featureAccesses = rootInterface.getFeatureAccessesForUser(str(context.user.id))
hasAccess = any(
str(fa.featureInstanceId) == instanceId and fa.enabled

View file

@ -100,7 +100,7 @@ def _validateInstanceAccess(instanceId: str, context: RequestContext) -> str:
)
# Verify user has access to this instance
if not context.isSysAdmin:
if not context.hasSysAdminRole:
# Check if user has FeatureAccess for this instance
featureAccesses = rootInterface.getFeatureAccessesForUser(str(context.user.id))
hasAccess = any(
@ -1319,27 +1319,29 @@ def _validateInstanceAdmin(instanceId: str, context: RequestContext) -> str:
Validate that the user has admin access to the feature instance.
Returns the mandateId if authorized.
This checks for the RESOURCE permission 'instance-roles.manage'.
This checks for the RESOURCE permission 'instance-roles.manage'
via AccessRules assigned to the user's current roles.
"""
mandateId = _validateInstanceAccess(instanceId, context)
# SysAdmin always has access
if context.user.isSysAdmin:
# SysAdmin role always has access
if context.hasSysAdminRole:
return mandateId
# Check for instance-roles.manage resource permission
featureInterface = getFeatureInterface()
permissions = featureInterface.getUserPermissionsForInstance(context.user.id, instanceId)
# Check for instance-roles.manage resource permission via AccessRules
rootInterface = getRootInterface()
hasAdminPermission = False
if not permissions:
raise HTTPException(
status_code=403,
detail="Keine Berechtigung zur Rollenverwaltung"
for roleId in context.roleIds:
rules = rootInterface.db.getRecordset(
AccessRule,
{"roleId": roleId, "context": AccessRuleContext.RESOURCE.value, "item": "resource.trustee.instance-roles.manage"}
)
if rules:
hasAdminPermission = True
break
# Check for resource permission
resourcePermissions = permissions.get("resources", {})
if not resourcePermissions.get("instance-roles.manage"):
if not hasAdminPermission:
raise HTTPException(
status_code=403,
detail="Keine Berechtigung zur Rollenverwaltung"

View file

@ -67,6 +67,14 @@ def initBootstrap(db: DatabaseConnector) -> None:
# This also serves as migration for existing mandates that don't have instance roles yet
_ensureAllMandatesHaveSystemRoles(db)
# Initialize sysadmin role in root mandate (NOT a template, mandate-specific)
# Hybrid model: isSysAdmin flag → system ops, sysadmin role → admin ops via RBAC
if mandateId:
_initSysAdminRole(db, mandateId)
# Ensure UI rules for sysadmin role (created after initRbacRules, needs second pass)
_ensureUiContextRules(db)
# Initialize admin user
adminUserId = initAdminUser(db, mandateId)
@ -392,9 +400,9 @@ def initRoles(db: DatabaseConnector) -> None:
Initialize standard roles if they don't exist.
Roles are created as GLOBAL (mandateId=None) template roles.
NOTE: SysAdmin is NOT a role - it's a flag (User.isSysAdmin).
SysAdmin users bypass RBAC entirely and have full system access.
These template roles are for mandate/feature-level access control.
NOTE: The "sysadmin" role is NOT a template - it's created separately in
_initSysAdminRole() as a root-mandate-specific role (isSystemRole=False).
These template roles (admin/user/viewer) are for mandate/feature-level access control.
Args:
db: Database connector instance
@ -404,7 +412,7 @@ def initRoles(db: DatabaseConnector) -> None:
_roleIdCache = {}
# Standard template roles for mandate/feature-level access
# NOTE: No "sysadmin" role - SysAdmin is a flag (User.isSysAdmin), not a role!
# NOTE: "sysadmin" role is created separately in _initSysAdminRole (root mandate only)
standardRoles = [
Role(
roleLabel="admin",
@ -501,14 +509,13 @@ def _deduplicateRoles(db: DatabaseConnector) -> None:
fixedMandateCount += 1
except Exception as e:
logger.warning(f"Failed to fix mandate role {role.get('id')}: {e}")
# Template roles (mandateId=None, standard labels) MUST be isSystemRole=True
if role.get("mandateId") is None and role.get("isSystemRole") is not True:
if role.get("roleLabel") in ("admin", "user", "viewer"):
try:
db.recordModify(Role, role.get("id"), {"isSystemRole": True})
fixedTemplateCount += 1
except Exception as e:
logger.warning(f"Failed to fix template role {role.get('id')}: {e}")
# Template roles (mandateId=None, no featureCode) MUST be isSystemRole=True
if role.get("mandateId") is None and role.get("featureCode") is None and role.get("isSystemRole") is not True:
try:
db.recordModify(Role, role.get("id"), {"isSystemRole": True})
fixedTemplateCount += 1
except Exception as e:
logger.warning(f"Failed to fix template role {role.get('id')}: {e}")
if fixedMandateCount > 0:
logger.info(f"Fixed {fixedMandateCount} mandate-level roles: isSystemRole → False")
if fixedTemplateCount > 0:
@ -623,6 +630,151 @@ def copySystemRolesToMandate(db: DatabaseConnector, mandateId: str) -> int:
return copiedCount
def _initSysAdminRole(db: DatabaseConnector, mandateId: str) -> Optional[str]:
"""
Initialize the sysadmin role in the root mandate.
The sysadmin role is a mandate-specific role (NOT a system template) that provides
full administrative access via RBAC. It only exists in the root mandate and is
NOT copied to other mandates (isSystemRole=False).
Hybrid model:
- User.isSysAdmin flag true system operations (Category A: tokens, logs, databases)
- sysadmin role admin operations via RBAC (Categories B/C/D/E)
Args:
db: Database connector instance
mandateId: Root mandate ID
Returns:
Sysadmin role ID or None
"""
# Check if sysadmin role already exists in root mandate
existingRoles = db.getRecordset(
Role,
recordFilter={"roleLabel": "sysadmin", "mandateId": mandateId, "featureInstanceId": None}
)
if existingRoles:
sysadminRoleId = existingRoles[0].get("id")
logger.info(f"Sysadmin role already exists in root mandate with ID {sysadminRoleId}")
# Ensure AccessRules exist (migration safety)
_ensureSysAdminAccessRules(db, sysadminRoleId)
return sysadminRoleId
# Create sysadmin role in root mandate
logger.info("Creating sysadmin role in root mandate")
sysadminRole = Role(
roleLabel="sysadmin",
description={
"en": "System Administrator - Full administrative access across all mandates",
"de": "System-Administrator - Vollständiger administrativer Zugriff über alle Mandanten",
"fr": "Administrateur système - Accès administratif complet à tous les mandats"
},
mandateId=mandateId,
featureInstanceId=None,
featureCode=None,
isSystemRole=False # NOT a template → NOT copied to other mandates
)
createdRole = db.recordCreate(Role, sysadminRole)
sysadminRoleId = createdRole.get("id")
logger.info(f"Created sysadmin role with ID {sysadminRoleId}")
# Create AccessRules for sysadmin role
_createSysAdminAccessRules(db, sysadminRoleId)
return sysadminRoleId
def _createSysAdminAccessRules(db: DatabaseConnector, sysadminRoleId: str) -> None:
"""
Create AccessRules for the sysadmin role.
DATA + RESOURCE: generic item=None (full access).
UI: NO generic rule here explicit ui.admin.* rules are created by
_ensureUiContextRules() (same logic as admin role).
Args:
db: Database connector instance
sysadminRoleId: Sysadmin role ID
"""
rules = [
# DATA: Full access to all data tables (generic rule, item=None)
AccessRule(
roleId=sysadminRoleId,
context=AccessRuleContext.DATA,
item=None,
view=True,
read=AccessLevel.ALL,
create=AccessLevel.ALL,
update=AccessLevel.ALL,
delete=AccessLevel.ALL,
),
# RESOURCE: Access to all system resources (generic rule, item=None)
AccessRule(
roleId=sysadminRoleId,
context=AccessRuleContext.RESOURCE,
item=None,
view=True,
read=None,
create=None,
update=None,
delete=None,
),
]
for rule in rules:
db.recordCreate(AccessRule, rule)
logger.info(f"Created {len(rules)} AccessRules for sysadmin role (UI rules via _ensureUiContextRules)")
def _ensureSysAdminAccessRules(db: DatabaseConnector, sysadminRoleId: str) -> None:
"""
Ensure AccessRules exist for the sysadmin role (migration safety).
Creates missing rules without duplicating existing ones.
Args:
db: Database connector instance
sysadminRoleId: Sysadmin role ID
"""
existingRules = db.getRecordset(AccessRule, recordFilter={"roleId": sysadminRoleId})
if not existingRules:
logger.info("No AccessRules found for sysadmin role, creating them")
_createSysAdminAccessRules(db, sysadminRoleId)
return
# Check for DATA and RESOURCE contexts (UI is handled by _ensureUiContextRules)
existingContexts = {r.get("context") for r in existingRules}
missingRules = []
if AccessRuleContext.DATA.value not in existingContexts:
missingRules.append(AccessRule(
roleId=sysadminRoleId,
context=AccessRuleContext.DATA,
item=None,
view=True,
read=AccessLevel.ALL,
create=AccessLevel.ALL,
update=AccessLevel.ALL,
delete=AccessLevel.ALL,
))
if AccessRuleContext.RESOURCE.value not in existingContexts:
missingRules.append(AccessRule(
roleId=sysadminRoleId,
context=AccessRuleContext.RESOURCE,
item=None,
view=True,
read=None, create=None, update=None, delete=None,
))
if missingRules:
for rule in missingRules:
db.recordCreate(AccessRule, rule)
logger.info(f"Created {len(missingRules)} missing AccessRules for sysadmin role")
def _getRoleId(db: DatabaseConnector, roleLabel: str) -> Optional[str]:
"""
Get role ID by label, using cache or database lookup.
@ -688,8 +840,8 @@ def _createDefaultRoleRules(db: DatabaseConnector) -> None:
Create default role rules for generic access (item = null).
Uses roleId instead of roleLabel.
NOTE: No rules for "sysadmin" - SysAdmin is a flag (User.isSysAdmin), not a role!
SysAdmin users bypass RBAC entirely via the isSysAdmin check in getRecordsetWithRBAC().
NOTE: Sysadmin role rules are created separately in _initSysAdminRole().
These default rules cover admin/user/viewer template roles.
Args:
db: Database connector instance
@ -710,19 +862,8 @@ def _createDefaultRoleRules(db: DatabaseConnector) -> None:
delete=AccessLevel.NONE,
))
# User Role - My records only
userId = _getRoleId(db, "user")
if userId:
defaultRules.append(AccessRule(
roleId=userId,
context=AccessRuleContext.DATA,
item=None,
view=True,
read=AccessLevel.MY,
create=AccessLevel.MY,
update=AccessLevel.MY,
delete=AccessLevel.MY,
))
# User Role - No access rights (mandate membership marker only)
# Users get their actual permissions from feature-instance-level roles
# Viewer Role - Read-only group access
viewerId = _getRoleId(db, "viewer")
@ -750,15 +891,15 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None:
These rules override generic rules for specific tables.
Uses roleId instead of roleLabel.
NOTE: No rules for "sysadmin" - SysAdmin is a flag (User.isSysAdmin), not a role!
SysAdmin users bypass RBAC entirely via the isSysAdmin check in getRecordsetWithRBAC().
NOTE: Sysadmin role rules are created separately in _initSysAdminRole().
These table-specific rules cover admin/user/viewer template roles.
Args:
db: Database connector instance
"""
tableRules = []
# Get role IDs (no sysadmin - that's a flag, not a role!)
# Get role IDs for template roles (sysadmin is a separate mandate-level role)
adminId = _getRoleId(db, "admin")
userId = _getRoleId(db, "user")
viewerId = _getRoleId(db, "viewer")
@ -1276,15 +1417,50 @@ def _ensureUiContextRules(db: DatabaseConnector) -> None:
Ensure UI context rules exist for all navigation items.
This is called during bootstrap to add missing UI rules for new navigation items.
Creates rules for BOTH template roles AND mandate-instance roles.
This ensures new navigation items are visible even for mandates created before
the navigation item was added.
Args:
db: Database connector instance
"""
from modules.system.mainSystem import NAVIGATION_SECTIONS
# Template role IDs
adminId = _getRoleId(db, "admin")
userId = _getRoleId(db, "user")
viewerId = _getRoleId(db, "viewer")
# Mandate-instance role IDs (same roleLabel, but mandateId set, featureInstanceId=None)
mandateAdminRoleIds = []
mandateUserRoleIds = []
mandateViewerRoleIds = []
sysadminRoleIds = []
mandateRoles = db.getRecordset(
Role,
recordFilter={"isSystemRole": False, "featureInstanceId": None}
)
for role in mandateRoles:
roleId = role.get("id")
label = role.get("roleLabel")
if not roleId or not label or not role.get("mandateId"):
continue
if label == "admin":
mandateAdminRoleIds.append(roleId)
elif label == "user":
mandateUserRoleIds.append(roleId)
elif label == "viewer":
mandateViewerRoleIds.append(roleId)
elif label == "sysadmin":
sysadminRoleIds.append(roleId)
# All role IDs per level (template + mandate-instance)
# sysadmin gets ALL UI rules (admin-only + public) — same logic, explicit rules
allAdminRoleIds = ([adminId] if adminId else []) + mandateAdminRoleIds + sysadminRoleIds
allUserRoleIds = ([userId] if userId else []) + mandateUserRoleIds
allViewerRoleIds = ([viewerId] if viewerId else []) + mandateViewerRoleIds
# Get existing UI rules
existingUiRules = db.getRecordset(
AccessRule,
@ -1312,19 +1488,20 @@ def _ensureUiContextRules(db: DatabaseConnector) -> None:
isAdminOnly = item.get("adminOnly", False) or isAdminSection
if isAdminOnly:
# Admin-only: only admin role
if adminId and (adminId, objectKey) not in existingCombinations:
missingRules.append(AccessRule(
roleId=adminId,
context=AccessRuleContext.UI,
item=objectKey,
view=True,
read=None, create=None, update=None, delete=None,
))
# Admin-only: all admin roles (template + mandate-instance)
for roleId in allAdminRoleIds:
if (roleId, objectKey) not in existingCombinations:
missingRules.append(AccessRule(
roleId=roleId,
context=AccessRuleContext.UI,
item=objectKey,
view=True,
read=None, create=None, update=None, delete=None,
))
else:
# Public/normal: all roles
for roleId in [adminId, userId, viewerId]:
if roleId and (roleId, objectKey) not in existingCombinations:
# Public/normal: all roles (template + mandate-instance)
for roleId in allAdminRoleIds + allUserRoleIds + allViewerRoleIds:
if (roleId, objectKey) not in existingCombinations:
missingRules.append(AccessRule(
roleId=roleId,
context=AccessRuleContext.UI,
@ -1337,7 +1514,7 @@ def _ensureUiContextRules(db: DatabaseConnector) -> None:
if missingRules:
for rule in missingRules:
db.recordCreate(AccessRule, rule)
logger.info(f"Created {len(missingRules)} missing UI context rules")
logger.info(f"Created {len(missingRules)} missing UI context rules (incl. mandate-instance roles)")
# All UI context rules already exist (nothing to create)
@ -1812,8 +1989,8 @@ def assignInitialUserMemberships(
Assign initial memberships to admin and event users via UserMandate + UserMandateRole.
This is the NEW multi-tenant way of assigning roles.
NOTE: SysAdmin is a flag (User.isSysAdmin), not a role. Initial users get the "admin" role
within the root mandate, plus they have isSysAdmin=True for system-level access.
Hybrid model: Initial users get BOTH the isSysAdmin flag (for system ops)
AND the "admin" + "sysadmin" roles in the root mandate (for RBAC-based admin ops).
Args:
db: Database connector instance
@ -1821,16 +1998,24 @@ def assignInitialUserMemberships(
adminUserId: Admin user ID
eventUserId: Event user ID
"""
# Use mandate-instance "admin" role (not the global template)
mandateAdminRoles = db.getRecordset(
# Find the highest-privilege mandate-level role (prefer "admin", fallback to first available)
mandateRoles = db.getRecordset(
Role,
recordFilter={"roleLabel": "admin", "mandateId": mandateId, "featureInstanceId": None}
recordFilter={"mandateId": mandateId, "featureInstanceId": None}
)
adminRoleId = mandateAdminRoles[0].get("id") if mandateAdminRoles else None
# Prefer "admin" role, fall back to first available mandate role
adminRole = next((r for r in mandateRoles if r.get("roleLabel") == "admin"), None)
adminRoleId = adminRole.get("id") if adminRole else (mandateRoles[0].get("id") if mandateRoles else None)
if not adminRoleId:
logger.warning(f"Admin role not found for mandate {mandateId}, skipping membership assignment")
logger.warning(f"No mandate-level role found for mandate {mandateId}, skipping membership assignment")
return
# Find sysadmin role in root mandate (created by _initSysAdminRole)
sysadminRole = next((r for r in mandateRoles if r.get("roleLabel") == "sysadmin"), None)
sysadminRoleId = sysadminRole.get("id") if sysadminRole else None
if not sysadminRoleId:
logger.warning("Sysadmin role not found in root mandate - run _initSysAdminRole first")
for userId, userName in [(adminUserId, "admin"), (eventUserId, "event")]:
# Check if UserMandate already exists
existingMemberships = db.getRecordset(
@ -1851,7 +2036,7 @@ def assignInitialUserMemberships(
userMandateId = createdMembership.get("id")
logger.info(f"Created UserMandate for {userName} user with ID {userMandateId}")
# Check if UserMandateRole already exists
# Check if UserMandateRole already exists for admin role
existingRoles = db.getRecordset(
UserMandateRole,
recordFilter={"userMandateId": userMandateId, "roleId": adminRoleId}
@ -1865,6 +2050,20 @@ def assignInitialUserMemberships(
)
db.recordCreate(UserMandateRole, userMandateRole)
logger.info(f"Assigned admin role to {userName} user in mandate")
# Assign sysadmin role (in addition to admin role)
if sysadminRoleId:
existingSysadminRoles = db.getRecordset(
UserMandateRole,
recordFilter={"userMandateId": userMandateId, "roleId": sysadminRoleId}
)
if not existingSysadminRoles:
sysadminMandateRole = UserMandateRole(
userMandateId=userMandateId,
roleId=sysadminRoleId
)
db.recordCreate(UserMandateRole, sysadminMandateRole)
logger.info(f"Assigned sysadmin role to {userName} user in root mandate")
def _getPasswordHash(password: Optional[str]) -> Optional[str]:

View file

@ -74,6 +74,7 @@ class AppObjects:
self.currentUser = currentUser # Store User object directly
self.userId = currentUser.id if currentUser else None
self.mandateId = None # mandateId comes from setUserContext, not from User
self.featureInstanceId = None # featureInstanceId comes from setUserContext
# Initialize database
self._initializeDatabase()
@ -501,34 +502,30 @@ class AppObjects:
def getUsersByMandate(self, mandateId: str, pagination: Optional[PaginationParams] = None) -> Union[List[User], PaginatedResult]:
"""
Returns users for a specific mandate if user has access.
Supports optional pagination, sorting, and filtering.
For SYSADMIN, returns all users regardless of mandate.
Returns users for a specific mandate.
Uses UserMandate junction table to find users belonging to the mandate.
Args:
mandateId: The mandate ID to get users for (ignored for SYSADMIN)
mandateId: The mandate ID to get users for
pagination: Optional pagination parameters. If None, returns all items.
Returns:
If pagination is None: List[User]
If pagination is provided: PaginatedResult with items and metadata
"""
# Use RBAC filtering
users = getRecordsetWithRBAC(
self.db,
UserInDB,
self.currentUser,
recordFilter={"mandateId": mandateId} if mandateId else None
)
# Get user IDs via UserMandate junction table (UserInDB has no mandateId column)
userMandates = self.db.getRecordset(UserMandate, recordFilter={"mandateId": mandateId})
userIds = [um.get("userId") for um in userMandates if um.get("userId")]
# Filter out database-specific fields and normalize data
# Fetch each user by ID
filteredUsers = []
for user in users:
cleanedUser = {k: v for k, v in user.items() if not k.startswith("_")}
# Ensure roleLabels is always a list, not None
if cleanedUser.get("roleLabels") is None:
cleanedUser["roleLabels"] = []
filteredUsers.append(cleanedUser)
for userId in userIds:
userRecords = self.db.getRecordset(UserInDB, recordFilter={"id": userId})
if userRecords:
cleanedUser = {k: v for k, v in userRecords[0].items() if not k.startswith("_")}
if cleanedUser.get("roleLabels") is None:
cleanedUser["roleLabels"] = []
filteredUsers.append(cleanedUser)
# If no pagination requested, return all items
if pagination is None:
@ -572,7 +569,8 @@ class AppObjects:
users = getRecordsetWithRBAC(self.db,
UserInDB,
self.currentUser,
recordFilter={"username": username}
recordFilter={"username": username},
mandateId=self.mandateId
)
if not users:
@ -599,7 +597,8 @@ class AppObjects:
users = getRecordsetWithRBAC(self.db,
UserInDB,
self.currentUser,
recordFilter={"id": userId}
recordFilter={"id": userId},
mandateId=self.mandateId
)
if not users:
@ -1202,7 +1201,8 @@ class AppObjects:
users = getRecordsetWithRBAC(self.db,
UserInDB,
self.currentUser,
recordFilter={"id": initialUserId}
recordFilter={"id": initialUserId},
mandateId=self.mandateId
)
return users[0] if users else None
except Exception as e:
@ -1384,7 +1384,7 @@ class AppObjects:
If pagination is provided: PaginatedResult with items and metadata
"""
# Use RBAC filtering
allMandates = getRecordsetWithRBAC(self.db, Mandate, self.currentUser)
allMandates = getRecordsetWithRBAC(self.db, Mandate, self.currentUser, mandateId=self.mandateId)
# Filter out database-specific fields
filteredMandates = []
@ -1428,7 +1428,8 @@ class AppObjects:
mandates = getRecordsetWithRBAC(self.db,
Mandate,
self.currentUser,
recordFilter={"id": mandateId}
recordFilter={"id": mandateId},
mandateId=self.mandateId
)
if not mandates:
@ -1968,6 +1969,7 @@ class AppObjects:
def createFeatureAccess(self, userId: str, featureInstanceId: str, roleIds: List[str] = None) -> FeatureAccess:
"""
Create a FeatureAccess record (grant user access to feature instance).
Also auto-assigns the user to the mandate with the 'user' role if not already a member.
Args:
userId: User ID
@ -1983,6 +1985,9 @@ class AppObjects:
if existing:
raise ValueError(f"User {userId} already has access to feature instance {featureInstanceId}")
# Auto-assign user to mandate with 'user' role if not already a member
self._ensureUserMandateMembership(userId, featureInstanceId)
# Create FeatureAccess
featureAccess = FeatureAccess(
userId=userId,
@ -2007,6 +2012,45 @@ class AppObjects:
logger.error(f"Error creating FeatureAccess: {e}")
raise ValueError(f"Failed to create FeatureAccess: {e}")
def _ensureUserMandateMembership(self, userId: str, featureInstanceId: str) -> None:
"""
Ensure user is a member of the mandate that owns the feature instance.
If not already a member, adds them with the 'user' role (no access rights, membership only).
"""
try:
from modules.interfaces.interfaceFeatures import getFeatureInterface
featureInterface = getFeatureInterface(self.db)
instance = featureInterface.getFeatureInstance(featureInstanceId)
if not instance or not instance.mandateId:
logger.warning(f"Cannot auto-assign mandate: feature instance {featureInstanceId} not found or has no mandateId")
return
mandateId = str(instance.mandateId)
# Check if user already has mandate membership
existing = self.getUserMandate(userId, mandateId)
if existing:
logger.debug(f"User {userId} already member of mandate {mandateId}")
return
# Find the mandate-level 'user' role (membership marker, no access rights)
userRoles = self.db.getRecordset(
Role,
recordFilter={"roleLabel": "user", "mandateId": mandateId, "featureInstanceId": None}
)
userRoleId = userRoles[0].get("id") if userRoles else None
roleIds = [userRoleId] if userRoleId else []
self.createUserMandate(userId, mandateId, roleIds)
logger.info(f"Auto-assigned user {userId} to mandate {mandateId} with 'user' role (via feature instance {featureInstanceId})")
except ValueError:
# createUserMandate raises ValueError if already exists - safe to ignore
pass
except Exception as e:
logger.error(f"Error auto-assigning user {userId} to mandate: {e}")
def getRoleIdsForFeatureAccess(self, featureAccessId: str) -> List[str]:
"""
Get all role IDs assigned to a FeatureAccess.
@ -2027,6 +2071,34 @@ class AppObjects:
logger.error(f"Error getting role IDs for FeatureAccess: {e}")
return []
def addRoleToFeatureAccess(self, featureAccessId: str, roleId: str) -> None:
"""
Add a role to a FeatureAccess (via junction table).
Skips if the role is already assigned.
Args:
featureAccessId: FeatureAccess ID
roleId: Role ID to add
"""
try:
# Check if already exists
existing = self.db.getRecordset(
FeatureAccessRole,
recordFilter={"featureAccessId": featureAccessId, "roleId": roleId}
)
if existing:
return # Already assigned
featureAccessRole = FeatureAccessRole(
featureAccessId=featureAccessId,
roleId=roleId
)
self.db.recordCreate(FeatureAccessRole, featureAccessRole.model_dump())
logger.debug(f"Added role {roleId} to FeatureAccess {featureAccessId}")
except Exception as e:
logger.error(f"Error adding role to FeatureAccess: {e}")
raise ValueError(f"Failed to add role to FeatureAccess: {e}")
def deleteFeatureAccessRoles(self, featureAccessId: str) -> int:
"""
Delete all FeatureAccessRole records for a FeatureAccess.
@ -2962,7 +3034,8 @@ class AppObjects:
self.db,
AccessRule,
self.currentUser,
recordFilter=recordFilter if recordFilter else None
recordFilter=recordFilter if recordFilter else None,
mandateId=self.mandateId
)
# Filter out database-specific fields

View file

@ -966,20 +966,22 @@ class BillingObjects:
Returns:
List of BillingBalanceResponse
"""
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
from modules.interfaces.interfaceDbApp import getRootInterface
balances = []
try:
appInterface = getAppInterface(self.currentUser)
userMandates = appInterface.getUserMandates(userId)
# Use rootInterface (privileged, SysAdmin context) to bypass RBAC
# for mandate/user lookups. User access is verified via UserMandate membership.
rootInterface = getRootInterface()
userMandates = rootInterface.getUserMandates(userId)
for um in userMandates:
mandateId = getattr(um, 'mandateId', None) or (um.get("mandateId") if isinstance(um, dict) else None)
if not mandateId:
continue
mandate = appInterface.getMandate(mandateId)
mandate = rootInterface.getMandate(mandateId)
if not mandate:
continue
@ -995,14 +997,14 @@ class BillingObjects:
# Determine effective balance based on billing model
if billingModel == BillingModelEnum.PREPAY_USER:
account = self.getUserAccount(mandateId, userId)
account = self.getOrCreateUserAccount(mandateId, userId)
if not account:
continue
balance = account.get("balance", 0.0)
warningThreshold = account.get("warningThreshold", 0.0)
creditLimit = account.get("creditLimit")
elif billingModel in [BillingModelEnum.PREPAY_MANDATE, BillingModelEnum.CREDIT_POSTPAY]:
poolAccount = self.getMandateAccount(mandateId)
poolAccount = self.getOrCreateMandateAccount(mandateId)
if not poolAccount:
continue
balance = poolAccount.get("balance", 0.0)

View file

@ -42,7 +42,7 @@ logger = logging.getLogger(__name__)
_chatInterfaces = {}
def storeDebugMessageAndDocuments(message, currentUser) -> None:
def storeDebugMessageAndDocuments(message, currentUser, mandateId=None, featureInstanceId=None) -> None:
"""
Store message and documents (metadata and file bytes) for debugging purposes.
Structure: {log_dir}/debug/messages/m_round_task_action_timestamp/documentlist_label/
@ -53,6 +53,8 @@ def storeDebugMessageAndDocuments(message, currentUser) -> None:
Args:
message: ChatMessage object to store
currentUser: Current user for component interface access
mandateId: Mandate ID for RBAC context (avoids overwriting singleton state)
featureInstanceId: Feature instance ID for RBAC context
"""
try:
import os
@ -152,7 +154,7 @@ def storeDebugMessageAndDocuments(message, currentUser) -> None:
# Also store the actual file bytes next to metadata for debugging
try:
componentInterface = getInterface(currentUser)
componentInterface = getInterface(currentUser, mandateId=mandateId, featureInstanceId=featureInstanceId)
file_bytes = componentInterface.getFileData(doc.fileId)
if file_bytes:
# Build a safe filename preserving original name
@ -715,8 +717,13 @@ class ChatObjects:
logger.debug(f"createWorkflow: Using Root mandate {effectiveMandateId}")
except Exception as e:
logger.warning(f"Could not get Root mandate: {e}")
# Note: Chat data is user-owned, no mandate/featureInstance context stored
# mandateId/featureInstanceId removed from ChatWorkflow model
# Note: ChatWorkflow has featureInstanceId for multi-tenancy isolation.
# Child tables (ChatMessage, ChatLog, ChatStat, ChatDocument) are user-owned
# and do NOT store featureInstanceId - they inherit isolation from ChatWorkflow.
# Ensure featureInstanceId is set from context if not already in workflowData
if "featureInstanceId" not in workflowData or not workflowData.get("featureInstanceId"):
if self.featureInstanceId:
workflowData["featureInstanceId"] = self.featureInstanceId
# Use generic field separation based on ChatWorkflow model
simpleFields, objectFields = self._separateObjectFields(ChatWorkflow, workflowData)
@ -726,7 +733,6 @@ class ChatObjects:
# Convert to ChatWorkflow model (empty related data for new workflow)
# Note: Chat data is user-owned, no mandate/featureInstance fields
return ChatWorkflow(
id=created["id"],
status=created.get("status", "running"),
@ -1122,7 +1128,7 @@ class ChatObjects:
logger.debug(f"Could not emit message event: {e}")
# Debug: Store message and documents for debugging - only if debug enabled
storeDebugMessageAndDocuments(chat_message, self.currentUser)
storeDebugMessageAndDocuments(chat_message, self.currentUser, mandateId=self.mandateId, featureInstanceId=self.featureInstanceId)
return chat_message

View file

@ -593,7 +593,11 @@ class ComponentObjects:
# Prompt methods
def _isSysAdmin(self) -> bool:
"""Check if the current user is a SysAdmin."""
"""Check if the current user has sysadmin role (or isSysAdmin flag as fallback)."""
from modules.auth.authentication import _hasSysAdminRole
userId = getattr(self.currentUser, 'id', None)
if userId and _hasSysAdminRole(str(userId)):
return True
return hasattr(self.currentUser, 'isSysAdmin') and self.currentUser.isSysAdmin
def _enrichPromptsWithPermissions(self, prompts: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
@ -1005,7 +1009,8 @@ class ComponentObjects:
# Get all files filtered by RBAC (will be filtered by user's access level)
files = getRecordsetWithRBAC(self.db,
FileItem,
self.currentUser
self.currentUser,
mandateId=self.mandateId
)
# Check if fileName exists (excluding the current file if updating)
@ -1208,7 +1213,7 @@ class ComponentObjects:
logger.warning(f"No access to file ID {fileId}")
return None
fileDataEntries = getRecordsetWithRBAC(self.db, FileData, self.currentUser, recordFilter={"id": fileId})
fileDataEntries = getRecordsetWithRBAC(self.db, FileData, self.currentUser, recordFilter={"id": fileId}, mandateId=self.mandateId)
if not fileDataEntries:
logger.warning(f"No data found for file ID {fileId}")
return None
@ -1367,7 +1372,8 @@ class ComponentObjects:
filteredSettings = getRecordsetWithRBAC(self.db,
VoiceSettings,
self.currentUser,
recordFilter={"userId": targetUserId}
recordFilter={"userId": targetUserId},
mandateId=self.mandateId
)
if not filteredSettings:
@ -1510,7 +1516,8 @@ class ComponentObjects:
try:
filteredSubscriptions = getRecordsetWithRBAC(self.db,
MessagingSubscription,
self.currentUser
self.currentUser,
mandateId=self.mandateId
)
if pagination is None:
@ -1547,7 +1554,8 @@ class ComponentObjects:
filteredSubscriptions = getRecordsetWithRBAC(self.db,
MessagingSubscription,
self.currentUser,
recordFilter={"subscriptionId": subscriptionId}
recordFilter={"subscriptionId": subscriptionId},
mandateId=self.mandateId
)
return MessagingSubscription(**filteredSubscriptions[0]) if filteredSubscriptions else None
@ -1556,7 +1564,8 @@ class ComponentObjects:
filteredSubscriptions = getRecordsetWithRBAC(self.db,
MessagingSubscription,
self.currentUser,
recordFilter={"id": id}
recordFilter={"id": id},
mandateId=self.mandateId
)
return MessagingSubscription(**filteredSubscriptions[0]) if filteredSubscriptions else None
@ -1629,7 +1638,8 @@ class ComponentObjects:
filteredRegistrations = getRecordsetWithRBAC(self.db,
MessagingSubscriptionRegistration,
self.currentUser,
recordFilter=recordFilter if recordFilter else None
recordFilter=recordFilter if recordFilter else None,
mandateId=self.mandateId
)
if pagination is None:
@ -1666,7 +1676,8 @@ class ComponentObjects:
filteredRegistrations = getRecordsetWithRBAC(self.db,
MessagingSubscriptionRegistration,
self.currentUser,
recordFilter={"id": registrationId}
recordFilter={"id": registrationId},
mandateId=self.mandateId
)
return MessagingSubscriptionRegistration(**filteredRegistrations[0]) if filteredRegistrations else None

View file

@ -163,13 +163,21 @@ def getRecordsetWithRBAC(
# Check view permission first
if not permissions.view:
logger.debug(f"User {currentUser.id} has no view permission for {objectKey} (mandateId={effectiveMandateId}, featureInstanceId={featureInstanceId})")
return []
# Build WHERE clause with RBAC filtering
whereConditions = []
whereValues = []
# CRITICAL: Only pass featureInstanceId to WHERE clause if the model actually has
# this column. Chat child tables (ChatMessage, ChatLog, ChatStat, ChatDocument)
# are user-owned and do NOT have featureInstanceId - only ChatWorkflow does.
# Without this check, the SQL query would reference a non-existent column,
# causing a silent error that returns empty results.
featureInstanceIdForQuery = featureInstanceId
if featureInstanceId and hasattr(modelClass, 'model_fields') and "featureInstanceId" not in modelClass.model_fields:
featureInstanceIdForQuery = None
# Add RBAC WHERE clause based on read permission
rbacWhereClause = buildRbacWhereClause(
permissions,
@ -177,7 +185,7 @@ def getRecordsetWithRBAC(
table,
connector,
mandateId=effectiveMandateId,
featureInstanceId=featureInstanceId
featureInstanceId=featureInstanceIdForQuery
)
if rbacWhereClause:
whereConditions.append(rbacWhereClause["condition"])

View file

@ -2,7 +2,7 @@
# All rights reserved.
"""
Admin automation events routes for the backend API.
Sysadmin-only endpoints for viewing and controlling automation events.
Sysadmin-only endpoints for viewing and controlling scheduler events.
"""
from fastapi import APIRouter, HTTPException, Depends, Path, Request, Response
@ -12,7 +12,7 @@ import logging
# Import interfaces and models from feature containers
import modules.features.automation.interfaceFeatureAutomation as interfaceAutomation
from modules.auth import limiter, getRequestContext, requireSysAdmin, RequestContext
from modules.auth import limiter, getRequestContext, requireSysAdminRole, RequestContext
from modules.datamodels.datamodelUam import User
# Configure logger
@ -35,27 +35,110 @@ router = APIRouter(
@limiter.limit("30/minute")
def get_all_automation_events(
request: Request,
currentUser: User = Depends(requireSysAdmin)
currentUser: User = Depends(requireSysAdminRole)
) -> List[Dict[str, Any]]:
"""
Get all automation events across all mandates (sysadmin only).
Returns list of all registered events with their automation IDs and schedules.
Get all active scheduler jobs (sysadmin only).
Each job is enriched with context from its automation definition
(name, mandate, feature instance, creator) for readability.
"""
try:
from modules.shared.eventManagement import eventManager
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.services import getInterface as getServices
# Get all jobs from scheduler
if not eventManager.scheduler:
return []
# 1. Collect all scheduler jobs
jobs = []
if eventManager.scheduler:
for job in eventManager.scheduler.get_jobs():
if job.id.startswith("automation."):
automation_id = job.id.replace("automation.", "")
jobs.append({
"eventId": job.id,
"automationId": automation_id,
"nextRunTime": str(job.next_run_time) if job.next_run_time else None,
"trigger": str(job.trigger) if job.trigger else None
})
automationIds = []
for job in eventManager.scheduler.get_jobs():
if job.id.startswith("automation."):
automationId = job.id.replace("automation.", "")
automationIds.append(automationId)
jobs.append({
"eventId": job.id,
"automationId": automationId,
"nextRunTime": str(job.next_run_time) if job.next_run_time else None,
"trigger": str(job.trigger) if job.trigger else None,
"name": "",
"createdBy": "",
"mandate": "",
"featureInstance": ""
})
# 2. Enrich with context from automation definitions
if jobs:
try:
rootInterface = getRootInterface()
eventUser = rootInterface.getUserByUsername("event")
if eventUser:
services = getServices(currentUser, None)
allAutomations = services.interfaceDbAutomation.getAllAutomationDefinitionsWithRBAC(eventUser)
# Build lookup by automation ID
automationLookup = {}
for a in allAutomations:
aId = a.get("id", "") if isinstance(a, dict) else getattr(a, "id", "")
automationLookup[aId] = a
# Caches for resolving UUIDs to names
_userCache = {}
_mandateCache = {}
_featureCache = {}
def _resolveUsername(userId):
if not userId:
return ""
if userId not in _userCache:
try:
user = rootInterface.getUser(userId)
_userCache[userId] = user.username if user else userId[:8]
except Exception:
_userCache[userId] = userId[:8]
return _userCache[userId]
def _resolveMandateLabel(mandateId):
if not mandateId:
return ""
if mandateId not in _mandateCache:
try:
mandate = rootInterface.getMandate(mandateId)
_mandateCache[mandateId] = getattr(mandate, "label", None) or mandateId[:8]
except Exception:
_mandateCache[mandateId] = mandateId[:8]
return _mandateCache[mandateId]
def _resolveFeatureLabel(featureInstanceId):
if not featureInstanceId:
return ""
if featureInstanceId not in _featureCache:
try:
instance = rootInterface.getFeatureInstance(featureInstanceId)
_featureCache[featureInstanceId] = getattr(instance, "label", None) or getattr(instance, "featureCode", None) or featureInstanceId[:8]
except Exception:
_featureCache[featureInstanceId] = featureInstanceId[:8]
return _featureCache[featureInstanceId]
# Enrich each job
for job in jobs:
automation = automationLookup.get(job["automationId"])
if automation:
if isinstance(automation, dict):
job["name"] = automation.get("label", "")
job["createdBy"] = _resolveUsername(automation.get("_createdBy", ""))
job["mandate"] = _resolveMandateLabel(automation.get("mandateId", ""))
job["featureInstance"] = _resolveFeatureLabel(automation.get("featureInstanceId", ""))
else:
job["name"] = getattr(automation, "label", "")
job["createdBy"] = _resolveUsername(getattr(automation, "_createdBy", ""))
job["mandate"] = _resolveMandateLabel(getattr(automation, "mandateId", ""))
job["featureInstance"] = _resolveFeatureLabel(getattr(automation, "featureInstanceId", ""))
else:
job["name"] = "(orphaned)"
except Exception as e:
logger.warning(f"Could not enrich automation events with context: {e}")
return jobs
except Exception as e:
@ -69,7 +152,7 @@ def get_all_automation_events(
@limiter.limit("5/minute")
async def sync_all_automation_events(
request: Request,
currentUser: User = Depends(requireSysAdmin)
currentUser: User = Depends(requireSysAdminRole)
) -> Dict[str, Any]:
"""
Manually trigger sync for all automations (sysadmin only).
@ -110,25 +193,26 @@ async def sync_all_automation_events(
def remove_event(
request: Request,
eventId: str = Path(..., description="Event ID to remove"),
currentUser: User = Depends(requireSysAdmin)
currentUser: User = Depends(requireSysAdminRole)
) -> Dict[str, Any]:
"""
Manually remove a specific event from scheduler (sysadmin only).
Used for debugging and manual event cleanup.
Remove a scheduler job (sysadmin only).
Removes the job from the scheduler and clears the eventId on the automation definition.
Does NOT delete the automation definition itself.
"""
try:
from modules.shared.eventManagement import eventManager
# Remove event
# Remove scheduler job
eventManager.remove(eventId)
# Update automation's eventId if it exists
# Clear eventId on the automation definition (so it can be re-synced later)
if eventId.startswith("automation."):
automation_id = eventId.replace("automation.", "")
automationId = eventId.replace("automation.", "")
automationInterface = interfaceAutomation.getInterface(currentUser)
automation = automationInterface.getAutomationDefinition(automation_id)
automation = automationInterface.getAutomationDefinition(automationId)
if automation and getattr(automation, "eventId", None) == eventId:
automationInterface.updateAutomationDefinition(automation_id, {"eventId": None})
automationInterface.updateAutomationDefinition(automationId, {"eventId": None})
return {
"success": True,

View file

@ -16,7 +16,7 @@ from fastapi import status
import logging
from pydantic import BaseModel, Field
from modules.auth import limiter, getRequestContext, RequestContext, requireSysAdmin
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
@ -337,7 +337,7 @@ def create_feature(
code: str = Query(..., description="Unique feature code"),
label: Dict[str, str] = None,
icon: str = Query("mdi-puzzle", description="Icon identifier"),
sysAdmin: User = Depends(requireSysAdmin)
sysAdmin: User = Depends(requireSysAdminRole)
) -> Dict[str, Any]:
"""
Create a new feature definition.
@ -453,7 +453,7 @@ def get_feature_instance(
# Verify mandate access (unless SysAdmin)
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
if not context.isSysAdmin:
if not context.hasSysAdminRole:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this feature instance"
@ -567,14 +567,14 @@ def delete_feature_instance(
# Verify mandate access
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
if not context.isSysAdmin:
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.isSysAdmin:
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"
@ -634,14 +634,14 @@ def updateFeatureInstance(
# Verify mandate access
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
if not context.isSysAdmin:
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.isSysAdmin:
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"
@ -712,14 +712,14 @@ def sync_instance_roles(
# Verify mandate access
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
if not context.isSysAdmin:
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.isSysAdmin:
if not _hasMandateAdminRole(context) and not context.hasSysAdminRole:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin role required to sync roles"
@ -752,7 +752,7 @@ def sync_instance_roles(
def list_template_roles(
request: Request,
featureCode: Optional[str] = Query(None, description="Filter by feature code"),
sysAdmin: User = Depends(requireSysAdmin)
sysAdmin: User = Depends(requireSysAdminRole)
) -> List[Dict[str, Any]]:
"""
List global template roles.
@ -784,7 +784,7 @@ def create_template_role(
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)
sysAdmin: User = Depends(requireSysAdminRole)
) -> Dict[str, Any]:
"""
Create a global template role for a feature.
@ -891,7 +891,7 @@ def list_feature_instance_users(
# Verify mandate access (unless SysAdmin)
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
if not context.isSysAdmin:
if not context.hasSysAdminRole:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this feature instance"
@ -971,14 +971,14 @@ def add_user_to_feature_instance(
# Verify mandate access
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
if not context.isSysAdmin:
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.isSysAdmin:
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"
@ -1072,14 +1072,14 @@ def remove_user_from_feature_instance(
# Verify mandate access
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
if not context.isSysAdmin:
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.isSysAdmin:
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"
@ -1153,14 +1153,14 @@ def update_feature_instance_user_roles(
# Verify mandate access
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
if not context.isSysAdmin:
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.isSysAdmin:
if not _hasMandateAdminRole(context) and not context.hasSysAdminRole:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin role required to update user roles"
@ -1243,7 +1243,7 @@ def get_feature_instance_available_roles(
# Verify mandate access
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
if not context.isSysAdmin:
if not context.hasSysAdminRole:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this feature instance"
@ -1326,7 +1326,7 @@ def _hasMandateAdminRole(context: RequestContext) -> bool:
A user is mandate admin if they have the 'admin' role at mandate level.
"""
if context.isSysAdmin:
if context.hasSysAdminRole:
return True
if not context.roleIds:
@ -1341,7 +1341,7 @@ def _hasMandateAdminRole(context: RequestContext) -> bool:
if role:
roleLabel = role.roleLabel
# Admin role at mandate level (not feature-instance level)
if roleLabel == "admin" and role.mandateId and not role.featureInstanceId:
if roleLabel == "admin" and not role.featureInstanceId:
return True
return False

View file

@ -18,7 +18,7 @@ import logging
import json
from pydantic import BaseModel, Field
from modules.auth import limiter, getRequestContext, RequestContext, requireSysAdmin
from modules.auth import limiter, getRequestContext, RequestContext, requireSysAdminRole
from modules.datamodels.datamodelUam import User
from modules.datamodels.datamodelRbac import Role, AccessRule
from modules.interfaces.interfaceDbApp import getRootInterface
@ -74,7 +74,7 @@ class RbacImportResult(BaseModel):
@limiter.limit("10/minute")
def export_global_rbac(
request: Request,
sysAdmin: User = Depends(requireSysAdmin)
sysAdmin: User = Depends(requireSysAdminRole)
) -> RbacExportData:
"""
Export global (template) RBAC rules.
@ -139,7 +139,7 @@ async def import_global_rbac(
request: Request,
file: UploadFile = File(..., description="JSON file with RBAC export data"),
updateExisting: bool = False,
sysAdmin: User = Depends(requireSysAdmin)
sysAdmin: User = Depends(requireSysAdminRole)
) -> RbacImportResult:
"""
Import global (template) RBAC rules.
@ -538,7 +538,7 @@ def _hasMandateAdminRole(context: RequestContext) -> bool:
"""
Check if the user has mandate admin role in the current context.
"""
if context.isSysAdmin:
if context.hasSysAdminRole:
return True
if not context.roleIds:
@ -552,7 +552,7 @@ def _hasMandateAdminRole(context: RequestContext) -> bool:
if role:
roleLabel = role.roleLabel
# Admin role at mandate level
if roleLabel == "admin" and role.mandateId and not role.featureInstanceId:
if roleLabel == "admin" and not role.featureInstanceId:
return True
return False

View file

@ -4,16 +4,19 @@
Admin RBAC Roles Management routes.
Provides endpoints for managing roles and role assignments to users.
MULTI-TENANT: These are SYSTEM-LEVEL operations requiring isSysAdmin=true.
Roles are global system resources, not mandate-specific.
MULTI-TENANT: Context-aware access control.
- SysAdmin: Full access to all roles and assignments across all mandates.
- MandateAdmin: Can manage roles and assignments within their own mandates.
Template roles (mandateId=None, isSystemRole=True) are read-only.
The sysadmin role (roleLabel="sysadmin") is not manageable by MandateAdmins.
Role assignments are managed via UserMandateRole (not User.roleLabels).
"""
from fastapi import APIRouter, HTTPException, Depends, Query, Body, Path, Request
from fastapi import APIRouter, HTTPException, Depends, Query, Body, Path, Request, status
from typing import List, Dict, Any, Optional, Set
import logging
from modules.auth import limiter, requireSysAdmin
from modules.auth import limiter, requireSysAdminRole, getRequestContext, RequestContext
from modules.datamodels.datamodelUam import User, UserInDB
from modules.datamodels.datamodelRbac import Role
from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole
@ -59,6 +62,31 @@ def _hasRoleLabel(interface, userId: str, roleLabel: str) -> bool:
"""
return roleLabel in _getUserRoleLabels(interface, userId)
def _getAdminMandateIds(context: RequestContext) -> List[str]:
"""Get mandate IDs where the user has admin role."""
mandateIds = []
try:
rootInterface = getRootInterface()
userMandates = rootInterface.getUserMandates(str(context.user.id))
for um in userMandates:
if not getattr(um, 'enabled', True):
continue
umId = getattr(um, 'id', None)
mandateId = getattr(um, 'mandateId', None)
if not umId or not mandateId:
continue
roleIds = rootInterface.getRoleIdsForUserMandate(str(umId))
for roleId in roleIds:
role = rootInterface.getRole(roleId)
if role and role.roleLabel == "admin" and not role.featureInstanceId:
mandateIds.append(str(mandateId))
break
except Exception as e:
logger.error(f"Error getting admin mandate IDs: {e}")
return mandateIds
router = APIRouter(
prefix="/api/admin/rbac/roles",
tags=["Admin RBAC Roles"],
@ -71,20 +99,32 @@ router = APIRouter(
def list_roles(
request: Request,
mandateId: Optional[str] = Query(None, description="Filter roles by mandate ID"),
currentUser: User = Depends(requireSysAdmin)
context: RequestContext = Depends(getRequestContext)
) -> List[Dict[str, Any]]:
"""
Get list of roles with metadata.
Context-aware: SysAdmin sees all roles. MandateAdmin sees roles from own mandates
plus template roles (read-only).
Without mandateId: returns system template roles (mandateId=NULL).
With mandateId: returns mandate-level roles for that mandate (featureInstanceId=NULL).
"""
isSysAdmin = context.hasSysAdminRole
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(context)
if not isSysAdmin and not adminMandateIds:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required")
currentUser = context.user # backward compat for existing code
try:
interface = getRootInterface()
# Get roles filtered by scope
print(f"[DEBUG list_roles] mandateId={mandateId}")
if mandateId:
# MandateAdmin can only query mandates they admin
if not isSysAdmin and mandateId not in adminMandateIds:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to this mandate")
# Mandate-specific roles (mandate-level only, no feature-instance roles)
dbRoles = interface.getRolesForMandate(mandateId)
print(f"[DEBUG list_roles] getRolesForMandate returned {len(dbRoles)} roles")
@ -92,6 +132,9 @@ def list_roles(
# System template roles only
dbRoles = interface.getAllRoles()
print(f"[DEBUG list_roles] getAllRoles returned {len(dbRoles)} roles")
# MandateAdmin: filter to template roles + roles from own mandates
if not isSysAdmin:
dbRoles = [r for r in dbRoles if r.mandateId is None or str(r.mandateId) in adminMandateIds]
# Count role assignments from UserMandateRole table
roleCounts = interface.countRoleAssignments()
@ -125,21 +168,32 @@ def list_roles(
@limiter.limit("60/minute")
def get_role_options(
request: Request,
currentUser: User = Depends(requireSysAdmin)
context: RequestContext = Depends(getRequestContext)
) -> List[Dict[str, Any]]:
"""
Get role options for select dropdowns.
MULTI-TENANT: SysAdmin-only.
Context-aware: SysAdmin sees all roles. MandateAdmin sees roles from own mandates
plus template roles.
Returns:
- List of role option dictionaries with value and label
"""
isSysAdmin = context.hasSysAdminRole
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(context)
if not isSysAdmin and not adminMandateIds:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required")
currentUser = context.user # backward compat for existing code
try:
interface = getRootInterface()
# Get all roles from database
dbRoles = interface.getAllRoles()
# MandateAdmin: filter to template roles + roles from own mandates
if not isSysAdmin:
dbRoles = [r for r in dbRoles if r.mandateId is None or str(r.mandateId) in adminMandateIds]
# Convert to options format
options = []
for role in dbRoles:
@ -167,11 +221,12 @@ def get_role_options(
def create_role(
request: Request,
role: Role = Body(...),
currentUser: User = Depends(requireSysAdmin)
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""
Create a new role.
MULTI-TENANT: SysAdmin-only (roles are system resources).
Context-aware: SysAdmin can create any role. MandateAdmin can create roles
within own mandates only (not template or sysadmin roles).
Request Body:
- role: Role object to create
@ -179,9 +234,24 @@ def create_role(
Returns:
- Created role dictionary
"""
isSysAdmin = context.hasSysAdminRole
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(context)
if not isSysAdmin and not adminMandateIds:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required")
currentUser = context.user # backward compat for existing code
try:
interface = getRootInterface()
# MandateAdmin restrictions
if not isSysAdmin:
if role.roleLabel == "sysadmin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot create sysadmin role")
if role.mandateId is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot create template roles")
if str(role.mandateId) not in adminMandateIds:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to this mandate")
createdRole = interface.createRole(role)
return {
@ -211,11 +281,12 @@ def create_role(
def get_role(
request: Request,
roleId: str = Path(..., description="Role ID"),
currentUser: User = Depends(requireSysAdmin)
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""
Get a role by ID.
MULTI-TENANT: SysAdmin-only.
Context-aware: SysAdmin sees all. MandateAdmin sees roles from own mandates
plus template roles (read-only).
Path Parameters:
- roleId: Role ID
@ -223,6 +294,12 @@ def get_role(
Returns:
- Role dictionary
"""
isSysAdmin = context.hasSysAdminRole
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(context)
if not isSysAdmin and not adminMandateIds:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required")
currentUser = context.user # backward compat for existing code
try:
interface = getRootInterface()
@ -233,6 +310,11 @@ def get_role(
detail=f"Role {roleId} not found"
)
# MandateAdmin: can view template roles (read-only) or own mandate roles
if not isSysAdmin:
if role.mandateId is not None and str(role.mandateId) not in adminMandateIds:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to this role")
return {
"id": role.id,
"roleLabel": role.roleLabel,
@ -256,11 +338,12 @@ def update_role(
request: Request,
roleId: str = Path(..., description="Role ID"),
role: Role = Body(...),
currentUser: User = Depends(requireSysAdmin)
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""
Update an existing role.
MULTI-TENANT: SysAdmin-only.
Context-aware: SysAdmin can update any role. MandateAdmin can update roles
within own mandates only. Template roles and sysadmin role are blocked.
Path Parameters:
- roleId: Role ID
@ -271,9 +354,27 @@ def update_role(
Returns:
- Updated role dictionary
"""
isSysAdmin = context.hasSysAdminRole
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(context)
if not isSysAdmin and not adminMandateIds:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required")
currentUser = context.user # backward compat for existing code
try:
interface = getRootInterface()
# MandateAdmin restrictions: check existing role before updating
if not isSysAdmin:
existingRole = interface.getRole(roleId)
if not existingRole:
raise HTTPException(status_code=404, detail=f"Role {roleId} not found")
if existingRole.roleLabel == "sysadmin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot modify sysadmin role")
if existingRole.mandateId is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot modify template roles")
if str(existingRole.mandateId) not in adminMandateIds:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to this role")
updatedRole = interface.updateRole(roleId, role)
return {
@ -303,11 +404,12 @@ def update_role(
def delete_role(
request: Request,
roleId: str = Path(..., description="Role ID"),
currentUser: User = Depends(requireSysAdmin)
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, str]:
"""
Delete a role.
MULTI-TENANT: SysAdmin-only.
Context-aware: SysAdmin can delete any role. MandateAdmin can delete roles
within own mandates only. Template roles and sysadmin role are blocked.
Path Parameters:
- roleId: Role ID
@ -315,9 +417,27 @@ def delete_role(
Returns:
- Success message
"""
isSysAdmin = context.hasSysAdminRole
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(context)
if not isSysAdmin and not adminMandateIds:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required")
currentUser = context.user # backward compat for existing code
try:
interface = getRootInterface()
# MandateAdmin restrictions: check existing role before deleting
if not isSysAdmin:
existingRole = interface.getRole(roleId)
if not existingRole:
raise HTTPException(status_code=404, detail=f"Role {roleId} not found")
if existingRole.roleLabel == "sysadmin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot delete sysadmin role")
if existingRole.mandateId is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot delete template roles")
if str(existingRole.mandateId) not in adminMandateIds:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to this role")
success = interface.deleteRole(roleId)
if not success:
raise HTTPException(
@ -348,11 +468,11 @@ def list_users_with_roles(
request: Request,
roleLabel: Optional[str] = Query(None, description="Filter by role label"),
mandateId: Optional[str] = Query(None, description="Filter by mandate ID (via UserMandate)"),
currentUser: User = Depends(requireSysAdmin)
context: RequestContext = Depends(getRequestContext)
) -> List[Dict[str, Any]]:
"""
Get list of users with their role assignments.
MULTI-TENANT: SysAdmin-only, can see all users across mandates.
Context-aware: SysAdmin sees all users. MandateAdmin sees users from own mandates only.
Query Parameters:
- roleLabel: Optional filter by role label
@ -361,6 +481,12 @@ def list_users_with_roles(
Returns:
- List of user dictionaries with role assignments
"""
isSysAdmin = context.hasSysAdminRole
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(context)
if not isSysAdmin and not adminMandateIds:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required")
currentUser = context.user # backward compat for existing code
try:
interface = getRootInterface()
@ -369,9 +495,20 @@ def list_users_with_roles(
# Filter by mandate if specified (via UserMandate table)
if mandateId:
# MandateAdmin can only query mandates they admin
if not isSysAdmin and mandateId not in adminMandateIds:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to this mandate")
userMandates = interface.getUserMandatesByMandate(mandateId)
mandateUserIds = {str(um.userId) for um in userMandates}
users = [u for u in users if str(u.id) in mandateUserIds]
elif not isSysAdmin:
# MandateAdmin without mandateId filter: restrict to users in admin's mandates
allowedUserIds: Set[str] = set()
for mId in adminMandateIds:
userMandates = interface.getUserMandatesByMandate(mId)
for um in userMandates:
allowedUserIds.add(str(um.userId))
users = [u for u in users if str(u.id) in allowedUserIds]
# Filter by role if specified (via UserMandateRole)
if roleLabel:
@ -409,11 +546,11 @@ def list_users_with_roles(
def get_user_roles(
request: Request,
userId: str = Path(..., description="User ID"),
currentUser: User = Depends(requireSysAdmin)
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""
Get role assignments for a specific user.
MULTI-TENANT: SysAdmin-only.
Context-aware: SysAdmin sees all. MandateAdmin can view users in own mandates only.
Path Parameters:
- userId: User ID
@ -421,6 +558,12 @@ def get_user_roles(
Returns:
- User dictionary with role assignments
"""
isSysAdmin = context.hasSysAdminRole
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(context)
if not isSysAdmin and not adminMandateIds:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required")
currentUser = context.user # backward compat for existing code
try:
interface = getRootInterface()
@ -432,6 +575,13 @@ def get_user_roles(
detail=f"User {userId} not found"
)
# MandateAdmin: check user is in one of admin's mandates
if not isSysAdmin:
userMandates = interface.getUserMandates(userId)
userMandateMandateIds = {str(um.mandateId) for um in userMandates}
if not userMandateMandateIds.intersection(adminMandateIds):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to this user")
userRoleLabels = _getUserRoleLabels(interface, str(user.id))
return {
"id": user.id,
@ -460,11 +610,12 @@ def update_user_roles(
request: Request,
userId: str = Path(..., description="User ID"),
newRoleLabels: List[str] = Body(..., description="List of role labels to assign"),
currentUser: User = Depends(requireSysAdmin)
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""
Update role assignments for a specific user.
MULTI-TENANT: SysAdmin-only. Updates roles in user's first mandate.
Context-aware: SysAdmin can update any user's roles. MandateAdmin can update roles
for users in own mandates only. Cannot assign sysadmin role.
Path Parameters:
- userId: User ID
@ -475,6 +626,12 @@ def update_user_roles(
Returns:
- Updated user dictionary with role assignments
"""
isSysAdmin = context.hasSysAdminRole
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(context)
if not isSysAdmin and not adminMandateIds:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required")
currentUser = context.user # backward compat for existing code
try:
interface = getRootInterface()
@ -486,6 +643,11 @@ def update_user_roles(
detail=f"User {userId} not found"
)
# MandateAdmin restrictions
if not isSysAdmin:
if "sysadmin" in newRoleLabels:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot assign sysadmin role")
# Validate role labels (basic validation - check against standard roles)
standardRoles = ["sysadmin", "admin", "user", "viewer"]
for roleLabel in newRoleLabels:
@ -501,17 +663,26 @@ def update_user_roles(
)
userMandateId = str(userMandates[0].id)
targetMandateId = str(userMandates[0].mandateId)
# MandateAdmin: check target mandate belongs to admin's mandates
if not isSysAdmin:
if targetMandateId not in adminMandateIds:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to this mandate")
# Get current roles for this mandate (Pydantic models)
existingRoles = interface.getUserMandateRoles(userMandateId)
existingRoleIds = {str(r.roleId) for r in existingRoles}
# Convert roleLabels to roleIds
# Convert roleLabels to roleIds - use mandate-scoped lookup to get instance roles
# (prevents assigning template roles instead of mandate-instance roles)
newRoleIds = set()
for roleLabel in newRoleLabels:
role = interface.getRoleByLabel(roleLabel)
if role:
newRoleIds.add(str(role.id))
role = interface.getRoleByLabelAndScope(roleLabel, mandateId=targetMandateId)
if not role:
logger.warning(f"Role '{roleLabel}' not found for mandate {targetMandateId}, skipping")
continue
newRoleIds.add(str(role.id))
# Remove roles that are no longer needed
for existingRole in existingRoles:
@ -524,7 +695,7 @@ def update_user_roles(
newRole = UserMandateRole(userMandateId=userMandateId, roleId=roleId)
interface.db.recordCreate(UserMandateRole, newRole.model_dump())
logger.info(f"Updated roles for user {userId}: {newRoleLabels} by SysAdmin {currentUser.id}")
logger.info(f"Updated roles for user {userId}: {newRoleLabels} by admin {currentUser.id}")
userRoleLabels = _getUserRoleLabels(interface, userId)
return {
@ -554,11 +725,12 @@ def add_user_role(
request: Request,
userId: str = Path(..., description="User ID"),
roleLabel: str = Path(..., description="Role label to add"),
currentUser: User = Depends(requireSysAdmin)
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""
Add a role to a user (if not already assigned).
MULTI-TENANT: SysAdmin-only. Adds role to user's first mandate.
Context-aware: SysAdmin can add any role. MandateAdmin can add roles to users
in own mandates only. Cannot assign sysadmin role.
Path Parameters:
- userId: User ID
@ -567,6 +739,12 @@ def add_user_role(
Returns:
- Updated user dictionary with role assignments
"""
isSysAdmin = context.hasSysAdminRole
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(context)
if not isSysAdmin and not adminMandateIds:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required")
currentUser = context.user # backward compat for existing code
try:
interface = getRootInterface()
@ -578,13 +756,10 @@ def add_user_role(
detail=f"User {userId} not found"
)
# Get role by label
role = interface.getRoleByLabel(roleLabel)
if not role:
raise HTTPException(
status_code=404,
detail=f"Role '{roleLabel}' not found"
)
# MandateAdmin restrictions
if not isSysAdmin:
if roleLabel == "sysadmin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot assign sysadmin role")
# Get user's first mandate
userMandates = interface.getUserMandates(userId)
@ -595,6 +770,21 @@ def add_user_role(
)
userMandateId = str(userMandates[0].id)
targetMandateId = str(userMandates[0].mandateId)
# MandateAdmin: check target mandate belongs to admin's mandates
if not isSysAdmin:
if targetMandateId not in adminMandateIds:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to this mandate")
# Get role by label - use mandate-scoped lookup to get instance role
# (prevents assigning template roles instead of mandate-instance roles)
role = interface.getRoleByLabelAndScope(roleLabel, mandateId=targetMandateId)
if not role:
raise HTTPException(
status_code=404,
detail=f"Role '{roleLabel}' not found for mandate {targetMandateId}"
)
# Check if role is already assigned - use interface method
existingRoles = interface.getUserMandateRoles(userMandateId)
@ -603,7 +793,7 @@ def add_user_role(
if not roleAlreadyAssigned:
# Add the role via interface method
interface.addRoleToUserMandate(userMandateId, str(role.id))
logger.info(f"Added role {roleLabel} to user {userId} by SysAdmin {currentUser.id}")
logger.info(f"Added role {roleLabel} to user {userId} by admin {currentUser.id}")
userRoleLabels = _getUserRoleLabels(interface, userId)
return {
@ -633,11 +823,12 @@ def remove_user_role(
request: Request,
userId: str = Path(..., description="User ID"),
roleLabel: str = Path(..., description="Role label to remove"),
currentUser: User = Depends(requireSysAdmin)
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""
Remove a role from a user.
MULTI-TENANT: SysAdmin-only. Removes role from all user's mandates.
Context-aware: SysAdmin can remove any role. MandateAdmin can remove roles from
users in own mandates only. Cannot remove sysadmin role.
Path Parameters:
- userId: User ID
@ -646,6 +837,12 @@ def remove_user_role(
Returns:
- Updated user dictionary with role assignments
"""
isSysAdmin = context.hasSysAdminRole
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(context)
if not isSysAdmin and not adminMandateIds:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required")
currentUser = context.user # backward compat for existing code
try:
interface = getRootInterface()
@ -657,6 +854,11 @@ def remove_user_role(
detail=f"User {userId} not found"
)
# MandateAdmin restrictions
if not isSysAdmin:
if roleLabel == "sysadmin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot remove sysadmin role")
# Get role by label
role = interface.getRoleByLabel(roleLabel)
if not role:
@ -665,19 +867,30 @@ def remove_user_role(
detail=f"Role '{roleLabel}' not found"
)
# Remove role from all user's mandates
# Remove role from user's mandates
userMandates = interface.getUserMandates(userId)
# MandateAdmin: check user's mandates overlap with admin's mandates
if not isSysAdmin:
userMandateMandateIds = {str(um.mandateId) for um in userMandates}
if not userMandateMandateIds.intersection(set(adminMandateIds)):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to this user")
roleRemoved = False
for um in userMandates:
userMandateId = str(um.id)
# MandateAdmin: only remove from mandates they admin
if not isSysAdmin and str(um.mandateId) not in adminMandateIds:
continue
# Remove role via interface method
if interface.removeRoleFromUserMandate(userMandateId, str(role.id)):
roleRemoved = True
if roleRemoved:
logger.info(f"Removed role {roleLabel} from user {userId} by SysAdmin {currentUser.id}")
logger.info(f"Removed role {roleLabel} from user {userId} by admin {currentUser.id}")
userRoleLabels = _getUserRoleLabels(interface, userId)
return {
@ -707,11 +920,11 @@ def get_users_with_role(
request: Request,
roleLabel: str = Path(..., description="Role label"),
mandateId: Optional[str] = Query(None, description="Filter by mandate ID (via UserMandate)"),
currentUser: User = Depends(requireSysAdmin)
context: RequestContext = Depends(getRequestContext)
) -> List[Dict[str, Any]]:
"""
Get all users with a specific role.
MULTI-TENANT: SysAdmin-only.
Context-aware: SysAdmin sees all. MandateAdmin sees users from own mandates only.
Path Parameters:
- roleLabel: Role label
@ -722,6 +935,12 @@ def get_users_with_role(
Returns:
- List of users with the specified role
"""
isSysAdmin = context.hasSysAdminRole
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(context)
if not isSysAdmin and not adminMandateIds:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required")
currentUser = context.user # backward compat for existing code
try:
interface = getRootInterface()
@ -747,6 +966,9 @@ def get_users_with_role(
# Filter by mandate if specified
if mandateId and str(um.mandateId) != mandateId:
continue
# MandateAdmin: filter to own mandates
if not isSysAdmin and str(um.mandateId) not in adminMandateIds:
continue
userIds.add(str(um.userId))
# Get users and format response

View file

@ -6,8 +6,9 @@ Implements endpoints for role-based access control permissions.
MULTI-TENANT:
- Permission queries use RequestContext (mandateId from header)
- AccessRule management is SysAdmin-only (system resources)
- Role management is SysAdmin-only (system resources)
- AccessRule management is Admin-aware (SysAdmin sees all, MandateAdmin sees own mandate's rules)
- Role management is Admin-aware (SysAdmin sees all, MandateAdmin sees own mandate's roles)
- Catalog stats and cleanup remain SysAdmin-only
"""
from fastapi import APIRouter, HTTPException, Depends, Query, Body, Path, Request
@ -16,7 +17,7 @@ import logging
import json
import math
from modules.auth import limiter, getRequestContext, requireSysAdmin, RequestContext
from modules.auth import limiter, getRequestContext, requireSysAdminRole, RequestContext
from modules.datamodels.datamodelUam import User, UserPermissions, AccessLevel
from modules.datamodels.datamodelRbac import AccessRuleContext, AccessRule, Role
from modules.datamodels.datamodelMembership import UserMandate
@ -33,6 +34,44 @@ router = APIRouter(
)
def _getAdminMandateIds(context: RequestContext) -> List[str]:
"""Get mandate IDs where the user has an admin role."""
mandateIds = []
try:
from modules.interfaces.interfaceDbApp import getRootInterface
rootInterface = getRootInterface()
userMandates = rootInterface.getUserMandates(str(context.user.id))
for um in userMandates:
if not getattr(um, 'enabled', True):
continue
umId = getattr(um, 'id', None)
mandateId = getattr(um, 'mandateId', None)
if not umId or not mandateId:
continue
roleIds = rootInterface.getRoleIdsForUserMandate(str(umId))
for roleId in roleIds:
role = rootInterface.getRole(roleId)
if role and role.roleLabel == "admin" and not role.featureInstanceId:
mandateIds.append(str(mandateId))
break
except Exception as e:
logger.error(f"Error getting admin mandate IDs: {e}")
return mandateIds
def _isRoleInAdminMandates(roleId: str, adminMandateIds: List[str]) -> bool:
"""Check if a role belongs to one of the admin's mandates."""
try:
from modules.interfaces.interfaceDbApp import getRootInterface
rootInterface = getRootInterface()
role = rootInterface.getRole(roleId)
if not role:
return False
return str(role.mandateId) in adminMandateIds if role.mandateId else False
except Exception:
return False
@router.get("/permissions", response_model=UserPermissions)
@limiter.limit("300/minute") # Raised from 60 - sidebar checks many pages individually
def get_permissions(
@ -201,7 +240,7 @@ def get_all_permissions(
logger.debug(f"UI/RESOURCE permissions: User has {len(roleIds)} roles across all mandates")
if not roleIds and not reqContext.isSysAdmin:
if not roleIds and not reqContext.hasSysAdminRole:
# No roles at all, return empty permissions
for ctx in contextsToFetch:
result[ctx.value.lower()] = {}
@ -306,11 +345,11 @@ def get_access_rules(
context: Optional[str] = Query(None, description="Filter by context (DATA, UI, RESOURCE)"),
item: Optional[str] = Query(None, description="Filter by item identifier"),
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
currentUser: User = Depends(requireSysAdmin)
reqContext: RequestContext = Depends(getRequestContext)
) -> PaginatedResponse:
"""
Get access rules with optional filters.
MULTI-TENANT: SysAdmin-only (AccessRules are system resources).
MULTI-TENANT: Admin-aware (SysAdmin sees all, MandateAdmin sees own mandate's rules).
Query Parameters:
- roleLabel: Optional role label filter
@ -321,7 +360,12 @@ def get_access_rules(
- List of AccessRule objects
"""
try:
# Get interface - SysAdmin uses root interface
isSysAdmin = reqContext.hasSysAdminRole
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
if not isSysAdmin and not adminMandateIds:
raise HTTPException(status_code=403, detail="Admin role required")
# Get interface - uses root interface for admin access
interface = getRootInterface()
# Parse context if provided
@ -350,6 +394,39 @@ def get_access_rules(
)
# Get rules with optional pagination
# MandateAdmin: fetch all then filter by admin's mandates
if not isSysAdmin:
allRules = interface.getAccessRules(
roleLabel=roleLabel,
context=accessContext,
item=item,
pagination=None
)
filteredRules = [rule for rule in allRules if _isRoleInAdminMandates(str(rule.roleId), adminMandateIds)]
if paginationParams:
totalItems = len(filteredRules)
totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0
startIdx = (paginationParams.page - 1) * paginationParams.pageSize
endIdx = startIdx + paginationParams.pageSize
return PaginatedResponse(
items=[rule.model_dump() for rule in filteredRules[startIdx:endIdx]],
pagination=PaginationMetadata(
currentPage=paginationParams.page,
pageSize=paginationParams.pageSize,
totalItems=totalItems,
totalPages=totalPages,
sort=paginationParams.sort,
filters=paginationParams.filters
)
)
else:
return PaginatedResponse(
items=[rule.model_dump() for rule in filteredRules],
pagination=None
)
# SysAdmin: use server-side pagination
result = interface.getAccessRules(
roleLabel=roleLabel,
context=accessContext,
@ -392,11 +469,11 @@ def get_access_rules(
def get_access_rules_by_role(
request: Request,
roleId: str = Path(..., description="Role ID to get rules for"),
currentUser: User = Depends(requireSysAdmin)
reqContext: RequestContext = Depends(getRequestContext)
) -> PaginatedResponse:
"""
Get all access rules for a specific role.
MULTI-TENANT: SysAdmin-only.
MULTI-TENANT: Admin-aware (SysAdmin sees all, MandateAdmin sees own mandate's roles).
Path Parameters:
- roleId: The role ID to get rules for
@ -405,6 +482,15 @@ def get_access_rules_by_role(
- List of AccessRule objects for the specified role
"""
try:
isSysAdmin = reqContext.hasSysAdminRole
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
if not isSysAdmin and not adminMandateIds:
raise HTTPException(status_code=403, detail="Admin role required")
# MandateAdmin: verify role belongs to their mandates
if not isSysAdmin and not _isRoleInAdminMandates(roleId, adminMandateIds):
raise HTTPException(status_code=403, detail="Access denied: role not in your mandates")
interface = getRootInterface()
# Get rules from database using interface method
@ -430,11 +516,11 @@ def get_access_rules_by_role(
def get_access_rule(
request: Request,
ruleId: str = Path(..., description="Access rule ID"),
currentUser: User = Depends(requireSysAdmin)
reqContext: RequestContext = Depends(getRequestContext)
) -> dict:
"""
Get a specific access rule by ID.
MULTI-TENANT: SysAdmin-only.
MULTI-TENANT: Admin-aware (SysAdmin sees all, MandateAdmin sees own mandate's rules).
Path Parameters:
- ruleId: Access rule ID
@ -443,7 +529,12 @@ def get_access_rule(
- AccessRule object
"""
try:
# Get interface - SysAdmin uses root interface
isSysAdmin = reqContext.hasSysAdminRole
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
if not isSysAdmin and not adminMandateIds:
raise HTTPException(status_code=403, detail="Admin role required")
# Get interface - uses root interface for admin access
interface = getRootInterface()
# Get rule
@ -454,6 +545,10 @@ def get_access_rule(
detail=f"Access rule {ruleId} not found"
)
# MandateAdmin: verify rule's role belongs to their mandates
if not isSysAdmin and not _isRoleInAdminMandates(str(rule.roleId), adminMandateIds):
raise HTTPException(status_code=403, detail="Access denied: rule's role not in your mandates")
# Convert to dict for JSON serialization
return rule.model_dump()
@ -472,11 +567,11 @@ def get_access_rule(
def create_access_rule(
request: Request,
accessRuleData: dict = Body(..., description="Access rule data"),
currentUser: User = Depends(requireSysAdmin)
reqContext: RequestContext = Depends(getRequestContext)
) -> dict:
"""
Create a new access rule.
MULTI-TENANT: SysAdmin-only.
MULTI-TENANT: Admin-aware (SysAdmin sees all, MandateAdmin creates for own mandate's roles).
Request Body:
- AccessRule object data (roleLabel, context, item, view, read, create, update, delete)
@ -485,7 +580,12 @@ def create_access_rule(
- Created AccessRule object
"""
try:
# Get interface - SysAdmin uses root interface
isSysAdmin = reqContext.hasSysAdminRole
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
if not isSysAdmin and not adminMandateIds:
raise HTTPException(status_code=403, detail="Admin role required")
# Get interface - uses root interface for admin access
interface = getRootInterface()
# Validate and parse access rule data
@ -515,10 +615,15 @@ def create_access_rule(
detail=f"Invalid access rule data: {str(e)}"
)
# MandateAdmin: verify the rule's role belongs to their mandates
if not isSysAdmin and accessRule.roleId:
if not _isRoleInAdminMandates(str(accessRule.roleId), adminMandateIds):
raise HTTPException(status_code=403, detail="Access denied: role not in your mandates")
# Create rule
createdRule = interface.createAccessRule(accessRule)
logger.info(f"Created access rule {createdRule.id} by SysAdmin {currentUser.id}")
logger.info(f"Created access rule {createdRule.id} by admin {reqContext.user.id}")
# Convert to dict for JSON serialization
return createdRule.model_dump()
@ -539,11 +644,11 @@ def update_access_rule(
request: Request,
ruleId: str = Path(..., description="Access rule ID"),
accessRuleData: dict = Body(..., description="Updated access rule data"),
currentUser: User = Depends(requireSysAdmin)
reqContext: RequestContext = Depends(getRequestContext)
) -> dict:
"""
Update an existing access rule.
MULTI-TENANT: SysAdmin-only.
MULTI-TENANT: Admin-aware (SysAdmin sees all, MandateAdmin updates own mandate's rules).
Path Parameters:
- ruleId: Access rule ID
@ -555,7 +660,12 @@ def update_access_rule(
- Updated AccessRule object
"""
try:
# Get interface - SysAdmin uses root interface
isSysAdmin = reqContext.hasSysAdminRole
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
if not isSysAdmin and not adminMandateIds:
raise HTTPException(status_code=403, detail="Admin role required")
# Get interface - uses root interface for admin access
interface = getRootInterface()
# Get existing rule to ensure it exists
@ -566,6 +676,10 @@ def update_access_rule(
detail=f"Access rule {ruleId} not found"
)
# MandateAdmin: verify existing rule's role belongs to their mandates
if not isSysAdmin and not _isRoleInAdminMandates(str(existingRule.roleId), adminMandateIds):
raise HTTPException(status_code=403, detail="Access denied: rule's role not in your mandates")
# Validate and parse access rule data
try:
# Merge with existing rule data
@ -601,7 +715,7 @@ def update_access_rule(
# Update rule
updatedRule = interface.updateAccessRule(ruleId, accessRule)
logger.info(f"Updated access rule {ruleId} by SysAdmin {currentUser.id}")
logger.info(f"Updated access rule {ruleId} by admin {reqContext.user.id}")
# Convert to dict for JSON serialization
return updatedRule.model_dump()
@ -621,11 +735,11 @@ def update_access_rule(
def delete_access_rule(
request: Request,
ruleId: str = Path(..., description="Access rule ID"),
currentUser: User = Depends(requireSysAdmin)
reqContext: RequestContext = Depends(getRequestContext)
) -> dict:
"""
Delete an access rule.
MULTI-TENANT: SysAdmin-only.
MULTI-TENANT: Admin-aware (SysAdmin sees all, MandateAdmin deletes own mandate's rules).
Path Parameters:
- ruleId: Access rule ID
@ -634,7 +748,12 @@ def delete_access_rule(
- Success message
"""
try:
# Get interface - SysAdmin uses root interface
isSysAdmin = reqContext.hasSysAdminRole
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
if not isSysAdmin and not adminMandateIds:
raise HTTPException(status_code=403, detail="Admin role required")
# Get interface - uses root interface for admin access
interface = getRootInterface()
# Get existing rule to ensure it exists
@ -645,6 +764,10 @@ def delete_access_rule(
detail=f"Access rule {ruleId} not found"
)
# MandateAdmin: verify rule's role belongs to their mandates
if not isSysAdmin and not _isRoleInAdminMandates(str(existingRule.roleId), adminMandateIds):
raise HTTPException(status_code=403, detail="Access denied: rule's role not in your mandates")
# Delete rule
success = interface.deleteAccessRule(ruleId)
@ -654,7 +777,7 @@ def delete_access_rule(
detail=f"Failed to delete access rule {ruleId}"
)
logger.info(f"Deleted access rule {ruleId} by SysAdmin {currentUser.id}")
logger.info(f"Deleted access rule {ruleId} by admin {reqContext.user.id}")
return {"success": True, "message": f"Access rule {ruleId} deleted successfully"}
@ -670,7 +793,7 @@ def delete_access_rule(
# ============================================================================
# Role Management Endpoints
# MULTI-TENANT: All role management is SysAdmin-only (roles are system resources)
# MULTI-TENANT: Admin-aware (SysAdmin sees all, MandateAdmin sees own mandate's roles)
# ============================================================================
@ -682,11 +805,11 @@ def list_roles(
includeTemplates: bool = Query(False, description="Include feature template roles"),
mandateId: Optional[str] = Query(None, description="Include mandate-specific roles for this mandate"),
scopeFilter: Optional[str] = Query(None, description="Filter by scope: 'all', 'mandate', 'global', 'system'"),
currentUser: User = Depends(requireSysAdmin)
reqContext: RequestContext = Depends(getRequestContext)
) -> PaginatedResponse:
"""
Get list of roles with metadata.
MULTI-TENANT: SysAdmin-only (roles are system resources).
MULTI-TENANT: Admin-aware (SysAdmin sees all, MandateAdmin sees own mandate's roles).
By default, only returns true global roles (mandateId=None, featureInstanceId=None, featureCode=None).
Feature template roles are managed via /api/features/templates/roles.
@ -701,6 +824,11 @@ def list_roles(
- List of role dictionaries with role label, description, user count, and computed scopeType
"""
try:
isSysAdmin = reqContext.hasSysAdminRole
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
if not isSysAdmin and not adminMandateIds:
raise HTTPException(status_code=403, detail="Admin role required")
interface = getRootInterface()
# Parse pagination parameter
@ -777,6 +905,10 @@ def list_roles(
"scopeType": scopeType # Computed field for frontend display
})
# MandateAdmin: filter to only roles in admin's mandates
if not isSysAdmin:
result = [r for r in result if r.get("mandateId") and str(r["mandateId"]) in adminMandateIds]
# Apply search, filtering and sorting if pagination requested
if paginationParams:
# Apply search (if search term provided in filters)
@ -850,7 +982,7 @@ def list_roles(
@limiter.limit("60/minute")
def get_role_options(
request: Request,
currentUser: User = Depends(requireSysAdmin)
currentUser: User = Depends(requireSysAdminRole)
) -> List[Dict[str, Any]]:
"""
Get role options for select dropdowns.
@ -892,11 +1024,11 @@ def get_role_options(
def create_role(
request: Request,
role: Role = Body(...),
currentUser: User = Depends(requireSysAdmin)
reqContext: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""
Create a new role.
MULTI-TENANT: SysAdmin-only.
MULTI-TENANT: Admin-aware (SysAdmin sees all, MandateAdmin creates in own mandate).
Request Body:
- role: Role object to create
@ -905,11 +1037,21 @@ def create_role(
- Created role dictionary
"""
try:
isSysAdmin = reqContext.hasSysAdminRole
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
if not isSysAdmin and not adminMandateIds:
raise HTTPException(status_code=403, detail="Admin role required")
# MandateAdmin: can only create roles in their own mandates
if not isSysAdmin:
if not role.mandateId or str(role.mandateId) not in adminMandateIds:
raise HTTPException(status_code=403, detail="Access denied: can only create roles in your own mandates")
interface = getRootInterface()
createdRole = interface.createRole(role)
logger.info(f"Created role {createdRole.roleLabel} by SysAdmin {currentUser.id}")
logger.info(f"Created role {createdRole.roleLabel} by admin {reqContext.user.id}")
return {
"id": createdRole.id,
@ -941,11 +1083,11 @@ def create_role(
def get_role(
request: Request,
roleId: str = Path(..., description="Role ID"),
currentUser: User = Depends(requireSysAdmin)
reqContext: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""
Get a role by ID.
MULTI-TENANT: SysAdmin-only.
MULTI-TENANT: Admin-aware (SysAdmin sees all, MandateAdmin sees own mandate's roles).
Path Parameters:
- roleId: Role ID
@ -954,6 +1096,11 @@ def get_role(
- Role dictionary
"""
try:
isSysAdmin = reqContext.hasSysAdminRole
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
if not isSysAdmin and not adminMandateIds:
raise HTTPException(status_code=403, detail="Admin role required")
interface = getRootInterface()
role = interface.getRole(roleId)
@ -963,6 +1110,11 @@ def get_role(
detail=f"Role {roleId} not found"
)
# MandateAdmin: verify role belongs to their mandates
if not isSysAdmin:
if not role.mandateId or str(role.mandateId) not in adminMandateIds:
raise HTTPException(status_code=403, detail="Access denied: role not in your mandates")
return {
"id": role.id,
"roleLabel": role.roleLabel,
@ -989,11 +1141,11 @@ def update_role(
request: Request,
roleId: str = Path(..., description="Role ID"),
role: Role = Body(...),
currentUser: User = Depends(requireSysAdmin)
reqContext: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""
Update an existing role.
MULTI-TENANT: SysAdmin-only.
MULTI-TENANT: Admin-aware (SysAdmin sees all, MandateAdmin updates own mandate's roles, not template/system).
Path Parameters:
- roleId: Role ID
@ -1005,11 +1157,26 @@ def update_role(
- Updated role dictionary
"""
try:
isSysAdmin = reqContext.hasSysAdminRole
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
if not isSysAdmin and not adminMandateIds:
raise HTTPException(status_code=403, detail="Admin role required")
interface = getRootInterface()
# MandateAdmin: verify role belongs to their mandates and is not a template/system role
if not isSysAdmin:
existingRole = interface.getRole(roleId)
if not existingRole:
raise HTTPException(status_code=404, detail=f"Role {roleId} not found")
if existingRole.isSystemRole and not existingRole.mandateId:
raise HTTPException(status_code=403, detail="Access denied: cannot modify template/system roles")
if not existingRole.mandateId or str(existingRole.mandateId) not in adminMandateIds:
raise HTTPException(status_code=403, detail="Access denied: role not in your mandates")
updatedRole = interface.updateRole(roleId, role)
logger.info(f"Updated role {roleId} by SysAdmin {currentUser.id}")
logger.info(f"Updated role {roleId} by admin {reqContext.user.id}")
return {
"id": updatedRole.id,
@ -1041,11 +1208,11 @@ def update_role(
def delete_role(
request: Request,
roleId: str = Path(..., description="Role ID"),
currentUser: User = Depends(requireSysAdmin)
reqContext: RequestContext = Depends(getRequestContext)
) -> Dict[str, str]:
"""
Delete a role.
MULTI-TENANT: SysAdmin-only.
MULTI-TENANT: Admin-aware (SysAdmin sees all, MandateAdmin deletes own mandate's roles, not template/system).
Path Parameters:
- roleId: Role ID
@ -1054,8 +1221,23 @@ def delete_role(
- Success message
"""
try:
isSysAdmin = reqContext.hasSysAdminRole
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
if not isSysAdmin and not adminMandateIds:
raise HTTPException(status_code=403, detail="Admin role required")
interface = getRootInterface()
# MandateAdmin: verify role belongs to their mandates and is not a template/system role
if not isSysAdmin:
existingRole = interface.getRole(roleId)
if not existingRole:
raise HTTPException(status_code=404, detail=f"Role {roleId} not found")
if existingRole.isSystemRole and not existingRole.mandateId:
raise HTTPException(status_code=403, detail="Access denied: cannot delete template/system roles")
if not existingRole.mandateId or str(existingRole.mandateId) not in adminMandateIds:
raise HTTPException(status_code=403, detail="Access denied: role not in your mandates")
success = interface.deleteRole(roleId)
if not success:
raise HTTPException(
@ -1063,7 +1245,7 @@ def delete_role(
detail=f"Role {roleId} not found"
)
logger.info(f"Deleted role {roleId} by SysAdmin {currentUser.id}")
logger.info(f"Deleted role {roleId} by admin {reqContext.user.id}")
return {"message": f"Role {roleId} deleted successfully"}
@ -1182,7 +1364,7 @@ def getCatalogObjects(
@limiter.limit("60/minute")
def getCatalogStats(
request: Request,
currentUser: User = Depends(requireSysAdmin)
currentUser: User = Depends(requireSysAdminRole)
) -> Dict[str, Any]:
"""
Get statistics about the RBAC catalog.
@ -1213,7 +1395,7 @@ def getCatalogStats(
def cleanup_duplicate_access_rules(
request: Request,
dryRun: bool = Query(True, description="If true, only report duplicates without deleting"),
currentUser: User = Depends(requireSysAdmin)
currentUser: User = Depends(requireSysAdminRole)
) -> dict:
"""
Find and remove duplicate AccessRules.
@ -1278,19 +1460,102 @@ def cleanup_duplicate_access_rules(
except Exception as e:
logger.warning(f"Failed to delete rule {ruleId}: {e}")
# =====================================================================
# Phase 2: Fix template role assignments
# UserMandateRole should reference mandate-instance roles, not templates
# =====================================================================
from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole
allUserMandateRoles = rootInterface.db.getRecordset(UserMandateRole)
templateFixDetails = []
templateFixedCount = 0
for umr in allUserMandateRoles:
roleId = umr.get("roleId")
userMandateId = umr.get("userMandateId")
umrId = umr.get("id")
if not roleId or not userMandateId:
continue
# Check if assigned role is a template
role = rootInterface.getRole(roleId)
if not role or role.mandateId is not None:
continue # Not a template role, OK
if not role.isSystemRole:
continue # Not a system template, skip
# Template role assigned! Find the UserMandate to get the mandateId
userMandateRecords = rootInterface.db.getRecordset(
UserMandate, recordFilter={"id": userMandateId}
)
if not userMandateRecords:
continue
mandateId = userMandateRecords[0].get("mandateId")
if not mandateId:
continue
# Find the correct mandate-instance role
mandateRoles = rootInterface.db.getRecordset(
Role, recordFilter={"roleLabel": role.roleLabel, "mandateId": mandateId, "featureInstanceId": None}
)
detail = {
"userMandateRoleId": umrId,
"userMandateId": userMandateId,
"mandateId": mandateId,
"templateRoleId": roleId,
"templateRoleLabel": role.roleLabel,
"action": "none"
}
if mandateRoles:
instanceRoleId = mandateRoles[0].get("id")
detail["instanceRoleId"] = instanceRoleId
detail["action"] = "replace" if not dryRun else "would_replace"
if not dryRun:
try:
rootInterface.db.recordModify(UserMandateRole, umrId, {"roleId": instanceRoleId})
templateFixedCount += 1
logger.info(f"Fixed template role assignment: {umrId}{role.roleLabel} template → instance {instanceRoleId}")
except Exception as e:
detail["action"] = f"error: {e}"
logger.warning(f"Failed to fix role assignment {umrId}: {e}")
else:
detail["action"] = "delete_inconsistent" if not dryRun else "would_delete_inconsistent"
if not dryRun:
try:
rootInterface.db.recordDelete(UserMandateRole, umrId)
templateFixedCount += 1
logger.info(f"Deleted inconsistent template role assignment: {umrId} (template '{role.roleLabel}' in mandate {mandateId}, no instance role found)")
except Exception as e:
detail["action"] = f"error: {e}"
logger.warning(f"Failed to delete inconsistent assignment {umrId}: {e}")
templateFixDetails.append(detail)
result = {
"dryRun": dryRun,
"totalRules": len(allRules),
"uniqueSignatures": len(rulesBySignature),
"duplicateGroups": len(duplicateGroups),
"duplicateRulesToDelete": len(idsToDelete),
"deletedCount": deletedCount,
"details": duplicateGroups[:50] # Limit details to 50 groups
"duplicateRules": {
"totalRules": len(allRules),
"uniqueSignatures": len(rulesBySignature),
"duplicateGroups": len(duplicateGroups),
"duplicateRulesToDelete": len(idsToDelete),
"deletedCount": deletedCount,
"details": duplicateGroups[:50]
},
"templateRoleAssignments": {
"totalUserMandateRoles": len(allUserMandateRoles),
"invalidAssignments": len(templateFixDetails),
"fixedCount": templateFixedCount,
"details": templateFixDetails[:50]
}
}
logger.info(f"AccessRule cleanup: dryRun={dryRun}, total={len(allRules)}, "
f"duplicateGroups={len(duplicateGroups)}, toDelete={len(idsToDelete)}, "
f"deleted={deletedCount}")
logger.info(f"RBAC cleanup: dryRun={dryRun}, "
f"duplicates={len(duplicateGroups)}/{deletedCount} deleted, "
f"templateFixes={len(templateFixDetails)}/{templateFixedCount} fixed")
return result

View file

@ -8,11 +8,12 @@ MULTI-TENANT: These are SYSTEM-LEVEL operations requiring isSysAdmin=true.
Shows comprehensive view of what a user can see and access.
"""
from fastapi import APIRouter, HTTPException, Depends, Query, Path, Request
from fastapi import APIRouter, HTTPException, Depends, Query, Path, Request, status
from typing import List, Dict, Any, Optional, Set
import logging
from modules.auth import limiter, requireSysAdmin
from modules.auth import limiter
from modules.auth.authentication import getRequestContext, RequestContext
from modules.datamodels.datamodelUam import User, UserInDB
from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext
from modules.datamodels.datamodelMembership import (
@ -67,34 +68,101 @@ def _getRoleScopePriority(scope: str) -> int:
return priorities.get(scope, 0)
def _hasMandateAdminRole(context: RequestContext) -> bool:
"""Check if the user has mandate admin role in ANY mandate.
Loads roles independently from request context (context.roleIds may be empty
when no X-Mandate-Id header is sent, e.g., on admin pages).
"""
if context.hasSysAdminRole:
return True
try:
rootInterface = getRootInterface()
userMandates = rootInterface.getUserMandates(str(context.user.id))
for um in userMandates:
umId = getattr(um, 'id', None)
if not umId:
continue
roleIds = rootInterface.getRoleIdsForUserMandate(str(umId))
for roleId in roleIds:
role = rootInterface.getRole(roleId)
if role and role.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
def _isUserInMandate(rootInterface, userId: str, mandateId: str) -> bool:
"""Check if a user belongs to a specific mandate."""
try:
userMandates = rootInterface.db.getRecordset(UserMandate, {"userId": userId, "mandateId": mandateId})
return len(userMandates) > 0
except Exception:
return False
@router.get("/users", response_model=List[Dict[str, Any]])
@limiter.limit("60/minute")
def listUsersForOverview(
request: Request,
currentUser: User = Depends(requireSysAdmin)
context: RequestContext = Depends(getRequestContext)
) -> List[Dict[str, Any]]:
"""
Get list of all users for selection in the overview.
MULTI-TENANT: SysAdmin-only.
Get list of users for selection in the overview.
SysAdmin sees all users. MandateAdmin sees users in their mandate.
Returns:
- List of user dictionaries with basic info
"""
if not _hasMandateAdminRole(context):
raise HTTPException(status_code=403, detail="Keine Berechtigung für die Benutzerzugriffsübersicht")
try:
interface = getRootInterface()
# Get all users using interface method
allUsers = interface.getAllUsers()
if context.hasSysAdminRole and not context.mandateId:
# SysAdmin without mandate context: all users
allUsers = interface.getAllUsers()
elif context.mandateId:
# With explicit mandate context: users in that mandate
allUsers = interface.getUsersByMandate(str(context.mandateId))
else:
# MandateAdmin without mandate context: aggregate across all admin mandates
userMandates = interface.getUserMandates(str(context.user.id))
adminMandateIds = []
for um in userMandates:
umId = getattr(um, 'id', None)
mid = getattr(um, 'mandateId', None)
if not umId or not mid:
continue
roleIds = interface.getRoleIdsForUserMandate(str(umId))
for roleId in roleIds:
role = interface.getRole(roleId)
if role and role.roleLabel == "admin" and not role.featureInstanceId:
adminMandateIds.append(str(mid))
break
seenUserIds = set()
allUsers = []
for mid in adminMandateIds:
mandateUsers = interface.getUsersByMandate(mid)
for u in (mandateUsers if isinstance(mandateUsers, list) else mandateUsers.items if hasattr(mandateUsers, 'items') else []):
uid = u.get("id") if isinstance(u, dict) else getattr(u, "id", None)
if uid and uid not in seenUserIds:
seenUserIds.add(uid)
allUsers.append(u)
result = []
for u in allUsers:
userData = u if isinstance(u, dict) else u.model_dump() if hasattr(u, 'model_dump') else vars(u)
result.append({
"id": u.id,
"username": u.username,
"email": u.email,
"fullName": u.fullName,
"isSysAdmin": u.isSysAdmin,
"enabled": u.enabled,
"id": userData.get("id"),
"username": userData.get("username"),
"email": userData.get("email"),
"fullName": userData.get("fullName"),
"isSysAdmin": userData.get("isSysAdmin", False),
"enabled": userData.get("enabled", True),
})
# Sort by username
@ -102,6 +170,8 @@ def listUsersForOverview(
return result
except HTTPException:
raise
except Exception as e:
logger.error(f"Error listing users for overview: {str(e)}")
raise HTTPException(
@ -117,11 +187,11 @@ def getUserAccessOverview(
userId: str = Path(..., description="User ID to get access overview for"),
mandateId: Optional[str] = Query(None, description="Filter by mandate ID"),
featureInstanceId: Optional[str] = Query(None, description="Filter by feature instance ID"),
currentUser: User = Depends(requireSysAdmin)
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""
Get comprehensive access overview for a specific user.
MULTI-TENANT: SysAdmin-only.
SysAdmin sees all users. MandateAdmin sees users in their mandate.
Path Parameters:
- userId: User ID
@ -138,9 +208,39 @@ def getUserAccessOverview(
- Data access (what tables/fields the user can access)
- Resource access (what resources the user can use)
"""
if not _hasMandateAdminRole(context):
raise HTTPException(status_code=403, detail="Keine Berechtigung für die Benutzerzugriffsübersicht")
try:
interface = getRootInterface()
# MandateAdmin: verify the requested user shares at least one admin mandate
if not context.hasSysAdminRole:
# Get admin's mandate IDs
adminMandateIds = []
userMandates = interface.getUserMandates(str(context.user.id))
for um in userMandates:
umId = getattr(um, 'id', None)
mid = getattr(um, 'mandateId', None)
if not umId or not mid:
continue
roleIds = interface.getRoleIdsForUserMandate(str(umId))
for roleId in roleIds:
role = interface.getRole(roleId)
if role and role.roleLabel == "admin" and not role.featureInstanceId:
adminMandateIds.append(str(mid))
break
# Check that requested user belongs to at least one of the admin's mandates
userInAdminMandate = False
for mid in adminMandateIds:
if _isUserInMandate(interface, userId, mid):
userInAdminMandate = True
break
if not userInAdminMandate:
raise HTTPException(status_code=403, detail="Benutzer gehört nicht zu Ihrem Mandate")
# Get user
user = interface.getUser(userId)
if not user:
@ -159,19 +259,6 @@ def getUserAccessOverview(
"enabled": user.enabled,
}
# If user is SysAdmin, they have full access to everything
if user.isSysAdmin:
return {
"user": userInfo,
"isSysAdmin": True,
"sysAdminNote": "SysAdmin users have full access to all system-level resources without mandate context.",
"roles": [],
"mandates": [],
"uiAccess": [],
"dataAccess": [],
"resourceAccess": [],
}
# Collect all roles for the user
allRoles = []
roleIdToInfo = {} # Map roleId to role info for later reference
@ -415,14 +502,14 @@ def getEffectivePermissions(
userId: str = Path(..., description="User ID"),
mandateId: str = Query(..., description="Mandate ID context"),
featureInstanceId: Optional[str] = Query(None, description="Feature instance ID context"),
context: str = Query("DATA", description="Context type: DATA, UI, or RESOURCE"),
accessContext: str = Query("DATA", alias="context", description="Context type: DATA, UI, or RESOURCE"),
item: Optional[str] = Query(None, description="Specific item to check permissions for"),
currentUser: User = Depends(requireSysAdmin)
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""
Get effective (resolved) permissions for a user in a specific context.
This uses the RBAC resolution logic to show what permissions actually apply.
MULTI-TENANT: SysAdmin-only.
MULTI-TENANT: SysAdmin sees all. MandateAdmin can check users in their own mandates.
Path Parameters:
- userId: User ID
@ -436,6 +523,11 @@ def getEffectivePermissions(
Returns:
- Effective permissions after RBAC resolution
"""
if not context.hasSysAdminRole:
# Check if user has admin role in any mandate
if not _hasMandateAdminRole(context):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required")
try:
interface = getRootInterface()
@ -449,11 +541,11 @@ def getEffectivePermissions(
# Convert context string to enum
try:
contextEnum = AccessRuleContext(context)
contextEnum = AccessRuleContext(accessContext)
except ValueError:
raise HTTPException(
status_code=400,
detail=f"Invalid context: {context}. Must be DATA, UI, or RESOURCE."
detail=f"Invalid context: {accessContext}. Must be DATA, UI, or RESOURCE."
)
# Use RBAC interface to get actual permissions
@ -472,7 +564,7 @@ def getEffectivePermissions(
"userId": userId,
"mandateId": mandateId,
"featureInstanceId": featureInstanceId,
"context": context,
"context": accessContext,
"item": item,
"effectivePermissions": {
"view": permissions.view,

View file

@ -17,7 +17,7 @@ from datetime import date, datetime
from pydantic import BaseModel, Field
# Import auth module
from modules.auth import limiter, requireSysAdmin, getRequestContext, RequestContext
from modules.auth import limiter, requireSysAdminRole, getRequestContext, RequestContext
# Import billing components
from modules.interfaces.interfaceDbBilling import getInterface as getBillingInterface
@ -84,7 +84,8 @@ def _getBillingDataScope(user) -> BillingDataScope:
"""
scope = BillingDataScope(userId=user.id)
if user.isSysAdmin:
from modules.auth.authentication import _hasSysAdminRole
if _hasSysAdminRole(str(user.id)):
scope.isGlobalAdmin = True
return scope
@ -137,6 +138,30 @@ def _getBillingDataScope(user) -> BillingDataScope:
return scope
def _isAdminOfMandate(ctx: RequestContext, targetMandateId: str) -> bool:
"""Check if user is SysAdmin or admin of the specified mandate."""
if ctx.hasSysAdminRole:
return True
try:
from modules.interfaces.interfaceDbApp import getRootInterface
rootInterface = getRootInterface()
userMandates = rootInterface.getUserMandates(str(ctx.user.id))
for um in userMandates:
if str(getattr(um, 'mandateId', None)) != str(targetMandateId):
continue
if not getattr(um, 'enabled', True):
continue
umId = str(getattr(um, 'id', ''))
roleIds = rootInterface.getRoleIdsForUserMandate(umId)
for roleId in roleIds:
role = rootInterface.getRole(roleId)
if role and role.roleLabel == "admin" and not role.featureInstanceId:
return True
return False
except Exception:
return False
def _filterTransactionsByScope(transactions: list, scope: BillingDataScope) -> list:
"""
Filter a list of transaction dicts based on the user's BillingDataScope.
@ -537,11 +562,13 @@ def getSettingsAdmin(
request: Request,
targetMandateId: str = Path(..., description="Mandate ID"),
ctx: RequestContext = Depends(getRequestContext),
_admin = Depends(requireSysAdmin)
):
"""
Get billing settings for a mandate (SysAdmin only).
Get billing settings for a mandate.
Access: SysAdmin (any mandate) or MandateAdmin (own mandate).
"""
if not _isAdminOfMandate(ctx, targetMandateId):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required for this mandate")
try:
billingInterface = getBillingInterface(ctx.user, targetMandateId)
settings = billingInterface.getSettings(targetMandateId)
@ -565,7 +592,7 @@ def createOrUpdateSettings(
targetMandateId: str = Path(..., description="Mandate ID"),
settingsUpdate: BillingSettingsUpdate = Body(...),
ctx: RequestContext = Depends(getRequestContext),
_admin = Depends(requireSysAdmin)
_admin = Depends(requireSysAdminRole)
):
"""
Create or update billing settings for a mandate (SysAdmin only).
@ -618,7 +645,7 @@ def addCredit(
targetMandateId: str = Path(..., description="Mandate ID"),
creditRequest: CreditAddRequest = Body(...),
ctx: RequestContext = Depends(getRequestContext),
_admin = Depends(requireSysAdmin)
_admin = Depends(requireSysAdminRole)
):
"""
Add credit to a billing account (SysAdmin only).
@ -681,11 +708,13 @@ def getAccounts(
request: Request,
targetMandateId: str = Path(..., description="Mandate ID"),
ctx: RequestContext = Depends(getRequestContext),
_admin = Depends(requireSysAdmin)
):
"""
Get all billing accounts for a mandate (SysAdmin only).
Get all billing accounts for a mandate.
Access: SysAdmin (any mandate) or MandateAdmin (own mandate).
"""
if not _isAdminOfMandate(ctx, targetMandateId):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required for this mandate")
try:
billingInterface = getBillingInterface(ctx.user, targetMandateId)
@ -728,12 +757,14 @@ def getUsersForMandate(
request: Request,
targetMandateId: str = Path(..., description="Mandate ID"),
ctx: RequestContext = Depends(getRequestContext),
_admin = Depends(requireSysAdmin)
):
"""
Get all users belonging to a mandate (SysAdmin only).
Get all users belonging to a mandate.
Access: SysAdmin (any mandate) or MandateAdmin (own mandate).
Used by billing admin to select users for credit assignment.
"""
if not _isAdminOfMandate(ctx, targetMandateId):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required for this mandate")
try:
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
@ -787,11 +818,13 @@ def getTransactionsAdmin(
targetMandateId: str = Path(..., description="Mandate ID"),
limit: int = Query(default=100, ge=1, le=1000),
ctx: RequestContext = Depends(getRequestContext),
_admin = Depends(requireSysAdmin)
):
"""
Get all transactions for a mandate (SysAdmin only).
Get all transactions for a mandate.
Access: SysAdmin (any mandate) or MandateAdmin (own mandate).
"""
if not _isAdminOfMandate(ctx, targetMandateId):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required for this mandate")
try:
billingInterface = getBillingInterface(ctx.user, targetMandateId)
transactions = billingInterface.getTransactionsByMandate(targetMandateId, limit=limit)
@ -830,7 +863,7 @@ def getTransactionsAdmin(
def getMandateViewBalances(
request: Request,
ctx: RequestContext = Depends(getRequestContext),
_admin = Depends(requireSysAdmin)
_admin = Depends(requireSysAdminRole)
):
"""
Get mandate-level balances (SysAdmin only).
@ -853,7 +886,7 @@ def getMandateViewTransactions(
request: Request,
limit: int = Query(default=100, ge=1, le=1000),
ctx: RequestContext = Depends(getRequestContext),
_admin = Depends(requireSysAdmin)
_admin = Depends(requireSysAdminRole)
):
"""
Get all transactions across mandates (SysAdmin only).

View file

@ -17,7 +17,7 @@ import json
from pydantic import BaseModel, Field
# Import auth module
from modules.auth import limiter, requireSysAdmin, getRequestContext, RequestContext
from modules.auth import limiter, requireSysAdminRole, getRequestContext, RequestContext
# Import interfaces
import modules.interfaces.interfaceDbApp as interfaceDbApp
@ -79,20 +79,30 @@ router = APIRouter(
def get_mandates(
request: Request,
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
currentUser: User = Depends(requireSysAdmin)
context: RequestContext = Depends(getRequestContext)
) -> PaginatedResponse[Mandate]:
"""
Get mandates with optional pagination, sorting, and filtering.
MULTI-TENANT: SysAdmin-only (mandates are system resources).
Access:
- SysAdmin: all mandates
- MandateAdmin: only mandates where user has admin role
- Other: 403
Query Parameters:
- pagination: JSON-encoded PaginationParams object, or None for no pagination
Examples:
- GET /api/mandates/ (no pagination - returns all items)
- GET /api/mandates/?pagination={"page":1,"pageSize":10,"sort":[]}
"""
try:
# Check admin access
isSysAdmin = context.hasSysAdminRole
if not isSysAdmin:
adminMandateIds = _getAdminMandateIds(context)
if not adminMandateIds:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin role required"
)
# Parse pagination parameter
paginationParams = None
if pagination:
@ -108,11 +118,24 @@ def get_mandates(
)
appInterface = interfaceDbApp.getRootInterface()
result = appInterface.getAllMandates(pagination=paginationParams)
if isSysAdmin:
# SysAdmin: all mandates
result = appInterface.getAllMandates(pagination=paginationParams)
else:
# MandateAdmin: only their mandates
allMandates = []
for mandateId in adminMandateIds:
mandate = appInterface.getMandate(mandateId)
if mandate:
mandateDict = mandate if isinstance(mandate, dict) else mandate.model_dump() if hasattr(mandate, 'model_dump') else vars(mandate)
allMandates.append(mandateDict)
result = allMandates
paginationParams = None # Client-side pagination for filtered results
# If pagination was requested, result is PaginatedResult
# If no pagination, result is List[Mandate]
if paginationParams:
if paginationParams and hasattr(result, 'items'):
return PaginatedResponse(
items=result.items,
pagination=PaginationMetadata(
@ -125,8 +148,9 @@ def get_mandates(
)
)
else:
items = result if isinstance(result, list) else (result.items if hasattr(result, 'items') else result)
return PaginatedResponse(
items=result,
items=items,
pagination=None
)
except HTTPException:
@ -138,18 +162,32 @@ def get_mandates(
detail=f"Failed to get mandates: {str(e)}"
)
@router.get("/{mandateId}", response_model=Mandate)
@router.get("/{targetMandateId}", response_model=Mandate)
@limiter.limit("30/minute")
def get_mandate(
request: Request,
mandateId: str = Path(..., description="ID of the mandate"),
currentUser: User = Depends(requireSysAdmin)
targetMandateId: str = Path(..., description="ID of the mandate"),
context: RequestContext = Depends(getRequestContext)
) -> Mandate:
"""
Get a specific mandate by ID.
MULTI-TENANT: SysAdmin-only.
Access:
- SysAdmin: any mandate
- MandateAdmin: only mandates where user has admin role
- Other: 403
"""
try:
mandateId = targetMandateId
# Check access
if not context.hasSysAdminRole:
adminMandateIds = _getAdminMandateIds(context)
if mandateId not in adminMandateIds:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin role required for this mandate"
)
appInterface = interfaceDbApp.getRootInterface()
mandate = appInterface.getMandate(mandateId)
@ -174,7 +212,7 @@ def get_mandate(
def create_mandate(
request: Request,
mandateData: dict = Body(..., description="Mandate data with at least 'name' field"),
currentUser: User = Depends(requireSysAdmin)
currentUser: User = Depends(requireSysAdminRole)
) -> Mandate:
"""
Create a new mandate.
@ -228,7 +266,7 @@ def update_mandate(
request: Request,
mandateId: str = Path(..., description="ID of the mandate to update"),
mandateData: dict = Body(..., description="Mandate update data"),
currentUser: User = Depends(requireSysAdmin)
currentUser: User = Depends(requireSysAdminRole)
) -> Mandate:
"""
Update an existing mandate.
@ -273,7 +311,7 @@ def update_mandate(
def delete_mandate(
request: Request,
mandateId: str = Path(..., description="ID of the mandate to delete"),
currentUser: User = Depends(requireSysAdmin)
currentUser: User = Depends(requireSysAdminRole)
) -> Dict[str, Any]:
"""
Delete a mandate.
@ -339,7 +377,7 @@ def list_mandate_users(
pagination: Optional pagination parameters (page, pageSize, search, filters, sort)
"""
# Check permission
if not _hasMandateAdminRole(context, targetMandateId) and not context.isSysAdmin:
if not _hasMandateAdminRole(context, targetMandateId) and not context.hasSysAdminRole:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Mandate-Admin role required"
@ -510,7 +548,7 @@ def add_user_to_mandate(
data: User ID and role IDs to assign
"""
# 1. SysAdmin Self-Eskalation Prevention
if context.isSysAdmin and data.targetUserId == str(context.user.id):
if context.hasSysAdminRole and data.targetUserId == str(context.user.id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="SysAdmin cannot add themselves to a mandate. A Mandate-Admin must grant access."
@ -784,35 +822,58 @@ def update_user_roles_in_mandate(
# Helper Functions
# =============================================================================
def _getAdminMandateIds(context: RequestContext) -> List[str]:
"""
Get list of mandate IDs where the user has the admin role.
Returns empty list if user has no admin roles.
"""
mandateIds = []
try:
rootInterface = interfaceDbApp.getRootInterface()
userId = str(context.user.id)
userMandates = rootInterface.getUserMandates(userId)
for um in userMandates:
if not getattr(um, 'enabled', True):
continue
umId = getattr(um, 'id', None)
mandateId = getattr(um, 'mandateId', None)
if not umId or not mandateId:
continue
roleIds = rootInterface.getRoleIdsForUserMandate(str(umId))
for roleId in roleIds:
role = rootInterface.getRole(roleId)
if role and role.roleLabel == "admin" and not role.featureInstanceId:
mandateIds.append(str(mandateId))
break
except Exception as e:
logger.error(f"Error getting admin mandate IDs: {e}")
return mandateIds
def _hasMandateAdminRole(context: RequestContext, mandateId: str) -> bool:
"""
Check if the user has mandate admin role for the specified mandate.
Works with or without X-Mandate-Id header (admin pages don't send it).
"""
if context.isSysAdmin:
if context.hasSysAdminRole:
return True
# Must be in the same mandate context
if str(context.mandateId) != str(mandateId):
# If mandate context matches, check roles from context directly
if context.mandateId and str(context.mandateId) == str(mandateId):
if context.roleIds:
try:
rootInterface = interfaceDbApp.getRootInterface()
for roleId in context.roleIds:
role = rootInterface.getRole(roleId)
if role and role.roleLabel == "admin" and not role.featureInstanceId:
return True
except Exception as e:
logger.error(f"Error checking mandate admin role: {e}")
return False
if not context.roleIds:
return False
try:
rootInterface = interfaceDbApp.getRootInterface()
for roleId in context.roleIds:
role = rootInterface.getRole(roleId)
if role:
# Admin role at mandate level (not feature-instance level)
if role.roleLabel == "admin" and role.mandateId and not role.featureInstanceId:
return True
return False
except Exception as e:
logger.error(f"Error checking mandate admin role: {e}")
return False # Fail-safe: no access on error
# No mandate context (admin pages) — check via user's mandate memberships
adminMandateIds = _getAdminMandateIds(context)
return str(mandateId) in adminMandateIds
def _isLastMandateAdmin(interface, mandateId: str, excludeUserId: str) -> bool:

View file

@ -29,6 +29,46 @@ from modules.datamodels.datamodelPagination import PaginationParams, PaginatedRe
logger = logging.getLogger(__name__)
def _isAdminForUser(context: RequestContext, targetUserId: str) -> bool:
"""
Check if the current user has admin rights for the target user.
SysAdmin can manage all users. MandateAdmin can manage users in their mandates.
Works without X-Mandate-Id header (admin pages don't send it).
"""
if context.hasSysAdminRole:
return True
# Find mandates where current user is admin
rootInterface = getRootInterface()
userId = str(context.user.id)
userMandates = rootInterface.getUserMandates(userId)
adminMandateIds = []
for um in userMandates:
if not getattr(um, 'enabled', True):
continue
umId = getattr(um, 'id', None)
mandateId = getattr(um, 'mandateId', None)
if not umId or not mandateId:
continue
roleIds = rootInterface.getRoleIdsForUserMandate(str(umId))
for roleId in roleIds:
role = rootInterface.getRole(roleId)
if role and role.roleLabel == "admin" and not role.featureInstanceId:
adminMandateIds.append(str(mandateId))
break
if not adminMandateIds:
return False
# Check if target user is in any of the admin's mandates
targetMandates = rootInterface.getUserMandates(targetUserId)
for tm in targetMandates:
if str(getattr(tm, 'mandateId', '')) in adminMandateIds:
return True
return False
def _applyFiltersAndSort(items: List[Dict[str, Any]], paginationParams: Optional[PaginationParams]) -> List[Dict[str, Any]]:
"""
Apply filters and sorting to a list of items.
@ -168,7 +208,7 @@ def get_user_options(
if context.mandateId:
result = appInterface.getUsersByMandate(str(context.mandateId), None)
users = result.items if hasattr(result, 'items') else result
elif context.isSysAdmin:
elif context.hasSysAdminRole:
users = appInterface.getAllUsers()
else:
raise HTTPException(status_code=403, detail="Access denied")
@ -222,7 +262,7 @@ def get_users(
detail=f"Invalid pagination parameter: {str(e)}"
)
appInterface = interfaceDbApp.getInterface(context.user)
appInterface = interfaceDbApp.getInterface(context.user, mandateId=context.mandateId)
# MULTI-TENANT: Use mandateId from context (header)
# SysAdmin without mandateId can see all users
@ -250,7 +290,7 @@ def get_users(
items=users,
pagination=None
)
elif context.isSysAdmin:
elif context.hasSysAdminRole:
# SysAdmin without mandateId sees all users
# Get all users via interface method (returns Pydantic User models)
allUserModels = appInterface.getAllUsers()
@ -288,11 +328,76 @@ def get_users(
pagination=None
)
else:
# Non-SysAdmin without mandateId - should not happen (getRequestContext enforces)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="X-Mandate-Id header is required"
)
# Non-SysAdmin without mandateId: aggregate users across all admin mandates
rootInterface = getRootInterface()
userMandates = rootInterface.getUserMandates(str(context.user.id))
# Find mandates where user has admin role
adminMandateIds = []
for um in userMandates:
umId = getattr(um, 'id', None)
mandateId = getattr(um, 'mandateId', None)
if not umId or not mandateId:
continue
roleIds = rootInterface.getRoleIdsForUserMandate(str(umId))
for roleId in roleIds:
role = rootInterface.getRole(roleId)
if role and role.roleLabel == "admin" and not role.featureInstanceId:
adminMandateIds.append(str(mandateId))
break
if not adminMandateIds:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="No admin access to any mandate"
)
# Aggregate users across all admin mandates (deduplicate by user ID)
seenUserIds = set()
allUsers = []
for mid in adminMandateIds:
mandateUsers = rootInterface.getUsersByMandate(mid)
if isinstance(mandateUsers, list):
users = mandateUsers
elif hasattr(mandateUsers, 'items'):
users = mandateUsers.items
else:
users = []
for u in users:
uid = u.get("id") if isinstance(u, dict) else getattr(u, "id", None)
if uid and uid not in seenUserIds:
seenUserIds.add(uid)
userData = u if isinstance(u, dict) else u.model_dump() if hasattr(u, 'model_dump') else vars(u)
allUsers.append(userData)
# Apply server-side filtering and sorting
filteredUsers = _applyFiltersAndSort(allUsers, paginationParams)
users = [User(**u) for u in filteredUsers]
if paginationParams:
import math
totalItems = len(users)
totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0
startIdx = (paginationParams.page - 1) * paginationParams.pageSize
endIdx = startIdx + paginationParams.pageSize
paginatedUsers = users[startIdx:endIdx]
return PaginatedResponse(
items=paginatedUsers,
pagination=PaginationMetadata(
currentPage=paginationParams.page,
pageSize=paginationParams.pageSize,
totalItems=totalItems,
totalPages=totalPages,
sort=paginationParams.sort,
filters=paginationParams.filters
)
)
else:
return PaginatedResponse(
items=users,
pagination=None
)
except HTTPException:
raise
except Exception as e:
@ -325,7 +430,7 @@ def get_user(
)
# MULTI-TENANT: Verify user is in the same mandate (unless SysAdmin)
if context.mandateId and not context.isSysAdmin:
if context.mandateId and not context.hasSysAdminRole:
userMandate = appInterface.getUserMandate(userId, str(context.mandateId))
if not userMandate:
raise HTTPException(
@ -404,29 +509,31 @@ def update_user(
) -> User:
"""
Update an existing user.
MULTI-TENANT: Can only update users in the same mandate (unless SysAdmin).
Self-service: Users can update their own profile (language, fullName, etc.).
Admin: MandateAdmin can update users in their mandates. SysAdmin for all.
"""
appInterface = interfaceDbApp.getInterface(context.user)
isSelfUpdate = str(context.user.id) == str(userId)
# Non-self updates require admin permission
if not isSelfUpdate and not _isAdminForUser(context, userId):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin role required to update other users"
)
# Use rootInterface for user lookup/update (avoids RBAC filtering on User table)
rootInterface = getRootInterface()
# Check if the user exists
existingUser = appInterface.getUser(userId)
existingUser = rootInterface.getUser(userId)
if not existingUser:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User with ID {userId} not found"
)
# MULTI-TENANT: Verify user is in the same mandate (unless SysAdmin)
if context.mandateId and not context.isSysAdmin:
userMandate = appInterface.getUserMandate(userId, str(context.mandateId))
if not userMandate:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot update user outside your mandate"
)
# Update user
updatedUser = appInterface.updateUser(userId, userData)
updatedUser = rootInterface.updateUser(userId, userData)
if not updatedUser:
raise HTTPException(
@ -446,36 +553,19 @@ def reset_user_password(
) -> Dict[str, Any]:
"""
Reset user password (Admin only).
MULTI-TENANT: Can only reset passwords for users in the same mandate (unless SysAdmin).
MULTI-TENANT: MandateAdmin can reset passwords for users in their mandates. SysAdmin for all.
"""
try:
# Check if current user is admin
if not context.isSysAdmin:
# Check admin permission (SysAdmin or MandateAdmin for this user)
if not _isAdminForUser(context, userId):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only administrators can reset passwords"
detail="Admin role required to reset passwords"
)
# Get user interface
appInterface = interfaceDbApp.getInterface(context.user)
# Get target user
target_user = appInterface.getUser(userId)
if not target_user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
# MULTI-TENANT: Verify user is in the same mandate (unless SysAdmin)
if context.mandateId and not context.isSysAdmin:
userMandate = appInterface.getUserMandate(userId, str(context.mandateId))
if not userMandate:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot reset password for user outside your mandate"
)
# Validate password strength
if len(newPassword) < 8:
raise HTTPException(
@ -622,7 +712,7 @@ def send_password_link(
) -> Dict[str, Any]:
"""
Send password setup/reset link to a user (admin function).
MULTI-TENANT: Can only send to users in the same mandate (unless SysAdmin).
MULTI-TENANT: MandateAdmin can send to users in their mandates. SysAdmin for all.
This allows admins to send a magic link to users to set or reset their password.
Used when creating users without password or to help users who forgot their password.
@ -634,6 +724,13 @@ def send_password_link(
try:
from modules.shared.configuration import APP_CONFIG
# Check admin permission (SysAdmin or MandateAdmin for this user)
if not _isAdminForUser(context, userId):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin role required to send password links"
)
# Get user interface
appInterface = interfaceDbApp.getInterface(context.user)
@ -645,15 +742,6 @@ def send_password_link(
detail="User not found"
)
# MULTI-TENANT: Verify user is in the same mandate (unless SysAdmin)
if context.mandateId and not context.isSysAdmin:
userMandate = appInterface.getUserMandate(userId, str(context.mandateId))
if not userMandate:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot send password link to user outside your mandate"
)
# Check if user has an email
if not targetUser.email:
raise HTTPException(
@ -769,7 +857,7 @@ def delete_user(
)
# MULTI-TENANT: Verify user is in the same mandate (unless SysAdmin)
if context.mandateId and not context.isSysAdmin:
if context.mandateId and not context.hasSysAdminRole:
userMandate = appInterface.getUserMandate(userId, str(context.mandateId))
if not userMandate:
raise HTTPException(

View file

@ -12,6 +12,7 @@ from fastapi import APIRouter, HTTPException, Depends, Body, Path, Query, Respon
# Import auth modules
from modules.auth import limiter, getCurrentUser
from modules.auth.authentication import getRequestContext, RequestContext
# Import interfaces from feature containers
import modules.interfaces.interfaceDbChat as interfaceDbChat
@ -44,8 +45,11 @@ router = APIRouter(
responses={404: {"description": "Not found"}}
)
def getServiceChat(currentUser: User):
return interfaceDbChat.getInterface(currentUser)
def getServiceChat(ctx: RequestContext):
# Workflows are not feature-instance-specific — only mandateId is needed for RBAC.
# Passing featureInstanceId would add a SQL WHERE filter that excludes workflows
# created by other features (e.g., automation vs chatbot).
return interfaceDbChat.getInterface(ctx.user, mandateId=ctx.mandateId)
# Consolidated endpoint for getting all workflows
@router.get("/", response_model=PaginatedResponse[ChatWorkflow])
@ -53,7 +57,7 @@ def getServiceChat(currentUser: User):
def get_workflows(
request: Request,
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
currentUser: User = Depends(getCurrentUser)
ctx: RequestContext = Depends(getRequestContext)
) -> PaginatedResponse[ChatWorkflow]:
"""
Get workflows with optional pagination, sorting, and filtering.
@ -81,7 +85,7 @@ def get_workflows(
detail=f"Invalid pagination parameter: {str(e)}"
)
appInterface = getInterface(currentUser)
appInterface = getServiceChat(ctx)
result = appInterface.getWorkflows(pagination=paginationParams)
# If pagination was requested, result is PaginatedResult with items as dicts
@ -126,12 +130,12 @@ def get_workflows(
def get_workflow(
request: Request,
workflowId: str = Path(..., description="ID of the workflow"),
currentUser: User = Depends(getCurrentUser)
ctx: RequestContext = Depends(getRequestContext)
) -> ChatWorkflow:
"""Get workflow by ID"""
try:
# Get workflow interface with current user context
workflowInterface = getInterface(currentUser)
workflowInterface = getServiceChat(ctx)
# Get workflow
workflow = workflowInterface.getWorkflow(workflowId)
@ -156,12 +160,12 @@ def update_workflow(
request: Request,
workflowId: str = Path(..., description="ID of the workflow to update"),
workflowData: Dict[str, Any] = Body(...),
currentUser: User = Depends(getCurrentUser)
ctx: RequestContext = Depends(getRequestContext)
) -> ChatWorkflow:
"""Update workflow by ID"""
try:
# Get workflow interface with current user context
workflowInterface = getInterface(currentUser)
workflowInterface = getServiceChat(ctx)
# Get workflow using interface method to check permissions
workflow = workflowInterface.getWorkflow(workflowId)
@ -203,12 +207,12 @@ def update_workflow(
def get_workflow_status(
request: Request,
workflowId: str = Path(..., description="ID of the workflow"),
currentUser: User = Depends(getCurrentUser)
ctx: RequestContext = Depends(getRequestContext)
) -> ChatWorkflow:
"""Get the current status of a workflow."""
try:
# Get service center
interfaceDbChat = getServiceChat(currentUser)
interfaceDbChat = getServiceChat(ctx)
# Retrieve workflow
workflow = interfaceDbChat.getWorkflow(workflowId)
@ -235,7 +239,7 @@ def get_workflow_status(
async def stop_workflow(
request: Request,
workflowId: str = Path(..., description="ID of the workflow to stop"),
currentUser: User = Depends(getCurrentUser)
ctx: RequestContext = Depends(getRequestContext)
) -> ChatWorkflow:
"""
Stop a running workflow.
@ -245,7 +249,7 @@ async def stop_workflow(
from modules.workflows.automation import chatStop
# Get the workflow first to get mandateId
interfaceChatDb = getServiceChat(currentUser)
interfaceChatDb = getServiceChat(ctx)
workflow = interfaceChatDb.getWorkflow(workflowId)
if not workflow:
@ -257,7 +261,7 @@ async def stop_workflow(
mandateId = workflow.get("mandateId") if isinstance(workflow, dict) else getattr(workflow, "mandateId", None)
# Stop the workflow
stoppedWorkflow = await chatStop(currentUser, workflowId, mandateId=mandateId)
stoppedWorkflow = await chatStop(ctx.user, workflowId, mandateId=mandateId)
return stoppedWorkflow
@ -279,7 +283,7 @@ def get_workflow_logs(
workflowId: str = Path(..., description="ID of the workflow"),
logId: Optional[str] = Query(None, description="Optional log ID to get only newer logs (legacy selective data transfer)"),
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
currentUser: User = Depends(getCurrentUser)
ctx: RequestContext = Depends(getRequestContext)
) -> PaginatedResponse[ChatLog]:
"""
Get logs for a workflow with optional pagination, sorting, and filtering.
@ -306,7 +310,7 @@ def get_workflow_logs(
)
# Get service center
interfaceDbChat = getServiceChat(currentUser)
interfaceDbChat = getServiceChat(ctx)
# Verify workflow exists
workflow = interfaceDbChat.getWorkflow(workflowId)
@ -370,7 +374,7 @@ def get_workflow_messages(
workflowId: str = Path(..., description="ID of the workflow"),
messageId: Optional[str] = Query(None, description="Optional message ID to get only newer messages (legacy selective data transfer)"),
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
currentUser: User = Depends(getCurrentUser)
ctx: RequestContext = Depends(getRequestContext)
) -> PaginatedResponse[ChatMessage]:
"""
Get messages for a workflow with optional pagination, sorting, and filtering.
@ -397,7 +401,7 @@ def get_workflow_messages(
)
# Get service center
interfaceDbChat = getServiceChat(currentUser)
interfaceDbChat = getServiceChat(ctx)
# Verify workflow exists
workflow = interfaceDbChat.getWorkflow(workflowId)
@ -460,19 +464,20 @@ def get_workflow_messages(
def delete_workflow(
request: Request,
workflowId: str = Path(..., description="ID of the workflow to delete"),
currentUser: User = Depends(getCurrentUser)
ctx: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""Deletes a workflow and its associated data."""
try:
# Get service center
interfaceDbChat = getServiceChat(currentUser)
interfaceDbChat = getServiceChat(ctx)
# Check workflow access and permission using RBAC
workflows = getRecordsetWithRBAC(
interfaceDbChat.db,
ChatWorkflow,
currentUser,
recordFilter={"id": workflowId}
ctx.user,
recordFilter={"id": workflowId},
mandateId=ctx.mandateId
)
if not workflows:
raise HTTPException(
@ -520,12 +525,12 @@ def delete_workflow_message(
request: Request,
workflowId: str = Path(..., description="ID of the workflow"),
messageId: str = Path(..., description="ID of the message to delete"),
currentUser: User = Depends(getCurrentUser)
ctx: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""Delete a message from a workflow."""
try:
# Get service center
interfaceDbChat = getServiceChat(currentUser)
interfaceDbChat = getServiceChat(ctx)
# Verify workflow exists
workflow = interfaceDbChat.getWorkflow(workflowId)
@ -571,12 +576,12 @@ def delete_file_from_message(
workflowId: str = Path(..., description="ID of the workflow"),
messageId: str = Path(..., description="ID of the message"),
fileId: str = Path(..., description="ID of the file to delete"),
currentUser: User = Depends(getCurrentUser)
ctx: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""Delete a file reference from a message in a workflow."""
try:
# Get service center
interfaceDbChat = getServiceChat(currentUser)
interfaceDbChat = getServiceChat(ctx)
# Verify workflow exists
workflow = interfaceDbChat.getWorkflow(workflowId)

View file

@ -129,7 +129,7 @@ def export_user_data(
"mandateName": mandateName,
"enabled": um.enabled,
"roleIds": roleIds,
"joinedAt": um.createdAt
"joinedAt": getattr(um, 'createdAt', None)
})
# Feature access records using interface method
@ -159,11 +159,11 @@ def export_user_data(
invitationsCreatedList = [
{
"id": inv.id,
"mandateId": inv.mandateId,
"createdAt": inv.createdAt,
"expiresAt": inv.expiresAt,
"maxUses": inv.maxUses,
"currentUses": inv.currentUses
"mandateId": getattr(inv, 'mandateId', None),
"createdAt": getattr(inv, 'createdAt', None),
"expiresAt": getattr(inv, 'expiresAt', None),
"maxUses": getattr(inv, 'maxUses', None),
"currentUses": getattr(inv, 'currentUses', None)
}
for inv in invitationsCreated
]
@ -174,8 +174,8 @@ def export_user_data(
invitationsUsedList = [
{
"id": inv.id,
"mandateId": inv.mandateId,
"usedAt": inv.usedAt
"mandateId": getattr(inv, 'mandateId', None),
"usedAt": getattr(inv, 'usedAt', None)
}
for inv in invitationsUsed
]

View file

@ -36,11 +36,14 @@ router = APIRouter(
# =============================================================================
class InvitationCreate(BaseModel):
"""Request model for creating an invitation"""
"""Request model for creating an invitation.
Invitations are feature-instance-level: the user selects a feature instance and
instance-level roles. The mandateId is derived from the feature instance automatically.
"""
targetUsername: str = Field(..., description="Username of the user to invite (must match on acceptance)")
email: Optional[str] = Field(None, description="Email address to send invitation link (optional)")
roleIds: List[str] = Field(..., description="Role IDs to assign to the invited user")
featureInstanceId: Optional[str] = Field(None, description="Optional feature instance access")
featureInstanceId: str = Field(..., description="Feature instance to grant access to")
roleIds: List[str] = Field(..., description="Instance-level role IDs to assign to the invited user")
frontendUrl: str = Field(..., description="Frontend URL for building the invite link (provided by frontend)")
expiresInHours: int = Field(
72,
@ -102,36 +105,46 @@ def create_invitation(
context: RequestContext = Depends(getRequestContext)
) -> InvitationResponse:
"""
Create a new invitation for the current mandate.
Create a new invitation for a feature instance.
Requires Mandate-Admin role. Creates a secure token that can be shared
with users to join the mandate with predefined roles.
Requires SysAdmin or Mandate-Admin role. Creates a secure token that can be shared
with users to join a feature instance with predefined roles.
The mandateId is derived from the feature instance automatically.
Args:
data: Invitation creation data
data: Invitation creation data (featureInstanceId + roleIds required)
"""
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 invitations"
)
try:
rootInterface = getRootInterface()
# Validate feature instance exists and get mandateId from it
instance = rootInterface.getFeatureInstance(data.featureInstanceId)
if not instance:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Feature instance '{data.featureInstanceId}' not found"
)
mandateId = str(instance.mandateId)
# Check admin permission: SysAdmin can invite for any mandate,
# MandateAdmin can invite for their own mandate
if not context.hasSysAdminRole:
if str(context.mandateId) != mandateId:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Feature instance belongs to a different mandate"
)
if not _hasMandateAdminRole(context):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Mandate-Admin role required to create invitations"
)
# Note: targetUsername does NOT need to exist yet!
# The invitation can be for a user who will register later.
# When they register with this username (or accept the invitation),
# they will get the assigned roles.
# Validate role IDs exist and belong to this mandate or are global
# Validate role IDs exist and belong to this feature instance
for roleId in data.roleIds:
role = rootInterface.getRole(roleId)
if not role:
@ -139,34 +152,20 @@ def create_invitation(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Role '{roleId}' not found"
)
# Role must be global or belong to this mandate
if role.mandateId and str(role.mandateId) != str(context.mandateId):
# Role must belong to this feature instance
if str(role.featureInstanceId or "") != data.featureInstanceId:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Role '{roleId}' belongs to a different mandate"
)
# Validate feature instance if provided
if data.featureInstanceId:
instance = rootInterface.getFeatureInstance(data.featureInstanceId)
if not instance:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Feature instance '{data.featureInstanceId}' not found"
)
if str(instance.mandateId) != str(context.mandateId):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Feature instance belongs to a different mandate"
detail=f"Role '{roleId}' does not belong to feature instance '{data.featureInstanceId}'"
)
# Calculate expiration time
currentTime = getUtcTimestamp()
expiresAt = currentTime + (data.expiresInHours * 3600)
# Create invitation
# Create invitation (mandateId derived from feature instance)
invitation = Invitation(
mandateId=str(context.mandateId),
mandateId=mandateId,
featureInstanceId=data.featureInstanceId,
roleIds=data.roleIds,
targetUsername=data.targetUsername,
@ -628,42 +627,50 @@ def accept_invitation(
roleIds = invitation.roleIds or []
featureInstanceId = str(invitation.featureInstanceId) if invitation.featureInstanceId else None
# Check if user is already a member
existingMembership = rootInterface.getUserMandate(str(currentUser.id), mandateId)
if existingMembership:
# Update existing membership with additional roles
for roleId in roleIds:
try:
rootInterface.addRoleToUserMandate(str(existingMembership.id), roleId)
except Exception:
pass # Role might already be assigned
userMandateId = str(existingMembership.id)
message = "Roles updated for existing membership"
else:
# Create new membership
userMandate = rootInterface.createUserMandate(
userId=str(currentUser.id),
mandateId=mandateId,
roleIds=roleIds
)
userMandateId = str(userMandate.id)
message = "Successfully joined mandate"
# Grant feature access if specified
# Grant feature access (creates FeatureAccess + auto-assigns mandate 'user' role via Regel 4)
featureAccessId = None
if featureInstanceId:
existingAccess = rootInterface.getFeatureAccess(str(currentUser.id), featureInstanceId)
if not existingAccess:
# Create feature access with instance-level roles if any
instanceRoleIds = [r for r in roleIds if _isInstanceRole(rootInterface, r, featureInstanceId)]
if existingAccess:
# Update existing access with additional roles
featureAccessId = str(existingAccess.id)
for roleId in roleIds:
try:
rootInterface.addRoleToFeatureAccess(str(existingAccess.id), roleId)
except Exception:
pass # Role might already be assigned
message = "Roles updated for existing feature access"
else:
# Create feature access with instance-level roles
# This auto-creates UserMandate with 'user' role (Regel 4)
featureAccess = rootInterface.createFeatureAccess(
userId=str(currentUser.id),
featureInstanceId=featureInstanceId,
roleIds=instanceRoleIds
roleIds=roleIds
)
featureAccessId = str(featureAccess.id)
message = "Successfully joined feature instance"
else:
# Legacy: mandate-only invitation (no feature instance)
existingMembership = rootInterface.getUserMandate(str(currentUser.id), mandateId)
if existingMembership:
for roleId in roleIds:
try:
rootInterface.addRoleToUserMandate(str(existingMembership.id), roleId)
except Exception:
pass
message = "Roles updated for existing membership"
else:
rootInterface.createUserMandate(
userId=str(currentUser.id),
mandateId=mandateId,
roleIds=roleIds
)
message = "Successfully joined mandate"
# Get userMandateId for response
userMandate = rootInterface.getUserMandate(str(currentUser.id), mandateId)
userMandateId = str(userMandate.id) if userMandate else None
# Update invitation usage
rootInterface.db.recordModify(
@ -707,7 +714,7 @@ def _hasMandateAdminRole(context: RequestContext) -> bool:
"""
Check if the user has mandate admin role in the current context.
"""
if context.isSysAdmin:
if context.hasSysAdminRole:
return True
if not context.roleIds:
@ -720,7 +727,7 @@ def _hasMandateAdminRole(context: RequestContext) -> bool:
role = rootInterface.getRole(roleId)
if role:
# Admin role at mandate level (not feature-instance level)
if role.roleLabel == "admin" and role.mandateId and not role.featureInstanceId:
if role.roleLabel == "admin" and not role.featureInstanceId:
return True
return False

View file

@ -410,7 +410,7 @@ def _hasTriggerPermission(context: RequestContext) -> bool:
Check if user has permission to trigger subscriptions.
Requires admin or mandate-admin role.
"""
if context.isSysAdmin:
if context.hasSysAdminRole:
return True
if not context.roleIds:

View file

@ -331,7 +331,7 @@ def executeAction(
# Execute action based on notification type
actionResult = None
if notification.get("type") == NotificationType.INVITATION.value:
if notification.type == NotificationType.INVITATION.value:
actionResult = _handleInvitationAction(
notification=notification,
actionId=actionRequest.actionId,
@ -381,7 +381,7 @@ def _handleInvitationAction(
from modules.datamodels.datamodelUam import Mandate
from modules.datamodels.datamodelMembership import UserMandate
invitationId = notification.get("referenceId")
invitationId = notification.referenceId
if not invitationId:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,

View file

@ -107,7 +107,7 @@ async def _validateInstanceAccess(instanceId: str, context: RequestContext) -> s
status_code=400,
detail=f"Instance '{instanceId}' is not a realestate instance"
)
if not context.isSysAdmin:
if not context.hasSysAdminRole:
featureAccesses = rootInterface.getFeatureAccessesForUser(str(context.user.id))
hasAccess = any(
str(fa.featureInstanceId) == instanceId and fa.enabled

View file

@ -75,7 +75,7 @@ def _checkUiPermission(roleIds: List[str], objectKey: str) -> bool:
if rule.item == objectKey:
return True
# Wildcard match (e.g., ui.system.* matches ui.system.playground)
# Wildcard match (e.g., ui.admin.* matches ui.admin.mandates)
if rule.item.endswith(".*"):
prefix = rule.item[:-2]
if objectKey.startswith(prefix):
@ -347,28 +347,21 @@ def _buildStaticBlocks(
blocks = []
for section in NAVIGATION_SECTIONS:
# Skip admin-only sections for non-admins
if section.get("adminOnly") and not isSysAdmin:
continue
# Filter items based on permissions
# Filter items based on UI AccessRules (ui.admin.*, ui.billing.*, etc.)
filteredItems = []
for item in section.get("items", []):
# Skip admin-only items for non-admins
if item.get("adminOnly") and not isSysAdmin:
continue
# Public items are always visible
if item.get("public"):
filteredItems.append(_formatBlockItem(item, language))
continue
# SysAdmin sees everything
if isSysAdmin:
filteredItems.append(_formatBlockItem(item, language))
# SysAdmin-only items (e.g. automation-events) require isSysAdmin
if item.get("sysAdminOnly") and not isSysAdmin:
continue
# Check permission for this item
# Check UI AccessRule for this objectKey
# Roles with item=None rule (e.g. sysadmin) get access to everything
# Roles with specific ui.admin.* rules get access to those items
if hasGlobalPermission or _checkUiPermission(roleIds, item["objectKey"]):
filteredItems.append(_formatBlockItem(item, language))
@ -459,7 +452,7 @@ def get_navigation(
}
"""
try:
isSysAdmin = reqContext.isSysAdmin
isSysAdmin = reqContext.hasSysAdminRole
userId = str(reqContext.user.id) if reqContext.user else None
# Get user's role IDs for permission checking

View file

@ -82,8 +82,23 @@ class RbacClass:
delete=AccessLevel.NONE
)
# SysAdmin hat vollen Zugriff - unabhängig vom Kontext (Mandant/Feature)
# Category A: isSysAdmin FLAG bypass (safety net for system operations)
# NOTE: sysadmin ROLE users get full access via AccessRules (DATA: ALL)
# This flag bypass is kept as fallback for true system-level operations
if hasattr(user, 'isSysAdmin') and user.isSysAdmin:
# User-owned namespaces: SysAdmin gets MY access only (own data).
# Every user -- including SysAdmin -- only has CRUD for their own
# chat workflows and files. Automation is excluded because it's
# managed by admins and the system event user needs ALL access.
_USER_OWNED_PREFIXES = ("data.chat.", "data.files.")
if item and any(item.startswith(p) for p in _USER_OWNED_PREFIXES):
return UserPermissions(
view=True,
read=AccessLevel.MY,
create=AccessLevel.MY,
update=AccessLevel.MY,
delete=AccessLevel.MY
)
return UserPermissions(
view=True,
read=AccessLevel.ALL,
@ -178,8 +193,8 @@ class RbacClass:
roleIds = set() # Use set to avoid duplicates
try:
# Load roles from the requested mandate
if mandateId:
# Specific mandate context: load roles from that mandate only
userMandateRecords = self.dbApp.getRecordset(
UserMandate,
recordFilter={"userId": user.id, "mandateId": mandateId, "enabled": True}
@ -196,9 +211,27 @@ class RbacClass:
foundRoles = [r["roleId"] for r in userMandateRoleRecords if r.get("roleId")]
roleIds.update(foundRoles)
else:
# No mandate context: load roles from ALL user's mandates.
# Required for user-owned namespaces (files, chat, automation) that
# are accessed without mandate context (e.g., /api/files/ endpoints).
# Data isolation is still enforced by _createdBy WHERE clause.
allUserMandates = self.dbApp.getRecordset(
UserMandate,
recordFilter={"userId": user.id, "enabled": True}
)
for um in allUserMandates:
userMandateId = um["id"]
userMandateRoleRecords = self.dbApp.getRecordset(
UserMandateRole,
recordFilter={"userMandateId": userMandateId}
)
roleIds.update([r["roleId"] for r in userMandateRoleRecords if r.get("roleId")])
# Load FeatureAccess + FeatureAccessRole (Instance-level roles)
if featureInstanceId:
# Specific feature instance: load roles from that instance only
featureAccessRecords = self.dbApp.getRecordset(
FeatureAccess,
recordFilter={
@ -217,6 +250,21 @@ class RbacClass:
)
roleIds.update([r["roleId"] for r in featureAccessRoleRecords if r.get("roleId")])
elif not mandateId:
# No context at all: also load feature-instance roles from ALL user's accesses.
# Same rationale: user-owned data needs roles for permission resolution.
allFeatureAccess = self.dbApp.getRecordset(
FeatureAccess,
recordFilter={"userId": user.id, "enabled": True}
)
for fa in allFeatureAccess:
featureAccessId = fa["id"]
featureAccessRoleRecords = self.dbApp.getRecordset(
FeatureAccessRole,
recordFilter={"featureAccessId": featureAccessId}
)
roleIds.update([r["roleId"] for r in featureAccessRoleRecords if r.get("roleId")])
except Exception as e:
logger.error(f"Error loading role IDs for user {user.id}: {e}")

View file

@ -64,9 +64,7 @@ class ExtractionService:
results: List[ContentExtracted] = []
# Lazy import to avoid circular deps and heavy init at module import
from modules.interfaces.interfaceDbManagement import getInterface
dbInterface = getInterface()
dbInterface = self.services.interfaceDbComponent
totalDocs = len(documents)

View file

@ -155,14 +155,14 @@ class UtilsService:
# Silent fail to never break main flow
pass
def storeDebugMessageAndDocuments(self, message, currentUser):
def storeDebugMessageAndDocuments(self, message, currentUser, mandateId=None, featureInstanceId=None):
"""
Wrapper to store debug messages and documents via interfaceDbChat.
Mirrors storeDebugMessageAndDocuments() in modules.interfaces.interfaceDbChat.
"""
try:
from modules.interfaces.interfaceDbChat import storeDebugMessageAndDocuments as _storeDebugMessageAndDocuments
_storeDebugMessageAndDocuments(message, currentUser)
_storeDebugMessageAndDocuments(message, currentUser, mandateId=mandateId, featureInstanceId=featureInstanceId)
except Exception:
# Silent fail to never break main flow
pass

View file

@ -196,11 +196,12 @@ NAVIGATION_SECTIONS = [
{
"id": "admin-feature-roles",
"objectKey": "ui.admin.featureRoles",
"label": {"en": "Feature Roles & Permissions", "de": "Features Rollen & Rechte", "fr": "Rôles et droits des features"},
"label": {"en": "Feature Role Templates", "de": "Features Rollen-Vorlagen", "fr": "Modèles de rôles features"},
"icon": "FaShieldAlt",
"path": "/admin/feature-roles",
"order": 50,
"adminOnly": True,
"sysAdminOnly": True,
},
{
"id": "admin-billing",
@ -210,6 +211,17 @@ NAVIGATION_SECTIONS = [
"path": "/admin/billing",
"order": 60,
"adminOnly": True,
"sysAdminOnly": True,
},
{
"id": "admin-automation-events",
"objectKey": "ui.admin.automationEvents",
"label": {"en": "Automation Events", "de": "Automation Events", "fr": "Événements d'automatisation"},
"icon": "FaClock",
"path": "/admin/automation-events",
"order": 65,
"adminOnly": True,
"sysAdminOnly": True,
},
],
},

View file

@ -158,10 +158,12 @@ async def executeAutomation(automationId: str, automation, creatorUser: User, se
executionLog["messages"].append(f"Workflow {workflow.id} started successfully")
logger.info(f"Started workflow {workflow.id} with plan containing {len(plan.get('tasks', []))} tasks (plan embedded in userInput)")
# Set workflow name with "automated" prefix
# Set workflow name with "automated" prefix — use creatorUser's Services
# (services parameter is eventServices with eventUser context, must use creatorUser context)
creatorServices = getServices(creatorUser, mandateId=automationMandateId, featureInstanceId=automationFeatureInstanceId)
automationLabel = automation.label or "Unknown Automation"
workflowName = f"automated: {automationLabel}"
services.interfaceDbChat.updateWorkflow(workflow.id, {"name": workflowName})
creatorServices.interfaceDbChat.updateWorkflow(workflow.id, {"name": workflowName})
logger.info(f"Set workflow {workflow.id} name to: {workflowName}")
# Save execution log (bypasses RBAC — system operation, not a user edit)