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)