fixed system and dynamic data rbac
This commit is contained in:
parent
a0304c6d78
commit
829711f755
15 changed files with 695 additions and 281 deletions
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
},
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue