fixed system and dynamic data rbac

This commit is contained in:
ValueOn AG 2026-01-26 12:39:00 +01:00
parent a0304c6d78
commit 829711f755
15 changed files with 695 additions and 281 deletions

View file

@ -11,15 +11,10 @@ import uuid
class ChatStat(BaseModel):
"""Statistics for chat operations. User-owned, no mandate context."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()), description="Primary key"
)
mandateId: Optional[str] = Field(
default="", description="ID of the mandate this stat belongs to"
)
featureInstanceId: Optional[str] = Field(
default="", description="ID of the feature instance this stat belongs to"
)
workflowId: Optional[str] = Field(
None, description="Foreign key to workflow (for workflow stats)"
)
@ -39,8 +34,6 @@ registerModelLabels(
{"en": "Chat Statistics", "fr": "Statistiques de chat"},
{
"id": {"en": "ID", "fr": "ID"},
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat"},
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"},
"workflowId": {"en": "Workflow ID", "fr": "ID du workflow"},
"processingTime": {"en": "Processing Time", "fr": "Temps de traitement"},
"bytesSent": {"en": "Bytes Sent", "fr": "Octets envoyés"},
@ -54,15 +47,10 @@ registerModelLabels(
class ChatLog(BaseModel):
"""Log entries for chat workflows. User-owned, no mandate context."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()), description="Primary key"
)
mandateId: Optional[str] = Field(
default="", description="ID of the mandate this log belongs to"
)
featureInstanceId: Optional[str] = Field(
default="", description="ID of the feature instance this log belongs to"
)
workflowId: str = Field(description="Foreign key to workflow")
message: str = Field(description="Log message")
type: str = Field(description="Log type (info, warning, error, etc.)")
@ -93,8 +81,6 @@ registerModelLabels(
{"en": "Chat Log", "fr": "Journal de chat"},
{
"id": {"en": "ID", "fr": "ID"},
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat"},
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"},
"workflowId": {"en": "Workflow ID", "fr": "ID du flux de travail"},
"message": {"en": "Message", "fr": "Message"},
"type": {"en": "Type", "fr": "Type"},
@ -107,15 +93,10 @@ registerModelLabels(
class ChatDocument(BaseModel):
"""Documents attached to chat messages. User-owned, no mandate context."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()), description="Primary key"
)
mandateId: Optional[str] = Field(
default="", description="ID of the mandate this document belongs to"
)
featureInstanceId: Optional[str] = Field(
default="", description="ID of the feature instance this document belongs to"
)
messageId: str = Field(description="Foreign key to message")
fileId: str = Field(description="Foreign key to file")
fileName: str = Field(description="Name of the file")
@ -134,8 +115,6 @@ registerModelLabels(
{"en": "Chat Document", "fr": "Document de chat"},
{
"id": {"en": "ID", "fr": "ID"},
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat"},
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"},
"messageId": {"en": "Message ID", "fr": "ID du message"},
"fileId": {"en": "File ID", "fr": "ID du fichier"},
"fileName": {"en": "File Name", "fr": "Nom du fichier"},
@ -221,15 +200,10 @@ registerModelLabels(
class ChatMessage(BaseModel):
"""Messages in chat workflows. User-owned, no mandate context."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()), description="Primary key"
)
mandateId: Optional[str] = Field(
default="", description="ID of the mandate this message belongs to"
)
featureInstanceId: Optional[str] = Field(
default="", description="ID of the feature instance this message belongs to"
)
workflowId: str = Field(description="Foreign key to workflow")
parentMessageId: Optional[str] = Field(
None, description="Parent message ID for threading"
@ -281,8 +255,6 @@ registerModelLabels(
{"en": "Chat Message", "fr": "Message de chat"},
{
"id": {"en": "ID", "fr": "ID"},
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat"},
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"},
"workflowId": {"en": "Workflow ID", "fr": "ID du flux de travail"},
"parentMessageId": {"en": "Parent Message ID", "fr": "ID du message parent"},
"documents": {"en": "Documents", "fr": "Documents"},
@ -326,9 +298,8 @@ registerModelLabels(
class ChatWorkflow(BaseModel):
"""Chat workflow container. User-owned, no mandate context."""
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 workflow 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 workflow belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
status: str = Field(default="running", description="Current status of the workflow", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
{"value": "running", "label": {"en": "Running", "fr": "En cours"}},
{"value": "completed", "label": {"en": "Completed", "fr": "Terminé"}},
@ -402,8 +373,6 @@ registerModelLabels(
{"en": "Chat Workflow", "fr": "Flux de travail de chat"},
{
"id": {"en": "ID", "fr": "ID"},
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat"},
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"},
"status": {"en": "Status", "fr": "Statut"},
"name": {"en": "Name", "fr": "Nom"},
"currentRound": {"en": "Current Round", "fr": "Tour actuel"},

View file

@ -12,29 +12,76 @@ from modules.features.neutralization.datamodelFeatureNeutralizer import (
DataNeutraliserConfig,
DataNeutralizerAttributes,
)
from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.interfaces.interfaceRbac import getRecordsetWithRBAC
from modules.shared.configuration import APP_CONFIG
from modules.shared.timeUtils import getUtcTimestamp
from modules.datamodels.datamodelUam import User
logger = logging.getLogger(__name__)
# Singleton cache for interface instances
_neutralizerInterfaces = {}
class InterfaceFeatureNeutralizer:
"""Database interface for Neutralizer feature operations"""
def __init__(self, db, currentUser, mandateId: str, userId: str):
# Feature code for RBAC objectKey construction
FEATURE_CODE = "neutralization"
def __init__(self, currentUser: Optional[User] = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None):
"""
Initialize the interface with database connection and user context.
Args:
db: Database connection instance
currentUser: Current user object for RBAC
mandateId: Current mandate ID
userId: Current user ID
featureInstanceId: Current feature instance ID
"""
self.db = db
self.currentUser = currentUser
self.mandateId = mandateId
self.userId = userId
self.featureInstanceId = featureInstanceId
self.userId = currentUser.id if currentUser else None
self.db = None
# Initialize database
self._initializeDatabase()
def _initializeDatabase(self):
"""Initialize the database connection."""
try:
# Use same database config pattern as other feature interfaces
dbHost = APP_CONFIG.get("DB_HOST", "localhost")
dbDatabase = APP_CONFIG.get("DB_DATABASE_NEUTRALIZATION", APP_CONFIG.get("DB_DATABASE", "poweron"))
dbUser = APP_CONFIG.get("DB_USER", "postgres")
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
self.db = DatabaseConnector(
dbHost=dbHost,
dbDatabase=dbDatabase,
dbUser=dbUser,
dbPassword=dbPassword,
dbPort=dbPort,
userId=self.userId,
)
self.db.initDbSystem()
logger.debug("Neutralizer database initialized successfully")
except Exception as e:
logger.error(f"Error initializing Neutralizer database: {str(e)}")
raise
def setUserContext(self, currentUser: User, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None):
"""Sets the user context for the interface."""
if not currentUser:
logger.info("Initializing interface without user context")
return
self.currentUser = currentUser
self.userId = currentUser.id
self.mandateId = mandateId
self.featureInstanceId = featureInstanceId
def getNeutralizationConfig(self) -> Optional[DataNeutraliserConfig]:
"""Get the data neutralization configuration for the current user's mandate"""
@ -160,17 +207,34 @@ class InterfaceFeatureNeutralizer:
return None
def getInterface(db, currentUser, mandateId: str, userId: str) -> InterfaceFeatureNeutralizer:
def getInterface(currentUser: Optional[User] = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None) -> InterfaceFeatureNeutralizer:
"""
Factory function to create a Neutralizer interface instance.
Factory function to get or create a Neutralizer interface instance.
Uses singleton pattern per user context.
Args:
db: Database connection
currentUser: Current user for RBAC
mandateId: Current mandate ID
userId: Current user ID
featureInstanceId: Current feature instance ID
Returns:
InterfaceFeatureNeutralizer instance
"""
return InterfaceFeatureNeutralizer(db, currentUser, mandateId, userId)
global _neutralizerInterfaces
if not currentUser:
raise ValueError("Valid user context required")
effectiveMandateId = str(mandateId) if mandateId else None
effectiveFeatureInstanceId = str(featureInstanceId) if featureInstanceId else None
# Include featureInstanceId in cache key for proper isolation
cacheKey = f"{currentUser.id}_{effectiveMandateId}_{effectiveFeatureInstanceId}"
if cacheKey not in _neutralizerInterfaces:
_neutralizerInterfaces[cacheKey] = InterfaceFeatureNeutralizer(currentUser, mandateId=effectiveMandateId, featureInstanceId=effectiveFeatureInstanceId)
else:
# Update user context if needed
_neutralizerInterfaces[cacheKey].setUserContext(currentUser, mandateId=effectiveMandateId, featureInstanceId=effectiveFeatureInstanceId)
return _neutralizerInterfaces[cacheKey]

View file

@ -14,7 +14,7 @@ import json
from typing import Dict, List, Any, Optional
from modules.features.neutralization.datamodelFeatureNeutralizer import DataNeutraliserConfig, DataNeutralizerAttributes
from modules.features.neutralization.interfaceFeatureNeutralizer import InterfaceFeatureNeutralizer
from modules.features.neutralization.interfaceFeatureNeutralizer import InterfaceFeatureNeutralizer, getInterface as getNeutralizerInterface
# Import all necessary classes and functions for neutralization
from .subProcessCommon import CommonUtils, NeutralizationResult, NeutralizationAttribute
@ -42,11 +42,10 @@ class NeutralizationService:
self.interfaceNeutralizer: InterfaceFeatureNeutralizer = None
if serviceCenter and serviceCenter.interfaceDbApp:
dbApp = serviceCenter.interfaceDbApp
self.interfaceNeutralizer = InterfaceFeatureNeutralizer(
db=dbApp.db,
self.interfaceNeutralizer = getNeutralizerInterface(
currentUser=dbApp.currentUser,
mandateId=dbApp.mandateId,
userId=dbApp.userId
featureInstanceId=getattr(dbApp, 'featureInstanceId', None)
)
# Initialize anonymization processors

View file

@ -277,6 +277,9 @@ def initRbacRules(db: DatabaseConnector) -> None:
existingRules = db.getRecordset(AccessRule)
if existingRules:
logger.info(f"RBAC rules already exist ({len(existingRules)} rules)")
# Still ensure UI and DATA rules exist (may have been added later)
_ensureUiContextRules(db)
_ensureDataContextRules(db)
return
logger.info("Initializing RBAC rules")
@ -377,20 +380,31 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None:
viewerId = _getRoleId(db, "viewer")
# ==========================================================================
# SYSTEM TABLE RULES - Using standardized dot format: data.system.{TableName}
# DATA TABLE RULES - Using semantic namespace structure
# ==========================================================================
# All DATA context items MUST use the full objectKey format for consistency.
# This matches the DATA_OBJECTS registration in mainSystem.py.
# Feature tables use: data.feature.{featureCode}.{TableName}
# Namespace structure:
# - data.uam.* → User Access Management (mandantenübergreifend)
# - data.chat.* → Chat/AI-Daten (benutzer-eigen, kein Mandantenkontext)
# - data.files.* → Dateien (benutzer-eigen)
# - data.automation.* → Automation (benutzer-eigen)
# - data.feature.* → Mandanten-/Feature-spezifische Daten (dynamisch)
#
# GROUP-Berechtigung:
# - data.uam.*: GROUP filtert nach Mandant (via UserMandate)
# - data.chat.*, data.files.*, data.automation.*: GROUP = MY (benutzer-eigen)
# ==========================================================================
# -------------------------------------------------------------------------
# UAM Namespace - User Access Management
# -------------------------------------------------------------------------
# Mandate table - Only SysAdmin (flag) can access, not roles
# Regular roles have no access to Mandate table
if adminId:
tableRules.append(AccessRule(
roleId=adminId,
context=AccessRuleContext.DATA,
item="data.system.Mandate",
item="data.uam.Mandate",
view=False,
read=AccessLevel.NONE,
create=AccessLevel.NONE,
@ -401,7 +415,7 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None:
tableRules.append(AccessRule(
roleId=userId,
context=AccessRuleContext.DATA,
item="data.system.Mandate",
item="data.uam.Mandate",
view=False,
read=AccessLevel.NONE,
create=AccessLevel.NONE,
@ -412,7 +426,7 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None:
tableRules.append(AccessRule(
roleId=viewerId,
context=AccessRuleContext.DATA,
item="data.system.Mandate",
item="data.uam.Mandate",
view=False,
read=AccessLevel.NONE,
create=AccessLevel.NONE,
@ -425,7 +439,7 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None:
tableRules.append(AccessRule(
roleId=adminId,
context=AccessRuleContext.DATA,
item="data.system.UserInDB",
item="data.uam.UserInDB",
view=True,
read=AccessLevel.GROUP,
create=AccessLevel.GROUP,
@ -436,7 +450,7 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None:
tableRules.append(AccessRule(
roleId=userId,
context=AccessRuleContext.DATA,
item="data.system.UserInDB",
item="data.uam.UserInDB",
view=True,
read=AccessLevel.MY,
create=AccessLevel.NONE,
@ -447,7 +461,7 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None:
tableRules.append(AccessRule(
roleId=viewerId,
context=AccessRuleContext.DATA,
item="data.system.UserInDB",
item="data.uam.UserInDB",
view=True,
read=AccessLevel.MY,
create=AccessLevel.NONE,
@ -455,92 +469,37 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None:
delete=AccessLevel.NONE,
))
# FileItem and UserConnection: All users (user, admin, viewer) only MY-level CRUD
restrictedTables = [
"data.system.UserConnection", # User connections/sessions - only own records
"data.system.FileItem", # Uploaded files - only own files
]
for objectKey in restrictedTables:
# Admin: Only MY-level access (not group-level!)
if adminId:
# UserConnection: All users only MY-level CRUD (UAM namespace)
for roleId in [adminId, userId]:
if roleId:
tableRules.append(AccessRule(
roleId=adminId,
roleId=roleId,
context=AccessRuleContext.DATA,
item=objectKey,
item="data.uam.UserConnection",
view=True,
read=AccessLevel.MY,
create=AccessLevel.MY,
update=AccessLevel.MY,
delete=AccessLevel.MY,
))
# User: MY-level CRUD
if userId:
tableRules.append(AccessRule(
roleId=userId,
context=AccessRuleContext.DATA,
item=objectKey,
view=True,
read=AccessLevel.MY,
create=AccessLevel.MY,
update=AccessLevel.MY,
delete=AccessLevel.MY,
))
# Viewer: MY-level read-only
if viewerId:
tableRules.append(AccessRule(
roleId=viewerId,
context=AccessRuleContext.DATA,
item=objectKey,
view=True,
read=AccessLevel.MY,
create=AccessLevel.NONE,
update=AccessLevel.NONE,
delete=AccessLevel.NONE,
))
# Prompt: Special rule - CRUD for MY + Read for GROUP
# Each user can manage own prompts (m) but can read group prompts (g)
if adminId:
tableRules.append(AccessRule(
roleId=adminId,
context=AccessRuleContext.DATA,
item="data.system.Prompt",
view=True,
read=AccessLevel.GROUP,
create=AccessLevel.MY,
update=AccessLevel.MY,
delete=AccessLevel.MY,
))
if userId:
tableRules.append(AccessRule(
roleId=userId,
context=AccessRuleContext.DATA,
item="data.system.Prompt",
view=True,
read=AccessLevel.GROUP,
create=AccessLevel.MY,
update=AccessLevel.MY,
delete=AccessLevel.MY,
))
if viewerId:
tableRules.append(AccessRule(
roleId=viewerId,
context=AccessRuleContext.DATA,
item="data.system.Prompt",
item="data.uam.UserConnection",
view=True,
read=AccessLevel.GROUP,
read=AccessLevel.MY,
create=AccessLevel.NONE,
update=AccessLevel.NONE,
delete=AccessLevel.NONE,
))
# Invitation: Standard group-level access
# Invitation: Standard group-level access (UAM namespace)
if adminId:
tableRules.append(AccessRule(
roleId=adminId,
context=AccessRuleContext.DATA,
item="data.system.Invitation",
item="data.uam.Invitation",
view=True,
read=AccessLevel.GROUP,
create=AccessLevel.GROUP,
@ -551,7 +510,7 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None:
tableRules.append(AccessRule(
roleId=userId,
context=AccessRuleContext.DATA,
item="data.system.Invitation",
item="data.uam.Invitation",
view=True,
read=AccessLevel.MY,
create=AccessLevel.MY,
@ -562,7 +521,7 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None:
tableRules.append(AccessRule(
roleId=viewerId,
context=AccessRuleContext.DATA,
item="data.system.Invitation",
item="data.uam.Invitation",
view=True,
read=AccessLevel.MY,
create=AccessLevel.NONE,
@ -570,13 +529,12 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None:
delete=AccessLevel.NONE,
))
# AuthEvent table - Audit logs (no delete allowed for audit integrity!)
# SysAdmin can delete via isSysAdmin bypass, but regular admins cannot
# AuthEvent table - Audit logs (UAM namespace, no delete for audit integrity!)
if adminId:
tableRules.append(AccessRule(
roleId=adminId,
context=AccessRuleContext.DATA,
item="data.system.AuthEvent",
item="data.uam.AuthEvent",
view=True,
read=AccessLevel.ALL,
create=AccessLevel.NONE,
@ -587,7 +545,7 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None:
tableRules.append(AccessRule(
roleId=userId,
context=AccessRuleContext.DATA,
item="data.system.AuthEvent",
item="data.uam.AuthEvent",
view=True,
read=AccessLevel.MY,
create=AccessLevel.NONE,
@ -598,7 +556,120 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None:
tableRules.append(AccessRule(
roleId=viewerId,
context=AccessRuleContext.DATA,
item="data.system.AuthEvent",
item="data.uam.AuthEvent",
view=True,
read=AccessLevel.MY,
create=AccessLevel.NONE,
update=AccessLevel.NONE,
delete=AccessLevel.NONE,
))
# -------------------------------------------------------------------------
# Chat Namespace - User-owned, no mandate context
# -------------------------------------------------------------------------
# Prompt: Only MY-level access (user-owned, no mandate context)
# Each user manages only their own prompts
for roleId in [adminId, userId]:
if roleId:
tableRules.append(AccessRule(
roleId=roleId,
context=AccessRuleContext.DATA,
item="data.chat.Prompt",
view=True,
read=AccessLevel.MY,
create=AccessLevel.MY,
update=AccessLevel.MY,
delete=AccessLevel.MY,
))
if viewerId:
tableRules.append(AccessRule(
roleId=viewerId,
context=AccessRuleContext.DATA,
item="data.chat.Prompt",
view=True,
read=AccessLevel.MY,
create=AccessLevel.NONE,
update=AccessLevel.NONE,
delete=AccessLevel.NONE,
))
# ChatWorkflow: Only MY-level access (user-owned, no mandate context)
for roleId in [adminId, userId]:
if roleId:
tableRules.append(AccessRule(
roleId=roleId,
context=AccessRuleContext.DATA,
item="data.chat.ChatWorkflow",
view=True,
read=AccessLevel.MY,
create=AccessLevel.MY,
update=AccessLevel.MY,
delete=AccessLevel.MY,
))
if viewerId:
tableRules.append(AccessRule(
roleId=viewerId,
context=AccessRuleContext.DATA,
item="data.chat.ChatWorkflow",
view=True,
read=AccessLevel.MY,
create=AccessLevel.NONE,
update=AccessLevel.NONE,
delete=AccessLevel.NONE,
))
# -------------------------------------------------------------------------
# Files Namespace - User-owned, no mandate context
# -------------------------------------------------------------------------
# FileItem: Only MY-level access (user-owned)
for roleId in [adminId, userId]:
if roleId:
tableRules.append(AccessRule(
roleId=roleId,
context=AccessRuleContext.DATA,
item="data.files.FileItem",
view=True,
read=AccessLevel.MY,
create=AccessLevel.MY,
update=AccessLevel.MY,
delete=AccessLevel.MY,
))
if viewerId:
tableRules.append(AccessRule(
roleId=viewerId,
context=AccessRuleContext.DATA,
item="data.files.FileItem",
view=True,
read=AccessLevel.MY,
create=AccessLevel.NONE,
update=AccessLevel.NONE,
delete=AccessLevel.NONE,
))
# -------------------------------------------------------------------------
# Automation Namespace - User-owned, no mandate context
# -------------------------------------------------------------------------
# AutomationDefinition: Only MY-level access (user-owned)
for roleId in [adminId, userId]:
if roleId:
tableRules.append(AccessRule(
roleId=roleId,
context=AccessRuleContext.DATA,
item="data.automation.AutomationDefinition",
view=True,
read=AccessLevel.MY,
create=AccessLevel.MY,
update=AccessLevel.MY,
delete=AccessLevel.MY,
))
if viewerId:
tableRules.append(AccessRule(
roleId=viewerId,
context=AccessRuleContext.DATA,
item="data.automation.AutomationDefinition",
view=True,
read=AccessLevel.MY,
create=AccessLevel.NONE,
@ -670,6 +741,160 @@ def _createUiContextRules(db: DatabaseConnector) -> None:
logger.info(f"Created {len(uiRules)} UI context rules")
def _ensureUiContextRules(db: DatabaseConnector) -> None:
"""
Ensure UI context rules exist for all navigation items.
This is called during bootstrap to add missing UI rules for new navigation items.
Args:
db: Database connector instance
"""
from modules.system.mainSystem import NAVIGATION_SECTIONS
adminId = _getRoleId(db, "admin")
userId = _getRoleId(db, "user")
viewerId = _getRoleId(db, "viewer")
# Get existing UI rules
existingUiRules = db.getRecordset(
AccessRule,
recordFilter={"context": AccessRuleContext.UI.value}
)
# Build set of existing (roleId, item) combinations
existingCombinations = set()
for rule in existingUiRules:
roleId = rule.get("roleId")
item = rule.get("item")
if roleId and item:
existingCombinations.add((roleId, item))
# Check each navigation item and add missing rules
missingRules = []
for section in NAVIGATION_SECTIONS:
isAdminSection = section.get("adminOnly", False)
for item in section.get("items", []):
objectKey = item.get("objectKey")
if not objectKey:
continue
isAdminOnly = item.get("adminOnly", False) or isAdminSection
if isAdminOnly:
# Admin-only: only admin role
if adminId and (adminId, objectKey) not in existingCombinations:
missingRules.append(AccessRule(
roleId=adminId,
context=AccessRuleContext.UI,
item=objectKey,
view=True,
read=None, create=None, update=None, delete=None,
))
else:
# Public/normal: all roles
for roleId in [adminId, userId, viewerId]:
if roleId and (roleId, objectKey) not in existingCombinations:
missingRules.append(AccessRule(
roleId=roleId,
context=AccessRuleContext.UI,
item=objectKey,
view=True,
read=None, create=None, update=None, delete=None,
))
# Create missing rules
if missingRules:
for rule in missingRules:
db.recordCreate(AccessRule, rule)
logger.info(f"Created {len(missingRules)} missing UI context rules")
else:
logger.debug("All UI context rules already exist")
def _ensureDataContextRules(db: DatabaseConnector) -> None:
"""
Ensure DATA context rules exist for key tables like ChatWorkflow and AutomationDefinition.
This is called during bootstrap to add missing DATA rules for new tables.
Args:
db: Database connector instance
"""
adminId = _getRoleId(db, "admin")
userId = _getRoleId(db, "user")
viewerId = _getRoleId(db, "viewer")
# Get existing DATA rules
existingDataRules = db.getRecordset(
AccessRule,
recordFilter={"context": AccessRuleContext.DATA.value}
)
# Build set of existing (roleId, item) combinations
existingCombinations = set()
for rule in existingDataRules:
roleId = rule.get("roleId")
item = rule.get("item")
if roleId and item:
existingCombinations.add((roleId, item))
# Define tables that need rules (user-owned, no mandate context)
# Users can only manage their own records (MY-level access)
tablesNeedingRules = [
"data.chat.ChatWorkflow",
"data.automation.AutomationDefinition",
]
missingRules = []
for objectKey in tablesNeedingRules:
# Admin: MY-level access (user-owned, no mandate context)
if adminId and (adminId, objectKey) not in existingCombinations:
missingRules.append(AccessRule(
roleId=adminId,
context=AccessRuleContext.DATA,
item=objectKey,
view=True,
read=AccessLevel.MY,
create=AccessLevel.MY,
update=AccessLevel.MY,
delete=AccessLevel.MY,
))
# User: MY-level access (user-owned, no mandate context)
if userId and (userId, objectKey) not in existingCombinations:
missingRules.append(AccessRule(
roleId=userId,
context=AccessRuleContext.DATA,
item=objectKey,
view=True,
read=AccessLevel.MY,
create=AccessLevel.MY,
update=AccessLevel.MY,
delete=AccessLevel.MY,
))
# Viewer: MY read-only (user-owned, no mandate context)
if viewerId and (viewerId, objectKey) not in existingCombinations:
missingRules.append(AccessRule(
roleId=viewerId,
context=AccessRuleContext.DATA,
item=objectKey,
view=True,
read=AccessLevel.MY,
create=AccessLevel.NONE,
update=AccessLevel.NONE,
delete=AccessLevel.NONE,
))
# Create missing rules
if missingRules:
for rule in missingRules:
db.recordCreate(AccessRule, rule)
logger.info(f"Created {len(missingRules)} missing DATA context rules")
else:
logger.debug("All DATA context rules already exist")
def _createResourceContextRules(db: DatabaseConnector) -> None:
"""
Create RESOURCE context rules for controlling resource access.

View file

@ -1217,10 +1217,10 @@ class AppObjects:
The created UserConnection object
"""
try:
# Get the user
user = self.getUser(userId)
if not user:
raise ValueError(f"User not found: {userId}")
# Note: User verification is skipped here because:
# 1. The caller (route) already has an authenticated currentUser
# 2. Users should always be able to create connections for themselves
# 3. getUser() uses RBAC filtering which may fail for users without UserInDB view permissions
# Create new connection with all required fields
connection = UserConnection(

View file

@ -364,10 +364,13 @@ class ChatObjects:
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,
featureInstanceId=self.featureInstanceId
)
@ -680,8 +683,7 @@ class ChatObjects:
startedAt=workflow.get("startedAt", getUtcTimestamp()),
logs=logs,
messages=messages,
stats=stats,
mandateId=workflow.get("mandateId", self.mandateId)
stats=stats
)
except Exception as e:
logger.error(f"Error validating workflow data: {str(e)}")
@ -702,9 +704,22 @@ class ChatObjects:
# Set mandateId and featureInstanceId from context for proper data isolation
if "mandateId" not in workflowData or not workflowData["mandateId"]:
workflowData["mandateId"] = self.mandateId
if "featureInstanceId" not in workflowData or not workflowData["featureInstanceId"]:
workflowData["featureInstanceId"] = self.featureInstanceId
# Use request context mandateId, or fall back to Root mandate
effectiveMandateId = self.mandateId
if not effectiveMandateId:
# Fall back to Root mandate (first mandate in system)
try:
from modules.datamodels.datamodelUam import Mandate
from modules.security.rootAccess import getRootDbAppConnector
dbAppConn = getRootDbAppConnector()
allMandates = dbAppConn.getRecordset(Mandate)
if allMandates:
effectiveMandateId = allMandates[0].get("id")
logger.debug(f"createWorkflow: Using Root mandate {effectiveMandateId}")
except Exception as e:
logger.warning(f"Could not get Root mandate: {e}")
# Note: Chat data is user-owned, no mandate/featureInstance context stored
# mandateId/featureInstanceId removed from ChatWorkflow model
# Use generic field separation based on ChatWorkflow model
simpleFields, objectFields = self._separateObjectFields(ChatWorkflow, workflowData)
@ -714,6 +729,7 @@ class ChatObjects:
# Convert to ChatWorkflow model (empty related data for new workflow)
# Note: Chat data is user-owned, no mandate/featureInstance fields
return ChatWorkflow(
id=created["id"],
status=created.get("status", "running"),
@ -728,7 +744,6 @@ class ChatObjects:
logs=[],
messages=[],
stats=[],
mandateId=created.get("mandateId", self.mandateId),
workflowMode=created["workflowMode"],
maxSteps=created.get("maxSteps", 1)
)
@ -774,8 +789,7 @@ class ChatObjects:
startedAt=updated.get("startedAt", workflow.startedAt),
logs=logs,
messages=messages,
stats=stats,
mandateId=updated.get("mandateId", workflow.mandateId)
stats=stats
)
def deleteWorkflow(self, workflowId: str) -> bool:
@ -886,7 +900,7 @@ class ChatObjects:
# Apply default sorting by publishedAt if no sort specified
if pagination is None or not pagination.sort:
messageDicts.sort(key=lambda x: x.get("publishedAt", getUtcTimestamp()))
messageDicts.sort(key=lambda x: x.get("publishedAt") or getUtcTimestamp())
# Apply filtering (if filters provided)
if pagination and pagination.filters:
@ -1026,11 +1040,8 @@ class ChatObjects:
if "actionNumber" not in messageData:
messageData["actionNumber"] = workflow.currentAction
# Set mandateId and featureInstanceId from context for proper data isolation
if "mandateId" not in messageData or not messageData["mandateId"]:
messageData["mandateId"] = self.mandateId
if "featureInstanceId" not in messageData or not messageData["featureInstanceId"]:
messageData["featureInstanceId"] = self.featureInstanceId
# Note: Chat data is user-owned, no mandate/featureInstance context stored
# mandateId/featureInstanceId removed from ChatMessage model
# Use generic field separation based on ChatMessage model
simpleFields, objectFields = self._separateObjectFields(ChatMessage, messageData)
@ -1306,11 +1317,8 @@ class ChatObjects:
def createDocument(self, documentData: Dict[str, Any]) -> ChatDocument:
"""Creates a document for a message in normalized table."""
try:
# Set mandateId and featureInstanceId from context for proper data isolation
if "mandateId" not in documentData or not documentData["mandateId"]:
documentData["mandateId"] = self.mandateId
if "featureInstanceId" not in documentData or not documentData["featureInstanceId"]:
documentData["featureInstanceId"] = self.featureInstanceId
# Note: Chat data is user-owned, no mandate/featureInstance context stored
# mandateId/featureInstanceId removed from ChatDocument model
# Validate and normalize document data to dict
document = ChatDocument(**documentData)
@ -1431,11 +1439,8 @@ class ChatObjects:
if "timestamp" not in logData:
logData["timestamp"] = getUtcTimestamp()
# Set mandateId and featureInstanceId from context for proper data isolation
if "mandateId" not in logData or not logData["mandateId"]:
logData["mandateId"] = self.mandateId
if "featureInstanceId" not in logData or not logData["featureInstanceId"]:
logData["featureInstanceId"] = self.featureInstanceId
# Note: Chat data is user-owned, no mandate/featureInstance context stored
# mandateId/featureInstanceId removed from ChatLog model
# Add status information if not present
if "status" not in logData and "type" in logData:
@ -1500,11 +1505,8 @@ class ChatObjects:
if "workflowId" not in statData:
raise ValueError("workflowId is required in statData")
# Set mandateId and featureInstanceId from context for proper data isolation
if "mandateId" not in statData or not statData["mandateId"]:
statData["mandateId"] = self.mandateId
if "featureInstanceId" not in statData or not statData["featureInstanceId"]:
statData["featureInstanceId"] = self.featureInstanceId
# Note: Chat data is user-owned, no mandate/featureInstance context stored
# mandateId/featureInstanceId removed from ChatStat model
# Validate the stat data against ChatStat model
stat = ChatStat(**statData)
@ -1783,8 +1785,22 @@ class ChatObjects:
automationData["id"] = str(uuid.uuid4())
# Ensure mandateId and featureInstanceId are set for proper data isolation
if "mandateId" not in automationData:
automationData["mandateId"] = self.mandateId
if "mandateId" not in automationData or not automationData.get("mandateId"):
# Use request context mandateId, or fall back to Root mandate
effectiveMandateId = self.mandateId
if not effectiveMandateId:
# Fall back to Root mandate (first mandate in system)
try:
from modules.datamodels.datamodelUam import Mandate
from modules.security.rootAccess import getRootDbAppConnector
dbAppConn = getRootDbAppConnector()
allMandates = dbAppConn.getRecordset(Mandate)
if allMandates:
effectiveMandateId = allMandates[0].get("id")
logger.debug(f"createAutomationDefinition: Using Root mandate {effectiveMandateId}")
except Exception as e:
logger.warning(f"Could not get Root mandate: {e}")
automationData["mandateId"] = effectiveMandateId
if "featureInstanceId" not in automationData:
automationData["featureInstanceId"] = self.featureInstanceId

View file

@ -7,6 +7,18 @@ Provides RBAC filtering for database queries without connectors importing securi
Multi-Tenant Design:
- mandateId kommt aus Request-Context (X-Mandate-Id Header)
- GROUP-Filter verwendet expliziten mandateId Parameter
Data Namespace Structure:
- data.uam.{Table} User Access Management (mandantenübergreifend)
- data.chat.{Table} Chat/AI-Daten (benutzer-eigen, kein Mandantenkontext)
- data.files.{Table} Dateien (benutzer-eigen)
- data.automation.{Table} Automation (benutzer-eigen)
- data.feature.{code}.{Table} Mandanten-/Feature-spezifische Daten (dynamisch)
GROUP-Berechtigung:
- data.uam.*: GROUP filtert nach Mandant (via UserMandate)
- data.chat.*, data.files.*, data.automation.*: GROUP = MY (benutzer-eigen, kein Mandantenkontext)
- data.feature.*: GROUP filtert nach mandateId/featureInstanceId
"""
import logging
@ -21,25 +33,70 @@ from modules.security.rootAccess import getRootDbAppConnector
logger = logging.getLogger(__name__)
# =============================================================================
# Namespace-Mapping für statische Tabellen
# =============================================================================
# Definiert, welcher Namespace für jede Tabelle verwendet wird.
# Tabellen ohne Eintrag fallen auf "system" zurück (Fallback für Rückwärtskompatibilität).
# =============================================================================
TABLE_NAMESPACE = {
# UAM (User Access Management) - mandantenübergreifend
"UserInDB": "uam",
"UserConnection": "uam",
"AuthEvent": "uam",
"Mandate": "uam",
"UserMandate": "uam",
"UserMandateRole": "uam",
"Invitation": "uam",
"Role": "uam",
"AccessRule": "uam",
"FeatureInstance": "uam",
"FeatureAccess": "uam",
"FeatureAccessRole": "uam",
# Chat - benutzer-eigen, kein Mandantenkontext
"ChatWorkflow": "chat",
"ChatMessage": "chat",
"ChatLog": "chat",
"ChatStat": "chat",
"ChatDocument": "chat",
"Prompt": "chat",
# Files - benutzer-eigen
"FileItem": "files",
"FileData": "files",
# Automation - benutzer-eigen
"AutomationDefinition": "automation",
}
# Namespaces ohne Mandantenkontext - GROUP wird auf MY gemappt
USER_OWNED_NAMESPACES = {"chat", "files", "automation"}
def buildDataObjectKey(tableName: str, featureCode: Optional[str] = None) -> str:
"""
Build the standardized objectKey for a DATA context item.
Format:
- System tables: data.system.{TableName}
- UAM tables: data.uam.{TableName}
- Chat tables: data.chat.{TableName}
- File tables: data.files.{TableName}
- Automation tables: data.automation.{TableName}
- Feature tables: data.feature.{featureCode}.{TableName}
Args:
tableName: The database table name (e.g., "UserInDB", "TrusteePosition")
tableName: The database table name (e.g., "UserInDB", "ChatWorkflow")
featureCode: Optional feature code (e.g., "trustee", "realestate")
If None, assumes system table.
If provided, uses data.feature.{featureCode}.{tableName}
Returns:
Full objectKey string (e.g., "data.system.UserInDB" or "data.feature.trustee.TrusteePosition")
Full objectKey string (e.g., "data.uam.UserInDB", "data.chat.ChatWorkflow",
or "data.feature.trustee.TrusteePosition")
"""
if featureCode:
return f"data.feature.{featureCode}.{tableName}"
return f"data.system.{tableName}"
namespace = TABLE_NAMESPACE.get(tableName, "system") # Fallback für unbekannte Tabellen
return f"data.{namespace}.{tableName}"
def getRecordsetWithRBAC(
@ -107,7 +164,7 @@ def getRecordsetWithRBAC(
permissions = rbacInstance.getUserPermissions(
currentUser,
AccessRuleContext.DATA,
objectKey, # Use full objectKey (e.g., "data.system.UserInDB")
objectKey, # Use full objectKey (e.g., "data.uam.UserInDB", "data.chat.ChatWorkflow")
mandateId=effectiveMandateId,
featureInstanceId=featureInstanceId
)
@ -271,10 +328,32 @@ def buildRbacWhereClause(
"values": [currentUser.id]
}
# Group records - filter by mandateId
# Group records - filter by mandateId or ownership based on namespace
if readLevel == AccessLevel.GROUP:
# Determine namespace for this table
namespace = TABLE_NAMESPACE.get(table, "system")
# For user-owned namespaces (chat, files, automation):
# GROUP has no meaning - these tables have no mandate context
# Simply ignore GROUP (no filtering)
if namespace in USER_OWNED_NAMESPACES:
return None
# For UAM and other namespaces: GROUP filters by mandate
effectiveMandateId = mandateId
if not effectiveMandateId:
# Fall back to Root mandate (first mandate in system) for GROUP access
# This allows system-level tables to be accessed without explicit mandate context
try:
from modules.datamodels.datamodelUam import Mandate
dbApp = getRootDbAppConnector()
allMandates = dbApp.getRecordset(Mandate)
if allMandates:
effectiveMandateId = allMandates[0].get("id")
except Exception as e:
logger.error(f"Error getting Root mandate: {e}")
if not effectiveMandateId:
logger.warning(f"User {currentUser.id} has no mandateId for GROUP access")
return {"condition": "1 = 0", "values": []}
@ -324,10 +403,16 @@ def buildRbacWhereClause(
logger.error(f"Error building GROUP filter for UserConnection: {e}")
return {"condition": "1 = 0", "values": []}
# For system tables without mandateId column (Mandate, Role, etc.):
# No row-level filtering - GROUP access = ALL access for these
elif table in ("Mandate", "Role"):
return None
# For other tables, filter by mandateId field
# Also include records with NULL mandateId for backwards compatibility
else:
return {
"condition": '"mandateId" = %s',
"condition": '("mandateId" = %s OR "mandateId" IS NULL)',
"values": [effectiveMandateId]
}

View file

@ -184,9 +184,12 @@ async def get_all_permissions(
recordFilter={"userId": str(reqContext.user.id), "enabled": True}
)
logger.debug(f"UI/RESOURCE permissions: Found {len(userMandates)} UserMandates for user {reqContext.user.id}")
# Collect all role IDs the user has across all mandates
for userMandate in userMandates:
mandateRoleIds = rootInterface.getRoleIdsForUserMandate(userMandate.get("id"))
logger.debug(f"UI/RESOURCE permissions: UserMandate {userMandate.get('id')} (mandate {userMandate.get('mandateId')}) has {len(mandateRoleIds)} roles: {mandateRoleIds}")
for rid in mandateRoleIds:
if rid not in roleIds:
roleIds.append(rid)
@ -261,20 +264,24 @@ async def get_all_permissions(
items.add(rule.item)
# For each item, calculate user permissions
# For UI/RESOURCE context: Calculate permissions directly from the collected rules
# (Don't use getUserPermissions with mandateId - that would limit to one mandate's roles)
for item in sorted(items):
permissions = interface.rbac.getUserPermissions(
reqContext.user, ctx, item,
mandateId=reqContext.mandateId,
featureInstanceId=reqContext.featureInstanceId
)
# Find matching rules for this item from the already-collected rules
itemView = False
for rule in allRules[ctx]:
if rule.item == item and rule.view:
itemView = True
break
# Only include if user has view permission
if permissions.view:
if itemView:
result[ctx.value.lower()][item] = {
"view": permissions.view,
"read": permissions.read.value if permissions.read else None,
"create": permissions.create.value if permissions.create else None,
"update": permissions.update.value if permissions.update else None,
"delete": permissions.delete.value if permissions.delete else None
"view": True,
"read": None, # UI context doesn't use CRUD permissions
"create": None,
"update": None,
"delete": None
}
return result

View file

@ -331,13 +331,8 @@ async def create_connection(
detail=f"Unsupported connection type: {connection_data.get('type')}"
)
# Get fresh copy of user from database
user = interface.getUser(currentUser.id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
# Note: currentUser is already authenticated via JWT - no need to re-verify from database
# The getCurrentUser dependency already validated the user exists
# Always create a new connection with PENDING status
connection = interface.addUserConnection(

View file

@ -19,6 +19,7 @@ from modules.datamodels.datamodelNotification import (
NotificationStatus,
NotificationAction
)
from modules.datamodels.datamodelRbac import Role
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.shared.timeUtils import getUtcTimestamp
@ -452,6 +453,17 @@ async def _handleInvitationAction(
mandateId = invitation.get("mandateId")
roleIds = invitation.get("roleIds", [])
# Ensure user gets the system "user" role for access to public UI elements (e.g. playground)
userRoles = rootInterface.db.getRecordset(
model_class=Role,
recordFilter={"roleLabel": "user"}
)
if userRoles:
userRoleId = userRoles[0].get("id")
if userRoleId and userRoleId not in roleIds:
roleIds = roleIds + [userRoleId]
logger.debug(f"Added system 'user' role {userRoleId} to invitation roles")
# Get mandate name for result message
mandates = rootInterface.db.getRecordset(
model_class=Mandate,

View file

@ -13,7 +13,7 @@ Multi-Tenant Design:
import logging
from typing import List, Optional, TYPE_CHECKING
from modules.datamodels.datamodelRbac import AccessRule, AccessRuleContext, Role
from modules.datamodels.datamodelUam import User, UserPermissions, AccessLevel
from modules.datamodels.datamodelUam import User, UserPermissions, AccessLevel, Mandate
from modules.datamodels.datamodelMembership import (
UserMandate,
UserMandateRole,
@ -148,6 +148,9 @@ class RbacClass:
Get all role IDs for a user in the given context.
Uses UserMandate + UserMandateRole for the new multi-tenant model.
Also includes roles from the Root mandate (first mandate) if different
from the requested mandate, so system-level permissions are always available.
Args:
user: User object
mandateId: Mandate context
@ -156,30 +159,40 @@ class RbacClass:
Returns:
List of role IDs
"""
roleIds = []
if not mandateId:
return roleIds
roleIds = set() # Use set to avoid duplicates
try:
# Lade UserMandate
userMandates = self.dbApp.getRecordset(
UserMandate,
recordFilter={"userId": user.id, "mandateId": mandateId, "enabled": True}
)
# Get Root mandate ID (first mandate in system)
allMandates = self.dbApp.getRecordset(Mandate)
rootMandateId = allMandates[0].get("id") if allMandates else None
if not userMandates:
return roleIds
# Collect mandates to check:
# - If mandateId provided: current mandate + Root mandate (if different)
# - If no mandateId: just Root mandate (for system-level access)
mandatesToCheck = []
if mandateId:
mandatesToCheck.append(mandateId)
if rootMandateId and rootMandateId not in mandatesToCheck:
mandatesToCheck.append(rootMandateId)
userMandateId = userMandates[0].get("id")
# Lade UserMandateRoles (Mandate-level roles)
userMandateRoles = self.dbApp.getRecordset(
UserMandateRole,
recordFilter={"userMandateId": userMandateId}
)
roleIds.extend([r.get("roleId") for r in userMandateRoles if r.get("roleId")])
# Load roles from each mandate
for checkMandateId in mandatesToCheck:
userMandates = self.dbApp.getRecordset(
UserMandate,
recordFilter={"userId": user.id, "mandateId": checkMandateId, "enabled": True}
)
if userMandates:
userMandateId = userMandates[0].get("id")
# Lade UserMandateRoles (Mandate-level roles)
userMandateRoles = self.dbApp.getRecordset(
UserMandateRole,
recordFilter={"userMandateId": userMandateId}
)
foundRoles = [r.get("roleId") for r in userMandateRoles if r.get("roleId")]
roleIds.update(foundRoles)
# Load FeatureAccess + FeatureAccessRole (Instance-level roles)
if featureInstanceId:
@ -200,12 +213,12 @@ class RbacClass:
recordFilter={"featureAccessId": featureAccessId}
)
roleIds.extend([r.get("roleId") for r in featureAccessRoles if r.get("roleId")])
roleIds.update([r.get("roleId") for r in featureAccessRoles if r.get("roleId")])
except Exception as e:
logger.error(f"Error loading role IDs for user {user.id}: {e}")
return roleIds
return list(roleIds)
def getRulesForUserBulk(
self,
@ -388,7 +401,10 @@ class RbacClass:
Example: rule "data.feature.trustee" matches item "data.feature.trustee.TrusteePosition"
All items MUST use the full objectKey format:
- System: data.system.{TableName} (e.g., "data.system.UserInDB")
- UAM: data.uam.{TableName} (e.g., "data.uam.UserInDB")
- Chat: data.chat.{TableName} (e.g., "data.chat.ChatWorkflow")
- Files: data.files.{TableName} (e.g., "data.files.FileItem")
- Automation: data.automation.{TableName} (e.g., "data.automation.AutomationDefinition")
- Feature: data.feature.{featureCode}.{TableName} (e.g., "data.feature.trustee.TrusteePosition")
- UI: ui.{area}.{page} (e.g., "ui.admin.users")

View file

@ -72,6 +72,7 @@ NAVIGATION_SECTIONS = [
"icon": "FaPlay",
"path": "/workflows/playground",
"order": 10,
"public": True,
},
{
"id": "chats",
@ -80,6 +81,7 @@ NAVIGATION_SECTIONS = [
"icon": "FaListAlt",
"path": "/workflows/list",
"order": 20,
"public": True,
},
{
"id": "automations",
@ -88,6 +90,7 @@ NAVIGATION_SECTIONS = [
"icon": "FaCogs",
"path": "/workflows/automations",
"order": 30,
"public": True,
},
],
},
@ -297,72 +300,83 @@ UI_OBJECTS = _buildUiObjectsFromNavigation()
# =============================================================================
# System DATA Objects
# =============================================================================
# Namespace structure:
# - data.uam.* → User Access Management (mandantenübergreifend)
# - data.chat.* → Chat/AI-Daten (benutzer-eigen, kein Mandantenkontext)
# - data.files.* → Dateien (benutzer-eigen)
# - data.automation.* → Automation (benutzer-eigen)
# - data.feature.* → Mandanten-/Feature-spezifische Daten (dynamisch)
# =============================================================================
DATA_OBJECTS = [
# User/Auth tables
# UAM (User Access Management) - mandantenübergreifend
{
"objectKey": "data.system.UserInDB",
"objectKey": "data.uam.UserInDB",
"label": {"en": "User", "de": "Benutzer", "fr": "Utilisateur"},
"meta": {"table": "UserInDB"}
"meta": {"table": "UserInDB", "namespace": "uam"}
},
{
"objectKey": "data.system.AuthEvent",
"objectKey": "data.uam.AuthEvent",
"label": {"en": "Auth Event", "de": "Auth-Ereignis", "fr": "Événement d'auth"},
"meta": {"table": "AuthEvent"}
"meta": {"table": "AuthEvent", "namespace": "uam"}
},
{
"objectKey": "data.system.UserConnection",
"objectKey": "data.uam.UserConnection",
"label": {"en": "Connection", "de": "Verbindung", "fr": "Connexion"},
"meta": {"table": "UserConnection"}
"meta": {"table": "UserConnection", "namespace": "uam"}
},
# Mandate/Membership tables
{
"objectKey": "data.system.Mandate",
"objectKey": "data.uam.Mandate",
"label": {"en": "Mandate", "de": "Mandant", "fr": "Mandat"},
"meta": {"table": "Mandate"}
"meta": {"table": "Mandate", "namespace": "uam"}
},
{
"objectKey": "data.system.UserMandate",
"objectKey": "data.uam.UserMandate",
"label": {"en": "User Mandate", "de": "Benutzer-Mandant", "fr": "Mandat utilisateur"},
"meta": {"table": "UserMandate"}
"meta": {"table": "UserMandate", "namespace": "uam"}
},
{
"objectKey": "data.system.Invitation",
"objectKey": "data.uam.Invitation",
"label": {"en": "Invitation", "de": "Einladung", "fr": "Invitation"},
"meta": {"table": "Invitation"}
"meta": {"table": "Invitation", "namespace": "uam"}
},
# RBAC tables
{
"objectKey": "data.system.Role",
"objectKey": "data.uam.Role",
"label": {"en": "Role", "de": "Rolle", "fr": "Rôle"},
"meta": {"table": "Role"}
"meta": {"table": "Role", "namespace": "uam"}
},
{
"objectKey": "data.system.AccessRule",
"objectKey": "data.uam.AccessRule",
"label": {"en": "Access Rule", "de": "Zugriffsregel", "fr": "Règle d'accès"},
"meta": {"table": "AccessRule"}
"meta": {"table": "AccessRule", "namespace": "uam"}
},
# Feature tables
{
"objectKey": "data.system.FeatureInstance",
"objectKey": "data.uam.FeatureInstance",
"label": {"en": "Feature Instance", "de": "Feature-Instanz", "fr": "Instance de feature"},
"meta": {"table": "FeatureInstance"}
"meta": {"table": "FeatureInstance", "namespace": "uam"}
},
# Content tables
# Chat - benutzer-eigen, kein Mandantenkontext
{
"objectKey": "data.system.Prompt",
"objectKey": "data.chat.Prompt",
"label": {"en": "Prompt", "de": "Prompt", "fr": "Prompt"},
"meta": {"table": "Prompt"}
"meta": {"table": "Prompt", "namespace": "chat", "groupDisabled": True}
},
{
"objectKey": "data.system.ChatWorkflow",
"objectKey": "data.chat.ChatWorkflow",
"label": {"en": "Chat Workflow", "de": "Chat-Workflow", "fr": "Workflow de chat"},
"meta": {"table": "ChatWorkflow"}
"meta": {"table": "ChatWorkflow", "namespace": "chat", "groupDisabled": True}
},
# Files - benutzer-eigen
{
"objectKey": "data.system.FileItem",
"objectKey": "data.files.FileItem",
"label": {"en": "File", "de": "Datei", "fr": "Fichier"},
"meta": {"table": "FileItem"}
"meta": {"table": "FileItem", "namespace": "files", "groupDisabled": True}
},
# Automation - benutzer-eigen
{
"objectKey": "data.automation.AutomationDefinition",
"label": {"en": "Automation", "de": "Automatisierung", "fr": "Automatisation"},
"meta": {"table": "AutomationDefinition", "namespace": "automation", "groupDisabled": True}
},
]

View file

@ -360,7 +360,7 @@ async def _extractExpensesWithAi(services, fileContent: bytes, fileName: str, pr
from modules.datamodels.datamodelChat import ChatDocument
from modules.datamodels.datamodelDocref import DocumentReferenceList
dbInterface = getDbInterface()
dbInterface = getDbInterface(services.user, mandateId=services.mandateId, featureInstanceId=featureInstanceId)
# Create file record
fileItem = dbInterface.createFile(
@ -375,40 +375,52 @@ async def _extractExpensesWithAi(services, fileContent: bytes, fileName: str, pr
logger.info(f"Stored PDF {fileName} ({len(fileContent)} bytes) with fileId: {fileItem.id}")
# Step 2: Create ChatDocument referencing the file
# Use workflow context if available
workflowId = services.workflow.id if services.workflow else str(uuid.uuid4())
messageId = f"expense_import_{workflowId}_{str(uuid.uuid4())[:8]}"
documentId = str(uuid.uuid4())
chatDocument = ChatDocument(
id=str(uuid.uuid4()),
id=documentId,
mandateId=services.mandateId or "",
featureInstanceId=featureInstanceId or "",
messageId=messageId,
messageId="", # Will be set when attached to message
fileId=fileItem.id,
fileName=fileName,
fileSize=len(fileContent),
mimeType="application/pdf"
)
# Step 3: Create DocumentReferenceList for AI service
# Step 3: Create a proper message with the document attached to the workflow
# This ensures getChatDocumentsFromDocumentList can find the document via workflow.messages
messageData = {
"id": f"msg_expense_import_{str(uuid.uuid4())[:8]}",
"documentsLabel": f"expense_pdf_{fileName}",
"role": "user",
"status": "step",
"message": f"PDF document for expense extraction: {fileName}"
}
# Use storeMessageWithDocuments to properly create message + document and sync with workflow
createdMessage = services.chat.storeMessageWithDocuments(
services.workflow,
messageData,
[chatDocument.model_dump()]
)
# Update documentId to match the created document's actual ID
if createdMessage and createdMessage.documents:
documentId = createdMessage.documents[0].id
logger.info(f"Created message {createdMessage.id} with ChatDocument {documentId} for AI processing")
# Step 4: Create DocumentReferenceList for AI service
from modules.datamodels.datamodelDocref import DocumentItemReference
documentList = DocumentReferenceList(
references=[
DocumentItemReference(
documentId=chatDocument.id,
documentId=documentId,
fileName=fileName
)
]
)
# Step 4: Store the ChatDocument so AI service can retrieve it
# The AI service uses getChatDocumentsFromDocumentList which queries the database
from modules.interfaces.interfaceDbChat import getInterface as getChatInterface
chatInterface = getChatInterface(services.user, mandateId=services.mandateId, featureInstanceId=featureInstanceId)
chatInterface.createDocument(chatDocument.model_dump())
logger.info(f"Created ChatDocument {chatDocument.id} for AI processing")
# Step 5: Call AI with documentList - let AI service handle everything
# (extraction, intent analysis, chunking, image processing)
options = AiCallOptions(

View file

@ -119,9 +119,9 @@ class TestRbacBootstrap:
# Should create multiple rules for different tables
assert db.recordCreate.call_count > 0
# Check that Mandate table rules are created with full objectKey
# Check that Mandate table rules are created with full objectKey (UAM namespace)
mandateCalls = [call for call in db.recordCreate.call_args_list
if call[0][1].item == "data.system.Mandate"]
if call[0][1].item == "data.uam.Mandate"]
assert len(mandateCalls) > 0
# Check that all roles have view=False and no access for Mandate
@ -134,11 +134,11 @@ class TestRbacBootstrap:
def testInitRbacRulesSkipsIfExists(self):
"""Test that initRbacRules skips default rule creation if rules already exist, but adds missing table-specific rules."""
db = Mock()
# Mock existing rules - include rules for ChatWorkflow and Prompt to prevent adding missing rules
# Mock existing rules - include rules for ChatWorkflow and AutomationDefinition to prevent adding missing rules
# Need rules for all required roles to fully prevent creation
# Using full objectKey format: data.system.{TableName}
# Using semantic namespace format: data.chat.{TableName}, data.automation.{TableName}
existingRules = []
for table in ["data.system.ChatWorkflow", "data.system.Prompt"]:
for table in ["data.chat.ChatWorkflow", "data.automation.AutomationDefinition"]:
for role in ["admin", "user", "viewer"]:
existingRules.append({
"id": f"rule_{table}_{role}",

View file

@ -94,7 +94,7 @@ class TestRbacPermissionResolution:
AccessRule(
roleLabel="user",
context=AccessRuleContext.DATA,
item="data.system.UserInDB", # Specific rule with full objectKey
item="data.uam.UserInDB", # Specific rule with UAM namespace
view=True,
read=AccessLevel.MY,
create=AccessLevel.NONE,
@ -107,11 +107,11 @@ class TestRbacPermissionResolution:
rbac._getRulesForRole = mockGetRulesForRole
# Get permissions for UserInDB table - should use specific rule
# Using full objectKey format: data.system.UserInDB
# Using UAM namespace: data.uam.UserInDB
permissions = rbac.getUserPermissions(
user,
AccessRuleContext.DATA,
"data.system.UserInDB"
"data.uam.UserInDB"
)
# Most specific rule should win
@ -253,29 +253,29 @@ class TestRbacPermissionResolution:
AccessRule(
roleLabel="user",
context=AccessRuleContext.DATA,
item="data.system.UserInDB", # Table-level with full objectKey
item="data.uam.UserInDB", # Table-level with UAM namespace
view=True,
read=AccessLevel.MY
),
AccessRule(
roleLabel="user",
context=AccessRuleContext.DATA,
item="data.system.UserInDB.email", # Field-level - most specific
item="data.uam.UserInDB.email", # Field-level - most specific
view=True,
read=AccessLevel.NONE
)
]
# Test exact match
rule = rbac.findMostSpecificRule(rules, "data.system.UserInDB.email")
rule = rbac.findMostSpecificRule(rules, "data.uam.UserInDB.email")
assert rule is not None
assert rule.item == "data.system.UserInDB.email"
assert rule.item == "data.uam.UserInDB.email"
assert rule.read == AccessLevel.NONE
# Test table-level match
rule = rbac.findMostSpecificRule(rules, "data.system.UserInDB")
rule = rbac.findMostSpecificRule(rules, "data.uam.UserInDB")
assert rule is not None
assert rule.item == "data.system.UserInDB"
assert rule.item == "data.uam.UserInDB"
assert rule.read == AccessLevel.MY
# Test generic fallback
@ -294,7 +294,7 @@ class TestRbacPermissionResolution:
rule1 = AccessRule(
roleLabel="user",
context=AccessRuleContext.DATA,
item="data.system.UserInDB",
item="data.uam.UserInDB",
view=True,
read=AccessLevel.MY,
create=AccessLevel.MY,
@ -307,7 +307,7 @@ class TestRbacPermissionResolution:
rule2 = AccessRule(
roleLabel="user",
context=AccessRuleContext.DATA,
item="data.system.UserInDB",
item="data.uam.UserInDB",
view=True,
read=AccessLevel.MY,
create=AccessLevel.GROUP, # Not allowed
@ -320,7 +320,7 @@ class TestRbacPermissionResolution:
rule3 = AccessRule(
roleLabel="admin",
context=AccessRuleContext.DATA,
item="data.system.UserInDB",
item="data.uam.UserInDB",
view=True,
read=AccessLevel.GROUP,
create=AccessLevel.GROUP,
@ -333,7 +333,7 @@ class TestRbacPermissionResolution:
rule4 = AccessRule(
roleLabel="user",
context=AccessRuleContext.DATA,
item="data.system.UserInDB",
item="data.uam.UserInDB",
view=True,
read=AccessLevel.NONE,
create=AccessLevel.MY, # Not allowed without read