From d98c31a4d1bc3adec78328f8df1fa8fde23522dc Mon Sep 17 00:00:00 2001
From: patrick-motsch
Date: Mon, 9 Feb 2026 23:44:52 +0100
Subject: [PATCH] logical fixes
---
app.py | 4 +-
modules/datamodels/datamodelFiles.py | 3 +-
modules/datamodels/datamodelUtils.py | 13 +-
.../automation/interfaceFeatureAutomation.py | 109 ++++---
.../automation/routeFeatureAutomation.py | 28 +-
.../chatbot/interfaceFeatureChatbot.py | 4 +-
modules/features/chatbot/service.py | 8 +-
.../chatplayground/mainChatplayground.py | 8 +-
.../realEstate/interfaceFeatureRealEstate.py | 4 +-
.../trustee/interfaceFeatureTrustee.py | 10 +-
modules/interfaces/interfaceBootstrap.py | 2 +-
modules/interfaces/interfaceDbApp.py | 5 +-
modules/interfaces/interfaceDbChat.py | 77 ++---
modules/interfaces/interfaceDbManagement.py | 296 +++++++++++-------
modules/interfaces/interfaceRbac.py | 2 +-
modules/routes/routeAdminAutomationEvents.py | 2 +-
modules/routes/routeAdminRbacRules.py | 9 +-
modules/routes/routeDataPrompts.py | 30 +-
modules/security/rbac.py | 7 +-
modules/services/__init__.py | 13 +-
.../services/serviceAi/subStructureFilling.py | 4 +-
.../mainServiceGeneration.py | 6 +-
.../serviceGeneration/paths/codePath.py | 4 +-
.../serviceGeneration/renderers/registry.py | 163 ++++++----
.../renderers/rendererCsv.py | 83 +++--
modules/shared/callbackRegistry.py | 15 +-
modules/workflows/automation/mainWorkflow.py | 105 +++----
.../automation/subAutomationSchedule.py | 11 +-
modules/workflows/methods/methodBase.py | 12 +-
29 files changed, 588 insertions(+), 449 deletions(-)
diff --git a/app.py b/app.py
index 32eb31f6..06ee8c2d 100644
--- a/app.py
+++ b/app.py
@@ -315,7 +315,7 @@ async def lifespan(app: FastAPI):
logger.warning(f"Could not initialize feature containers: {e}")
# --- Init Managers ---
- await subAutomationSchedule.start(eventUser) # Automation scheduler
+ subAutomationSchedule.start(eventUser) # Automation scheduler
eventManager.start()
# Register audit log cleanup scheduler
@@ -345,7 +345,7 @@ async def lifespan(app: FastAPI):
# --- Stop Managers ---
eventManager.stop()
- await subAutomationSchedule.stop(eventUser) # Automation scheduler
+ subAutomationSchedule.stop(eventUser) # Automation scheduler
# --- Stop Feature Containers (Plug&Play) ---
try:
diff --git a/modules/datamodels/datamodelFiles.py b/modules/datamodels/datamodelFiles.py
index f1b07eb3..588097e4 100644
--- a/modules/datamodels/datamodelFiles.py
+++ b/modules/datamodels/datamodelFiles.py
@@ -3,7 +3,7 @@
"""File-related datamodels: FileItem, FilePreview, FileData."""
from typing import Dict, Any, Optional, Union
-from pydantic import BaseModel, Field
+from pydantic import BaseModel, ConfigDict, Field
from modules.shared.attributeUtils import registerModelLabels
from modules.shared.timeUtils import getUtcTimestamp
import uuid
@@ -11,6 +11,7 @@ import base64
class FileItem(BaseModel):
+ model_config = ConfigDict(extra='allow') # Preserve system fields (_createdBy, _createdAt, etc.)
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
mandateId: Optional[str] = Field(default="", description="ID of the mandate this file belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
featureInstanceId: Optional[str] = Field(default="", description="ID of the feature instance this file belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
diff --git a/modules/datamodels/datamodelUtils.py b/modules/datamodels/datamodelUtils.py
index 1ac9ad33..614d6592 100644
--- a/modules/datamodels/datamodelUtils.py
+++ b/modules/datamodels/datamodelUtils.py
@@ -3,22 +3,33 @@
"""Utility datamodels: Prompt, TextMultilingual."""
from typing import Dict, Optional
-from pydantic import BaseModel, Field, field_validator
+from pydantic import BaseModel, ConfigDict, Field, field_validator
from modules.shared.attributeUtils import registerModelLabels
import uuid
class Prompt(BaseModel):
+ model_config = ConfigDict(extra='allow') # Preserve system fields (_createdBy, _createdAt, etc.)
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
mandateId: str = Field(default="", description="ID of the mandate this prompt belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
+ isSystem: bool = Field(default=False, description="System prompt visible to all users (read-only for non-SysAdmin)", json_schema_extra={"frontend_type": "boolean", "frontend_readonly": True, "frontend_required": False})
content: str = Field(description="Content of the prompt", json_schema_extra={"frontend_type": "textarea", "frontend_readonly": False, "frontend_required": True})
name: str = Field(description="Name of the prompt", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True})
+
+ @field_validator('isSystem', mode='before')
+ @classmethod
+ def _coerceIsSystem(cls, v):
+ """Existing records may have isSystem=None (field didn't exist). Treat None as False."""
+ if v is None:
+ return False
+ return v
registerModelLabels(
"Prompt",
{"en": "Prompt", "fr": "Invite"},
{
"id": {"en": "ID", "fr": "ID"},
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat"},
+ "isSystem": {"en": "System", "fr": "Système"},
"content": {"en": "Content", "fr": "Contenu"},
"name": {"en": "Name", "fr": "Nom"},
},
diff --git a/modules/features/automation/interfaceFeatureAutomation.py b/modules/features/automation/interfaceFeatureAutomation.py
index e99f7683..3a7cba08 100644
--- a/modules/features/automation/interfaceFeatureAutomation.py
+++ b/modules/features/automation/interfaceFeatureAutomation.py
@@ -8,7 +8,6 @@ Uses the PostgreSQL connector for data access with user/mandate filtering.
import logging
import uuid
import math
-import asyncio
from typing import Dict, Any, List, Optional, Union
from modules.security.rbac import RbacClass
@@ -99,7 +98,7 @@ class AutomationObjects:
return True
elif accessLevel == AccessLevel.MY:
if recordId:
- record = self.db.getRecordset(model, {"id": recordId})
+ record = self.db.getRecordset(model, recordFilter={"id": recordId})
if record:
return record[0].get("_createdBy") == self.userId
else:
@@ -118,16 +117,17 @@ class AutomationObjects:
def _enrichAutomationsWithUserAndMandate(self, automations: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
- Batch enrich automations with user names and mandate names for display.
+ Batch enrich automations with user names, mandate names and feature instance labels.
Uses direct DB lookup (no RBAC) because this is purely cosmetic enrichment —
the user already has RBAC-verified access to the automations themselves.
"""
if not automations:
return automations
- # Collect all unique user IDs and mandate IDs
+ # Collect all unique IDs
userIds = set()
mandateIds = set()
+ featureInstanceIds = set()
for automation in automations:
createdBy = automation.get("_createdBy")
@@ -137,48 +137,63 @@ class AutomationObjects:
mandateId = automation.get("mandateId")
if mandateId:
mandateIds.add(mandateId)
+
+ featureInstanceId = automation.get("featureInstanceId")
+ if featureInstanceId:
+ featureInstanceIds.add(featureInstanceId)
# Use root DB connector for display-only lookups (no RBAC needed)
+ usersMap = {}
+ mandatesMap = {}
+ featureInstancesMap = {}
try:
from modules.datamodels.datamodelUam import UserInDB, Mandate
+ from modules.datamodels.datamodelFeatures import FeatureInstance
from modules.security.rootAccess import getRootDbAppConnector
dbAppConn = getRootDbAppConnector()
# Batch fetch user display names
- usersMap = {}
if userIds:
for userId in userIds:
- users = dbAppConn.getRecordset(UserInDB, {"id": userId})
+ users = dbAppConn.getRecordset(UserInDB, recordFilter={"id": userId})
if users:
user = users[0]
- fullName = f"{user.get('firstName', '')} {user.get('lastName', '')}".strip()
- usersMap[userId] = fullName or user.get("email") or user.get("username") or userId
+ displayName = user.get("fullName") or user.get("username") or user.get("email") or None
+ if displayName:
+ usersMap[userId] = displayName
# Batch fetch mandate display names
- mandatesMap = {}
if mandateIds:
for mandateId in mandateIds:
- mandates = dbAppConn.getRecordset(Mandate, {"id": mandateId})
+ mandates = dbAppConn.getRecordset(Mandate, recordFilter={"id": mandateId})
if mandates:
- mandatesMap[mandateId] = mandates[0].get("name") or mandateId
+ label = mandates[0].get("label") or mandates[0].get("name") or None
+ if label:
+ mandatesMap[mandateId] = label
+
+ # Batch fetch feature instance labels
+ if featureInstanceIds:
+ for fiId in featureInstanceIds:
+ instances = dbAppConn.getRecordset(FeatureInstance, recordFilter={"id": fiId})
+ if instances:
+ fi = instances[0]
+ label = fi.get("label") or fi.get("featureCode") or None
+ if label:
+ featureInstancesMap[fiId] = label
except Exception as e:
- logger.warning(f"Could not enrich automations with user/mandate names: {e}")
- usersMap = {}
- mandatesMap = {}
+ logger.warning(f"Could not enrich automations with display names: {e}")
# Enrich each automation with the fetched data
+ # SECURITY: Never show a fallback name — if lookup fails, show empty string
for automation in automations:
createdBy = automation.get("_createdBy")
- if createdBy:
- automation["_createdByUserName"] = usersMap.get(createdBy, createdBy)
- else:
- automation["_createdByUserName"] = "-"
+ automation["_createdByUserName"] = usersMap.get(createdBy, "") if createdBy else ""
mandateId = automation.get("mandateId")
- if mandateId:
- automation["mandateName"] = mandatesMap.get(mandateId, mandateId)
- else:
- automation["mandateName"] = "-"
+ automation["mandateName"] = mandatesMap.get(mandateId, "") if mandateId else ""
+
+ featureInstanceId = automation.get("featureInstanceId")
+ automation["featureInstanceName"] = featureInstancesMap.get(featureInstanceId, "") if featureInstanceId else ""
return automations
@@ -195,11 +210,13 @@ class AutomationObjects:
Supports optional pagination, sorting, and filtering.
Computes status field for each automation.
"""
- # Use RBAC filtering
+ # AutomationDefinitions can belong to any feature instance within a mandate.
+ # Filter by mandateId only — not by featureInstanceId — to show all definitions across features.
filteredAutomations = getRecordsetWithRBAC(
self.db,
AutomationDefinition,
- self.currentUser
+ self.currentUser,
+ mandateId=self.mandateId
)
# Compute status for each automation and normalize executionLogs
@@ -282,12 +299,14 @@ class AutomationObjects:
If False (default), returns Pydantic model without system fields.
"""
try:
- # Use RBAC filtering
+ # AutomationDefinitions can belong to any feature instance within a mandate.
+ # Filter by mandateId only — not by featureInstanceId.
filtered = getRecordsetWithRBAC(
self.db,
AutomationDefinition,
self.currentUser,
- recordFilter={"id": automationId}
+ recordFilter={"id": automationId},
+ mandateId=self.mandateId
)
if not filtered:
@@ -363,8 +382,8 @@ class AutomationObjects:
if createdAutomation.get("executionLogs") is None:
createdAutomation["executionLogs"] = []
- # Trigger automation change callback (async, don't wait)
- asyncio.create_task(self._notifyAutomationChanged())
+ # Trigger automation change callback
+ self._notifyAutomationChanged()
# Clean metadata fields and return Pydantic model
cleanedRecord = {k: v for k, v in createdAutomation.items() if not k.startswith("_")}
@@ -408,8 +427,8 @@ class AutomationObjects:
if updatedAutomation.get("executionLogs") is None:
updatedAutomation["executionLogs"] = []
- # Trigger automation change callback (async, don't wait)
- asyncio.create_task(self._notifyAutomationChanged())
+ # Trigger automation change callback
+ self._notifyAutomationChanged()
# Clean metadata fields and return Pydantic model
cleanedRecord = {k: v for k, v in updatedAutomation.items() if not k.startswith("_")}
@@ -432,8 +451,8 @@ class AutomationObjects:
# Delete automation from database
self.db.recordDelete(AutomationDefinition, automationId)
- # Trigger automation change callback (async, don't wait)
- asyncio.create_task(self._notifyAutomationChanged())
+ # Trigger automation change callback
+ self._notifyAutomationChanged()
return True
except Exception as e:
@@ -454,7 +473,9 @@ class AutomationObjects:
return getRecordsetWithRBAC(
self.db,
AutomationDefinition,
- user
+ user,
+ mandateId=self.mandateId,
+ featureInstanceId=self.featureInstanceId
)
# =========================================================================
@@ -466,7 +487,7 @@ class AutomationObjects:
Returns automation templates filtered by RBAC (MY = own templates).
Supports optional pagination, sorting, and filtering.
"""
- # Use RBAC filtering
+ # Templates are global (not mandate/feature-instance scoped) — no mandateId/featureInstanceId filter
filteredTemplates = getRecordsetWithRBAC(
self.db,
AutomationTemplate,
@@ -526,23 +547,24 @@ class AutomationObjects:
userNameMap = {}
for userId in userIds:
- users = dbAppConn.getRecordset(UserInDB, {"id": userId})
+ users = dbAppConn.getRecordset(UserInDB, recordFilter={"id": userId})
if users:
user = users[0]
- fullName = f"{user.get('firstName', '')} {user.get('lastName', '')}".strip()
- userNameMap[userId] = fullName or user.get("email", "Unknown")
+ displayName = user.get("fullName") or user.get("username") or user.get("email") or None
+ if displayName:
+ userNameMap[userId] = displayName
- # Apply to templates
+ # Apply to templates — SECURITY: no fallback, empty if not found
for template in templates:
createdBy = template.get("_createdBy")
- if createdBy and createdBy in userNameMap:
- template["_createdByUserName"] = userNameMap[createdBy]
+ template["_createdByUserName"] = userNameMap.get(createdBy, "") if createdBy else ""
except Exception as e:
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."""
try:
+ # Templates are global — no mandateId/featureInstanceId filter
filtered = getRecordsetWithRBAC(
self.db,
AutomationTemplate,
@@ -645,12 +667,13 @@ class AutomationObjects:
logger.error(f"Error deleting automation template: {str(e)}")
raise
- async def _notifyAutomationChanged(self):
- """Notify registered callbacks about automation changes (decoupled from features)."""
+ def _notifyAutomationChanged(self):
+ """Notify registered callbacks about automation changes (decoupled from features).
+ Sync-safe: works from both sync and async contexts."""
try:
from modules.shared.callbackRegistry import callbackRegistry
# Trigger callbacks without knowing which features are listening
- await callbackRegistry.trigger('automation.changed', self)
+ callbackRegistry.trigger('automation.changed', self)
except Exception as e:
logger.error(f"Error notifying automation change: {str(e)}")
diff --git a/modules/features/automation/routeFeatureAutomation.py b/modules/features/automation/routeFeatureAutomation.py
index f7c5feda..d6845a3e 100644
--- a/modules/features/automation/routeFeatureAutomation.py
+++ b/modules/features/automation/routeFeatureAutomation.py
@@ -66,7 +66,9 @@ def get_automations(
detail=f"Invalid pagination parameter: {str(e)}"
)
- chatInterface = getAutomationInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None)
+ # AutomationDefinitions can belong to ANY feature instance within a mandate.
+ # The list endpoint must show all definitions for the user's mandate, not filter by a specific featureInstanceId.
+ chatInterface = getAutomationInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None)
result = chatInterface.getAllAutomationDefinitions(pagination=paginationParams)
# If pagination was requested, result is PaginatedResult
@@ -150,7 +152,7 @@ def get_available_actions(
# Ensure methods are discovered (need a service center for discovery)
if not methods:
# Create a lightweight service center for method discovery
- services = getServices(context.user, context.mandateId)
+ services = getServices(context.user, mandateId=context.mandateId)
discoverMethods(services)
actionsList = []
@@ -235,7 +237,7 @@ def get_automation(
) -> AutomationDefinition:
"""Get a single automation definition by ID"""
try:
- chatInterface = getAutomationInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None)
+ chatInterface = getAutomationInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None)
automation = chatInterface.getAutomationDefinition(automationId)
if not automation:
raise HTTPException(
@@ -263,7 +265,7 @@ def update_automation(
) -> AutomationDefinition:
"""Update an automation definition"""
try:
- chatInterface = getAutomationInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None)
+ chatInterface = getAutomationInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None)
automationData = automation.model_dump()
updated = chatInterface.updateAutomationDefinition(automationId, automationData)
return updated
@@ -291,7 +293,7 @@ def update_automation_status(
) -> AutomationDefinition:
"""Update only the active status of an automation definition"""
try:
- chatInterface = getAutomationInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None)
+ chatInterface = getAutomationInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None)
# Get existing automation
automation = chatInterface.getAutomationDefinition(automationId)
@@ -331,7 +333,7 @@ def delete_automation(
) -> Response:
"""Delete an automation definition"""
try:
- chatInterface = getAutomationInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None)
+ chatInterface = getAutomationInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None)
success = chatInterface.deleteAutomationDefinition(automationId)
if success:
return Response(status_code=204)
@@ -364,13 +366,15 @@ async def execute_automation_route(
"""Execute an automation immediately (test mode)"""
try:
from modules.services import getInterface as getServices
- services = getServices(context.user, context.mandateId)
- # Propagate feature context for billing
- if context.featureInstanceId:
- services.featureInstanceId = str(context.featureInstanceId)
- services.featureCode = 'automation'
+ services = getServices(context.user, mandateId=context.mandateId, featureInstanceId=context.featureInstanceId)
+
+ # Load automation with current user's context (user has RBAC permissions via UI)
+ automation = services.interfaceDbAutomation.getAutomationDefinition(automationId, includeSystemFields=True)
+ if not automation:
+ raise ValueError(f"Automation {automationId} not found")
+
from modules.workflows.automation import executeAutomation
- workflow = await executeAutomation(automationId, services)
+ workflow = await executeAutomation(automationId, automation, context.user, services)
return workflow
except HTTPException:
raise
diff --git a/modules/features/chatbot/interfaceFeatureChatbot.py b/modules/features/chatbot/interfaceFeatureChatbot.py
index 4d77f633..edfc0bc2 100644
--- a/modules/features/chatbot/interfaceFeatureChatbot.py
+++ b/modules/features/chatbot/interfaceFeatureChatbot.py
@@ -360,10 +360,12 @@ class ChatObjects:
return False
tableName = modelClass.__name__
+ from modules.interfaces.interfaceRbac import buildDataObjectKey
+ objectKey = buildDataObjectKey(tableName, featureCode=self.featureCode if hasattr(self, 'featureCode') else None)
permissions = self.rbac.getUserPermissions(
self.currentUser,
AccessRuleContext.DATA,
- tableName,
+ objectKey,
mandateId=self.mandateId,
featureInstanceId=self.featureInstanceId
)
diff --git a/modules/features/chatbot/service.py b/modules/features/chatbot/service.py
index 3afd1632..d6a8d1a4 100644
--- a/modules/features/chatbot/service.py
+++ b/modules/features/chatbot/service.py
@@ -91,12 +91,8 @@ async def chatProcess(
ChatWorkflow instance
"""
try:
- # Get services with mandate context
- services = getServices(currentUser, mandateId)
-
- # Set feature context for billing
- if featureInstanceId:
- services.featureInstanceId = featureInstanceId
+ # Get services with mandate and feature instance context
+ services = getServices(currentUser, mandateId=mandateId, featureInstanceId=featureInstanceId)
services.featureCode = 'chatbot'
interfaceDbChat = services.interfaceDbChat
diff --git a/modules/features/chatplayground/mainChatplayground.py b/modules/features/chatplayground/mainChatplayground.py
index 085d93e4..246236a1 100644
--- a/modules/features/chatplayground/mainChatplayground.py
+++ b/modules/features/chatplayground/mainChatplayground.py
@@ -49,10 +49,10 @@ RESOURCE_OBJECTS = [
]
# Template roles for this feature
-# IMPORTANT: "viewer" role is required for automatic user assignment!
+# Role names MUST follow convention: {featureCode}-{roleName}
TEMPLATE_ROLES = [
{
- "roleLabel": "viewer",
+ "roleLabel": "chatplayground-viewer",
"description": {
"en": "Chat Playground Viewer - View chat playground (read-only)",
"de": "Chat Playground Betrachter - Chat Playground ansehen (nur lesen)",
@@ -67,7 +67,7 @@ TEMPLATE_ROLES = [
]
},
{
- "roleLabel": "user",
+ "roleLabel": "chatplayground-user",
"description": {
"en": "Chat Playground User - Use chat playground and workflows",
"de": "Chat Playground Benutzer - Chat Playground und Workflows nutzen",
@@ -86,7 +86,7 @@ TEMPLATE_ROLES = [
]
},
{
- "roleLabel": "admin",
+ "roleLabel": "chatplayground-admin",
"description": {
"en": "Chat Playground Admin - Full access to chat playground",
"de": "Chat Playground Admin - Vollzugriff auf Chat Playground",
diff --git a/modules/features/realEstate/interfaceFeatureRealEstate.py b/modules/features/realEstate/interfaceFeatureRealEstate.py
index 86374a2c..57e14f23 100644
--- a/modules/features/realEstate/interfaceFeatureRealEstate.py
+++ b/modules/features/realEstate/interfaceFeatureRealEstate.py
@@ -749,10 +749,12 @@ class RealEstateObjects:
return False
tableName = modelClass.__name__
+ from modules.interfaces.interfaceRbac import buildDataObjectKey
+ objectKey = buildDataObjectKey(tableName, featureCode=self.featureCode if hasattr(self, 'featureCode') else None)
permissions = self.rbac.getUserPermissions(
self.currentUser,
AccessRuleContext.DATA,
- tableName,
+ objectKey,
mandateId=self.mandateId,
featureInstanceId=self.featureInstanceId
)
diff --git a/modules/features/trustee/interfaceFeatureTrustee.py b/modules/features/trustee/interfaceFeatureTrustee.py
index 8710d148..7b3e4a6b 100644
--- a/modules/features/trustee/interfaceFeatureTrustee.py
+++ b/modules/features/trustee/interfaceFeatureTrustee.py
@@ -171,10 +171,12 @@ class TrusteeObjects:
return False
tableName = modelClass.__name__
+ from modules.interfaces.interfaceRbac import buildDataObjectKey
+ objectKey = buildDataObjectKey(tableName, featureCode=self.featureCode if hasattr(self, 'featureCode') else None)
permissions = self.rbac.getUserPermissions(
self.currentUser,
AccessRuleContext.DATA,
- tableName,
+ objectKey,
mandateId=self.mandateId,
featureInstanceId=self.featureInstanceId
)
@@ -198,10 +200,12 @@ class TrusteeObjects:
return AccessLevel.NONE
tableName = modelClass.__name__
+ from modules.interfaces.interfaceRbac import buildDataObjectKey
+ objectKey = buildDataObjectKey(tableName, featureCode=self.featureCode if hasattr(self, 'featureCode') else None)
permissions = self.rbac.getUserPermissions(
self.currentUser,
AccessRuleContext.DATA,
- tableName,
+ objectKey,
mandateId=self.mandateId,
featureInstanceId=self.featureInstanceId
)
@@ -1470,7 +1474,7 @@ class TrusteeObjects:
def getAllUserAccess(self, userId: str) -> List[Dict[str, Any]]:
"""Get all access records for a user across all organisations."""
- return self.db.getRecordset(TrusteeAccess, {"userId": userId})
+ return self.db.getRecordset(TrusteeAccess, recordFilter={"userId": userId})
def getUserTrusteeRoles(self, userId: str, organisationId: str, contractId: Optional[str] = None) -> List[str]:
"""
diff --git a/modules/interfaces/interfaceBootstrap.py b/modules/interfaces/interfaceBootstrap.py
index f750565d..8a58f352 100644
--- a/modules/interfaces/interfaceBootstrap.py
+++ b/modules/interfaces/interfaceBootstrap.py
@@ -129,7 +129,7 @@ def initAutomationTemplates(dbApp: DatabaseConnector, adminUserId: Optional[str]
# Get admin user ID if not provided (from poweron_app)
if not adminUserId:
- adminUsers = dbApp.getRecordset(UserInDB, {"email": APP_CONFIG.ADMIN_EMAIL})
+ adminUsers = dbApp.getRecordset(UserInDB, recordFilter={"email": APP_CONFIG.ADMIN_EMAIL})
adminUserId = adminUsers[0]["id"] if adminUsers else None
# Update context with admin user
if adminUserId:
diff --git a/modules/interfaces/interfaceDbApp.py b/modules/interfaces/interfaceDbApp.py
index 68fee415..2ca8232b 100644
--- a/modules/interfaces/interfaceDbApp.py
+++ b/modules/interfaces/interfaceDbApp.py
@@ -245,10 +245,13 @@ class AppObjects:
return False
tableName = modelClass.__name__
+ # Use buildDataObjectKey for semantic namespace lookup
+ from modules.interfaces.interfaceRbac import buildDataObjectKey
+ objectKey = buildDataObjectKey(tableName)
permissions = self.rbac.getUserPermissions(
self.currentUser,
AccessRuleContext.DATA,
- tableName,
+ objectKey,
mandateId=self.mandateId
)
diff --git a/modules/interfaces/interfaceDbChat.py b/modules/interfaces/interfaceDbChat.py
index e7925dbd..dfdefe3c 100644
--- a/modules/interfaces/interfaceDbChat.py
+++ b/modules/interfaces/interfaceDbChat.py
@@ -339,6 +339,18 @@ class ChatObjects:
pass
+ def _getRecordset(self, modelClass, recordFilter=None, **kwargs):
+ """Wrapper for getRecordsetWithRBAC that automatically includes mandateId/featureInstanceId."""
+ return getRecordsetWithRBAC(
+ self.db,
+ modelClass,
+ self.currentUser,
+ recordFilter=recordFilter,
+ mandateId=self.mandateId,
+ featureInstanceId=self.featureInstanceId,
+ **kwargs
+ )
+
def checkRbacPermission(
self,
modelClass: type,
@@ -610,12 +622,7 @@ class ChatObjects:
If pagination is provided: PaginatedResult with items and metadata
"""
# Use RBAC filtering with featureInstanceId for instance-level isolation
- filteredWorkflows = getRecordsetWithRBAC(self.db,
- ChatWorkflow,
- self.currentUser,
- mandateId=self.mandateId,
- featureInstanceId=self.featureInstanceId
- )
+ filteredWorkflows = self._getRecordset(ChatWorkflow)
# If no pagination requested, return all items (no sorting - frontend handles it)
if pagination is None:
@@ -647,13 +654,7 @@ class ChatObjects:
def getWorkflow(self, workflowId: str) -> Optional[ChatWorkflow]:
"""Returns a workflow by ID if user has access."""
# Use RBAC filtering with featureInstanceId for instance-level isolation
- workflows = getRecordsetWithRBAC(self.db,
- ChatWorkflow,
- self.currentUser,
- recordFilter={"id": workflowId},
- mandateId=self.mandateId,
- featureInstanceId=self.featureInstanceId
- )
+ workflows = self._getRecordset(ChatWorkflow, recordFilter={"id": workflowId})
if not workflows:
return None
@@ -809,7 +810,7 @@ class ChatObjects:
# Delete message documents (but NOT the files!)
# Note: ChatStat does NOT have messageId - stats are only at workflow level
try:
- existing_docs = getRecordsetWithRBAC(self.db, ChatDocument, self.currentUser, recordFilter={"messageId": messageId})
+ existing_docs = self._getRecordset(ChatDocument, recordFilter={"messageId": messageId})
for doc in existing_docs:
self.db.recordDelete(ChatDocument, doc["id"])
except Exception as e:
@@ -819,12 +820,12 @@ class ChatObjects:
self.db.recordDelete(ChatMessage, messageId)
# 2. Delete workflow stats
- existing_stats = getRecordsetWithRBAC(self.db, ChatStat, self.currentUser, recordFilter={"workflowId": workflowId})
+ existing_stats = self._getRecordset(ChatStat, recordFilter={"workflowId": workflowId})
for stat in existing_stats:
self.db.recordDelete(ChatStat, stat["id"])
# 3. Delete workflow logs
- existing_logs = getRecordsetWithRBAC(self.db, ChatLog, self.currentUser, recordFilter={"workflowId": workflowId})
+ existing_logs = self._getRecordset(ChatLog, recordFilter={"workflowId": workflowId})
for log in existing_logs:
self.db.recordDelete(ChatLog, log["id"])
@@ -855,11 +856,7 @@ class ChatObjects:
"""
# Check workflow access first (without calling getWorkflow to avoid circular reference)
# Use RBAC filtering
- workflows = getRecordsetWithRBAC(self.db,
- ChatWorkflow,
- self.currentUser,
- recordFilter={"id": workflowId}
- )
+ workflows = self._getRecordset(ChatWorkflow, recordFilter={"id": workflowId})
if not workflows:
if pagination is None:
@@ -867,7 +864,7 @@ class ChatObjects:
return PaginatedResult(items=[], totalItems=0, totalPages=0)
# Get messages for this workflow from normalized table
- messages = getRecordsetWithRBAC(self.db, ChatMessage, self.currentUser, recordFilter={"workflowId": workflowId})
+ messages = self._getRecordset(ChatMessage, recordFilter={"workflowId": workflowId})
# Convert raw messages to dict format for sorting/filtering
messageDicts = []
@@ -1143,7 +1140,7 @@ class ChatObjects:
raise ValueError("messageId cannot be empty")
# Check if message exists in database
- messages = getRecordsetWithRBAC(self.db, ChatMessage, self.currentUser, recordFilter={"id": messageId})
+ messages = self._getRecordset(ChatMessage, recordFilter={"id": messageId})
if not messages:
logger.warning(f"Message with ID {messageId} does not exist in database")
@@ -1250,12 +1247,12 @@ class ChatObjects:
# CASCADE DELETE: Delete all related data first
# 1. Delete message stats
- existing_stats = getRecordsetWithRBAC(self.db, ChatStat, self.currentUser, recordFilter={"messageId": messageId})
+ existing_stats = self._getRecordset(ChatStat, recordFilter={"messageId": messageId})
for stat in existing_stats:
self.db.recordDelete(ChatStat, stat["id"])
# 2. Delete message documents (but NOT the files!)
- existing_docs = getRecordsetWithRBAC(self.db, ChatDocument, self.currentUser, recordFilter={"messageId": messageId})
+ existing_docs = self._getRecordset(ChatDocument, recordFilter={"messageId": messageId})
for doc in existing_docs:
self.db.recordDelete(ChatDocument, doc["id"])
@@ -1282,7 +1279,7 @@ class ChatObjects:
# Get documents for this message from normalized table
- documents = getRecordsetWithRBAC(self.db, ChatDocument, self.currentUser, recordFilter={"messageId": messageId})
+ documents = self._getRecordset(ChatDocument, recordFilter={"messageId": messageId})
if not documents:
logger.warning(f"No documents found for message {messageId}")
@@ -1323,7 +1320,7 @@ class ChatObjects:
def getDocuments(self, messageId: str) -> List[ChatDocument]:
"""Returns documents for a message from normalized table."""
try:
- documents = getRecordsetWithRBAC(self.db, ChatDocument, self.currentUser, recordFilter={"messageId": messageId})
+ documents = self._getRecordset(ChatDocument, recordFilter={"messageId": messageId})
return [ChatDocument(**doc) for doc in documents]
except Exception as e:
logger.error(f"Error getting message documents: {str(e)}")
@@ -1369,11 +1366,7 @@ class ChatObjects:
"""
# Check workflow access first (without calling getWorkflow to avoid circular reference)
# Use RBAC filtering
- workflows = getRecordsetWithRBAC(self.db,
- ChatWorkflow,
- self.currentUser,
- recordFilter={"id": workflowId}
- )
+ workflows = self._getRecordset(ChatWorkflow, recordFilter={"id": workflowId})
if not workflows:
if pagination is None:
@@ -1381,7 +1374,7 @@ class ChatObjects:
return PaginatedResult(items=[], totalItems=0, totalPages=0)
# Get logs for this workflow from normalized table
- logs = getRecordsetWithRBAC(self.db, ChatLog, self.currentUser, recordFilter={"workflowId": workflowId})
+ logs = self._getRecordset(ChatLog, recordFilter={"workflowId": workflowId})
# Convert raw logs to dict format for sorting/filtering
logDicts = []
@@ -1513,17 +1506,13 @@ class ChatObjects:
"""Returns list of statistics for a workflow if user has access."""
# Check workflow access first (without calling getWorkflow to avoid circular reference)
# Use RBAC filtering
- workflows = getRecordsetWithRBAC(self.db,
- ChatWorkflow,
- self.currentUser,
- recordFilter={"id": workflowId}
- )
+ workflows = self._getRecordset(ChatWorkflow, recordFilter={"id": workflowId})
if not workflows:
return []
# Get stats for this workflow from normalized table
- stats = getRecordsetWithRBAC(self.db, ChatStat, self.currentUser, recordFilter={"workflowId": workflowId})
+ stats = self._getRecordset(ChatStat, recordFilter={"workflowId": workflowId})
if not stats:
return []
@@ -1581,11 +1570,7 @@ class ChatObjects:
"""
# Check workflow access first
# Use RBAC filtering
- workflows = getRecordsetWithRBAC(self.db,
- ChatWorkflow,
- self.currentUser,
- recordFilter={"id": workflowId}
- )
+ workflows = self._getRecordset(ChatWorkflow, recordFilter={"id": workflowId})
if not workflows:
return {"items": []}
@@ -1594,7 +1579,7 @@ class ChatObjects:
items = []
# Get messages
- messages = getRecordsetWithRBAC(self.db, ChatMessage, self.currentUser, recordFilter={"workflowId": workflowId})
+ messages = self._getRecordset(ChatMessage, recordFilter={"workflowId": workflowId})
for msg in messages:
# Apply timestamp filtering in Python
msgTimestamp = parseTimestamp(msg.get("publishedAt"), default=getUtcTimestamp())
@@ -1635,7 +1620,7 @@ class ChatObjects:
})
# Get logs - return all logs with roundNumber if available
- logs = getRecordsetWithRBAC(self.db, ChatLog, self.currentUser, recordFilter={"workflowId": workflowId})
+ logs = self._getRecordset(ChatLog, recordFilter={"workflowId": workflowId})
for log in logs:
# Apply timestamp filtering in Python
logTimestamp = parseTimestamp(log.get("timestamp"), default=getUtcTimestamp())
diff --git a/modules/interfaces/interfaceDbManagement.py b/modules/interfaces/interfaceDbManagement.py
index 59fc4c1c..b387b34c 100644
--- a/modules/interfaces/interfaceDbManagement.py
+++ b/modules/interfaces/interfaceDbManagement.py
@@ -313,10 +313,12 @@ class ComponentObjects:
return False
tableName = modelClass.__name__
+ from modules.interfaces.interfaceRbac import buildDataObjectKey
+ objectKey = buildDataObjectKey(tableName)
permissions = self.rbac.getUserPermissions(
self.currentUser,
AccessRuleContext.DATA,
- tableName,
+ objectKey,
mandateId=self.mandateId,
featureInstanceId=self.featureInstanceId
)
@@ -590,10 +592,58 @@ class ComponentObjects:
# Prompt methods
+ def _isSysAdmin(self) -> bool:
+ """Check if the current user is a SysAdmin."""
+ return hasattr(self.currentUser, 'isSysAdmin') and self.currentUser.isSysAdmin
+
+ def _enrichPromptsWithPermissions(self, prompts: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
+ """Enrich prompts with row-level _permissions based on ownership and isSystem flag.
+
+ - SysAdmin: canUpdate=True, canDelete=True on all prompts
+ - Regular user on own prompts: canUpdate=True, canDelete=True
+ - Regular user on system prompts: canUpdate=False, canDelete=False (read-only)
+ """
+ isSysAdmin = self._isSysAdmin()
+ for prompt in prompts:
+ isOwner = prompt.get("_createdBy") == self.userId
+ prompt["_permissions"] = {
+ "canUpdate": isOwner or isSysAdmin,
+ "canDelete": isOwner or isSysAdmin
+ }
+ return prompts
+
+ def _getPromptsForUser(self) -> List[Dict[str, Any]]:
+ """Returns prompts visible to the current user.
+
+ Visibility rules:
+ - SysAdmin: ALL prompts
+ - Regular user: own prompts (_createdBy) + system prompts (isSystem=True)
+ """
+ if self._isSysAdmin():
+ return self.db.getRecordset(Prompt)
+
+ # Get own prompts
+ ownPrompts = self.db.getRecordset(Prompt, recordFilter={"_createdBy": self.userId})
+
+ # Get system prompts
+ systemPrompts = self.db.getRecordset(Prompt, recordFilter={"isSystem": True})
+
+ # Merge and deduplicate (a user's own prompt could also be isSystem)
+ seen = {}
+ for p in ownPrompts:
+ seen[p["id"]] = p
+ for p in systemPrompts:
+ if p["id"] not in seen:
+ seen[p["id"]] = p
+
+ return list(seen.values())
+
def getAllPrompts(self, pagination: Optional[PaginationParams] = None) -> Union[List[Prompt], PaginatedResult]:
"""
- Returns prompts based on user access level.
- Supports optional pagination, sorting, and filtering.
+ Returns prompts with visibility rules:
+ - SysAdmin: sees ALL prompts, can CRUD all
+ - Regular user: sees own prompts + system prompts (isSystem=True), can only CRUD own
+ - Row-level _permissions control edit/delete buttons in the UI
Args:
pagination: Optional pagination parameters. If None, returns all items.
@@ -603,11 +653,11 @@ class ComponentObjects:
If pagination is provided: PaginatedResult with items and metadata
"""
try:
- # Use RBAC filtering
- filteredPrompts = getRecordsetWithRBAC(self.db,
- Prompt,
- self.currentUser
- )
+ # Get prompts based on user role (own + system for regular, all for SysAdmin)
+ filteredPrompts = self._getPromptsForUser()
+
+ # Enrich with row-level permissions (_permissions: canUpdate, canDelete)
+ filteredPrompts = self._enrichPromptsWithPermissions(filteredPrompts)
# If no pagination requested, return all items
if pagination is None:
@@ -630,7 +680,7 @@ class ComponentObjects:
endIdx = startIdx + pagination.pageSize
pagedPrompts = filteredPrompts[startIdx:endIdx]
- # Convert to model objects
+ # Convert to model objects (extra='allow' on Prompt preserves system fields)
items = [Prompt(**prompt) for prompt in pagedPrompts]
return PaginatedResult(
@@ -646,15 +696,24 @@ class ComponentObjects:
return PaginatedResult(items=[], totalItems=0, totalPages=0)
def getPrompt(self, promptId: str) -> Optional[Prompt]:
- """Returns a prompt by ID if user has access."""
- # Use RBAC filtering
- filteredPrompts = getRecordsetWithRBAC(self.db,
- Prompt,
- self.currentUser,
- recordFilter={"id": promptId}
- )
+ """Returns a prompt by ID if the user has visibility.
- return Prompt(**filteredPrompts[0]) if filteredPrompts else None
+ Visibility: SysAdmin sees all, regular user sees own + system prompts.
+ """
+ filteredPrompts = self.db.getRecordset(Prompt, recordFilter={"id": promptId})
+ if not filteredPrompts:
+ return None
+
+ prompt = filteredPrompts[0]
+
+ # Visibility check for non-SysAdmin: must be owner or system prompt
+ if not self._isSysAdmin():
+ isOwner = prompt.get("_createdBy") == self.userId
+ isSystem = prompt.get("isSystem", False)
+ if not isOwner and not isSystem:
+ return None
+
+ return Prompt(**prompt)
def createPrompt(self, promptData: Dict[str, Any]) -> Dict[str, Any]:
"""Creates a new prompt if user has permission."""
@@ -669,13 +728,25 @@ class ComponentObjects:
return createdRecord
def updatePrompt(self, promptId: str, updateData: Dict[str, Any]) -> Dict[str, Any]:
- """Updates a prompt if user has access."""
+ """Updates a prompt. Rules:
+ - SysAdmin: can update any prompt (including system prompts)
+ - Regular user: can only update own prompts (not system prompts)
+ """
try:
- # Get prompt
+ # Get prompt (visibility-checked)
prompt = self.getPrompt(promptId)
if not prompt:
raise ValueError(f"Prompt {promptId} not found")
+ # Permission check: owner or SysAdmin
+ isOwner = (getattr(prompt, '_createdBy', None) == self.userId)
+ if not self._isSysAdmin() and not isOwner:
+ raise PermissionError(f"No permission to update prompt {promptId}")
+
+ # Regular users cannot set isSystem flag
+ if not self._isSysAdmin() and 'isSystem' in updateData:
+ del updateData['isSystem']
+
# Update prompt record directly with the update data
self.db.recordModify(Prompt, promptId, updateData)
@@ -688,77 +759,69 @@ class ComponentObjects:
return updatedPrompt.model_dump()
+ except PermissionError:
+ raise
except Exception as e:
logger.error(f"Error updating prompt: {str(e)}")
raise ValueError(f"Failed to update prompt: {str(e)}")
def deletePrompt(self, promptId: str) -> bool:
- """Deletes a prompt if user has access."""
- # Check if the prompt exists and user has access
+ """Deletes a prompt. Rules:
+ - SysAdmin: can delete any prompt (including system prompts)
+ - Regular user: can only delete own prompts (not system prompts)
+ """
+ # Get prompt (visibility-checked)
prompt = self.getPrompt(promptId)
if not prompt:
return False
-
- if not self.checkRbacPermission(Prompt, "update", promptId):
+
+ # Permission check: owner or SysAdmin
+ isOwner = (getattr(prompt, '_createdBy', None) == self.userId)
+ if not self._isSysAdmin() and not isOwner:
raise PermissionError(f"No permission to delete prompt {promptId}")
# Delete prompt
success = self.db.recordDelete(Prompt, promptId)
-
return success
# File Utilities
- def checkForDuplicateFile(self, fileHash: str, fileName: str = None) -> Optional[FileItem]:
- """Checks if a file with the same hash already exists for the current user and mandate.
- If fileName is provided, also checks for exact name+hash match.
- Only returns files the current user has access to."""
- # Get files with the hash, filtered by RBAC
- accessibleFiles = getRecordsetWithRBAC(self.db,
+ def checkForDuplicateFile(self, fileHash: str, fileName: str) -> Optional[FileItem]:
+ """Checks if a file with the same hash AND fileName already exists for the current user.
+
+ Duplicate = same user (_createdBy) + same fileHash + same fileName.
+ Same hash with different name is allowed (intentional copy by user).
+ Uses direct DB query (not RBAC) because files are isolated per user.
+ """
+ if not self.userId:
+ return None
+
+ # Direct DB query: find files with matching hash + name + user
+ matchingFiles = self.db.getRecordset(
FileItem,
- self.currentUser,
- recordFilter={"fileHash": fileHash}
+ recordFilter={
+ "_createdBy": self.userId,
+ "fileHash": fileHash,
+ "fileName": fileName
+ }
)
- if not accessibleFiles:
+ if not matchingFiles:
return None
-
- # If fileName is provided, check for exact name+hash match first
- if fileName:
- for file in accessibleFiles:
- # Skip files without fileName key or with None/empty fileName
- if "fileName" not in file or not file["fileName"]:
- continue
- if file["fileName"] == fileName:
- return FileItem(
- id=file["id"],
- mandateId=file["mandateId"],
- fileName=file["fileName"],
- mimeType=file["mimeType"],
- fileHash=file["fileHash"],
- fileSize=file["fileSize"],
- creationDate=file["creationDate"]
- )
- # Return first valid file with matching hash (for general duplicate detection)
- for file in accessibleFiles:
- # Skip files without fileName key or with None/empty fileName
- if "fileName" not in file or not file["fileName"]:
- continue
- # Use first valid file
- return FileItem(
- id=file["id"],
- mandateId=file["mandateId"],
- fileName=file["fileName"],
- mimeType=file["mimeType"],
- fileHash=file["fileHash"],
- fileSize=file["fileSize"],
- creationDate=file["creationDate"]
- )
-
- # If no valid files found, return None
- return None
+ # Return first match
+ file = matchingFiles[0]
+ return FileItem(
+ id=file["id"],
+ mandateId=file.get("mandateId", ""),
+ featureInstanceId=file.get("featureInstanceId", ""),
+ fileName=file["fileName"],
+ mimeType=file["mimeType"],
+ fileHash=file["fileHash"],
+ fileSize=file["fileSize"],
+ creationDate=file["creationDate"]
+ )
def getMimeType(self, fileName: str) -> str:
"""Determines the MIME type based on the file extension."""
@@ -832,9 +895,18 @@ class ComponentObjects:
# File methods - metadata-based operations
+ def _getFilesByCurrentUser(self, recordFilter: Dict[str, Any] = None) -> List[Dict[str, Any]]:
+ """Files are always user-scoped. Returns only files owned by the current user,
+ regardless of role (including SysAdmin). This bypasses RBAC intentionally."""
+ filterDict = {"_createdBy": self.userId}
+ if recordFilter:
+ filterDict.update(recordFilter)
+ return self.db.getRecordset(FileItem, recordFilter=filterDict)
+
def getAllFiles(self, pagination: Optional[PaginationParams] = None) -> Union[List[FileItem], PaginatedResult]:
"""
- Returns files based on user access level.
+ Returns files owned by the current user (user-scoped, not RBAC-based).
+ Every user (including SysAdmin) only sees their own files.
Supports optional pagination, sorting, and filtering.
Args:
@@ -844,13 +916,10 @@ class ComponentObjects:
If pagination is None: List[FileItem]
If pagination is provided: PaginatedResult with items and metadata
"""
- # Use RBAC filtering
- filteredFiles = getRecordsetWithRBAC(self.db,
- FileItem,
- self.currentUser
- )
+ # Files are always user-scoped: filter by _createdBy (bypasses RBAC SysAdmin override)
+ filteredFiles = self._getFilesByCurrentUser()
- # Convert database records to FileItem instances (for both paginated and non-paginated)
+ # Convert database records to FileItem instances (extra='allow' preserves system fields like _createdBy)
def convertFileItems(files):
fileItems = []
for file in files:
@@ -858,21 +927,14 @@ class ComponentObjects:
# Ensure proper values, use defaults for invalid data
creationDate = file.get("creationDate")
if creationDate is None or not isinstance(creationDate, (int, float)) or creationDate <= 0:
- creationDate = getUtcTimestamp()
+ file["creationDate"] = getUtcTimestamp()
fileName = file.get("fileName")
if not fileName or fileName == "None":
continue # Skip records with invalid fileName
- fileItem = FileItem(
- id=file.get("id"),
- mandateId=file.get("mandateId"),
- fileName=fileName,
- mimeType=file.get("mimeType"),
- fileHash=file.get("fileHash"),
- fileSize=file.get("fileSize"),
- creationDate=creationDate
- )
+ # Use **file to pass all fields including system fields (_createdBy, etc.)
+ fileItem = FileItem(**file)
fileItems.append(fileItem)
except Exception as e:
logger.warning(f"Skipping invalid file record: {str(e)}")
@@ -900,7 +962,7 @@ class ComponentObjects:
endIdx = startIdx + pagination.pageSize
pagedFiles = filteredFiles[startIdx:endIdx]
- # Convert to model objects
+ # Convert to model objects (extra='allow' on FileItem preserves system fields)
items = convertFileItems(pagedFiles)
return PaginatedResult(
@@ -910,13 +972,9 @@ class ComponentObjects:
)
def getFile(self, fileId: str) -> Optional[FileItem]:
- """Returns a file by ID if user has access."""
- # Use RBAC filtering
- filteredFiles = getRecordsetWithRBAC(self.db,
- FileItem,
- self.currentUser,
- recordFilter={"id": fileId}
- )
+ """Returns a file by ID if it belongs to the current user (user-scoped)."""
+ # Files are always user-scoped: filter by _createdBy (bypasses RBAC SysAdmin override)
+ filteredFiles = self._getFilesByCurrentUser(recordFilter={"id": fileId})
if not filteredFiles:
return None
@@ -976,17 +1034,28 @@ class ComponentObjects:
counter += 1
def createFile(self, name: str, mimeType: str, content: bytes) -> FileItem:
- """Creates a new file entry if user has permission. Computes fileHash and fileSize from content."""
+ """Creates a new file entry if user has permission. Computes fileHash and fileSize from content.
+
+ Duplicate check: if a file with the same user + fileHash + fileName already exists,
+ the existing file is returned instead of creating a new one.
+ Same hash with different name is allowed (intentional copy by user).
+ """
if not self.checkRbacPermission(FileItem, "create"):
raise PermissionError("No permission to create files")
- # Ensure fileName is unique
- uniqueName = self._generateUniquefileName(name)
-
# Compute file size and hash
fileSize = len(content)
fileHash = hashlib.sha256(content).hexdigest()
+ # Duplicate check: same user + same hash + same fileName → return existing
+ existingFile = self.checkForDuplicateFile(fileHash, name)
+ if existingFile:
+ logger.info(f"Duplicate file detected in createFile: '{name}' (hash={fileHash[:12]}...) for user {self.userId} — returning existing file {existingFile.id}")
+ return existingFile
+
+ # Ensure fileName is unique
+ uniqueName = self._generateUniquefileName(name)
+
# Use mandateId and featureInstanceId from context for proper data isolation
# Convert None to empty string to satisfy Pydantic validation
mandateId = self.mandateId or ""
@@ -1005,7 +1074,6 @@ class ComponentObjects:
# Store in database
self.db.recordCreate(FileItem, fileItem)
-
return fileItem
def updateFile(self, fileId: str, updateData: Dict[str, Any]) -> Dict[str, Any]:
@@ -1040,20 +1108,16 @@ class ComponentObjects:
if not self.checkRbacPermission(FileItem, "update", fileId):
raise PermissionError(f"No permission to delete file {fileId}")
- # Check for other references to this file (by hash) - use RBAC to only check files user has access to
+ # Check for other references to this file (by hash) - user-scoped check
fileHash = file.fileHash
if fileHash:
- allReferences = getRecordsetWithRBAC(self.db,
- FileItem,
- self.currentUser,
- recordFilter={"fileHash": fileHash}
- )
+ allReferences = self._getFilesByCurrentUser(recordFilter={"fileHash": fileHash})
otherReferences = [f for f in allReferences if f["id"] != fileId]
# Only delete associated fileData if no other references exist
if not otherReferences:
try:
- fileDataEntries = getRecordsetWithRBAC(self.db, FileData, self.currentUser, recordFilter={"id": fileId})
+ fileDataEntries = self.db.getRecordset(FileData, recordFilter={"id": fileId})
if fileDataEntries:
self.db.recordDelete(FileData, fileId)
logger.debug(f"FileData for file {fileId} deleted")
@@ -1113,6 +1177,12 @@ class ComponentObjects:
base64Encoded = True
logger.debug(f"Stored file {fileId} as base64")
+ # Check if file data already exists (e.g., when createFile returned a duplicate)
+ existingData = self.db.getRecordset(FileData, recordFilter={"id": fileId})
+ if existingData:
+ logger.debug(f"File data already exists for {fileId} — skipping duplicate storage")
+ return True
+
# Create the fileData record with data and encoding flag
fileDataObj = {
"id": fileId,
@@ -1245,25 +1315,21 @@ class ComponentObjects:
logger.error(f"Invalid fileContent type: {type(fileContent)}")
raise ValueError(f"fileContent must be bytes, got {type(fileContent)}")
- # Compute file hash first to check for duplicates
+ # Compute file hash to check for duplicates before any DB writes
fileHash = hashlib.sha256(fileContent).hexdigest()
- # Check for exact name+hash match first (same name + same content)
+ # Duplicate check: same user + same fileHash + same fileName → return existing file
+ # Same hash with different name is allowed (intentional copy by user)
existingFile = self.checkForDuplicateFile(fileHash, fileName)
if existingFile:
- logger.info(f"Exact duplicate detected: {fileName} with same hash. Returning existing file reference.")
+ logger.info(f"Duplicate detected for user {self.userId}: '{fileName}' with hash {fileHash[:12]}... — returning existing file {existingFile.id}")
return existingFile, "exact_duplicate"
- # Check for hash-only match (same content, different name)
- existingFileWithSameHash = self.checkForDuplicateFile(fileHash)
- if existingFileWithSameHash:
- logger.info(f"Content duplicate detected: {fileName} has same content as {existingFileWithSameHash.fileName}")
- # Continue with upload - filename will be made unique if needed
-
# Determine MIME type
mimeType = self.getMimeType(fileName)
- # Save metadata and file (hash/size computed inside createFile)
+ # createFile handles its own duplicate check (for calls from other code paths)
+ # Here we already checked, so this will create a new file
logger.debug(f"Saving file metadata to database for file: {fileName}")
fileItem = self.createFile(
name=fileName,
diff --git a/modules/interfaces/interfaceRbac.py b/modules/interfaces/interfaceRbac.py
index 21fd6fa2..313165a0 100644
--- a/modules/interfaces/interfaceRbac.py
+++ b/modules/interfaces/interfaceRbac.py
@@ -163,7 +163,7 @@ def getRecordsetWithRBAC(
# Check view permission first
if not permissions.view:
- logger.debug(f"User {currentUser.id} has no view permission for {objectKey}")
+ logger.debug(f"User {currentUser.id} has no view permission for {objectKey} (mandateId={effectiveMandateId}, featureInstanceId={featureInstanceId})")
return []
# Build WHERE clause with RBAC filtering
diff --git a/modules/routes/routeAdminAutomationEvents.py b/modules/routes/routeAdminAutomationEvents.py
index 7765d621..c89f1030 100644
--- a/modules/routes/routeAdminAutomationEvents.py
+++ b/modules/routes/routeAdminAutomationEvents.py
@@ -90,7 +90,7 @@ async def sync_all_automation_events(
from modules.services import getInterface as getServices
services = getServices(currentUser, None)
- result = await syncAutomationEvents(services, eventUser)
+ result = syncAutomationEvents(services, eventUser)
return {
"success": True,
"synced": result.get("synced", 0),
diff --git a/modules/routes/routeAdminRbacRules.py b/modules/routes/routeAdminRbacRules.py
index 1feb64a2..5a639431 100644
--- a/modules/routes/routeAdminRbacRules.py
+++ b/modules/routes/routeAdminRbacRules.py
@@ -78,11 +78,18 @@ def get_permissions(
)
# MULTI-TENANT: Get permissions using context (mandateId/featureInstanceId)
+ # For DATA context, resolve short model names to full objectKeys
+ # e.g., "ChatWorkflow" → "data.chat.ChatWorkflow"
+ resolvedItem = item or ""
+ if accessContext == AccessRuleContext.DATA and resolvedItem and "." not in resolvedItem:
+ from modules.interfaces.interfaceRbac import buildDataObjectKey
+ resolvedItem = buildDataObjectKey(resolvedItem)
+
# Pass mandateId and featureInstanceId to load Feature-Instance roles
permissions = interface.rbac.getUserPermissions(
reqContext.user,
accessContext,
- item or "",
+ resolvedItem,
mandateId=reqContext.mandateId,
featureInstanceId=reqContext.featureInstanceId
)
diff --git a/modules/routes/routeDataPrompts.py b/modules/routes/routeDataPrompts.py
index 4aad221d..faf692fe 100644
--- a/modules/routes/routeDataPrompts.py
+++ b/modules/routes/routeDataPrompts.py
@@ -121,10 +121,10 @@ def get_prompt(
def update_prompt(
request: Request,
promptId: str = Path(..., description="ID of the prompt to update"),
- promptData: Prompt = Body(...),
+ promptData: Dict[str, Any] = Body(...),
currentUser: User = Depends(getCurrentUser)
) -> Prompt:
- """Update an existing prompt"""
+ """Update an existing prompt (supports partial updates for inline editing)"""
managementInterface = interfaceDbManagement.getInterface(currentUser)
# Check if the prompt exists
@@ -135,14 +135,17 @@ def update_prompt(
detail=f"Prompt with ID {promptId} not found"
)
- # Convert Prompt to dict for interface, excluding the id field
- if hasattr(promptData, "model_dump"):
- update_data = promptData.model_dump(exclude={"id"})
- else:
- update_data = promptData.model_dump(exclude={"id"})
+ # Remove id from update data if present
+ update_data = {k: v for k, v in promptData.items() if k != "id"}
- # Update prompt
- updatedPrompt = managementInterface.updatePrompt(promptId, update_data)
+ # Update prompt (ownership check happens in interface)
+ try:
+ updatedPrompt = managementInterface.updatePrompt(promptId, update_data)
+ except PermissionError as e:
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail=str(e)
+ )
if not updatedPrompt:
raise HTTPException(
@@ -170,7 +173,14 @@ def delete_prompt(
detail=f"Prompt with ID {promptId} not found"
)
- success = managementInterface.deletePrompt(promptId)
+ try:
+ success = managementInterface.deletePrompt(promptId)
+ except PermissionError as e:
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail=str(e)
+ )
+
if not success:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
diff --git a/modules/security/rbac.py b/modules/security/rbac.py
index 0a2136b1..55048e15 100644
--- a/modules/security/rbac.py
+++ b/modules/security/rbac.py
@@ -62,7 +62,7 @@ class RbacClass:
Multi-Tenant Design:
- Lädt Rollen aus UserMandate + UserMandateRole wenn mandateId gegeben
- - isSysAdmin gibt vollen Zugriff auf System-Level (kein mandateId)
+ - isSysAdmin gibt vollen Zugriff, unabhängig vom Kontext
Args:
user: User object
@@ -82,8 +82,8 @@ class RbacClass:
delete=AccessLevel.NONE
)
- # SysAdmin auf System-Level (kein Mandant) hat vollen Zugriff
- if hasattr(user, 'isSysAdmin') and user.isSysAdmin and not mandateId:
+ # SysAdmin hat vollen Zugriff - unabhängig vom Kontext (Mandant/Feature)
+ if hasattr(user, 'isSysAdmin') and user.isSysAdmin:
return UserPermissions(
view=True,
read=AccessLevel.ALL,
@@ -96,6 +96,7 @@ class RbacClass:
roleIds = self._getRoleIdsForUser(user, mandateId, featureInstanceId)
if not roleIds:
+ logger.debug(f"getUserPermissions: NO roles found for user={user.id}, mandateId={mandateId}, featureInstanceId={featureInstanceId}, item={item}")
return permissions
# Lade alle relevanten Regeln für alle Rollen
diff --git a/modules/services/__init__.py b/modules/services/__init__.py
index 3e0a2560..6712372a 100644
--- a/modules/services/__init__.py
+++ b/modules/services/__init__.py
@@ -63,10 +63,11 @@ class Services:
- Feature-specific Services are loaded dynamically via filename discovery
"""
- def __init__(self, user: User, workflow: "ChatWorkflow" = None, mandateId: Optional[str] = None):
+ def __init__(self, user: User, workflow: "ChatWorkflow" = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None):
self.user: User = user
self.workflow = workflow
self.mandateId: Optional[str] = mandateId
+ self.featureInstanceId: Optional[str] = featureInstanceId
self.currentUserPrompt: str = ""
self.rawUserPrompt: str = ""
@@ -83,7 +84,7 @@ class Services:
# CENTRAL INTERFACE (Chat/Workflow)
# ============================================================
from modules.interfaces.interfaceDbChat import getInterface as getChatInterface
- self.interfaceDbChat = getChatInterface(user, mandateId=mandateId)
+ self.interfaceDbChat = getChatInterface(user, mandateId=mandateId, featureInstanceId=featureInstanceId)
# ============================================================
# SHARED SERVICES (from modules/services/)
@@ -143,7 +144,7 @@ class Services:
# Get interface via getInterface()
if hasattr(module, "getInterface"):
- interface = module.getInterface(self.user, mandateId=self.mandateId)
+ interface = module.getInterface(self.user, mandateId=self.mandateId, featureInstanceId=self.featureInstanceId)
# Derive attribute name: interfaceFeatureAiChat -> interfaceDbChat
attrName = filename.replace("interfaceFeature", "interfaceDb")
setattr(self, attrName, interface)
@@ -191,6 +192,6 @@ class Services:
logger.debug(f"Could not load service from {filepath}: {e}")
-def getInterface(user: User, workflow: "ChatWorkflow" = None, mandateId: Optional[str] = None) -> Services:
- """Get Services instance for the given user and mandate context."""
- return Services(user, workflow, mandateId=mandateId)
+def getInterface(user: User, workflow: "ChatWorkflow" = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None) -> Services:
+ """Get Services instance for the given user, mandate, and feature instance context."""
+ return Services(user, workflow, mandateId=mandateId, featureInstanceId=featureInstanceId)
diff --git a/modules/services/serviceAi/subStructureFilling.py b/modules/services/serviceAi/subStructureFilling.py
index 9b503567..a96b8353 100644
--- a/modules/services/serviceAi/subStructureFilling.py
+++ b/modules/services/serviceAi/subStructureFilling.py
@@ -2574,8 +2574,8 @@ CRITICAL:
"""
from modules.services.serviceGeneration.renderers.registry import getRenderer
- # Get renderer for this format - NO FALLBACK
- renderer = getRenderer(outputFormat, self.services)
+ # Get document renderer for this format (structure filling is document generation path)
+ renderer = getRenderer(outputFormat, self.services, outputStyle='document')
if not renderer:
raise ValueError(f"No renderer found for output format '{outputFormat}'. Check renderer registry.")
diff --git a/modules/services/serviceGeneration/mainServiceGeneration.py b/modules/services/serviceGeneration/mainServiceGeneration.py
index 4720c9a0..447b7f9d 100644
--- a/modules/services/serviceGeneration/mainServiceGeneration.py
+++ b/modules/services/serviceGeneration/mainServiceGeneration.py
@@ -556,10 +556,10 @@ class GenerationService:
def _getFormatRenderer(self, output_format: str):
- """Get the appropriate renderer for the specified format using auto-discovery."""
+ """Get the appropriate document renderer for the specified format."""
try:
from .renderers.registry import getRenderer, getSupportedFormats
- renderer = getRenderer(output_format, services=self.services)
+ renderer = getRenderer(output_format, services=self.services, outputStyle='document')
if renderer:
return renderer
@@ -573,7 +573,7 @@ class GenerationService:
# Fallback to text renderer if no specific renderer found
logger.warning(f"Falling back to text renderer for format {output_format}")
- fallbackRenderer = getRenderer('text', services=self.services)
+ fallbackRenderer = getRenderer('text', services=self.services, outputStyle='document')
if fallbackRenderer:
return fallbackRenderer
diff --git a/modules/services/serviceGeneration/paths/codePath.py b/modules/services/serviceGeneration/paths/codePath.py
index f2470385..d43d275f 100644
--- a/modules/services/serviceGeneration/paths/codePath.py
+++ b/modules/services/serviceGeneration/paths/codePath.py
@@ -922,7 +922,7 @@ CRITICAL:
"""Get code renderer for file type."""
from modules.services.serviceGeneration.renderers.registry import getRenderer
- # Map file types to renderer formats
+ # Map file types to renderer formats (code path)
formatMap = {
'json': 'json',
'csv': 'csv',
@@ -931,7 +931,7 @@ CRITICAL:
rendererFormat = formatMap.get(fileType.lower())
if rendererFormat:
- renderer = getRenderer(rendererFormat, self.services)
+ renderer = getRenderer(rendererFormat, self.services, outputStyle='code')
# Check if renderer supports code rendering
if renderer and hasattr(renderer, 'renderCodeFiles'):
return renderer
diff --git a/modules/services/serviceGeneration/renderers/registry.py b/modules/services/serviceGeneration/renderers/registry.py
index c7e2d9f6..b0c96e80 100644
--- a/modules/services/serviceGeneration/renderers/registry.py
+++ b/modules/services/serviceGeneration/renderers/registry.py
@@ -2,20 +2,30 @@
# All rights reserved.
"""
Renderer registry for automatic discovery and registration of renderers.
+
+Renderers are indexed by (format, outputStyle) so that document generation
+and code generation each get the correct renderer for the same format.
"""
import logging
import importlib
-from typing import Dict, Type, List, Optional
+from typing import Dict, Type, List, Optional, Tuple
from .documentRendererBaseTemplate import BaseRenderer
logger = logging.getLogger(__name__)
+
class RendererRegistry:
- """Registry for automatic renderer discovery and management."""
+ """Registry for automatic renderer discovery and management.
+
+ Maintains separate renderer mappings per outputStyle ('document', 'code', etc.)
+ so that document-generation and code-generation paths each resolve to the
+ correct renderer, even when both support the same format (e.g. 'csv').
+ """
def __init__(self):
- self._renderers: Dict[str, Type[BaseRenderer]] = {}
+ # Key: (formatName, outputStyle) -> rendererClass
+ self._renderers: Dict[Tuple[str, str], Type[BaseRenderer]] = {}
self._format_mappings: Dict[str, str] = {}
self._discovered = False
@@ -25,39 +35,27 @@ class RendererRegistry:
return
try:
- import os
- import sys
from pathlib import Path
- # Get the directory containing this registry file
currentDir = Path(__file__).parent
- renderersDir = currentDir
-
- # Get the package name dynamically
packageName = __name__.rsplit('.', 1)[0]
- # Scan all Python files in the renderers directory
- for filePath in renderersDir.glob("*.py"):
- if filePath.name in ['registry.py', 'documentRendererBaseTemplate.py', '__init__.py']:
+ for filePath in currentDir.glob("*.py"):
+ if filePath.name in ['registry.py', 'documentRendererBaseTemplate.py', 'codeRendererBaseTemplate.py', '__init__.py']:
continue
- # Extract module name from filename
moduleName = filePath.stem
try:
- # Import the module dynamically
fullModuleName = f"{packageName}.{moduleName}"
module = importlib.import_module(fullModuleName)
- # Look for renderer classes in the module
for attrName in dir(module):
attr = getattr(module, attrName)
if (isinstance(attr, type) and
issubclass(attr, BaseRenderer) and
attr != BaseRenderer and
hasattr(attr, 'getSupportedFormats')):
-
- # Register the renderer
self._registerRendererClass(attr)
except Exception as e:
@@ -68,60 +66,75 @@ class RendererRegistry:
except Exception as e:
logger.error(f"Error during renderer discovery: {str(e)}")
- self._discovered = True # Mark as discovered to avoid repeated attempts
+ self._discovered = True
def _registerRendererClass(self, rendererClass: Type[BaseRenderer]) -> None:
- """Register a renderer class with its supported formats."""
+ """Register a renderer class keyed by (format, outputStyle)."""
try:
- # Get supported formats from the renderer class
supportedFormats = rendererClass.getSupportedFormats()
-
- # Get priority (default to 0 if not specified)
+ outputStyle = rendererClass.getOutputStyle() if hasattr(rendererClass, 'getOutputStyle') else 'document'
priority = rendererClass.getPriority() if hasattr(rendererClass, 'getPriority') else 0
for formatName in supportedFormats:
formatKey = formatName.lower()
+ registryKey = (formatKey, outputStyle)
- # Check if format already registered - use priority to decide
- if formatKey in self._renderers:
- existingRenderer = self._renderers[formatKey]
+ if registryKey in self._renderers:
+ existingRenderer = self._renderers[registryKey]
existingPriority = existingRenderer.getPriority() if hasattr(existingRenderer, 'getPriority') else 0
- # Only replace if new renderer has higher priority
if priority > existingPriority:
- logger.debug(f"Replacing {existingRenderer.__name__} with {rendererClass.__name__} for format '{formatName}' (priority {priority} > {existingPriority})")
- self._renderers[formatKey] = rendererClass
+ logger.debug(f"Replacing {existingRenderer.__name__} with {rendererClass.__name__} for ({formatKey}, {outputStyle}) (priority {priority} > {existingPriority})")
+ self._renderers[registryKey] = rendererClass
else:
- logger.debug(f"Keeping {existingRenderer.__name__} for format '{formatName}' (priority {existingPriority} >= {priority})")
+ logger.debug(f"Keeping {existingRenderer.__name__} for ({formatKey}, {outputStyle}) (priority {existingPriority} >= {priority})")
else:
- # Register primary format
- self._renderers[formatKey] = rendererClass
+ self._renderers[registryKey] = rendererClass
- # Register aliases if any
+ # Register aliases
if hasattr(rendererClass, 'getFormatAliases'):
aliases = rendererClass.getFormatAliases()
for alias in aliases:
- self._format_mappings[alias.lower()] = formatName.lower()
+ self._format_mappings[alias.lower()] = formatKey
- logger.debug(f"Registered {rendererClass.__name__} for formats: {supportedFormats} (priority: {priority})")
+ logger.debug(f"Registered {rendererClass.__name__} for formats={supportedFormats}, style={outputStyle}, priority={priority}")
except Exception as e:
logger.error(f"Error registering renderer {rendererClass.__name__}: {str(e)}")
- def getRenderer(self, outputFormat: str, services=None) -> Optional[BaseRenderer]:
- """Get a renderer instance for the specified format."""
+ def getRenderer(self, outputFormat: str, services=None, outputStyle: str = None) -> Optional[BaseRenderer]:
+ """Get a renderer instance for the specified format and style.
+
+ Args:
+ outputFormat: Format name (e.g. 'csv', 'json', 'pdf')
+ services: Services instance passed to renderer constructor
+ outputStyle: 'document' or 'code'. If None, returns the first match
+ with preference: document > code (most callers are document path).
+ """
if not self._discovered:
self.discoverRenderers()
- # Normalize format name
formatName = outputFormat.lower().strip()
-
- # Check for aliases first
if formatName in self._format_mappings:
formatName = self._format_mappings[formatName]
- # Get renderer class
- rendererClass = self._renderers.get(formatName)
+ rendererClass = None
+
+ if outputStyle:
+ # Exact match by style
+ rendererClass = self._renderers.get((formatName, outputStyle))
+ else:
+ # No style specified — prefer 'document', then 'code', then any
+ for style in ['document', 'code']:
+ rendererClass = self._renderers.get((formatName, style))
+ if rendererClass:
+ break
+ # Fallback: check any registered style
+ if not rendererClass:
+ for key, cls in self._renderers.items():
+ if key[0] == formatName:
+ rendererClass = cls
+ break
if rendererClass:
try:
@@ -130,7 +143,7 @@ class RendererRegistry:
logger.error(f"Error creating renderer instance for {formatName}: {str(e)}")
return None
- logger.warning(f"No renderer found for format: {outputFormat}")
+ logger.warning(f"No renderer found for format={outputFormat}, style={outputStyle}")
return None
def getSupportedFormats(self) -> List[str]:
@@ -138,9 +151,11 @@ class RendererRegistry:
if not self._discovered:
self.discoverRenderers()
- formats = list(self._renderers.keys())
- formats.extend(self._format_mappings.keys())
- return sorted(set(formats))
+ formats = set()
+ for (fmt, _style) in self._renderers.keys():
+ formats.add(fmt)
+ formats.update(self._format_mappings.keys())
+ return sorted(formats)
def getRendererInfo(self) -> Dict[str, Dict[str, str]]:
"""Get information about all registered renderers."""
@@ -148,10 +163,12 @@ class RendererRegistry:
self.discoverRenderers()
info = {}
- for formatName, rendererClass in self._renderers.items():
- info[formatName] = {
+ for (formatName, style), rendererClass in self._renderers.items():
+ key = f"{formatName}:{style}"
+ info[key] = {
'class_name': rendererClass.__name__,
'module': rendererClass.__module__,
+ 'outputStyle': style,
'description': getattr(rendererClass, '__doc__', 'No description').strip().split('\n')[0] if rendererClass.__doc__ else 'No description'
}
@@ -160,44 +177,62 @@ class RendererRegistry:
def getOutputStyle(self, outputFormat: str) -> Optional[str]:
"""
Get the output style classification for a given format.
- Returns: 'code', 'document', 'image', or other (e.g., 'video' for future use)
+ When both 'document' and 'code' renderers exist for a format,
+ returns the default ('document') since this is called during document generation.
"""
if not self._discovered:
self.discoverRenderers()
- # Normalize format name
formatName = outputFormat.lower().strip()
-
- # Check for aliases first
if formatName in self._format_mappings:
formatName = self._format_mappings[formatName]
- # Get renderer class and call getOutputStyle (all renderers have same signature)
- rendererClass = self._renderers.get(formatName)
- try:
- return rendererClass.getOutputStyle(formatName)
- except (AttributeError, TypeError) as e:
- logger.warning(f"No renderer found for format: {outputFormat}, cannot determine output style")
- return None
- except Exception as e:
- logger.warning(f"Error getting output style for {outputFormat}: {str(e)}")
- return None
+ # Check document first, then code
+ for style in ['document', 'code']:
+ rendererClass = self._renderers.get((formatName, style))
+ if rendererClass:
+ try:
+ return rendererClass.getOutputStyle(formatName)
+ except Exception:
+ pass
+
+ # Fallback: any style
+ for key, rendererClass in self._renderers.items():
+ if key[0] == formatName:
+ try:
+ return rendererClass.getOutputStyle(formatName)
+ except Exception:
+ pass
+
+ logger.warning(f"No renderer found for format: {outputFormat}, cannot determine output style")
+ return None
+
# Global registry instance
_registry = RendererRegistry()
-def getRenderer(outputFormat: str, services=None) -> Optional[BaseRenderer]:
- """Get a renderer instance for the specified format."""
- return _registry.getRenderer(outputFormat, services)
+
+def getRenderer(outputFormat: str, services=None, outputStyle: str = None) -> Optional[BaseRenderer]:
+ """Get a renderer instance for the specified format and style.
+
+ Args:
+ outputFormat: Format name (e.g. 'csv', 'json', 'pdf')
+ services: Services instance
+ outputStyle: 'document' or 'code'. If None, prefers document renderer.
+ """
+ return _registry.getRenderer(outputFormat, services, outputStyle=outputStyle)
+
def getSupportedFormats() -> List[str]:
"""Get list of all supported formats."""
return _registry.getSupportedFormats()
+
def getRendererInfo() -> Dict[str, Dict[str, str]]:
"""Get information about all registered renderers."""
return _registry.getRendererInfo()
+
def getOutputStyle(outputFormat: str) -> Optional[str]:
"""Get the output style classification for a given format."""
return _registry.getOutputStyle(outputFormat)
diff --git a/modules/services/serviceGeneration/renderers/rendererCsv.py b/modules/services/serviceGeneration/renderers/rendererCsv.py
index 45871922..91312299 100644
--- a/modules/services/serviceGeneration/renderers/rendererCsv.py
+++ b/modules/services/serviceGeneration/renderers/rendererCsv.py
@@ -35,9 +35,9 @@ class RendererCsv(BaseRenderer):
def getAcceptedSectionTypes(cls, formatName: Optional[str] = None) -> List[str]:
"""
Return list of section content types that CSV renderer accepts.
- CSV renderer only accepts table sections.
+ CSV renderer accepts table sections and code_block sections (for raw CSV content).
"""
- return ["table"]
+ return ["table", "code_block"]
async def render(self, extractedContent: Dict[str, Any], title: str, userPrompt: str = None, aiService=None) -> List[RenderedDocument]:
"""Render extracted JSON content to CSV format. Produces one CSV file per table section."""
@@ -62,16 +62,24 @@ class RendererCsv(BaseRenderer):
if baseFilename.endswith('.csv'):
baseFilename = baseFilename[:-4]
- # Find all table sections
+ # Collect CSV-producing sections: table sections AND code_block sections with CSV language
tableSections = []
+ codeBlockCsvSections = []
for section in sections:
sectionType = section.get("content_type", "paragraph")
if sectionType == "table":
tableSections.append(section)
+ elif sectionType == "code_block":
+ # Check if any element is a code_block with language "csv"
+ for element in section.get("elements", []):
+ content = element.get("content", {})
+ if isinstance(content, dict) and content.get("language", "").lower() == "csv":
+ codeBlockCsvSections.append(section)
+ break
- # If no table sections found, return empty CSV
- if not tableSections:
- self.logger.warning("No table sections found in CSV document - returning empty CSV")
+ # If no usable sections found, return empty CSV
+ if not tableSections and not codeBlockCsvSections:
+ self.logger.warning("No table or CSV code_block sections found in CSV document - returning empty CSV")
emptyCsv = self._convertRowsToCsv([["No table data available"]])
return [
RenderedDocument(
@@ -83,45 +91,52 @@ class RendererCsv(BaseRenderer):
)
]
- # Generate one CSV file per table section
+ allCsvSections = tableSections + codeBlockCsvSections
+
+ # Generate one CSV file per section
renderedDocuments = []
- for i, tableSection in enumerate(tableSections):
- # Generate CSV content for this table section
- csvRows = []
+ for i, csvSection in enumerate(allCsvSections):
+ sectionType = csvSection.get("content_type", "paragraph")
+ sectionTitle = csvSection.get("title")
+ csvContent = ""
- # Add section title if available
- sectionTitle = tableSection.get("title")
- if sectionTitle:
- csvRows.append([sectionTitle])
- csvRows.append([]) # Empty row after title
+ if sectionType == "code_block":
+ # Extract raw CSV content directly from code_block elements
+ rawCsvParts = []
+ for element in csvSection.get("elements", []):
+ content = element.get("content", {})
+ if isinstance(content, dict) and content.get("language", "").lower() == "csv":
+ code = content.get("code", "")
+ if code:
+ rawCsvParts.append(code)
+ csvContent = "\n".join(rawCsvParts)
+ else:
+ # Table section — render via table logic
+ csvRows = []
+ if sectionTitle:
+ csvRows.append([sectionTitle])
+ csvRows.append([]) # Empty row after title
+
+ elements = csvSection.get("elements", [])
+ for element in elements:
+ tableRows = self._renderJsonTableToCsv(element)
+ if tableRows:
+ csvRows.extend(tableRows)
+
+ csvContent = self._convertRowsToCsv(csvRows)
- # Render table from section elements
- elements = tableSection.get("elements", [])
- for element in elements:
- tableRows = self._renderJsonTableToCsv(element)
- if tableRows:
- csvRows.extend(tableRows)
-
- # Convert to CSV string
- csvContent = self._convertRowsToCsv(csvRows)
-
- # Determine filename for this table
- if len(tableSections) == 1:
- # Single table - use base filename
+ # Determine filename
+ if len(allCsvSections) == 1:
filename = f"{baseFilename}.csv"
else:
- # Multiple tables - add index or section title to filename
- sectionId = tableSection.get("id", f"table_{i+1}")
- # Use section title if available, otherwise use section ID
+ sectionId = csvSection.get("id", f"csv_{i+1}")
if sectionTitle:
- # Sanitize section title for filename
safeTitle = "".join(c for c in sectionTitle if c.isalnum() or c in (' ', '-', '_')).strip()
- safeTitle = safeTitle.replace(' ', '_')[:30] # Limit length
+ safeTitle = safeTitle.replace(' ', '_')[:30]
filename = f"{baseFilename}_{safeTitle}.csv"
else:
filename = f"{baseFilename}_{sectionId}.csv"
- # Extract document type from metadata
documentType = metadata.get("documentType") if isinstance(metadata, dict) else None
renderedDocuments.append(
diff --git a/modules/shared/callbackRegistry.py b/modules/shared/callbackRegistry.py
index 23eb84ab..361f4e1d 100644
--- a/modules/shared/callbackRegistry.py
+++ b/modules/shared/callbackRegistry.py
@@ -9,7 +9,6 @@ Features can register callbacks to be notified when automations change.
import logging
from typing import Callable, List, Dict, Any
-import asyncio
logger = logging.getLogger(__name__)
@@ -25,7 +24,7 @@ class CallbackRegistry:
Args:
event_type: Type of event (e.g., 'automation.changed')
- callback: Async or sync callback function
+ callback: Sync callback function
"""
if event_type not in self._callbacks:
self._callbacks[event_type] = []
@@ -41,8 +40,8 @@ class CallbackRegistry:
except ValueError:
logger.warning(f"Callback not found for event type: {event_type}")
- async def trigger(self, event_type: str, *args, **kwargs):
- """Trigger all callbacks registered for an event type.
+ def trigger(self, event_type: str, *args, **kwargs):
+ """Trigger all registered callbacks for an event type.
Args:
event_type: Type of event to trigger
@@ -55,18 +54,14 @@ class CallbackRegistry:
for callback in callbacks:
try:
- if asyncio.iscoroutinefunction(callback):
- await callback(*args, **kwargs)
- else:
- callback(*args, **kwargs)
+ callback(*args, **kwargs)
except Exception as e:
logger.error(f"Error executing callback for {event_type}: {str(e)}", exc_info=True)
- def has_callbacks(self, event_type: str) -> bool:
+ def hasCallbacks(self, event_type: str) -> bool:
"""Check if there are any callbacks registered for an event type."""
return event_type in self._callbacks and len(self._callbacks[event_type]) > 0
# Global singleton instance
callbackRegistry = CallbackRegistry()
-
diff --git a/modules/workflows/automation/mainWorkflow.py b/modules/workflows/automation/mainWorkflow.py
index 06d36dae..6e34c3cb 100644
--- a/modules/workflows/automation/mainWorkflow.py
+++ b/modules/workflows/automation/mainWorkflow.py
@@ -38,16 +38,14 @@ async def chatStart(currentUser: User, userInput: UserInputRequest, workflowMode
featureCode: Feature code (e.g., 'chatplayground', 'automation')
"""
try:
- services = getServices(currentUser, mandateId=mandateId)
+ services = getServices(currentUser, mandateId=mandateId, featureInstanceId=featureInstanceId)
# Store allowedProviders in services context for model selection
if hasattr(userInput, 'allowedProviders') and userInput.allowedProviders:
services.allowedProviders = userInput.allowedProviders
logger.info(f"AI provider filter active: {userInput.allowedProviders}")
- # Store feature context in services (for billing and RBAC)
- if featureInstanceId:
- services.featureInstanceId = featureInstanceId
+ # Store feature code in services (for billing)
if featureCode:
services.featureCode = featureCode
@@ -61,10 +59,8 @@ async def chatStart(currentUser: User, userInput: UserInputRequest, workflowMode
async def chatStop(currentUser: User, workflowId: str, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None) -> ChatWorkflow:
"""Stops a running chat."""
try:
- services = getServices(currentUser, mandateId=mandateId)
- # Store feature instance ID in services context for proper RBAC filtering
+ services = getServices(currentUser, mandateId=mandateId, featureInstanceId=featureInstanceId)
if featureInstanceId:
- services.featureInstanceId = featureInstanceId
services.featureCode = 'chatplayground'
workflowManager = WorkflowManager(services)
return await workflowManager.workflowStop(workflowId)
@@ -73,12 +69,17 @@ async def chatStop(currentUser: User, workflowId: str, mandateId: Optional[str]
raise
-async def executeAutomation(automationId: str, services) -> ChatWorkflow:
- """Execute automation workflow immediately (test mode) with placeholder replacement.
+async def executeAutomation(automationId: str, automation, creatorUser: User, services) -> ChatWorkflow:
+ """Execute automation workflow with the creator user's context.
+
+ The automation object and creatorUser are resolved by the caller (handler)
+ using the SysAdmin eventUser. This function does NOT re-load them.
Args:
automationId: ID of automation to execute
- services: Services instance for data access
+ automation: Pre-loaded automation object (with system fields like _createdBy)
+ creatorUser: The user who created the automation (workflow runs in this context)
+ services: Services instance (used for interfaceDbApp etc.)
Returns:
ChatWorkflow instance created by automation execution
@@ -92,11 +93,6 @@ async def executeAutomation(automationId: str, services) -> ChatWorkflow:
}
try:
- # 1. Load automation definition (with system fields for _createdBy access)
- automation = services.interfaceDbAutomation.getAutomationDefinition(automationId, includeSystemFields=True)
- if not automation:
- raise ValueError(f"Automation {automationId} not found")
-
executionLog["messages"].append(f"Started execution at {executionStartTime}")
# Store allowed providers from automation in services context
@@ -105,12 +101,12 @@ async def executeAutomation(automationId: str, services) -> ChatWorkflow:
logger.debug(f"Automation {automationId} restricted to providers: {automation.allowedProviders}")
# Context comes EXCLUSIVELY from the automation definition
- services.mandateId = str(automation.mandateId)
- services.featureInstanceId = str(automation.featureInstanceId)
- services.featureCode = 'automation'
- featureInstanceId = services.featureInstanceId
+ automationMandateId = str(automation.mandateId)
+ automationFeatureInstanceId = str(automation.featureInstanceId)
- # 2. Replace placeholders in template to generate plan
+ logger.info(f"Executing automation {automationId} as user {creatorUser.id} with mandateId={automationMandateId}, featureInstanceId={automationFeatureInstanceId}")
+
+ # 1. Replace placeholders in template to generate plan
template = automation.template or ""
placeholders = automation.placeholders or {}
planJson = replacePlaceholders(template, placeholders)
@@ -128,24 +124,9 @@ async def executeAutomation(automationId: str, services) -> ChatWorkflow:
logger.error(f"Context around error: ...{planJson[start:end]}...")
raise ValueError(f"Invalid JSON after placeholder replacement: {str(e)}")
executionLog["messages"].append("Template placeholders replaced successfully")
+ executionLog["messages"].append(f"Using creator user: {creatorUser.id}")
- # 3. Get user who created automation
- creatorUserId = getattr(automation, "_createdBy", None)
-
- # _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 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
+ # 2. Create UserInputRequest from plan
# Embed plan JSON in prompt for TemplateMode to extract
promptText = planToPrompt(plan)
planJsonStr = json.dumps(plan)
@@ -160,16 +141,15 @@ async def executeAutomation(automationId: str, services) -> ChatWorkflow:
executionLog["messages"].append("Starting workflow execution")
- # 5. Start workflow using chatStart
- # Pass mandateId, featureInstanceId, and featureCode from original services context
- # so billing is recorded correctly with full feature context
+ # 3. Start workflow using chatStart with creator's context
+ # mandateId and featureInstanceId come from the automation definition
workflow = await chatStart(
currentUser=creatorUser,
userInput=userInput,
workflowMode=WorkflowModeEnum.WORKFLOW_AUTOMATION,
workflowId=None,
- mandateId=services.mandateId,
- featureInstanceId=featureInstanceId,
+ mandateId=automationMandateId,
+ featureInstanceId=automationFeatureInstanceId,
featureCode='automation'
)
@@ -200,22 +180,22 @@ async def executeAutomation(automationId: str, services) -> ChatWorkflow:
executionLog["messages"].append(f"Error: {str(e)}")
# Save execution log even on error (bypasses RBAC — system operation)
+ # Use the automation object already passed in (no re-load needed)
try:
- automation = services.interfaceDbAutomation.getAutomationDefinition(automationId)
- if automation:
- executionLogs = list(automation.executionLogs or [])
- executionLogs.append(executionLog)
- if len(executionLogs) > 50:
- executionLogs = executionLogs[-50:]
- services.interfaceDbAutomation._saveExecutionLog(automationId, executionLogs)
+ executionLogs = list(getattr(automation, 'executionLogs', None) or [])
+ executionLogs.append(executionLog)
+ if len(executionLogs) > 50:
+ executionLogs = executionLogs[-50:]
+ services.interfaceDbAutomation._saveExecutionLog(automationId, executionLogs)
except Exception as logError:
logger.error(f"Error saving execution log: {str(logError)}")
raise
-async def syncAutomationEvents(services, eventUser) -> Dict[str, Any]:
- """Automation event handler - syncs scheduler with all active automations.
+def syncAutomationEvents(services, eventUser) -> Dict[str, Any]:
+ """Sync scheduler with all active automations.
+ All operations (DB reads, scheduler registration) are synchronous.
Args:
services: Services instance for data access
@@ -316,37 +296,28 @@ def createAutomationEventHandler(automationId: str, eventUser):
logger.error("Event user not available for automation execution")
return
- # Get services for event user (provides access to interfaces)
+ # Load automation using SysAdmin eventUser (has unrestricted access)
eventServices = getServices(eventUser, None)
-
- # Load automation using event user context (with system fields for _createdBy access)
automation = eventServices.interfaceDbAutomation.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
- # Get creator user
+ # Get creator user ID from automation's _createdBy system field
creatorUserId = getattr(automation, "_createdBy", None)
if not creatorUserId:
- logger.error(f"Automation {automationId} has no creator user")
+ logger.error(f"Automation {automationId} has no creator user (_createdBy missing)")
return
- # Get mandate context from automation definition
- automationMandateId = getattr(automation, "mandateId", None)
-
- # Get creator user from database using services
- eventServices = getServices(eventUser, None)
+ # Get creator user from database (using SysAdmin access)
creatorUser = eventServices.interfaceDbApp.getUser(creatorUserId)
if not creatorUser:
logger.error(f"Creator user {creatorUserId} not found for automation {automationId}")
return
- # Get services for creator user WITH mandate context from automation
- creatorServices = getServices(creatorUser, automationMandateId)
-
- # Execute automation with creator user's context and mandate
- # executeAutomation is in same module, so we can call it directly
- await executeAutomation(automationId, creatorServices)
+ # Execute automation — pass automation object and creatorUser directly
+ # No re-load needed in executeAutomation
+ await executeAutomation(automationId, automation, creatorUser, eventServices)
logger.info(f"Successfully executed automation {automationId} as user {creatorUserId}")
except Exception as e:
logger.error(f"Error executing automation {automationId}: {str(e)}")
diff --git a/modules/workflows/automation/subAutomationSchedule.py b/modules/workflows/automation/subAutomationSchedule.py
index 1061d65e..40638461 100644
--- a/modules/workflows/automation/subAutomationSchedule.py
+++ b/modules/workflows/automation/subAutomationSchedule.py
@@ -14,9 +14,10 @@ from modules.services import getInterface as getServices
logger = logging.getLogger(__name__)
-async def start(eventUser) -> None:
+def start(eventUser) -> bool:
"""
Start automation scheduler and sync scheduled events.
+ All operations are synchronous (DB access, scheduler registration).
Args:
eventUser: System-level event user for background operations (provided by app.py)
@@ -33,16 +34,16 @@ async def start(eventUser) -> None:
services = getServices(eventUser, None)
# Register callback for automation changes
- async def onAutomationChanged(chatInterface):
+ def onAutomationChanged(chatInterface):
"""Callback triggered when automations are created/updated/deleted."""
eventServices = getServices(eventUser, None)
- await syncAutomationEvents(eventServices, eventUser)
+ syncAutomationEvents(eventServices, eventUser)
callbackRegistry.register('automation.changed', onAutomationChanged)
logger.info("Automation: Registered change callback")
# Initial sync on startup
- await syncAutomationEvents(services, eventUser)
+ syncAutomationEvents(services, eventUser)
logger.info("Automation: Scheduled events synced on startup")
except Exception as e:
@@ -52,7 +53,7 @@ async def start(eventUser) -> None:
return True
-async def stop(eventUser) -> None:
+def stop(eventUser) -> bool:
"""
Stop automation scheduler.
diff --git a/modules/workflows/methods/methodBase.py b/modules/workflows/methods/methodBase.py
index 7934ea19..173023f1 100644
--- a/modules/workflows/methods/methodBase.py
+++ b/modules/workflows/methods/methodBase.py
@@ -139,11 +139,16 @@ class MethodBase:
return False
# RBAC-Check: RESOURCE context, item = actionId
+ # mandateId/featureInstanceId from services context needed to resolve user roles
try:
+ mandateId = getattr(self.services, 'mandateId', None)
+ featureInstanceId = getattr(self.services, 'featureInstanceId', None)
permissions = self.services.rbac.getUserPermissions(
user=currentUser,
context=AccessRuleContext.RESOURCE,
- item=actionId
+ item=actionId,
+ mandateId=str(mandateId) if mandateId else None,
+ featureInstanceId=str(featureInstanceId) if featureInstanceId else None
)
hasPermission = permissions.view
if not hasPermission:
@@ -151,8 +156,9 @@ class MethodBase:
userRoles = getattr(currentUser, 'roleLabels', []) or []
self.logger.warning(
f"RBAC denied action {actionId} for user {currentUser.id}. "
- f"User roles: {userRoles}, "
- f"Permissions: view={permissions.view}, edit={permissions.edit}, delete={permissions.delete}. "
+ f"User roles: {userRoles}, mandateId={mandateId}, "
+ f"Permissions: view={permissions.view}, read={permissions.read}, "
+ f"create={permissions.create}, update={permissions.update}, delete={permissions.delete}. "
f"No matching RBAC rule found for context=RESOURCE, item={actionId}"
)
return hasPermission