From 50e3fce12b4f682ee4864224c0e9748e3c4ec7cf Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Sat, 24 Jan 2026 02:06:49 +0100 Subject: [PATCH] fixed automation and trustee --- app.py | 3 + .../automation/routeFeatureAutomation.py | 30 +-- modules/features/featureRegistry.py | 10 + modules/features/trustee/mainTrustee.py | 216 +++++++++++++++++- modules/interfaces/interfaceDbChat.py | 25 +- modules/routes/routeAdminFeatures.py | 15 +- modules/shared/attributeUtils.py | 4 +- modules/workflows/automation/mainWorkflow.py | 28 ++- 8 files changed, 296 insertions(+), 35 deletions(-) diff --git a/app.py b/app.py index 4e3e0c67..57274338 100644 --- a/app.py +++ b/app.py @@ -47,6 +47,9 @@ class DailyRotatingFileHandler(RotatingFileHandler): def _updateFileIfNeeded(self): """Update the log file if the date has changed""" + # Guard against interpreter shutdown when datetime may be None + if datetime is None: + return False today = datetime.now().strftime("%Y%m%d") if self.currentDate != today: diff --git a/modules/features/automation/routeFeatureAutomation.py b/modules/features/automation/routeFeatureAutomation.py index 49c1606e..4eef9381 100644 --- a/modules/features/automation/routeFeatureAutomation.py +++ b/modules/features/automation/routeFeatureAutomation.py @@ -14,7 +14,7 @@ import json # Import interfaces and models from modules.interfaces.interfaceDbChat import getInterface as getChatInterface -from modules.auth import getCurrentUser, limiter +from modules.auth import getCurrentUser, limiter, getRequestContext, RequestContext from modules.features.automation.datamodelFeatureAutomation import AutomationDefinition from modules.datamodels.datamodelChat import ChatWorkflow from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict @@ -46,7 +46,7 @@ router = APIRouter( async def get_automations( request: Request, pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"), - currentUser = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> PaginatedResponse[AutomationDefinition]: """ Get automation definitions with optional pagination, sorting, and filtering. @@ -69,7 +69,7 @@ async def get_automations( detail=f"Invalid pagination parameter: {str(e)}" ) - chatInterface = getChatInterface(currentUser) + chatInterface = getChatInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None) result = chatInterface.getAllAutomationDefinitions(pagination=paginationParams) # If pagination was requested, result is PaginatedResult @@ -111,11 +111,11 @@ async def get_automations( async def create_automation( request: Request, automation: AutomationDefinition, - currentUser = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> AutomationDefinition: """Create a new automation definition""" try: - chatInterface = getChatInterface(currentUser) + chatInterface = getChatInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None) automationData = automation.model_dump() created = chatInterface.createAutomationDefinition(automationData) return created @@ -132,7 +132,7 @@ async def create_automation( @limiter.limit("30/minute") async def get_automation_templates( request: Request, - currentUser = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> JSONResponse: """ Get automation templates from backend module. @@ -160,11 +160,11 @@ async def get_automation_attributes( async def get_automation( request: Request, automationId: str = Path(..., description="Automation ID"), - currentUser = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> AutomationDefinition: """Get a single automation definition by ID""" try: - chatInterface = getChatInterface(currentUser) + chatInterface = getChatInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None) automation = chatInterface.getAutomationDefinition(automationId) if not automation: raise HTTPException( @@ -188,11 +188,11 @@ async def update_automation( request: Request, automationId: str = Path(..., description="Automation ID"), automation: AutomationDefinition = Body(...), - currentUser = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> AutomationDefinition: """Update an automation definition""" try: - chatInterface = getChatInterface(currentUser) + chatInterface = getChatInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None) automationData = automation.model_dump() updated = chatInterface.updateAutomationDefinition(automationId, automationData) return updated @@ -215,11 +215,11 @@ async def update_automation( async def delete_automation( request: Request, automationId: str = Path(..., description="Automation ID"), - currentUser = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> Response: """Delete an automation definition""" try: - chatInterface = getChatInterface(currentUser) + chatInterface = getChatInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None) success = chatInterface.deleteAutomationDefinition(automationId) if success: return Response(status_code=204) @@ -244,15 +244,15 @@ async def delete_automation( @router.post("/{automationId}/execute", response_model=ChatWorkflow) @limiter.limit("5/minute") -async def execute_automation( +async def execute_automation_route( request: Request, automationId: str = Path(..., description="Automation ID"), - currentUser = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> ChatWorkflow: """Execute an automation immediately (test mode)""" try: from modules.services import getInterface as getServices - services = getServices(currentUser, None) + services = getServices(context.user, context.mandateId) workflow = await executeAutomation(automationId, services) return workflow except HTTPException: diff --git a/modules/features/featureRegistry.py b/modules/features/featureRegistry.py index 29eb35c9..4bf6d82e 100644 --- a/modules/features/featureRegistry.py +++ b/modules/features/featureRegistry.py @@ -37,6 +37,7 @@ def discoverFeatureContainers() -> List[str]: def loadFeatureRouters(app: FastAPI) -> Dict[str, Any]: """ Dynamically load and register routers from all discovered feature containers. + Also registers feature template roles and AccessRules in the database. """ results = {} pattern = os.path.join(FEATURES_DIR, "*", "routeFeature*.py") @@ -64,6 +65,15 @@ def loadFeatureRouters(app: FastAPI) -> Dict[str, Any]: logger.error(f"Failed to load router from {featureDir}: {e}") results[featureDir] = {"status": "error", "error": str(e)} + # Register features in RBAC catalog and sync template roles to database + from modules.security.rbacCatalog import getCatalogService + catalogService = getCatalogService() + registrationResults = registerAllFeaturesInCatalog(catalogService) + + for featureName, success in registrationResults.items(): + if featureName in results: + results[featureName]["rbac_registered"] = success + return results diff --git a/modules/features/trustee/mainTrustee.py b/modules/features/trustee/mainTrustee.py index 176317a7..8cda0cd0 100644 --- a/modules/features/trustee/mainTrustee.py +++ b/modules/features/trustee/mainTrustee.py @@ -103,7 +103,8 @@ RESOURCE_OBJECTS = [ }, ] -# Template roles for this feature +# Template roles for this feature with AccessRules +# Each role defines default UI and DATA permissions TEMPLATE_ROLES = [ { "roleLabel": "trustee-admin", @@ -111,7 +112,13 @@ TEMPLATE_ROLES = [ "en": "Trustee Administrator - Full access to all trustee data and settings", "de": "Treuhand-Administrator - Vollzugriff auf alle Treuhand-Daten und Einstellungen", "fr": "Administrateur fiduciaire - Accès complet aux données et paramètres fiduciaires" - } + }, + "accessRules": [ + # Full UI access + {"context": "UI", "item": None, "view": True}, + # Full DATA access + {"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"}, + ] }, { "roleLabel": "trustee-accountant", @@ -119,7 +126,13 @@ TEMPLATE_ROLES = [ "en": "Trustee Accountant - Manage accounting and financial data", "de": "Treuhand-Buchhalter - Buchhaltungs- und Finanzdaten verwalten", "fr": "Comptable fiduciaire - Gérer les données comptables et financières" - } + }, + "accessRules": [ + # Full UI access + {"context": "UI", "item": None, "view": True}, + # Group-level DATA access + {"context": "DATA", "item": None, "view": True, "read": "g", "create": "g", "update": "g", "delete": "g"}, + ] }, { "roleLabel": "trustee-client", @@ -127,7 +140,13 @@ TEMPLATE_ROLES = [ "en": "Trustee Client - View own accounting data and documents", "de": "Treuhand-Kunde - Eigene Buchhaltungsdaten und Dokumente einsehen", "fr": "Client fiduciaire - Consulter ses propres données comptables et documents" - } + }, + "accessRules": [ + # Full UI access + {"context": "UI", "item": None, "view": True}, + # Own records only (MY level) + {"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"}, + ] }, ] @@ -185,9 +204,198 @@ def registerFeature(catalogService) -> bool: meta=resObj.get("meta") ) + # Sync template roles to database (with AccessRules) + _syncTemplateRolesToDb() + logger.info(f"Feature '{FEATURE_CODE}' registered {len(UI_OBJECTS)} UI objects and {len(RESOURCE_OBJECTS)} resource objects") return True except Exception as e: logger.error(f"Failed to register feature '{FEATURE_CODE}': {e}") return False + + +def _syncTemplateRolesToDb() -> int: + """ + Sync template roles and their AccessRules to the database. + Creates global template roles (mandateId=None) if they don't exist. + + Returns: + Number of roles created/updated + """ + try: + from modules.interfaces.interfaceDbApp import getRootInterface + from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext + + rootInterface = getRootInterface() + db = rootInterface.db + + # Get existing template roles for this feature + existingRoles = db.getRecordset( + Role, + recordFilter={"featureCode": FEATURE_CODE, "mandateId": None} + ) + existingRoleLabels = {r.get("roleLabel"): r.get("id") for r in existingRoles} + + createdCount = 0 + for roleTemplate in TEMPLATE_ROLES: + roleLabel = roleTemplate["roleLabel"] + + if roleLabel in existingRoleLabels: + roleId = existingRoleLabels[roleLabel] + logger.debug(f"Template role '{roleLabel}' already exists with ID {roleId}") + + # Ensure AccessRules exist for this role + _ensureAccessRulesForRole(db, roleId, roleTemplate.get("accessRules", [])) + else: + # Create new template role + newRole = Role( + roleLabel=roleLabel, + description=roleTemplate.get("description", {}), + featureCode=FEATURE_CODE, + mandateId=None, # Global template + featureInstanceId=None, + isSystemRole=False + ) + createdRole = db.recordCreate(Role, newRole.model_dump()) + roleId = createdRole.get("id") + + # Create AccessRules for this role + _ensureAccessRulesForRole(db, roleId, roleTemplate.get("accessRules", [])) + + logger.info(f"Created template role '{roleLabel}' with ID {roleId}") + createdCount += 1 + + if createdCount > 0: + logger.info(f"Feature '{FEATURE_CODE}': Created {createdCount} template roles") + + # Repair instance-specific roles that are missing AccessRules + _repairInstanceRolesAccessRules(db, existingRoleLabels) + + return createdCount + + except Exception as e: + logger.error(f"Error syncing template roles for feature '{FEATURE_CODE}': {e}") + return 0 + + +def _repairInstanceRolesAccessRules(db, templateRoleLabels: Dict[str, str]) -> int: + """ + Repair instance-specific roles by copying AccessRules from their template roles. + This ensures instance roles created before AccessRules were defined get updated. + + Args: + db: Database connector + templateRoleLabels: Dict mapping roleLabel to template role ID + + Returns: + Number of instance roles repaired + """ + from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext + + repairedCount = 0 + + # Get all instance-specific roles for this feature (mandateId is NOT None) + allRoles = db.getRecordset(Role, recordFilter={"featureCode": FEATURE_CODE}) + instanceRoles = [r for r in allRoles if r.get("mandateId") is not None] + + for instanceRole in instanceRoles: + roleLabel = instanceRole.get("roleLabel") + instanceRoleId = instanceRole.get("id") + + # Find matching template role + templateRoleId = templateRoleLabels.get(roleLabel) + if not templateRoleId: + continue + + # Check if instance role has AccessRules + existingRules = db.getRecordset(AccessRule, recordFilter={"roleId": instanceRoleId}) + if existingRules: + continue # Already has rules, skip + + # Copy AccessRules from template role + templateRules = db.getRecordset(AccessRule, recordFilter={"roleId": templateRoleId}) + if not templateRules: + continue # Template has no rules + + for rule in templateRules: + newRule = AccessRule( + roleId=instanceRoleId, + context=rule.get("context"), + item=rule.get("item"), + view=rule.get("view", False), + read=rule.get("read"), + create=rule.get("create"), + update=rule.get("update"), + delete=rule.get("delete"), + ) + db.recordCreate(AccessRule, newRule.model_dump()) + + logger.info(f"Repaired instance role '{roleLabel}' (ID: {instanceRoleId}): copied {len(templateRules)} AccessRules from template") + repairedCount += 1 + + if repairedCount > 0: + logger.info(f"Feature '{FEATURE_CODE}': Repaired {repairedCount} instance roles with missing AccessRules") + + return repairedCount + + +def _ensureAccessRulesForRole(db, roleId: str, ruleTemplates: List[Dict[str, Any]]) -> int: + """ + Ensure AccessRules exist for a role based on templates. + + Args: + db: Database connector + roleId: Role ID + ruleTemplates: List of rule templates + + Returns: + Number of rules created + """ + from modules.datamodels.datamodelRbac import AccessRule, AccessRuleContext + + # Get existing rules for this role + existingRules = db.getRecordset(AccessRule, recordFilter={"roleId": roleId}) + + # Create a set of existing rule signatures to avoid duplicates + existingSignatures = set() + for rule in existingRules: + sig = (rule.get("context"), rule.get("item")) + existingSignatures.add(sig) + + createdCount = 0 + for template in ruleTemplates: + context = template.get("context", "UI") + item = template.get("item") + sig = (context, item) + + if sig in existingSignatures: + continue + + # Map context string to enum + if context == "UI": + contextEnum = AccessRuleContext.UI + elif context == "DATA": + contextEnum = AccessRuleContext.DATA + elif context == "RESOURCE": + contextEnum = AccessRuleContext.RESOURCE + else: + contextEnum = context + + newRule = AccessRule( + roleId=roleId, + context=contextEnum, + item=item, + view=template.get("view", False), + read=template.get("read"), + create=template.get("create"), + update=template.get("update"), + delete=template.get("delete"), + ) + db.recordCreate(AccessRule, newRule.model_dump()) + createdCount += 1 + + if createdCount > 0: + logger.debug(f"Created {createdCount} AccessRules for role {roleId}") + + return createdCount diff --git a/modules/interfaces/interfaceDbChat.py b/modules/interfaces/interfaceDbChat.py index bbe2d9ce..de16d6af 100644 --- a/modules/interfaces/interfaceDbChat.py +++ b/modules/interfaces/interfaceDbChat.py @@ -1729,8 +1729,14 @@ class ChatObjects: totalPages=totalPages ) - def getAutomationDefinition(self, automationId: str) -> Optional[AutomationDefinition]: - """Returns an automation definition by ID if user has access, with computed status.""" + def getAutomationDefinition(self, automationId: str, includeSystemFields: bool = False) -> Optional[AutomationDefinition]: + """Returns an automation definition by ID if user has access, with computed status. + + Args: + automationId: ID of the automation to get + includeSystemFields: If True, returns raw dict with system fields (_createdBy, etc). + If False (default), returns Pydantic model without system fields. + """ try: # Use RBAC filtering filtered = getRecordsetWithRBAC(self.db, @@ -1749,6 +1755,16 @@ class ChatObjects: automation["executionLogs"] = [] # Enrich with user and mandate names self._enrichAutomationWithUserAndMandate(automation) + + # For internal use (execution), return raw dict with system fields + if includeSystemFields: + # Return as simple namespace object so getattr works + class AutomationWithSystemFields: + def __init__(self, data): + for key, value in data.items(): + setattr(self, key, value) + return AutomationWithSystemFields(automation) + # Clean metadata fields and return Pydantic model cleanedRecord = {k: v for k, v in automation.items() if not k.startswith("_")} return AutomationDefinition(**cleanedRecord) @@ -1771,9 +1787,12 @@ class ChatObjects: # Ensure database connector has correct userId context # The connector should have been initialized with userId, but ensure it's updated - if self.userId and hasattr(self.db, 'updateContext'): + if not self.userId: + logger.error(f"createAutomationDefinition: userId is not set! Cannot set _createdBy. currentUser={self.currentUser}") + elif hasattr(self.db, 'updateContext'): try: self.db.updateContext(self.userId) + logger.debug(f"createAutomationDefinition: Updated database context with userId={self.userId}") except Exception as e: logger.warning(f"Could not update database context: {e}") diff --git a/modules/routes/routeAdminFeatures.py b/modules/routes/routeAdminFeatures.py index 575af01e..9cb023c6 100644 --- a/modules/routes/routeAdminFeatures.py +++ b/modules/routes/routeAdminFeatures.py @@ -248,7 +248,10 @@ def _getInstancePermissions(rootInterface, userId: str, instanceId: str) -> Dict recordFilter={"userId": userId, "featureInstanceId": instanceId} ) + logger.debug(f"_getInstancePermissions: userId={userId}, instanceId={instanceId}, featureAccesses={len(featureAccesses) if featureAccesses else 0}") + if not featureAccesses: + logger.debug(f"_getInstancePermissions: No FeatureAccess found for user {userId} and instance {instanceId}") return permissions # Get role IDs via FeatureAccessRole junction table @@ -259,7 +262,10 @@ def _getInstancePermissions(rootInterface, userId: str, instanceId: str) -> Dict ) roleIds = [far.get("roleId") for far in featureAccessRoles] + logger.debug(f"_getInstancePermissions: featureAccessId={featureAccessId}, roleIds={roleIds}") + if not roleIds: + logger.debug(f"_getInstancePermissions: No roles found for FeatureAccess {featureAccessId}") return permissions # Get permissions (AccessRules) for all roles @@ -269,6 +275,8 @@ def _getInstancePermissions(rootInterface, userId: str, instanceId: str) -> Dict recordFilter={"roleId": roleId} ) + logger.debug(f"_getInstancePermissions: roleId={roleId}, accessRules={len(accessRules) if accessRules else 0}") + for rule in accessRules: context = rule.get("context", "") item = rule.get("item", "") @@ -303,8 +311,13 @@ def _getInstancePermissions(rootInterface, userId: str, instanceId: str) -> Dict # Handle UI context (views) elif context == "UI" or context == AccessRuleContext.UI: + ruleView = rule.get("view", False) if item: - permissions["views"][item] = permissions["views"].get(item, False) or rule.get("view", False) + # Specific view rule + permissions["views"][item] = permissions["views"].get(item, False) or ruleView + elif ruleView: + # item=None means all views - set a wildcard flag + permissions["views"]["_all"] = True return permissions diff --git a/modules/shared/attributeUtils.py b/modules/shared/attributeUtils.py index 5f6c2531..863d7f36 100644 --- a/modules/shared/attributeUtils.py +++ b/modules/shared/attributeUtils.py @@ -5,6 +5,7 @@ Shared utilities for model attributes and labels. """ from pydantic import BaseModel, Field, ConfigDict +from pydantic_core import PydanticUndefined from typing import Dict, Any, List, Type, Optional, Union import inspect import importlib @@ -212,7 +213,8 @@ def getModelAttributeDefinitions(modelClass: Type[BaseModel] = None, userLanguag # Don't call it here - it's meant to be called per-instance # Instead, store a marker that indicates it exists field_default = None # Frontend should use first option or specific logic - elif default_value is not ...: # Ellipsis means no default + elif default_value is not ... and default_value is not PydanticUndefined: + # Ellipsis or PydanticUndefined means no default # Convert enum to its value if it's an enum if hasattr(default_value, 'value'): field_default = default_value.value diff --git a/modules/workflows/automation/mainWorkflow.py b/modules/workflows/automation/mainWorkflow.py index 23bbf125..503d1d13 100644 --- a/modules/workflows/automation/mainWorkflow.py +++ b/modules/workflows/automation/mainWorkflow.py @@ -76,8 +76,8 @@ async def executeAutomation(automationId: str, services) -> ChatWorkflow: } try: - # 1. Load automation definition - automation = services.interfaceDbChat.getAutomationDefinition(automationId) + # 1. Load automation definition (with system fields for _createdBy access) + automation = services.interfaceDbChat.getAutomationDefinition(automationId, includeSystemFields=True) if not automation: raise ValueError(f"Automation {automationId} not found") @@ -105,18 +105,17 @@ async def executeAutomation(automationId: str, services) -> ChatWorkflow: # 3. Get user who created automation creatorUserId = getattr(automation, "_createdBy", None) - # CRITICAL: Automation MUST run as creator user only, or fail + # _createdBy is a system attribute - must be present if not creatorUserId: errorMsg = f"Automation {automationId} has no creator user (_createdBy field missing). Cannot execute automation." logger.error(errorMsg) executionLog["messages"].append(errorMsg) raise ValueError(errorMsg) - # Get user from database using services + # Get creator user from database creatorUser = services.interfaceDbApp.getUser(creatorUserId) if not creatorUser: raise ValueError(f"Creator user {creatorUserId} not found") - executionLog["messages"].append(f"Using creator user: {creatorUserId}") # 4. Create UserInputRequest from plan @@ -205,10 +204,17 @@ async def syncAutomationEvents(services, eventUser) -> Dict[str, Any]: registeredEvents = {} for automation in filtered: - automationId = automation.id - isActive = automation.active if hasattr(automation, 'active') else False - currentEventId = automation.eventId if hasattr(automation, 'eventId') else None - schedule = automation.schedule if hasattr(automation, 'schedule') else None + # Handle both dict and object access patterns + if isinstance(automation, dict): + automationId = automation.get('id') + isActive = automation.get('active', False) + currentEventId = automation.get('eventId') + schedule = automation.get('schedule') + else: + automationId = automation.id + isActive = automation.active if hasattr(automation, 'active') else False + currentEventId = automation.eventId if hasattr(automation, 'eventId') else None + schedule = automation.schedule if hasattr(automation, 'schedule') else None if not schedule: logger.warning(f"Automation {automationId} has no schedule, skipping") @@ -287,8 +293,8 @@ def createAutomationEventHandler(automationId: str, eventUser): # Get services for event user (provides access to interfaces) eventServices = getServices(eventUser, None) - # Load automation using event user context - automation = eventServices.interfaceDbChat.getAutomationDefinition(automationId) + # Load automation using event user context (with system fields for _createdBy access) + automation = eventServices.interfaceDbChat.getAutomationDefinition(automationId, includeSystemFields=True) if not automation or not getattr(automation, "active", False): logger.warning(f"Automation {automationId} not found or not active, skipping execution") return