fixed automation and trustee

This commit is contained in:
ValueOn AG 2026-01-24 02:06:49 +01:00
parent a0b9a6e4b5
commit 50e3fce12b
8 changed files with 296 additions and 35 deletions

3
app.py
View file

@ -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:

View file

@ -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:

View file

@ -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

View file

@ -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

View file

@ -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}")

View file

@ -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

View file

@ -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

View file

@ -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