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

View file

@ -12,29 +12,76 @@ from modules.features.neutralization.datamodelFeatureNeutralizer import (
DataNeutraliserConfig, DataNeutraliserConfig,
DataNeutralizerAttributes, DataNeutralizerAttributes,
) )
from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.interfaces.interfaceRbac import getRecordsetWithRBAC from modules.interfaces.interfaceRbac import getRecordsetWithRBAC
from modules.shared.configuration import APP_CONFIG
from modules.shared.timeUtils import getUtcTimestamp from modules.shared.timeUtils import getUtcTimestamp
from modules.datamodels.datamodelUam import User
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Singleton cache for interface instances
_neutralizerInterfaces = {}
class InterfaceFeatureNeutralizer: class InterfaceFeatureNeutralizer:
"""Database interface for Neutralizer feature operations""" """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. Initialize the interface with database connection and user context.
Args: Args:
db: Database connection instance
currentUser: Current user object for RBAC currentUser: Current user object for RBAC
mandateId: Current mandate ID mandateId: Current mandate ID
userId: Current user ID featureInstanceId: Current feature instance ID
""" """
self.db = db
self.currentUser = currentUser self.currentUser = currentUser
self.mandateId = mandateId 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]: def getNeutralizationConfig(self) -> Optional[DataNeutraliserConfig]:
"""Get the data neutralization configuration for the current user's mandate""" """Get the data neutralization configuration for the current user's mandate"""
@ -160,17 +207,34 @@ class InterfaceFeatureNeutralizer:
return None 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: Args:
db: Database connection
currentUser: Current user for RBAC currentUser: Current user for RBAC
mandateId: Current mandate ID mandateId: Current mandate ID
userId: Current user ID featureInstanceId: Current feature instance ID
Returns: Returns:
InterfaceFeatureNeutralizer instance 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 typing import Dict, List, Any, Optional
from modules.features.neutralization.datamodelFeatureNeutralizer import DataNeutraliserConfig, DataNeutralizerAttributes 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 # Import all necessary classes and functions for neutralization
from .subProcessCommon import CommonUtils, NeutralizationResult, NeutralizationAttribute from .subProcessCommon import CommonUtils, NeutralizationResult, NeutralizationAttribute
@ -42,11 +42,10 @@ class NeutralizationService:
self.interfaceNeutralizer: InterfaceFeatureNeutralizer = None self.interfaceNeutralizer: InterfaceFeatureNeutralizer = None
if serviceCenter and serviceCenter.interfaceDbApp: if serviceCenter and serviceCenter.interfaceDbApp:
dbApp = serviceCenter.interfaceDbApp dbApp = serviceCenter.interfaceDbApp
self.interfaceNeutralizer = InterfaceFeatureNeutralizer( self.interfaceNeutralizer = getNeutralizerInterface(
db=dbApp.db,
currentUser=dbApp.currentUser, currentUser=dbApp.currentUser,
mandateId=dbApp.mandateId, mandateId=dbApp.mandateId,
userId=dbApp.userId featureInstanceId=getattr(dbApp, 'featureInstanceId', None)
) )
# Initialize anonymization processors # Initialize anonymization processors

View file

@ -277,6 +277,9 @@ def initRbacRules(db: DatabaseConnector) -> None:
existingRules = db.getRecordset(AccessRule) existingRules = db.getRecordset(AccessRule)
if existingRules: if existingRules:
logger.info(f"RBAC rules already exist ({len(existingRules)} rules)") 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 return
logger.info("Initializing RBAC rules") logger.info("Initializing RBAC rules")
@ -377,20 +380,31 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None:
viewerId = _getRoleId(db, "viewer") 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. # Namespace structure:
# This matches the DATA_OBJECTS registration in mainSystem.py. # - data.uam.* → User Access Management (mandantenübergreifend)
# Feature tables use: data.feature.{featureCode}.{TableName} # - 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 # Mandate table - Only SysAdmin (flag) can access, not roles
# Regular roles have no access to Mandate table # Regular roles have no access to Mandate table
if adminId: if adminId:
tableRules.append(AccessRule( tableRules.append(AccessRule(
roleId=adminId, roleId=adminId,
context=AccessRuleContext.DATA, context=AccessRuleContext.DATA,
item="data.system.Mandate", item="data.uam.Mandate",
view=False, view=False,
read=AccessLevel.NONE, read=AccessLevel.NONE,
create=AccessLevel.NONE, create=AccessLevel.NONE,
@ -401,7 +415,7 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None:
tableRules.append(AccessRule( tableRules.append(AccessRule(
roleId=userId, roleId=userId,
context=AccessRuleContext.DATA, context=AccessRuleContext.DATA,
item="data.system.Mandate", item="data.uam.Mandate",
view=False, view=False,
read=AccessLevel.NONE, read=AccessLevel.NONE,
create=AccessLevel.NONE, create=AccessLevel.NONE,
@ -412,7 +426,7 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None:
tableRules.append(AccessRule( tableRules.append(AccessRule(
roleId=viewerId, roleId=viewerId,
context=AccessRuleContext.DATA, context=AccessRuleContext.DATA,
item="data.system.Mandate", item="data.uam.Mandate",
view=False, view=False,
read=AccessLevel.NONE, read=AccessLevel.NONE,
create=AccessLevel.NONE, create=AccessLevel.NONE,
@ -425,7 +439,7 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None:
tableRules.append(AccessRule( tableRules.append(AccessRule(
roleId=adminId, roleId=adminId,
context=AccessRuleContext.DATA, context=AccessRuleContext.DATA,
item="data.system.UserInDB", item="data.uam.UserInDB",
view=True, view=True,
read=AccessLevel.GROUP, read=AccessLevel.GROUP,
create=AccessLevel.GROUP, create=AccessLevel.GROUP,
@ -436,7 +450,7 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None:
tableRules.append(AccessRule( tableRules.append(AccessRule(
roleId=userId, roleId=userId,
context=AccessRuleContext.DATA, context=AccessRuleContext.DATA,
item="data.system.UserInDB", item="data.uam.UserInDB",
view=True, view=True,
read=AccessLevel.MY, read=AccessLevel.MY,
create=AccessLevel.NONE, create=AccessLevel.NONE,
@ -447,7 +461,7 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None:
tableRules.append(AccessRule( tableRules.append(AccessRule(
roleId=viewerId, roleId=viewerId,
context=AccessRuleContext.DATA, context=AccessRuleContext.DATA,
item="data.system.UserInDB", item="data.uam.UserInDB",
view=True, view=True,
read=AccessLevel.MY, read=AccessLevel.MY,
create=AccessLevel.NONE, create=AccessLevel.NONE,
@ -455,92 +469,37 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None:
delete=AccessLevel.NONE, delete=AccessLevel.NONE,
)) ))
# FileItem and UserConnection: All users (user, admin, viewer) only MY-level CRUD # UserConnection: All users only MY-level CRUD (UAM namespace)
restrictedTables = [ for roleId in [adminId, userId]:
"data.system.UserConnection", # User connections/sessions - only own records if roleId:
"data.system.FileItem", # Uploaded files - only own files
]
for objectKey in restrictedTables:
# Admin: Only MY-level access (not group-level!)
if adminId:
tableRules.append(AccessRule( tableRules.append(AccessRule(
roleId=adminId, roleId=roleId,
context=AccessRuleContext.DATA, context=AccessRuleContext.DATA,
item=objectKey, item="data.uam.UserConnection",
view=True, view=True,
read=AccessLevel.MY, read=AccessLevel.MY,
create=AccessLevel.MY, create=AccessLevel.MY,
update=AccessLevel.MY, update=AccessLevel.MY,
delete=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: if viewerId:
tableRules.append(AccessRule( tableRules.append(AccessRule(
roleId=viewerId, roleId=viewerId,
context=AccessRuleContext.DATA, context=AccessRuleContext.DATA,
item="data.system.Prompt", item="data.uam.UserConnection",
view=True, view=True,
read=AccessLevel.GROUP, read=AccessLevel.MY,
create=AccessLevel.NONE, create=AccessLevel.NONE,
update=AccessLevel.NONE, update=AccessLevel.NONE,
delete=AccessLevel.NONE, delete=AccessLevel.NONE,
)) ))
# Invitation: Standard group-level access # Invitation: Standard group-level access (UAM namespace)
if adminId: if adminId:
tableRules.append(AccessRule( tableRules.append(AccessRule(
roleId=adminId, roleId=adminId,
context=AccessRuleContext.DATA, context=AccessRuleContext.DATA,
item="data.system.Invitation", item="data.uam.Invitation",
view=True, view=True,
read=AccessLevel.GROUP, read=AccessLevel.GROUP,
create=AccessLevel.GROUP, create=AccessLevel.GROUP,
@ -551,7 +510,7 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None:
tableRules.append(AccessRule( tableRules.append(AccessRule(
roleId=userId, roleId=userId,
context=AccessRuleContext.DATA, context=AccessRuleContext.DATA,
item="data.system.Invitation", item="data.uam.Invitation",
view=True, view=True,
read=AccessLevel.MY, read=AccessLevel.MY,
create=AccessLevel.MY, create=AccessLevel.MY,
@ -562,7 +521,7 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None:
tableRules.append(AccessRule( tableRules.append(AccessRule(
roleId=viewerId, roleId=viewerId,
context=AccessRuleContext.DATA, context=AccessRuleContext.DATA,
item="data.system.Invitation", item="data.uam.Invitation",
view=True, view=True,
read=AccessLevel.MY, read=AccessLevel.MY,
create=AccessLevel.NONE, create=AccessLevel.NONE,
@ -570,13 +529,12 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None:
delete=AccessLevel.NONE, delete=AccessLevel.NONE,
)) ))
# AuthEvent table - Audit logs (no delete allowed for audit integrity!) # AuthEvent table - Audit logs (UAM namespace, no delete for audit integrity!)
# SysAdmin can delete via isSysAdmin bypass, but regular admins cannot
if adminId: if adminId:
tableRules.append(AccessRule( tableRules.append(AccessRule(
roleId=adminId, roleId=adminId,
context=AccessRuleContext.DATA, context=AccessRuleContext.DATA,
item="data.system.AuthEvent", item="data.uam.AuthEvent",
view=True, view=True,
read=AccessLevel.ALL, read=AccessLevel.ALL,
create=AccessLevel.NONE, create=AccessLevel.NONE,
@ -587,7 +545,7 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None:
tableRules.append(AccessRule( tableRules.append(AccessRule(
roleId=userId, roleId=userId,
context=AccessRuleContext.DATA, context=AccessRuleContext.DATA,
item="data.system.AuthEvent", item="data.uam.AuthEvent",
view=True, view=True,
read=AccessLevel.MY, read=AccessLevel.MY,
create=AccessLevel.NONE, create=AccessLevel.NONE,
@ -598,7 +556,120 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None:
tableRules.append(AccessRule( tableRules.append(AccessRule(
roleId=viewerId, roleId=viewerId,
context=AccessRuleContext.DATA, 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, view=True,
read=AccessLevel.MY, read=AccessLevel.MY,
create=AccessLevel.NONE, create=AccessLevel.NONE,
@ -670,6 +741,160 @@ def _createUiContextRules(db: DatabaseConnector) -> None:
logger.info(f"Created {len(uiRules)} UI context rules") 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: def _createResourceContextRules(db: DatabaseConnector) -> None:
""" """
Create RESOURCE context rules for controlling resource access. Create RESOURCE context rules for controlling resource access.

View file

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

View file

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

View file

@ -7,6 +7,18 @@ Provides RBAC filtering for database queries without connectors importing securi
Multi-Tenant Design: Multi-Tenant Design:
- mandateId kommt aus Request-Context (X-Mandate-Id Header) - mandateId kommt aus Request-Context (X-Mandate-Id Header)
- GROUP-Filter verwendet expliziten mandateId Parameter - 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 import logging
@ -21,25 +33,70 @@ from modules.security.rootAccess import getRootDbAppConnector
logger = logging.getLogger(__name__) 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: def buildDataObjectKey(tableName: str, featureCode: Optional[str] = None) -> str:
""" """
Build the standardized objectKey for a DATA context item. Build the standardized objectKey for a DATA context item.
Format: 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} - Feature tables: data.feature.{featureCode}.{TableName}
Args: 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") featureCode: Optional feature code (e.g., "trustee", "realestate")
If None, assumes system table. If provided, uses data.feature.{featureCode}.{tableName}
Returns: 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: if featureCode:
return f"data.feature.{featureCode}.{tableName}" 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( def getRecordsetWithRBAC(
@ -107,7 +164,7 @@ def getRecordsetWithRBAC(
permissions = rbacInstance.getUserPermissions( permissions = rbacInstance.getUserPermissions(
currentUser, currentUser,
AccessRuleContext.DATA, 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, mandateId=effectiveMandateId,
featureInstanceId=featureInstanceId featureInstanceId=featureInstanceId
) )
@ -271,10 +328,32 @@ def buildRbacWhereClause(
"values": [currentUser.id] "values": [currentUser.id]
} }
# Group records - filter by mandateId # Group records - filter by mandateId or ownership based on namespace
if readLevel == AccessLevel.GROUP: 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 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: if not effectiveMandateId:
logger.warning(f"User {currentUser.id} has no mandateId for GROUP access") logger.warning(f"User {currentUser.id} has no mandateId for GROUP access")
return {"condition": "1 = 0", "values": []} return {"condition": "1 = 0", "values": []}
@ -324,10 +403,16 @@ def buildRbacWhereClause(
logger.error(f"Error building GROUP filter for UserConnection: {e}") logger.error(f"Error building GROUP filter for UserConnection: {e}")
return {"condition": "1 = 0", "values": []} 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 # For other tables, filter by mandateId field
# Also include records with NULL mandateId for backwards compatibility
else: else:
return { return {
"condition": '"mandateId" = %s', "condition": '("mandateId" = %s OR "mandateId" IS NULL)',
"values": [effectiveMandateId] "values": [effectiveMandateId]
} }

View file

@ -184,9 +184,12 @@ async def get_all_permissions(
recordFilter={"userId": str(reqContext.user.id), "enabled": True} 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 # Collect all role IDs the user has across all mandates
for userMandate in userMandates: for userMandate in userMandates:
mandateRoleIds = rootInterface.getRoleIdsForUserMandate(userMandate.get("id")) 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: for rid in mandateRoleIds:
if rid not in roleIds: if rid not in roleIds:
roleIds.append(rid) roleIds.append(rid)
@ -261,20 +264,24 @@ async def get_all_permissions(
items.add(rule.item) items.add(rule.item)
# For each item, calculate user permissions # 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): for item in sorted(items):
permissions = interface.rbac.getUserPermissions( # Find matching rules for this item from the already-collected rules
reqContext.user, ctx, item, itemView = False
mandateId=reqContext.mandateId, for rule in allRules[ctx]:
featureInstanceId=reqContext.featureInstanceId if rule.item == item and rule.view:
) itemView = True
break
# Only include if user has view permission # Only include if user has view permission
if permissions.view: if itemView:
result[ctx.value.lower()][item] = { result[ctx.value.lower()][item] = {
"view": permissions.view, "view": True,
"read": permissions.read.value if permissions.read else None, "read": None, # UI context doesn't use CRUD permissions
"create": permissions.create.value if permissions.create else None, "create": None,
"update": permissions.update.value if permissions.update else None, "update": None,
"delete": permissions.delete.value if permissions.delete else None "delete": None
} }
return result return result

View file

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

View file

@ -19,6 +19,7 @@ from modules.datamodels.datamodelNotification import (
NotificationStatus, NotificationStatus,
NotificationAction NotificationAction
) )
from modules.datamodels.datamodelRbac import Role
from modules.interfaces.interfaceDbApp import getRootInterface from modules.interfaces.interfaceDbApp import getRootInterface
from modules.shared.timeUtils import getUtcTimestamp from modules.shared.timeUtils import getUtcTimestamp
@ -452,6 +453,17 @@ async def _handleInvitationAction(
mandateId = invitation.get("mandateId") mandateId = invitation.get("mandateId")
roleIds = invitation.get("roleIds", []) 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 # Get mandate name for result message
mandates = rootInterface.db.getRecordset( mandates = rootInterface.db.getRecordset(
model_class=Mandate, model_class=Mandate,

View file

@ -13,7 +13,7 @@ Multi-Tenant Design:
import logging import logging
from typing import List, Optional, TYPE_CHECKING from typing import List, Optional, TYPE_CHECKING
from modules.datamodels.datamodelRbac import AccessRule, AccessRuleContext, Role 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 ( from modules.datamodels.datamodelMembership import (
UserMandate, UserMandate,
UserMandateRole, UserMandateRole,
@ -148,6 +148,9 @@ class RbacClass:
Get all role IDs for a user in the given context. Get all role IDs for a user in the given context.
Uses UserMandate + UserMandateRole for the new multi-tenant model. 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: Args:
user: User object user: User object
mandateId: Mandate context mandateId: Mandate context
@ -156,21 +159,30 @@ class RbacClass:
Returns: Returns:
List of role IDs List of role IDs
""" """
roleIds = [] roleIds = set() # Use set to avoid duplicates
if not mandateId:
return roleIds
try: try:
# Lade UserMandate # Get Root mandate ID (first mandate in system)
allMandates = self.dbApp.getRecordset(Mandate)
rootMandateId = allMandates[0].get("id") if allMandates else None
# 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)
# Load roles from each mandate
for checkMandateId in mandatesToCheck:
userMandates = self.dbApp.getRecordset( userMandates = self.dbApp.getRecordset(
UserMandate, UserMandate,
recordFilter={"userId": user.id, "mandateId": mandateId, "enabled": True} recordFilter={"userId": user.id, "mandateId": checkMandateId, "enabled": True}
) )
if not userMandates: if userMandates:
return roleIds
userMandateId = userMandates[0].get("id") userMandateId = userMandates[0].get("id")
# Lade UserMandateRoles (Mandate-level roles) # Lade UserMandateRoles (Mandate-level roles)
@ -179,7 +191,8 @@ class RbacClass:
recordFilter={"userMandateId": userMandateId} recordFilter={"userMandateId": userMandateId}
) )
roleIds.extend([r.get("roleId") for r in userMandateRoles if r.get("roleId")]) foundRoles = [r.get("roleId") for r in userMandateRoles if r.get("roleId")]
roleIds.update(foundRoles)
# Load FeatureAccess + FeatureAccessRole (Instance-level roles) # Load FeatureAccess + FeatureAccessRole (Instance-level roles)
if featureInstanceId: if featureInstanceId:
@ -200,12 +213,12 @@ class RbacClass:
recordFilter={"featureAccessId": featureAccessId} 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: except Exception as e:
logger.error(f"Error loading role IDs for user {user.id}: {e}") logger.error(f"Error loading role IDs for user {user.id}: {e}")
return roleIds return list(roleIds)
def getRulesForUserBulk( def getRulesForUserBulk(
self, self,
@ -388,7 +401,10 @@ class RbacClass:
Example: rule "data.feature.trustee" matches item "data.feature.trustee.TrusteePosition" Example: rule "data.feature.trustee" matches item "data.feature.trustee.TrusteePosition"
All items MUST use the full objectKey format: 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") - Feature: data.feature.{featureCode}.{TableName} (e.g., "data.feature.trustee.TrusteePosition")
- UI: ui.{area}.{page} (e.g., "ui.admin.users") - UI: ui.{area}.{page} (e.g., "ui.admin.users")

View file

@ -72,6 +72,7 @@ NAVIGATION_SECTIONS = [
"icon": "FaPlay", "icon": "FaPlay",
"path": "/workflows/playground", "path": "/workflows/playground",
"order": 10, "order": 10,
"public": True,
}, },
{ {
"id": "chats", "id": "chats",
@ -80,6 +81,7 @@ NAVIGATION_SECTIONS = [
"icon": "FaListAlt", "icon": "FaListAlt",
"path": "/workflows/list", "path": "/workflows/list",
"order": 20, "order": 20,
"public": True,
}, },
{ {
"id": "automations", "id": "automations",
@ -88,6 +90,7 @@ NAVIGATION_SECTIONS = [
"icon": "FaCogs", "icon": "FaCogs",
"path": "/workflows/automations", "path": "/workflows/automations",
"order": 30, "order": 30,
"public": True,
}, },
], ],
}, },
@ -297,72 +300,83 @@ UI_OBJECTS = _buildUiObjectsFromNavigation()
# ============================================================================= # =============================================================================
# System DATA Objects # 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 = [ 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"}, "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"}, "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"}, "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"}, "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"}, "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"}, "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"}, "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"}, "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"}, "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"}, "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"}, "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"}, "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.datamodelChat import ChatDocument
from modules.datamodels.datamodelDocref import DocumentReferenceList from modules.datamodels.datamodelDocref import DocumentReferenceList
dbInterface = getDbInterface() dbInterface = getDbInterface(services.user, mandateId=services.mandateId, featureInstanceId=featureInstanceId)
# Create file record # Create file record
fileItem = dbInterface.createFile( 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}") logger.info(f"Stored PDF {fileName} ({len(fileContent)} bytes) with fileId: {fileItem.id}")
# Step 2: Create ChatDocument referencing the file # Step 2: Create ChatDocument referencing the file
# Use workflow context if available documentId = str(uuid.uuid4())
workflowId = services.workflow.id if services.workflow else str(uuid.uuid4())
messageId = f"expense_import_{workflowId}_{str(uuid.uuid4())[:8]}"
chatDocument = ChatDocument( chatDocument = ChatDocument(
id=str(uuid.uuid4()), id=documentId,
mandateId=services.mandateId or "", mandateId=services.mandateId or "",
featureInstanceId=featureInstanceId or "", featureInstanceId=featureInstanceId or "",
messageId=messageId, messageId="", # Will be set when attached to message
fileId=fileItem.id, fileId=fileItem.id,
fileName=fileName, fileName=fileName,
fileSize=len(fileContent), fileSize=len(fileContent),
mimeType="application/pdf" 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 from modules.datamodels.datamodelDocref import DocumentItemReference
documentList = DocumentReferenceList( documentList = DocumentReferenceList(
references=[ references=[
DocumentItemReference( DocumentItemReference(
documentId=chatDocument.id, documentId=documentId,
fileName=fileName 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 # Step 5: Call AI with documentList - let AI service handle everything
# (extraction, intent analysis, chunking, image processing) # (extraction, intent analysis, chunking, image processing)
options = AiCallOptions( options = AiCallOptions(

View file

@ -119,9 +119,9 @@ class TestRbacBootstrap:
# Should create multiple rules for different tables # Should create multiple rules for different tables
assert db.recordCreate.call_count > 0 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 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 assert len(mandateCalls) > 0
# Check that all roles have view=False and no access for Mandate # Check that all roles have view=False and no access for Mandate
@ -134,11 +134,11 @@ class TestRbacBootstrap:
def testInitRbacRulesSkipsIfExists(self): def testInitRbacRulesSkipsIfExists(self):
"""Test that initRbacRules skips default rule creation if rules already exist, but adds missing table-specific rules.""" """Test that initRbacRules skips default rule creation if rules already exist, but adds missing table-specific rules."""
db = Mock() 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 # 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 = [] 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"]: for role in ["admin", "user", "viewer"]:
existingRules.append({ existingRules.append({
"id": f"rule_{table}_{role}", "id": f"rule_{table}_{role}",

View file

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