From 7051a6e35f720da19be10645bb843b0fe7969ee7 Mon Sep 17 00:00:00 2001 From: patrick-motsch Date: Thu, 12 Feb 2026 00:34:17 +0100 Subject: [PATCH] fixed rbac issues and sysadmin integration --- modules/auth/__init__.py | 2 + modules/auth/authentication.py | 123 +++++- .../automation/datamodelFeatureAutomation.py | 18 +- .../automation/interfaceFeatureAutomation.py | 203 ++++++++-- modules/features/automation/mainAutomation.py | 41 ++ .../automation/routeFeatureAutomation.py | 66 +++- .../chatbot/interfaceFeatureChatbot.py | 8 +- .../features/chatbot/routeFeatureChatbot.py | 2 +- .../interfaceFeatureNeutralizer.py | 6 +- .../realEstate/routeFeatureRealEstate.py | 2 +- .../features/trustee/routeFeatureTrustee.py | 30 +- modules/interfaces/interfaceBootstrap.py | 301 ++++++++++++--- modules/interfaces/interfaceDbApp.py | 121 ++++-- modules/interfaces/interfaceDbBilling.py | 16 +- modules/interfaces/interfaceDbChat.py | 18 +- modules/interfaces/interfaceDbManagement.py | 29 +- modules/interfaces/interfaceRbac.py | 12 +- modules/routes/routeAdminAutomationEvents.py | 134 +++++-- modules/routes/routeAdminFeatures.py | 42 +- modules/routes/routeAdminRbacExport.py | 10 +- modules/routes/routeAdminRbacRoles.py | 306 +++++++++++++-- modules/routes/routeAdminRbacRules.py | 365 +++++++++++++++--- .../routes/routeAdminUserAccessOverview.py | 160 ++++++-- modules/routes/routeBilling.py | 61 ++- modules/routes/routeDataMandates.py | 141 +++++-- modules/routes/routeDataUsers.py | 196 +++++++--- modules/routes/routeDataWorkflows.py | 55 +-- modules/routes/routeGdpr.py | 16 +- modules/routes/routeInvitations.py | 151 ++++---- modules/routes/routeMessaging.py | 2 +- modules/routes/routeNotifications.py | 4 +- modules/routes/routeRealEstate.py | 2 +- modules/routes/routeSystem.py | 23 +- modules/security/rbac.py | 52 ++- .../mainServiceExtraction.py | 4 +- .../services/serviceUtils/mainServiceUtils.py | 4 +- modules/system/mainSystem.py | 14 +- modules/workflows/automation/mainWorkflow.py | 6 +- 38 files changed, 2170 insertions(+), 576 deletions(-) diff --git a/modules/auth/__init__.py b/modules/auth/__init__.py index 49024b66..28bc1ed2 100644 --- a/modules/auth/__init__.py +++ b/modules/auth/__init__.py @@ -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", diff --git a/modules/auth/authentication.py b/modules/auth/authentication.py index 8c918e0f..07c0e5d0 100644 --- a/modules/auth/authentication.py +++ b/modules/auth/authentication.py @@ -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 + diff --git a/modules/features/automation/datamodelFeatureAutomation.py b/modules/features/automation/datamodelFeatureAutomation.py index b6b32c22..732f3163 100644 --- a/modules/features/automation/datamodelFeatureAutomation.py +++ b/modules/features/automation/datamodelFeatureAutomation.py @@ -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é"}, }, ) diff --git a/modules/features/automation/interfaceFeatureAutomation.py b/modules/features/automation/interfaceFeatureAutomation.py index 3a7cba08..87c1c3ef 100644 --- a/modules/features/automation/interfaceFeatureAutomation.py +++ b/modules/features/automation/interfaceFeatureAutomation.py @@ -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). diff --git a/modules/features/automation/mainAutomation.py b/modules/features/automation/mainAutomation.py index bbf258cc..503e7b41 100644 --- a/modules/features/automation/mainAutomation.py +++ b/modules/features/automation/mainAutomation.py @@ -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}") diff --git a/modules/features/automation/routeFeatureAutomation.py b/modules/features/automation/routeFeatureAutomation.py index d6845a3e..4c008301 100644 --- a/modules/features/automation/routeFeatureAutomation.py +++ b/modules/features/automation/routeFeatureAutomation.py @@ -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)}" + ) diff --git a/modules/features/chatbot/interfaceFeatureChatbot.py b/modules/features/chatbot/interfaceFeatureChatbot.py index edfc0bc2..2c801d11 100644 --- a/modules/features/chatbot/interfaceFeatureChatbot.py +++ b/modules/features/chatbot/interfaceFeatureChatbot.py @@ -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 diff --git a/modules/features/chatbot/routeFeatureChatbot.py b/modules/features/chatbot/routeFeatureChatbot.py index ade51e9f..ca0c8054 100644 --- a/modules/features/chatbot/routeFeatureChatbot.py +++ b/modules/features/chatbot/routeFeatureChatbot.py @@ -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( diff --git a/modules/features/neutralization/interfaceFeatureNeutralizer.py b/modules/features/neutralization/interfaceFeatureNeutralizer.py index 32d9493b..bea7c7b3 100644 --- a/modules/features/neutralization/interfaceFeatureNeutralizer.py +++ b/modules/features/neutralization/interfaceFeatureNeutralizer.py @@ -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 diff --git a/modules/features/realEstate/routeFeatureRealEstate.py b/modules/features/realEstate/routeFeatureRealEstate.py index 18fecd73..9c56541f 100644 --- a/modules/features/realEstate/routeFeatureRealEstate.py +++ b/modules/features/realEstate/routeFeatureRealEstate.py @@ -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 diff --git a/modules/features/trustee/routeFeatureTrustee.py b/modules/features/trustee/routeFeatureTrustee.py index 408871c5..aa1f3246 100644 --- a/modules/features/trustee/routeFeatureTrustee.py +++ b/modules/features/trustee/routeFeatureTrustee.py @@ -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" diff --git a/modules/interfaces/interfaceBootstrap.py b/modules/interfaces/interfaceBootstrap.py index 8b51940c..5c595613 100644 --- a/modules/interfaces/interfaceBootstrap.py +++ b/modules/interfaces/interfaceBootstrap.py @@ -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]: diff --git a/modules/interfaces/interfaceDbApp.py b/modules/interfaces/interfaceDbApp.py index ecb1af1b..4de62bd0 100644 --- a/modules/interfaces/interfaceDbApp.py +++ b/modules/interfaces/interfaceDbApp.py @@ -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 diff --git a/modules/interfaces/interfaceDbBilling.py b/modules/interfaces/interfaceDbBilling.py index 1fd14307..07261d99 100644 --- a/modules/interfaces/interfaceDbBilling.py +++ b/modules/interfaces/interfaceDbBilling.py @@ -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) diff --git a/modules/interfaces/interfaceDbChat.py b/modules/interfaces/interfaceDbChat.py index dfdefe3c..53a33bc3 100644 --- a/modules/interfaces/interfaceDbChat.py +++ b/modules/interfaces/interfaceDbChat.py @@ -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 diff --git a/modules/interfaces/interfaceDbManagement.py b/modules/interfaces/interfaceDbManagement.py index b387b34c..6d4a912e 100644 --- a/modules/interfaces/interfaceDbManagement.py +++ b/modules/interfaces/interfaceDbManagement.py @@ -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 diff --git a/modules/interfaces/interfaceRbac.py b/modules/interfaces/interfaceRbac.py index 313165a0..f52f0fde 100644 --- a/modules/interfaces/interfaceRbac.py +++ b/modules/interfaces/interfaceRbac.py @@ -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"]) diff --git a/modules/routes/routeAdminAutomationEvents.py b/modules/routes/routeAdminAutomationEvents.py index c89f1030..867390df 100644 --- a/modules/routes/routeAdminAutomationEvents.py +++ b/modules/routes/routeAdminAutomationEvents.py @@ -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, diff --git a/modules/routes/routeAdminFeatures.py b/modules/routes/routeAdminFeatures.py index 3adc8025..2219e75d 100644 --- a/modules/routes/routeAdminFeatures.py +++ b/modules/routes/routeAdminFeatures.py @@ -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 diff --git a/modules/routes/routeAdminRbacExport.py b/modules/routes/routeAdminRbacExport.py index d22a6ba7..c499a147 100644 --- a/modules/routes/routeAdminRbacExport.py +++ b/modules/routes/routeAdminRbacExport.py @@ -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 diff --git a/modules/routes/routeAdminRbacRoles.py b/modules/routes/routeAdminRbacRoles.py index 91dafb38..c3cf655c 100644 --- a/modules/routes/routeAdminRbacRoles.py +++ b/modules/routes/routeAdminRbacRoles.py @@ -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 diff --git a/modules/routes/routeAdminRbacRules.py b/modules/routes/routeAdminRbacRules.py index 25719c5d..50b12f47 100644 --- a/modules/routes/routeAdminRbacRules.py +++ b/modules/routes/routeAdminRbacRules.py @@ -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 diff --git a/modules/routes/routeAdminUserAccessOverview.py b/modules/routes/routeAdminUserAccessOverview.py index b330d57e..3dd47342 100644 --- a/modules/routes/routeAdminUserAccessOverview.py +++ b/modules/routes/routeAdminUserAccessOverview.py @@ -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, diff --git a/modules/routes/routeBilling.py b/modules/routes/routeBilling.py index 586cc6dd..f34acbd5 100644 --- a/modules/routes/routeBilling.py +++ b/modules/routes/routeBilling.py @@ -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). diff --git a/modules/routes/routeDataMandates.py b/modules/routes/routeDataMandates.py index b74b6277..b7feacbc 100644 --- a/modules/routes/routeDataMandates.py +++ b/modules/routes/routeDataMandates.py @@ -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: diff --git a/modules/routes/routeDataUsers.py b/modules/routes/routeDataUsers.py index b269e57e..9717a602 100644 --- a/modules/routes/routeDataUsers.py +++ b/modules/routes/routeDataUsers.py @@ -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( diff --git a/modules/routes/routeDataWorkflows.py b/modules/routes/routeDataWorkflows.py index 88b41009..da18d23c 100644 --- a/modules/routes/routeDataWorkflows.py +++ b/modules/routes/routeDataWorkflows.py @@ -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) diff --git a/modules/routes/routeGdpr.py b/modules/routes/routeGdpr.py index abc39b1f..f923932e 100644 --- a/modules/routes/routeGdpr.py +++ b/modules/routes/routeGdpr.py @@ -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 ] diff --git a/modules/routes/routeInvitations.py b/modules/routes/routeInvitations.py index 58a87a16..8a72c67c 100644 --- a/modules/routes/routeInvitations.py +++ b/modules/routes/routeInvitations.py @@ -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 diff --git a/modules/routes/routeMessaging.py b/modules/routes/routeMessaging.py index 223181e0..3615d76e 100644 --- a/modules/routes/routeMessaging.py +++ b/modules/routes/routeMessaging.py @@ -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: diff --git a/modules/routes/routeNotifications.py b/modules/routes/routeNotifications.py index fc2e2608..f1a279fe 100644 --- a/modules/routes/routeNotifications.py +++ b/modules/routes/routeNotifications.py @@ -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, diff --git a/modules/routes/routeRealEstate.py b/modules/routes/routeRealEstate.py index 3f870957..39c13729 100644 --- a/modules/routes/routeRealEstate.py +++ b/modules/routes/routeRealEstate.py @@ -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 diff --git a/modules/routes/routeSystem.py b/modules/routes/routeSystem.py index 83f4ed85..25c99749 100644 --- a/modules/routes/routeSystem.py +++ b/modules/routes/routeSystem.py @@ -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 diff --git a/modules/security/rbac.py b/modules/security/rbac.py index 55048e15..f660e419 100644 --- a/modules/security/rbac.py +++ b/modules/security/rbac.py @@ -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}") diff --git a/modules/services/serviceExtraction/mainServiceExtraction.py b/modules/services/serviceExtraction/mainServiceExtraction.py index e0bed993..9be6fbfa 100644 --- a/modules/services/serviceExtraction/mainServiceExtraction.py +++ b/modules/services/serviceExtraction/mainServiceExtraction.py @@ -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) diff --git a/modules/services/serviceUtils/mainServiceUtils.py b/modules/services/serviceUtils/mainServiceUtils.py index 2c975f1d..745d2f28 100644 --- a/modules/services/serviceUtils/mainServiceUtils.py +++ b/modules/services/serviceUtils/mainServiceUtils.py @@ -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 diff --git a/modules/system/mainSystem.py b/modules/system/mainSystem.py index 3137604a..137bf17b 100644 --- a/modules/system/mainSystem.py +++ b/modules/system/mainSystem.py @@ -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, }, ], }, diff --git a/modules/workflows/automation/mainWorkflow.py b/modules/workflows/automation/mainWorkflow.py index c8ff6d35..f1b91939 100644 --- a/modules/workflows/automation/mainWorkflow.py +++ b/modules/workflows/automation/mainWorkflow.py @@ -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)