From 2fc80342600022c5209c5766d8b0afc332a07a8f Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Sun, 25 Jan 2026 03:01:01 +0100 Subject: [PATCH] rbac rules tested and fixed --- app.py | 15 +- modules/auth/authentication.py | 76 +-- modules/features/aichat/mainAiChat.py | 166 ------ modules/features/automation/mainAutomation.py | 27 +- .../chatbot/interfaceFeatureChatbot.py | 4 +- modules/features/chatbot/mainChatbot.py | 20 +- .../datamodelFeatureNeutralizer.py | 0 .../interfaceFeatureNeutralizer.py | 2 +- .../mainNeutralizePlayground.py | 0 .../mainNeutralizer.py | 29 +- .../routeFeatureNeutralizer.py | 0 .../mainServiceNeutralization.py | 4 +- .../serviceNeutralization/subParseString.py | 0 .../serviceNeutralization/subPatterns.py | 0 .../serviceNeutralization/subProcessBinary.py | 0 .../serviceNeutralization/subProcessCommon.py | 0 .../serviceNeutralization/subProcessList.py | 0 .../serviceNeutralization/subProcessText.py | 0 .../realEstate/interfaceFeatureRealEstate.py | 4 +- modules/features/realEstate/mainRealEstate.py | 34 +- .../trustee/interfaceFeatureTrustee.py | 99 +++- modules/features/trustee/mainTrustee.py | 69 ++- .../features/trustee/routeFeatureTrustee.py | 66 ++- modules/interfaces/interfaceBootstrap.py | 159 +++++- modules/interfaces/interfaceDbApp.py | 3 +- modules/interfaces/interfaceDbChat.py | 4 +- modules/interfaces/interfaceDbManagement.py | 4 +- modules/interfaces/interfaceRbac.py | 102 +++- modules/routes/routeAdminFeatures.py | 52 +- modules/routes/routeAdminRbacRules.py | 219 +++++++- modules/security/rbacCatalog.py | 45 +- modules/system/__init__.py | 14 + modules/system/mainSystem.py | 423 ++++++++++++++ .../featureRegistry.py => system/registry.py} | 24 +- modules/system/routeSystem.py | 515 ++++++++++++++++++ ...cript_db_migrate_accessrules_objectkeys.py | 183 +++++++ scripts/script_export_accessrules.py | 96 ++++ 37 files changed, 2081 insertions(+), 377 deletions(-) delete mode 100644 modules/features/aichat/mainAiChat.py rename modules/features/{neutralizer => neutralization}/datamodelFeatureNeutralizer.py (100%) rename modules/features/{neutralizer => neutralization}/interfaceFeatureNeutralizer.py (98%) rename modules/features/{neutralizer => neutralization}/mainNeutralizePlayground.py (100%) rename modules/features/{neutralizer => neutralization}/mainNeutralizer.py (76%) rename modules/features/{neutralizer => neutralization}/routeFeatureNeutralizer.py (100%) rename modules/features/{neutralizer => neutralization}/serviceNeutralization/mainServiceNeutralization.py (98%) rename modules/features/{neutralizer => neutralization}/serviceNeutralization/subParseString.py (100%) rename modules/features/{neutralizer => neutralization}/serviceNeutralization/subPatterns.py (100%) rename modules/features/{neutralizer => neutralization}/serviceNeutralization/subProcessBinary.py (100%) rename modules/features/{neutralizer => neutralization}/serviceNeutralization/subProcessCommon.py (100%) rename modules/features/{neutralizer => neutralization}/serviceNeutralization/subProcessList.py (100%) rename modules/features/{neutralizer => neutralization}/serviceNeutralization/subProcessText.py (100%) create mode 100644 modules/system/__init__.py create mode 100644 modules/system/mainSystem.py rename modules/{features/featureRegistry.py => system/registry.py} (82%) create mode 100644 modules/system/routeSystem.py create mode 100644 scripts/script_db_migrate_accessrules_objectkeys.py create mode 100644 scripts/script_export_accessrules.py diff --git a/app.py b/app.py index a73cbcbc..b78e5d8b 100644 --- a/app.py +++ b/app.py @@ -21,7 +21,7 @@ from modules.shared.configuration import APP_CONFIG from modules.shared.eventManagement import eventManager from modules.workflows.automation import subAutomationSchedule from modules.interfaces.interfaceDbApp import getRootInterface -from modules.features.featureRegistry import loadFeatureMainModules +from modules.system.registry import loadFeatureMainModules class DailyRotatingFileHandler(RotatingFileHandler): """ @@ -346,8 +346,8 @@ def _generateOperationId(route) -> str: # START APP app = FastAPI( - title="PowerOn | Data Platform API", - description=f"Backend API for the Multi-Agent Platform by ValueOn AG ({instanceLabel})", + title="PowerOn AG | Workflow Engine", + description=f"API for dynamic SaaS platforms ({instanceLabel})", lifespan=lifespan, swagger_ui_init_oauth={ "usePkceWithAuthorizationCodeGrant": True, @@ -501,11 +501,18 @@ app.include_router(gdprRouter) from modules.routes.routeChat import router as chatRouter app.include_router(chatRouter) +# ============================================================================ +# SYSTEM ROUTES (Navigation, etc.) +# ============================================================================ +from modules.system.routeSystem import router as systemRouter, navigationRouter +app.include_router(systemRouter) +app.include_router(navigationRouter) + # ============================================================================ # PLUG&PLAY FEATURE ROUTERS # Dynamically load routers from feature containers in modules/features/ # ============================================================================ -from modules.features.featureRegistry import loadFeatureRouters +from modules.system.registry import loadFeatureRouters featureLoadResults = loadFeatureRouters(app) logger.info(f"Feature router load results: {featureLoadResults}") diff --git a/modules/auth/authentication.py b/modules/auth/authentication.py index c6eaafad..8c918e0f 100644 --- a/modules/auth/authentication.py +++ b/modules/auth/authentication.py @@ -276,8 +276,11 @@ def getRequestContext( Determines request context from headers. Checks authorization and loads role IDs. - IMPORTANT: Even SysAdmin needs explicit membership for mandate context! - SysAdmin flag does NOT give implicit access to mandate data. + Security Model: + - Regular users: Must be explicit members of mandates/feature instances + - SysAdmin users: Can access ANY mandate for administrative operations, + but don't get implicit roleIds (no automatic data access rights). + Routes can check ctx.isSysAdmin to allow admin operations. Args: request: FastAPI Request object @@ -289,57 +292,66 @@ def getRequestContext( RequestContext with user, mandate, roles Raises: - HTTPException 403: If user is not member of mandate or has no feature access + HTTPException 403: If non-SysAdmin user is not member of mandate or has no feature access """ ctx = RequestContext(user=currentUser) + isSysAdmin = getattr(currentUser, 'isSysAdmin', False) # Get root interface for membership checks rootInterface = getRootInterface() if mandateId: - # Check mandate membership - ALSO for SysAdmin! - # SysAdmin must be explicitly added to the mandate + # Check mandate membership membership = rootInterface.getUserMandate(currentUser.id, mandateId) - if not membership: - # No implicit access for SysAdmin - Fail-Fast! + + if membership: + # User is a member - load their roles + if not membership.enabled: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Mandate membership is disabled" + ) + ctx.mandateId = mandateId + ctx.roleIds = rootInterface.getRoleIdsForUserMandate(membership.id) + elif isSysAdmin: + # SysAdmin can access any mandate for admin operations + # But they don't get roleIds - no implicit data access + ctx.mandateId = mandateId + # roleIds stays empty - SysAdmin must rely on isSysAdmin flag for authorization + logger.debug(f"SysAdmin {currentUser.id} accessing mandate {mandateId} without membership") + else: + # Regular user without membership - denied logger.warning(f"User {currentUser.id} is not member of mandate {mandateId}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Not member of mandate" ) - - if not membership.enabled: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Mandate membership is disabled" - ) - - ctx.mandateId = mandateId - - # Load roles via Junction Table - ctx.roleIds = rootInterface.getRoleIdsForUserMandate(membership.id) if featureInstanceId: - # Check feature access - ALSO for SysAdmin! + # Check feature access access = rootInterface.getFeatureAccess(currentUser.id, featureInstanceId) - if not access: + + if access: + # User has access - load their instance roles + if not access.enabled: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Feature access is disabled" + ) + ctx.featureInstanceId = featureInstanceId + instanceRoleIds = rootInterface.getRoleIdsForFeatureAccess(access.id) + ctx.roleIds.extend(instanceRoleIds) + elif isSysAdmin: + # SysAdmin can access any feature instance for admin operations + ctx.featureInstanceId = featureInstanceId + logger.debug(f"SysAdmin {currentUser.id} accessing feature instance {featureInstanceId} without explicit access") + else: + # Regular user without access - denied logger.warning(f"User {currentUser.id} has no access to feature instance {featureInstanceId}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="No access to feature instance" ) - - if not access.enabled: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Feature access is disabled" - ) - - ctx.featureInstanceId = featureInstanceId - - # Add instance roles - instanceRoleIds = rootInterface.getRoleIdsForFeatureAccess(access.id) - ctx.roleIds.extend(instanceRoleIds) return ctx diff --git a/modules/features/aichat/mainAiChat.py b/modules/features/aichat/mainAiChat.py deleted file mode 100644 index 2e6514e6..00000000 --- a/modules/features/aichat/mainAiChat.py +++ /dev/null @@ -1,166 +0,0 @@ -# Copyright (c) 2025 Patrick Motsch -# All rights reserved. -""" -AIChat Feature Container - Main Module. -Handles feature initialization and RBAC catalog registration. - -AIChat is the dynamic chat workflow feature that handles: -- AI-powered document processing -- Dynamic workflow execution -- Automation definitions -""" - -import logging -from typing import Dict, List, Any - -logger = logging.getLogger(__name__) - -# Feature metadata -FEATURE_CODE = "chatworkflow" -FEATURE_LABEL = {"en": "Chat Workflow", "de": "Chat-Workflow", "fr": "Workflow de Chat"} -FEATURE_ICON = "mdi-message-cog" - -# UI Objects for RBAC catalog -UI_OBJECTS = [ - { - "objectKey": "ui.feature.aichat.workflows", - "label": {"en": "Workflows", "de": "Workflows", "fr": "Workflows"}, - "meta": {"area": "workflows"} - }, - { - "objectKey": "ui.feature.aichat.automations", - "label": {"en": "Automations", "de": "Automatisierungen", "fr": "Automatisations"}, - "meta": {"area": "automations"} - }, - { - "objectKey": "ui.feature.aichat.logs", - "label": {"en": "Logs", "de": "Logs", "fr": "Journaux"}, - "meta": {"area": "logs"} - }, -] - -# Resource Objects for RBAC catalog -RESOURCE_OBJECTS = [ - { - "objectKey": "resource.feature.aichat.workflow.start", - "label": {"en": "Start Workflow", "de": "Workflow starten", "fr": "Démarrer workflow"}, - "meta": {"endpoint": "/api/chat/playground/start", "method": "POST"} - }, - { - "objectKey": "resource.feature.aichat.workflow.stop", - "label": {"en": "Stop Workflow", "de": "Workflow stoppen", "fr": "Arrêter workflow"}, - "meta": {"endpoint": "/api/chat/playground/stop/{workflowId}", "method": "POST"} - }, - { - "objectKey": "resource.feature.aichat.workflow.delete", - "label": {"en": "Delete Workflow", "de": "Workflow löschen", "fr": "Supprimer workflow"}, - "meta": {"endpoint": "/api/chat/playground/workflow/{workflowId}", "method": "DELETE"} - }, -] - -# Template roles for this feature -TEMPLATE_ROLES = [ - { - "roleLabel": "workflow-admin", - "description": { - "en": "Workflow Administrator - Full access to workflow configuration and execution", - "de": "Workflow-Administrator - Vollzugriff auf Workflow-Konfiguration und Ausführung", - "fr": "Administrateur workflow - Accès complet à la configuration et exécution" - } - }, - { - "roleLabel": "workflow-editor", - "description": { - "en": "Workflow Editor - Create and modify workflows", - "de": "Workflow-Editor - Workflows erstellen und bearbeiten", - "fr": "Éditeur workflow - Créer et modifier les workflows" - } - }, - { - "roleLabel": "workflow-viewer", - "description": { - "en": "Workflow Viewer - View workflows and execution results", - "de": "Workflow-Betrachter - Workflows und Ausführungsergebnisse einsehen", - "fr": "Visualiseur workflow - Consulter les workflows et résultats" - } - }, -] - - -def getFeatureDefinition() -> Dict[str, Any]: - """Return the feature definition for registration.""" - return { - "code": FEATURE_CODE, - "label": FEATURE_LABEL, - "icon": FEATURE_ICON - } - - -def getUiObjects() -> List[Dict[str, Any]]: - """Return UI objects for RBAC catalog registration.""" - return UI_OBJECTS - - -def getResourceObjects() -> List[Dict[str, Any]]: - """Return resource objects for RBAC catalog registration.""" - return RESOURCE_OBJECTS - - -def getTemplateRoles() -> List[Dict[str, Any]]: - """Return template roles for this feature.""" - return TEMPLATE_ROLES - - -def registerFeature(catalogService) -> bool: - """ - Register this feature's RBAC objects in the catalog. - - Args: - catalogService: The RBAC catalog service instance - - Returns: - True if registration was successful - """ - try: - # Register UI objects - for uiObj in UI_OBJECTS: - catalogService.registerUiObject( - featureCode=FEATURE_CODE, - objectKey=uiObj["objectKey"], - label=uiObj["label"], - meta=uiObj.get("meta") - ) - - # Register Resource objects - for resObj in RESOURCE_OBJECTS: - catalogService.registerResourceObject( - featureCode=FEATURE_CODE, - objectKey=resObj["objectKey"], - label=resObj["label"], - meta=resObj.get("meta") - ) - - logger.info(f"Feature '{FEATURE_CODE}' registered {len(UI_OBJECTS)} UI objects and {len(RESOURCE_OBJECTS)} resource objects") - return True - - except Exception as e: - logger.error(f"Failed to register feature '{FEATURE_CODE}': {e}") - return False - - -async def onStart(eventUser) -> None: - """ - Called when the feature container starts. - Initializes AI connectors for model registry. - """ - try: - from modules.aicore.aicoreModelRegistry import modelRegistry - modelRegistry.ensureConnectorsRegistered() - logger.info(f"Feature '{FEATURE_CODE}' started - AI connectors initialized") - except Exception as e: - logger.error(f"Feature '{FEATURE_CODE}' failed to initialize AI connectors: {e}") - - -async def onStop(eventUser) -> None: - """Called when the feature container stops.""" - logger.info(f"Feature '{FEATURE_CODE}' stopped") diff --git a/modules/features/automation/mainAutomation.py b/modules/features/automation/mainAutomation.py index a0b8ba0f..88828442 100644 --- a/modules/features/automation/mainAutomation.py +++ b/modules/features/automation/mainAutomation.py @@ -66,7 +66,13 @@ TEMPLATE_ROLES = [ "en": "Automation Administrator - Full access to automation configuration and execution", "de": "Automatisierungs-Administrator - Vollzugriff auf Automatisierungs-Konfiguration und Ausführung", "fr": "Administrateur automatisation - Accès complet à la configuration et exécution" - } + }, + "accessRules": [ + # Full UI access + {"context": "UI", "item": None, "view": True}, + # Full DATA access + {"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"}, + ] }, { "roleLabel": "automation-editor", @@ -74,7 +80,15 @@ TEMPLATE_ROLES = [ "en": "Automation Editor - Create and modify automations", "de": "Automatisierungs-Editor - Automatisierungen erstellen und bearbeiten", "fr": "Éditeur automatisation - Créer et modifier les automatisations" - } + }, + "accessRules": [ + # UI access to definitions and templates - vollqualifizierte ObjectKeys + {"context": "UI", "item": "ui.feature.automation.definitions", "view": True}, + {"context": "UI", "item": "ui.feature.automation.templates", "view": True}, + {"context": "UI", "item": "ui.feature.automation.logs", "view": True}, + # Group-level DATA access + {"context": "DATA", "item": None, "view": True, "read": "g", "create": "g", "update": "g", "delete": "n"}, + ] }, { "roleLabel": "automation-viewer", @@ -82,7 +96,14 @@ TEMPLATE_ROLES = [ "en": "Automation Viewer - View automations and execution results", "de": "Automatisierungs-Betrachter - Automatisierungen und Ausführungsergebnisse einsehen", "fr": "Visualiseur automatisation - Consulter les automatisations et résultats" - } + }, + "accessRules": [ + # UI access to view only - vollqualifizierte ObjectKeys + {"context": "UI", "item": "ui.feature.automation.definitions", "view": True}, + {"context": "UI", "item": "ui.feature.automation.logs", "view": True}, + # Read-only DATA access (my level) + {"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"}, + ] }, ] diff --git a/modules/features/chatbot/interfaceFeatureChatbot.py b/modules/features/chatbot/interfaceFeatureChatbot.py index 7db04c46..160addca 100644 --- a/modules/features/chatbot/interfaceFeatureChatbot.py +++ b/modules/features/chatbot/interfaceFeatureChatbot.py @@ -367,7 +367,9 @@ class ChatObjects: permissions = self.rbac.getUserPermissions( self.currentUser, AccessRuleContext.DATA, - tableName + tableName, + mandateId=self.mandateId, + featureInstanceId=self.featureInstanceId ) if operation == "create": diff --git a/modules/features/chatbot/mainChatbot.py b/modules/features/chatbot/mainChatbot.py index 560a77b9..451a28f8 100644 --- a/modules/features/chatbot/mainChatbot.py +++ b/modules/features/chatbot/mainChatbot.py @@ -51,7 +51,15 @@ TEMPLATE_ROLES = [ "en": "Chatbot Administrator - Full access to chatbot settings and all conversations", "de": "Chatbot-Administrator - Vollzugriff auf Chatbot-Einstellungen und alle Konversationen", "fr": "Administrateur chatbot - Accès complet aux paramètres et conversations" - } + }, + "accessRules": [ + # Full UI access + {"context": "UI", "item": None, "view": True}, + # Full DATA access + {"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"}, + # Resource access + {"context": "RESOURCE", "item": "resource.feature.chatbot.start", "view": True}, + ] }, { "roleLabel": "chatbot-user", @@ -59,7 +67,15 @@ TEMPLATE_ROLES = [ "en": "Chatbot User - Use chatbot and view own conversations", "de": "Chatbot-Benutzer - Chatbot nutzen und eigene Konversationen einsehen", "fr": "Utilisateur chatbot - Utiliser le chatbot et consulter ses conversations" - } + }, + "accessRules": [ + # UI access to conversations - vollqualifizierte ObjectKeys + {"context": "UI", "item": "ui.feature.chatbot.conversations", "view": True}, + # Own DATA access (my level) + {"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "m"}, + # Resource access + {"context": "RESOURCE", "item": "resource.feature.chatbot.start", "view": True}, + ] }, ] diff --git a/modules/features/neutralizer/datamodelFeatureNeutralizer.py b/modules/features/neutralization/datamodelFeatureNeutralizer.py similarity index 100% rename from modules/features/neutralizer/datamodelFeatureNeutralizer.py rename to modules/features/neutralization/datamodelFeatureNeutralizer.py diff --git a/modules/features/neutralizer/interfaceFeatureNeutralizer.py b/modules/features/neutralization/interfaceFeatureNeutralizer.py similarity index 98% rename from modules/features/neutralizer/interfaceFeatureNeutralizer.py rename to modules/features/neutralization/interfaceFeatureNeutralizer.py index 47439d62..970f51ff 100644 --- a/modules/features/neutralizer/interfaceFeatureNeutralizer.py +++ b/modules/features/neutralization/interfaceFeatureNeutralizer.py @@ -8,7 +8,7 @@ Handles CRUD operations for neutralization configuration and attributes. import logging from typing import Dict, List, Any, Optional -from modules.features.neutralizer.datamodelFeatureNeutralizer import ( +from modules.features.neutralization.datamodelFeatureNeutralizer import ( DataNeutraliserConfig, DataNeutralizerAttributes, ) diff --git a/modules/features/neutralizer/mainNeutralizePlayground.py b/modules/features/neutralization/mainNeutralizePlayground.py similarity index 100% rename from modules/features/neutralizer/mainNeutralizePlayground.py rename to modules/features/neutralization/mainNeutralizePlayground.py diff --git a/modules/features/neutralizer/mainNeutralizer.py b/modules/features/neutralization/mainNeutralizer.py similarity index 76% rename from modules/features/neutralizer/mainNeutralizer.py rename to modules/features/neutralization/mainNeutralizer.py index 44d495c4..d05f2b3f 100644 --- a/modules/features/neutralizer/mainNeutralizer.py +++ b/modules/features/neutralization/mainNeutralizer.py @@ -18,17 +18,17 @@ FEATURE_ICON = "mdi-shield-check" # UI Objects for RBAC catalog UI_OBJECTS = [ { - "objectKey": "ui.feature.neutralizer.playground", + "objectKey": "ui.feature.neutralization.playground", "label": {"en": "Playground", "de": "Spielwiese", "fr": "Bac à sable"}, "meta": {"area": "playground"} }, { - "objectKey": "ui.feature.neutralizer.config", + "objectKey": "ui.feature.neutralization.config", "label": {"en": "Configuration", "de": "Konfiguration", "fr": "Configuration"}, "meta": {"area": "config"} }, { - "objectKey": "ui.feature.neutralizer.attributes", + "objectKey": "ui.feature.neutralization.attributes", "label": {"en": "Attributes", "de": "Attribute", "fr": "Attributs"}, "meta": {"area": "attributes"} }, @@ -37,17 +37,17 @@ UI_OBJECTS = [ # Resource Objects for RBAC catalog RESOURCE_OBJECTS = [ { - "objectKey": "resource.feature.neutralizer.process.text", + "objectKey": "resource.feature.neutralization.process.text", "label": {"en": "Process Text", "de": "Text verarbeiten", "fr": "Traiter texte"}, "meta": {"endpoint": "/api/neutralization/process/text", "method": "POST"} }, { - "objectKey": "resource.feature.neutralizer.process.files", + "objectKey": "resource.feature.neutralization.process.files", "label": {"en": "Process Files", "de": "Dateien verarbeiten", "fr": "Traiter fichiers"}, "meta": {"endpoint": "/api/neutralization/process/files", "method": "POST"} }, { - "objectKey": "resource.feature.neutralizer.config.update", + "objectKey": "resource.feature.neutralization.config.update", "label": {"en": "Update Config", "de": "Konfiguration aktualisieren", "fr": "Mettre à jour config"}, "meta": {"endpoint": "/api/neutralization/config", "method": "PUT"} }, @@ -61,7 +61,13 @@ TEMPLATE_ROLES = [ "en": "Neutralization Administrator - Full access to neutralization settings and data", "de": "Neutralisierungs-Administrator - Vollzugriff auf Neutralisierungs-Einstellungen und Daten", "fr": "Administrateur neutralisation - Accès complet aux paramètres et données" - } + }, + "accessRules": [ + # Full UI access (all views including admin views) + {"context": "UI", "item": None, "view": True}, + # Full DATA access + {"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"}, + ] }, { "roleLabel": "neutralization-analyst", @@ -69,7 +75,14 @@ TEMPLATE_ROLES = [ "en": "Neutralization Analyst - Analyze and process neutralization data", "de": "Neutralisierungs-Analyst - Neutralisierungsdaten analysieren und verarbeiten", "fr": "Analyste neutralisation - Analyser et traiter les données de neutralisation" - } + }, + "accessRules": [ + # UI access to specific views - vollqualifizierte ObjectKeys + {"context": "UI", "item": "ui.feature.neutralization.playground", "view": True}, + {"context": "UI", "item": "ui.feature.neutralization.attributes", "view": True}, + # Group-level DATA access (read-only for sensitive config) + {"context": "DATA", "item": None, "view": True, "read": "g", "create": "n", "update": "n", "delete": "n"}, + ] }, ] diff --git a/modules/features/neutralizer/routeFeatureNeutralizer.py b/modules/features/neutralization/routeFeatureNeutralizer.py similarity index 100% rename from modules/features/neutralizer/routeFeatureNeutralizer.py rename to modules/features/neutralization/routeFeatureNeutralizer.py diff --git a/modules/features/neutralizer/serviceNeutralization/mainServiceNeutralization.py b/modules/features/neutralization/serviceNeutralization/mainServiceNeutralization.py similarity index 98% rename from modules/features/neutralizer/serviceNeutralization/mainServiceNeutralization.py rename to modules/features/neutralization/serviceNeutralization/mainServiceNeutralization.py index f9e65284..4351f400 100644 --- a/modules/features/neutralizer/serviceNeutralization/mainServiceNeutralization.py +++ b/modules/features/neutralization/serviceNeutralization/mainServiceNeutralization.py @@ -13,8 +13,8 @@ import re import json from typing import Dict, List, Any, Optional -from modules.features.neutralizer.datamodelFeatureNeutralizer import DataNeutraliserConfig, DataNeutralizerAttributes -from modules.features.neutralizer.interfaceFeatureNeutralizer import InterfaceFeatureNeutralizer +from modules.features.neutralization.datamodelFeatureNeutralizer import DataNeutraliserConfig, DataNeutralizerAttributes +from modules.features.neutralization.interfaceFeatureNeutralizer import InterfaceFeatureNeutralizer # Import all necessary classes and functions for neutralization from .subProcessCommon import CommonUtils, NeutralizationResult, NeutralizationAttribute diff --git a/modules/features/neutralizer/serviceNeutralization/subParseString.py b/modules/features/neutralization/serviceNeutralization/subParseString.py similarity index 100% rename from modules/features/neutralizer/serviceNeutralization/subParseString.py rename to modules/features/neutralization/serviceNeutralization/subParseString.py diff --git a/modules/features/neutralizer/serviceNeutralization/subPatterns.py b/modules/features/neutralization/serviceNeutralization/subPatterns.py similarity index 100% rename from modules/features/neutralizer/serviceNeutralization/subPatterns.py rename to modules/features/neutralization/serviceNeutralization/subPatterns.py diff --git a/modules/features/neutralizer/serviceNeutralization/subProcessBinary.py b/modules/features/neutralization/serviceNeutralization/subProcessBinary.py similarity index 100% rename from modules/features/neutralizer/serviceNeutralization/subProcessBinary.py rename to modules/features/neutralization/serviceNeutralization/subProcessBinary.py diff --git a/modules/features/neutralizer/serviceNeutralization/subProcessCommon.py b/modules/features/neutralization/serviceNeutralization/subProcessCommon.py similarity index 100% rename from modules/features/neutralizer/serviceNeutralization/subProcessCommon.py rename to modules/features/neutralization/serviceNeutralization/subProcessCommon.py diff --git a/modules/features/neutralizer/serviceNeutralization/subProcessList.py b/modules/features/neutralization/serviceNeutralization/subProcessList.py similarity index 100% rename from modules/features/neutralizer/serviceNeutralization/subProcessList.py rename to modules/features/neutralization/serviceNeutralization/subProcessList.py diff --git a/modules/features/neutralizer/serviceNeutralization/subProcessText.py b/modules/features/neutralization/serviceNeutralization/subProcessText.py similarity index 100% rename from modules/features/neutralizer/serviceNeutralization/subProcessText.py rename to modules/features/neutralization/serviceNeutralization/subProcessText.py diff --git a/modules/features/realEstate/interfaceFeatureRealEstate.py b/modules/features/realEstate/interfaceFeatureRealEstate.py index 5a8c6d70..7a96afaa 100644 --- a/modules/features/realEstate/interfaceFeatureRealEstate.py +++ b/modules/features/realEstate/interfaceFeatureRealEstate.py @@ -742,7 +742,9 @@ class RealEstateObjects: permissions = self.rbac.getUserPermissions( self.currentUser, AccessRuleContext.DATA, - tableName + tableName, + mandateId=self.mandateId, + featureInstanceId=self.featureInstanceId ) if operation == "create": diff --git a/modules/features/realEstate/mainRealEstate.py b/modules/features/realEstate/mainRealEstate.py index 76f658ba..d8447b96 100644 --- a/modules/features/realEstate/mainRealEstate.py +++ b/modules/features/realEstate/mainRealEstate.py @@ -41,7 +41,8 @@ RESOURCE_OBJECTS = [ }, ] -# Template roles for this feature +# Template roles for this feature with AccessRules +# IMPORTANT: item uses vollqualifizierte ObjectKeys (gemäss Navigation-API-Konzept) TEMPLATE_ROLES = [ { "roleLabel": "realestate-admin", @@ -49,7 +50,16 @@ TEMPLATE_ROLES = [ "en": "Real Estate Administrator - Full access to all property data and settings", "de": "Immobilien-Administrator - Vollzugriff auf alle Immobiliendaten und Einstellungen", "fr": "Administrateur immobilier - Accès complet aux données et paramètres" - } + }, + "accessRules": [ + # Full UI access (all views including admin views) + {"context": "UI", "item": None, "view": True}, + # Full DATA access + {"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"}, + # Admin resources + {"context": "RESOURCE", "item": "resource.feature.realestate.project.create", "view": True}, + {"context": "RESOURCE", "item": "resource.feature.realestate.project.delete", "view": True}, + ] }, { "roleLabel": "realestate-manager", @@ -57,7 +67,16 @@ TEMPLATE_ROLES = [ "en": "Real Estate Manager - Manage properties and tenants", "de": "Immobilien-Verwalter - Immobilien und Mieter verwalten", "fr": "Gestionnaire immobilier - Gérer les propriétés et locataires" - } + }, + "accessRules": [ + # UI access to main views - vollqualifizierte ObjectKeys + {"context": "UI", "item": "ui.feature.realestate.projects", "view": True}, + {"context": "UI", "item": "ui.feature.realestate.parcels", "view": True}, + # Group-level DATA access + {"context": "DATA", "item": None, "view": True, "read": "g", "create": "g", "update": "g", "delete": "g"}, + # Resource: create projects + {"context": "RESOURCE", "item": "resource.feature.realestate.project.create", "view": True}, + ] }, { "roleLabel": "realestate-viewer", @@ -65,7 +84,14 @@ TEMPLATE_ROLES = [ "en": "Real Estate Viewer - View property information", "de": "Immobilien-Betrachter - Immobilien-Informationen einsehen", "fr": "Visualiseur immobilier - Consulter les informations immobilières" - } + }, + "accessRules": [ + # UI access to view-only views - vollqualifizierte ObjectKeys + {"context": "UI", "item": "ui.feature.realestate.projects", "view": True}, + {"context": "UI", "item": "ui.feature.realestate.parcels", "view": True}, + # Read-only DATA access (my records) + {"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"}, + ] }, ] diff --git a/modules/features/trustee/interfaceFeatureTrustee.py b/modules/features/trustee/interfaceFeatureTrustee.py index 7fc90bdb..729793b4 100644 --- a/modules/features/trustee/interfaceFeatureTrustee.py +++ b/modules/features/trustee/interfaceFeatureTrustee.py @@ -171,7 +171,9 @@ class TrusteeObjects: permissions = self.rbac.getUserPermissions( self.currentUser, AccessRuleContext.DATA, - tableName + tableName, + mandateId=self.mandateId, + featureInstanceId=self.featureInstanceId ) if not permissions.view: @@ -196,7 +198,9 @@ class TrusteeObjects: permissions = self.rbac.getUserPermissions( self.currentUser, AccessRuleContext.DATA, - tableName + tableName, + mandateId=self.mandateId, + featureInstanceId=self.featureInstanceId ) if not permissions.view: @@ -258,7 +262,9 @@ class TrusteeObjects: modelClass=TrusteeOrganisation, currentUser=self.currentUser, recordFilter=None, - orderBy="id" + orderBy="id", + mandateId=self.mandateId, + featureInstanceId=self.featureInstanceId ) logger.debug(f"getAllOrganisations: getRecordsetWithRBAC returned {len(records)} records") @@ -349,7 +355,9 @@ class TrusteeObjects: modelClass=TrusteeRole, currentUser=self.currentUser, recordFilter=None, - orderBy="id" + orderBy="id", + mandateId=self.mandateId, + featureInstanceId=self.featureInstanceId ) # Users with ALL access level (from system RBAC) see all roles @@ -457,7 +465,9 @@ class TrusteeObjects: modelClass=TrusteeAccess, currentUser=self.currentUser, recordFilter=None, - orderBy="id" + orderBy="id", + mandateId=self.mandateId, + featureInstanceId=self.featureInstanceId ) # Users with ALL access level (from system RBAC) see all records @@ -514,7 +524,9 @@ class TrusteeObjects: modelClass=TrusteeAccess, currentUser=self.currentUser, recordFilter={"organisationId": organisationId}, - orderBy="id" + orderBy="id", + mandateId=self.mandateId, + featureInstanceId=self.featureInstanceId ) return [TrusteeAccess(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in records] @@ -529,7 +541,9 @@ class TrusteeObjects: modelClass=TrusteeAccess, currentUser=self.currentUser, recordFilter={"userId": userId}, - orderBy="id" + orderBy="id", + mandateId=self.mandateId, + featureInstanceId=self.featureInstanceId ) # Users with ALL access level (from system RBAC) see all records @@ -644,7 +658,9 @@ class TrusteeObjects: modelClass=TrusteeContract, currentUser=self.currentUser, recordFilter=None, - orderBy="id" + orderBy="id", + mandateId=self.mandateId, + featureInstanceId=self.featureInstanceId ) # Step 2: Feature-level filtering based on trustee.access @@ -678,7 +694,9 @@ class TrusteeObjects: modelClass=TrusteeContract, currentUser=self.currentUser, recordFilter={"organisationId": organisationId}, - orderBy="label" + orderBy="label", + mandateId=self.mandateId, + featureInstanceId=self.featureInstanceId ) # Step 2: Feature-level filtering based on trustee.access @@ -785,7 +803,9 @@ class TrusteeObjects: modelClass=TrusteeDocument, currentUser=self.currentUser, recordFilter=None, - orderBy="documentName" + orderBy="documentName", + mandateId=self.mandateId, + featureInstanceId=self.featureInstanceId ) # Step 2: Feature-level filtering based on trustee.access @@ -826,7 +846,9 @@ class TrusteeObjects: modelClass=TrusteeDocument, currentUser=self.currentUser, recordFilter={"contractId": contractId}, - orderBy="documentName" + orderBy="documentName", + mandateId=self.mandateId, + featureInstanceId=self.featureInstanceId ) # Step 2: Feature-level filtering based on trustee.access @@ -934,7 +956,9 @@ class TrusteeObjects: modelClass=TrusteePosition, currentUser=self.currentUser, recordFilter=None, - orderBy="valuta" + orderBy="valuta", + mandateId=self.mandateId, + featureInstanceId=self.featureInstanceId ) # Step 2: Feature-level filtering based on trustee.access @@ -975,7 +999,9 @@ class TrusteeObjects: modelClass=TrusteePosition, currentUser=self.currentUser, recordFilter={"contractId": contractId}, - orderBy="valuta" + orderBy="valuta", + mandateId=self.mandateId, + featureInstanceId=self.featureInstanceId ) # Step 2: Feature-level filtering based on trustee.access @@ -990,7 +1016,9 @@ class TrusteeObjects: modelClass=TrusteePosition, currentUser=self.currentUser, recordFilter={"organisationId": organisationId}, - orderBy="valuta" + orderBy="valuta", + mandateId=self.mandateId, + featureInstanceId=self.featureInstanceId ) # Step 2: Feature-level filtering based on trustee.access @@ -1078,15 +1106,46 @@ class TrusteeObjects: return None return TrusteePositionDocument(**{k: v for k, v in records[0].items() if not k.startswith("_")}) + def updatePositionDocument(self, linkId: str, data: Dict[str, Any]) -> Optional[TrusteePositionDocument]: + """Update a position-document link.""" + # Check permission + if not self.checkCombinedPermission(TrusteePositionDocument, "update"): + logger.warning(f"User {self.userId} lacks permission to update position-document link") + return None + + # Verify link exists and belongs to this instance + existing = self.db.getRecordset(TrusteePositionDocument, recordFilter={"id": linkId}) + if not existing: + logger.warning(f"Position-document link {linkId} not found") + return None + + existingRecord = existing[0] + if existingRecord.get("featureInstanceId") != self.featureInstanceId: + logger.warning(f"Link {linkId} belongs to different instance") + return None + + # Prevent changing context fields + data.pop("id", None) + data.pop("mandateId", None) + data.pop("featureInstanceId", None) + + updatedRecord = self.db.recordModify(TrusteePositionDocument, linkId, data) + if updatedRecord and updatedRecord.get("id"): + return TrusteePositionDocument(**{k: v for k, v in updatedRecord.items() if not k.startswith("_")}) + return None + def getAllPositionDocuments(self, params: Optional[PaginationParams] = None) -> PaginatedResult: """Get all position-document links with RBAC filtering + feature-level access filtering.""" - # Step 1: System RBAC filtering + # Step 1: System RBAC filtering with per-row permissions records = getRecordsetWithRBAC( connector=self.db, modelClass=TrusteePositionDocument, currentUser=self.currentUser, recordFilter=None, - orderBy="id" + orderBy="id", + mandateId=self.mandateId, + featureInstanceId=self.featureInstanceId, + enrichPermissions=True ) # Step 2: Feature-level filtering based on trustee.access @@ -1121,7 +1180,9 @@ class TrusteeObjects: modelClass=TrusteePositionDocument, currentUser=self.currentUser, recordFilter={"positionId": positionId}, - orderBy="id" + orderBy="id", + mandateId=self.mandateId, + featureInstanceId=self.featureInstanceId ) # Step 2: Feature-level filtering based on trustee.access @@ -1136,7 +1197,9 @@ class TrusteeObjects: modelClass=TrusteePositionDocument, currentUser=self.currentUser, recordFilter={"documentId": documentId}, - orderBy="id" + orderBy="id", + mandateId=self.mandateId, + featureInstanceId=self.featureInstanceId ) # Step 2: Feature-level filtering based on trustee.access diff --git a/modules/features/trustee/mainTrustee.py b/modules/features/trustee/mainTrustee.py index 5ee0ed08..311293f6 100644 --- a/modules/features/trustee/mainTrustee.py +++ b/modules/features/trustee/mainTrustee.py @@ -45,6 +45,31 @@ UI_OBJECTS = [ }, ] +# DATA Objects for RBAC catalog (tables/entities) +# Used for AccessRules on data-level permissions +DATA_OBJECTS = [ + { + "objectKey": "data.feature.trustee.TrusteePosition", + "label": {"en": "Position", "de": "Position", "fr": "Position"}, + "meta": {"table": "TrusteePosition", "fields": ["id", "label", "description", "organisationId"]} + }, + { + "objectKey": "data.feature.trustee.TrusteeDocument", + "label": {"en": "Document", "de": "Dokument", "fr": "Document"}, + "meta": {"table": "TrusteeDocument", "fields": ["id", "filename", "mimeType", "fileSize", "uploadDate"]} + }, + { + "objectKey": "data.feature.trustee.TrusteePositionDocument", + "label": {"en": "Position-Document Assignment", "de": "Position-Dokument Zuordnung", "fr": "Assignation Position-Document"}, + "meta": {"table": "TrusteePositionDocument", "fields": ["id", "positionId", "documentId"]} + }, + { + "objectKey": "data.feature.trustee.*", + "label": {"en": "All Trustee Data", "de": "Alle Treuhand-Daten", "fr": "Toutes les données fiduciaires"}, + "meta": {"wildcard": True, "description": "Wildcard for all trustee data tables"} + }, +] + # Resource Objects for RBAC catalog # Note: organisations and contracts removed - feature instance = organisation RESOURCE_OBJECTS = [ @@ -88,6 +113,7 @@ RESOURCE_OBJECTS = [ # Template roles for this feature with AccessRules # Each role defines default UI and DATA permissions # Note: UI item=None means ALL views, specific items restrict to named views +# IMPORTANT: item uses vollqualifizierte ObjectKeys (gemäss Navigation-API-Konzept) TEMPLATE_ROLES = [ { "roleLabel": "trustee-admin", @@ -102,7 +128,7 @@ TEMPLATE_ROLES = [ # Full DATA access {"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"}, # Admin resource: manage instance roles - {"context": "RESOURCE", "item": "instance-roles.manage", "view": True}, + {"context": "RESOURCE", "item": "resource.feature.trustee.instance-roles.manage", "view": True}, ] }, { @@ -113,11 +139,11 @@ TEMPLATE_ROLES = [ "fr": "Comptable fiduciaire - Gérer les données comptables et financières" }, "accessRules": [ - # UI access to main views (not admin views) - {"context": "UI", "item": "dashboard", "view": True}, - {"context": "UI", "item": "positions", "view": True}, - {"context": "UI", "item": "documents", "view": True}, - {"context": "UI", "item": "position-documents", "view": True}, + # UI access to main views (not admin views) - vollqualifizierte ObjectKeys + {"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True}, + {"context": "UI", "item": "ui.feature.trustee.positions", "view": True}, + {"context": "UI", "item": "ui.feature.trustee.documents", "view": True}, + {"context": "UI", "item": "ui.feature.trustee.position-documents", "view": True}, # Group-level DATA access {"context": "DATA", "item": None, "view": True, "read": "g", "create": "g", "update": "g", "delete": "g"}, ] @@ -130,12 +156,15 @@ TEMPLATE_ROLES = [ "fr": "Client fiduciaire - Consulter ses propres données comptables et documents" }, "accessRules": [ - # UI access to main views only (read-only focus) - {"context": "UI", "item": "dashboard", "view": True}, - {"context": "UI", "item": "positions", "view": True}, - {"context": "UI", "item": "documents", "view": True}, - # Own records only (MY level) - {"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"}, + # UI access to main views only (read-only focus) - vollqualifizierte ObjectKeys + {"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True}, + {"context": "UI", "item": "ui.feature.trustee.positions", "view": True}, + {"context": "UI", "item": "ui.feature.trustee.documents", "view": True}, + {"context": "UI", "item": "ui.feature.trustee.position-documents", "view": True}, + # Own records only (MY level) - explizite Regeln pro Tabelle + {"context": "DATA", "item": "data.feature.trustee.TrusteePosition", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"}, + {"context": "DATA", "item": "data.feature.trustee.TrusteeDocument", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"}, + {"context": "DATA", "item": "data.feature.trustee.TrusteePositionDocument", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"}, ] }, ] @@ -165,6 +194,11 @@ def getTemplateRoles() -> List[Dict[str, Any]]: return TEMPLATE_ROLES +def getDataObjects() -> List[Dict[str, Any]]: + """Return DATA objects for RBAC catalog registration.""" + return DATA_OBJECTS + + def registerFeature(catalogService) -> bool: """ Register this feature's RBAC objects in the catalog. @@ -194,10 +228,19 @@ def registerFeature(catalogService) -> bool: meta=resObj.get("meta") ) + # Register DATA objects (tables/entities) + for dataObj in DATA_OBJECTS: + catalogService.registerDataObject( + featureCode=FEATURE_CODE, + objectKey=dataObj["objectKey"], + label=dataObj["label"], + meta=dataObj.get("meta") + ) + # Sync template roles to database (with AccessRules) _syncTemplateRolesToDb() - logger.info(f"Feature '{FEATURE_CODE}' registered {len(UI_OBJECTS)} UI objects and {len(RESOURCE_OBJECTS)} resource objects") + logger.info(f"Feature '{FEATURE_CODE}' registered {len(UI_OBJECTS)} UI, {len(RESOURCE_OBJECTS)} resource, {len(DATA_OBJECTS)} data objects") return True except Exception as e: diff --git a/modules/features/trustee/routeFeatureTrustee.py b/modules/features/trustee/routeFeatureTrustee.py index f2b2ead2..9b1b1fca 100644 --- a/modules/features/trustee/routeFeatureTrustee.py +++ b/modules/features/trustee/routeFeatureTrustee.py @@ -282,12 +282,13 @@ async def get_document_options( instanceId: str = Path(..., description="Feature Instance ID"), context: RequestContext = Depends(getRequestContext) ) -> List[Dict[str, Any]]: - """Get document options for select dropdowns. Returns: [{ value, label }]""" + """Get document options for select dropdowns. Returns: [{ id, value, label }]""" mandateId = await _validateInstanceAccess(instanceId, context) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) result = interface.getAllDocuments(None) items = result.items if hasattr(result, 'items') else result - return [{"value": d.id, "label": d.documentName or d.id} for d in items] + # Include 'id' for FK resolution in tables + return [{"id": d.id, "value": d.id, "label": d.documentName or d.id} for d in items] @router.get("/{instanceId}/positions/options", response_model=List[Dict[str, Any]]) @@ -297,7 +298,7 @@ async def get_position_options( instanceId: str = Path(..., description="Feature Instance ID"), context: RequestContext = Depends(getRequestContext) ) -> List[Dict[str, Any]]: - """Get position options for select dropdowns. Returns: [{ value, label }]""" + """Get position options for select dropdowns. Returns: [{ id, value, label }]""" mandateId = await _validateInstanceAccess(instanceId, context) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) result = interface.getAllPositions(None) @@ -313,7 +314,8 @@ async def get_position_options( parts.append(p.desc[:30]) return " - ".join(parts) if parts else p.id - return [{"value": p.id, "label": _makePositionLabel(p)} for p in items] + # Include 'id' for FK resolution in tables + return [{"id": p.id, "value": p.id, "label": _makePositionLabel(p)} for p in items] # ============================================================================ @@ -1166,15 +1168,18 @@ async def delete_position( # ===== Position-Document Link Routes ===== -@router.get("/{instanceId}/position-documents", response_model=PaginatedResponse[TrusteePositionDocument]) +@router.get("/{instanceId}/position-documents") @limiter.limit("30/minute") async def get_position_documents( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), pagination: Optional[str] = Query(None), context: RequestContext = Depends(getRequestContext) -) -> PaginatedResponse[TrusteePositionDocument]: - """Get all position-document links with optional pagination.""" +) -> Dict[str, Any]: + """Get all position-document links with optional pagination. + + Each item includes _permissions: { canUpdate, canDelete } for row-level permission UI. + """ mandateId = await _validateInstanceAccess(instanceId, context) paginationParams = _parsePagination(pagination) @@ -1182,18 +1187,18 @@ async def get_position_documents( result = interface.getAllPositionDocuments(paginationParams) if paginationParams: - return PaginatedResponse( - items=result.items, - pagination=PaginationMetadata( - currentPage=paginationParams.page or 1, - pageSize=paginationParams.pageSize or 20, - totalItems=result.totalItems, - totalPages=result.totalPages, - sort=paginationParams.sort if paginationParams else [], - filters=paginationParams.filters if paginationParams else None - ) - ) - return PaginatedResponse(items=result.items, pagination=None) + return { + "items": result.items, + "pagination": { + "currentPage": paginationParams.page or 1, + "pageSize": paginationParams.pageSize or 20, + "totalItems": result.totalItems, + "totalPages": result.totalPages, + "sort": paginationParams.sort if paginationParams else [], + "filters": paginationParams.filters if paginationParams else None + } + } + return {"items": result.items, "pagination": None} @router.get("/{instanceId}/position-documents/{linkId}", response_model=TrusteePositionDocument) @@ -1262,6 +1267,25 @@ async def create_position_document( return result +@router.put("/{instanceId}/position-documents/{linkId}", response_model=TrusteePositionDocument) +@limiter.limit("10/minute") +async def update_position_document( + request: Request, + instanceId: str = Path(..., description="Feature Instance ID"), + linkId: str = Path(...), + data: TrusteePositionDocument = Body(...), + context: RequestContext = Depends(getRequestContext) +) -> TrusteePositionDocument: + """Update a position-document link.""" + mandateId = await _validateInstanceAccess(instanceId, context) + + interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) + result = interface.updatePositionDocument(linkId, data.model_dump(exclude_unset=True)) + if not result: + raise HTTPException(status_code=400, detail="Failed to update link") + return result + + @router.delete("/{instanceId}/position-documents/{linkId}") @limiter.limit("10/minute") async def delete_position_document( @@ -1505,10 +1529,10 @@ async def update_instance_role_rule( updateData["delete"] = ruleData["delete"] if not updateData: - return existingRule + return existingRules[0] try: - updated = rootInterface.db.recordUpdate(AccessRule, ruleId, updateData) + updated = rootInterface.db.recordModify(AccessRule, ruleId, updateData) return updated except Exception as e: logger.error(f"Error updating AccessRule: {e}") diff --git a/modules/interfaces/interfaceBootstrap.py b/modules/interfaces/interfaceBootstrap.py index 4b291537..472e77f9 100644 --- a/modules/interfaces/interfaceBootstrap.py +++ b/modules/interfaces/interfaceBootstrap.py @@ -447,29 +447,33 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None: delete=AccessLevel.NONE, )) - # Standard tables with typical access patterns + # System tables only - NOT feature-specific tables! + # Feature tables (TrusteeXXX, Projekt, etc.) are handled by FEATURE-TEMPLATE roles. # NOTE: No sysadmin rules - SysAdmin users bypass RBAC via isSysAdmin flag - standardTables = [ - "UserConnection", "DataNeutraliserConfig", "DataNeutralizerAttributes", - "ChatWorkflow", "Prompt", "Projekt", "Parzelle", "Dokument", - "Gemeinde", "Kanton", "Land", - "TrusteeOrganisation", "TrusteeRole", "TrusteeAccess", "TrusteeContract", - "TrusteeDocument", "TrusteePosition", "TrusteePositionDocument" + # + # Proper format: Just table names for DATA context (item="TableName") + # The full data.system.TableName format is for catalog registration only. + + # FileItem and UserConnection: All users (user, admin, viewer) only MY-level CRUD + restrictedTables = [ + "UserConnection", # User connections/sessions - only own records + "FileItem", # Uploaded files - only own files ] - for table in standardTables: - # Admin gets full group-level access (highest role-based permission) + for table in restrictedTables: + # Admin: Only MY-level access (not group-level!) if adminId: tableRules.append(AccessRule( roleId=adminId, context=AccessRuleContext.DATA, item=table, view=True, - read=AccessLevel.GROUP, - create=AccessLevel.GROUP, - update=AccessLevel.GROUP, - delete=AccessLevel.GROUP, + read=AccessLevel.MY, + create=AccessLevel.MY, + update=AccessLevel.MY, + delete=AccessLevel.MY, )) + # User: MY-level CRUD if userId: tableRules.append(AccessRule( roleId=userId, @@ -481,6 +485,7 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None: update=AccessLevel.MY, delete=AccessLevel.MY, )) + # Viewer: MY-level read-only if viewerId: tableRules.append(AccessRule( roleId=viewerId, @@ -493,6 +498,80 @@ def _createTableSpecificRules(db: DatabaseConnector) -> 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: + # Admin: MY-level CRUD + GROUP-level read + tableRules.append(AccessRule( + roleId=adminId, + context=AccessRuleContext.DATA, + item="Prompt", + view=True, + read=AccessLevel.GROUP, # Can read group prompts + create=AccessLevel.MY, # Can create own prompts + update=AccessLevel.MY, # Can update own prompts + delete=AccessLevel.MY, # Can delete own prompts + )) + if userId: + # User: MY-level CRUD + GROUP-level read + tableRules.append(AccessRule( + roleId=userId, + context=AccessRuleContext.DATA, + item="Prompt", + view=True, + read=AccessLevel.GROUP, # Can read group prompts + create=AccessLevel.MY, # Can create own prompts + update=AccessLevel.MY, # Can update own prompts + delete=AccessLevel.MY, # Can delete own prompts + )) + if viewerId: + # Viewer: MY-level read + GROUP-level read + tableRules.append(AccessRule( + roleId=viewerId, + context=AccessRuleContext.DATA, + item="Prompt", + view=True, + read=AccessLevel.GROUP, # Can read group prompts + create=AccessLevel.NONE, + update=AccessLevel.NONE, + delete=AccessLevel.NONE, + )) + + # Invitation: Standard group-level access + if adminId: + tableRules.append(AccessRule( + roleId=adminId, + context=AccessRuleContext.DATA, + item="Invitation", + view=True, + read=AccessLevel.GROUP, + create=AccessLevel.GROUP, + update=AccessLevel.GROUP, + delete=AccessLevel.GROUP, + )) + if userId: + tableRules.append(AccessRule( + roleId=userId, + context=AccessRuleContext.DATA, + item="Invitation", + 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="Invitation", + view=True, + read=AccessLevel.MY, + create=AccessLevel.NONE, + update=AccessLevel.NONE, + delete=AccessLevel.NONE, + )) + # AuthEvent table - Audit logs (no delete allowed for audit integrity!) # SysAdmin can delete via isSysAdmin bypass, but regular admins cannot if adminId: @@ -541,27 +620,51 @@ def _createUiContextRules(db: DatabaseConnector) -> None: Create UI context rules for controlling UI element visibility. Uses roleId instead of roleLabel. + Creates rules for system pages based on NAVIGATION_SECTIONS. + Admin pages require admin role, public pages are available to all. + NOTE: No sysadmin rules - SysAdmin users bypass RBAC via isSysAdmin flag. Args: db: Database connector instance """ - uiRules = [] + from modules.system.mainSystem import NAVIGATION_SECTIONS - # All roles get full UI access by default (no sysadmin - that's a flag) - for roleLabel in ["admin", "user", "viewer"]: - roleId = _getRoleId(db, roleLabel) - if roleId: - uiRules.append(AccessRule( - roleId=roleId, - context=AccessRuleContext.UI, - item=None, - view=True, - read=None, - create=None, - update=None, - delete=None, - )) + uiRules = [] + adminId = _getRoleId(db, "admin") + userId = _getRoleId(db, "user") + viewerId = _getRoleId(db, "viewer") + + # Create rules based on navigation sections + for section in NAVIGATION_SECTIONS: + isAdminSection = section.get("adminOnly", False) + + for item in section.get("items", []): + objectKey = item.get("objectKey") + isPublic = item.get("public", False) + isAdminOnly = item.get("adminOnly", False) or isAdminSection + + if isAdminOnly: + # Admin-only pages: only admin role + if adminId: + uiRules.append(AccessRule( + roleId=adminId, + context=AccessRuleContext.UI, + item=objectKey, + view=True, + read=None, create=None, update=None, delete=None, + )) + else: + # Public/normal pages: all roles + for roleId in [adminId, userId, viewerId]: + if roleId: + uiRules.append(AccessRule( + roleId=roleId, + context=AccessRuleContext.UI, + item=objectKey, + view=True, + read=None, create=None, update=None, delete=None, + )) for rule in uiRules: db.recordCreate(AccessRule, rule) diff --git a/modules/interfaces/interfaceDbApp.py b/modules/interfaces/interfaceDbApp.py index 9c769e7c..a7dfc689 100644 --- a/modules/interfaces/interfaceDbApp.py +++ b/modules/interfaces/interfaceDbApp.py @@ -227,7 +227,8 @@ class AppObjects: permissions = self.rbac.getUserPermissions( self.currentUser, AccessRuleContext.DATA, - tableName + tableName, + mandateId=self.mandateId ) if operation == "create": diff --git a/modules/interfaces/interfaceDbChat.py b/modules/interfaces/interfaceDbChat.py index 25b3d3d6..3c4d35ad 100644 --- a/modules/interfaces/interfaceDbChat.py +++ b/modules/interfaces/interfaceDbChat.py @@ -367,7 +367,9 @@ class ChatObjects: permissions = self.rbac.getUserPermissions( self.currentUser, AccessRuleContext.DATA, - tableName + tableName, + mandateId=self.mandateId, + featureInstanceId=self.featureInstanceId ) if operation == "create": diff --git a/modules/interfaces/interfaceDbManagement.py b/modules/interfaces/interfaceDbManagement.py index a26e8c98..10c47a19 100644 --- a/modules/interfaces/interfaceDbManagement.py +++ b/modules/interfaces/interfaceDbManagement.py @@ -319,7 +319,9 @@ class ComponentObjects: permissions = self.rbac.getUserPermissions( self.currentUser, AccessRuleContext.DATA, - tableName + tableName, + mandateId=self.mandateId, + featureInstanceId=self.featureInstanceId ) if operation == "create": diff --git a/modules/interfaces/interfaceRbac.py b/modules/interfaces/interfaceRbac.py index 8ceefa6a..b34b2e36 100644 --- a/modules/interfaces/interfaceRbac.py +++ b/modules/interfaces/interfaceRbac.py @@ -30,6 +30,7 @@ def getRecordsetWithRBAC( limit: int = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None, + enrichPermissions: bool = False, ) -> List[Dict[str, Any]]: """ Get records with RBAC filtering applied at database level. @@ -47,9 +48,11 @@ def getRecordsetWithRBAC( limit: Maximum number of records to return mandateId: Explicit mandate context (from request header). Required for GROUP access. featureInstanceId: Explicit feature instance context + enrichPermissions: If True, adds _permissions field to each record with row-level + permissions { canUpdate, canDelete } based on RBAC rules and _createdBy Returns: - List of filtered records + List of filtered records (with _permissions if enrichPermissions=True) """ table = modelClass.__name__ @@ -64,7 +67,12 @@ def getRecordsetWithRBAC( if isSysAdmin: # Direct access without RBAC filtering # Note: getRecordset doesn't support orderBy/limit - these are only used in RBAC path - return connector.getRecordset(modelClass, recordFilter=recordFilter) + records = connector.getRecordset(modelClass, recordFilter=recordFilter) + if enrichPermissions: + # SysAdmin has full permissions on all records + for record in records: + record["_permissions"] = {"canUpdate": True, "canDelete": True} + return records # Get RBAC permissions for this table # AccessRule table is always in DbApp database @@ -173,6 +181,12 @@ def getRecordsetWithRBAC( f"Could not parse JSONB field {fieldName}, keeping as string: {record[fieldName]}" ) + # Enrich records with row-level permissions if requested + if enrichPermissions: + records = _enrichRecordsWithPermissions( + records, permissions, currentUser + ) + return records except Exception as e: logger.error(f"Error loading records with RBAC from table {table}: {e}") @@ -292,3 +306,87 @@ def buildRbacWhereClause( } return None + + +def _enrichRecordsWithPermissions( + records: List[Dict[str, Any]], + permissions: UserPermissions, + currentUser: User +) -> List[Dict[str, Any]]: + """ + Enrich records with per-row permissions (_permissions field). + + The _permissions field contains: + - canUpdate: bool - whether current user can update this record + - canDelete: bool - whether current user can delete this record + + Logic: + - AccessLevel.ALL ('a'): User can update/delete all records + - AccessLevel.MY ('m'): User can only update/delete records where _createdBy == userId + - AccessLevel.GROUP ('g'): Same as MY for now (group-level ownership) + - AccessLevel.NONE ('n'): User cannot update/delete any records + + Args: + records: List of record dicts + permissions: UserPermissions with update/delete levels + currentUser: Current user object + + Returns: + Records with _permissions field added + """ + enriched = [] + userId = currentUser.id if currentUser else None + + for record in records: + recordCopy = dict(record) + createdBy = record.get("_createdBy") + + # Determine canUpdate + canUpdate = _checkRowPermission(permissions.update, userId, createdBy) + # Determine canDelete + canDelete = _checkRowPermission(permissions.delete, userId, createdBy) + + recordCopy["_permissions"] = { + "canUpdate": canUpdate, + "canDelete": canDelete + } + enriched.append(recordCopy) + + return enriched + + +def _checkRowPermission( + accessLevel: Optional[AccessLevel], + userId: Optional[str], + recordCreatedBy: Optional[str] +) -> bool: + """ + Check if user has permission for a specific row based on access level. + + Args: + accessLevel: The permission level (ALL, MY, GROUP, NONE) + userId: Current user's ID + recordCreatedBy: The _createdBy value of the record + + Returns: + True if user has permission, False otherwise + """ + if not accessLevel or accessLevel == AccessLevel.NONE: + return False + + if accessLevel == AccessLevel.ALL: + return True + + # MY and GROUP: Check ownership via _createdBy + if accessLevel in (AccessLevel.MY, AccessLevel.GROUP): + # If record has no _createdBy, allow access (can't verify ownership) + if not recordCreatedBy: + return True + # If no userId, can't verify - deny + if not userId: + return False + # Check ownership + return recordCreatedBy == userId + + # Unknown level - deny by default + return False diff --git a/modules/routes/routeAdminFeatures.py b/modules/routes/routeAdminFeatures.py index 2c8408e3..1bb6be16 100644 --- a/modules/routes/routeAdminFeatures.py +++ b/modules/routes/routeAdminFeatures.py @@ -325,19 +325,16 @@ def _getInstancePermissions(rootInterface, userId: str, instanceId: str) -> Dict current["delete"] = _mergeAccessLevel(current["delete"], rule.get("delete") or "n") # Handle UI context (views) + # Views are stored with full objectKey (e.g., ui.feature.trustee.dashboard) elif context == "UI" or context == AccessRuleContext.UI: ruleView = rule.get("view", False) if item: - # Specific view rule + # Store with full objectKey as per Navigation-API-Konzept permissions["views"][item] = permissions["views"].get(item, False) or ruleView elif ruleView: # item=None means all views - set a wildcard flag permissions["views"]["_all"] = True - # Derive view permissions from table permissions - # This allows UI navigation to be controlled by data access rights - _deriveViewPermissions(permissions) - return permissions except Exception as e: @@ -345,51 +342,6 @@ def _getInstancePermissions(rootInterface, userId: str, instanceId: str) -> Dict return permissions -def _deriveViewPermissions(permissions: Dict[str, Any]) -> None: - """ - Derive UI view permissions from table/data permissions. - - Mapping: - - trustee-dashboard: always visible (basic access) - - trustee-positions: visible if READ on TrusteePosition - - trustee-documents: visible if READ on TrusteeDocument - - trustee-position-documents: visible if READ on TrusteePositionDocument - - trustee-instance-roles: visible only for admin roles - - This function modifies permissions["views"] in place. - """ - tables = permissions.get("tables", {}) - views = permissions.get("views", {}) - isAdmin = permissions.get("isAdmin", False) - - # If user has _all views permission, skip derivation - if views.get("_all"): - return - - # Dashboard is always visible for users with any access - if "trustee-dashboard" not in views: - views["trustee-dashboard"] = True - - # Positions view: requires READ on TrusteePosition - if "trustee-positions" not in views: - positionPerms = tables.get("TrusteePosition", {}) - views["trustee-positions"] = positionPerms.get("read", "n") != "n" - - # Documents view: requires READ on TrusteeDocument - if "trustee-documents" not in views: - documentPerms = tables.get("TrusteeDocument", {}) - views["trustee-documents"] = documentPerms.get("read", "n") != "n" - - # Position-Documents view: requires READ on TrusteePositionDocument - if "trustee-position-documents" not in views: - linkPerms = tables.get("TrusteePositionDocument", {}) - views["trustee-position-documents"] = linkPerms.get("read", "n") != "n" - - # Instance-roles (admin) view: requires admin role - if "trustee-instance-roles" not in views: - views["trustee-instance-roles"] = isAdmin - - def _mergeAccessLevel(current: str, new: str) -> str: """Merge two access levels, returning the highest.""" levels = {"n": 0, "m": 1, "g": 2, "a": 3} diff --git a/modules/routes/routeAdminRbacRules.py b/modules/routes/routeAdminRbacRules.py index a1990543..d125bc2c 100644 --- a/modules/routes/routeAdminRbacRules.py +++ b/modules/routes/routeAdminRbacRules.py @@ -19,6 +19,7 @@ import math from modules.auth import limiter, getRequestContext, requireSysAdmin, RequestContext from modules.datamodels.datamodelUam import User, UserPermissions, AccessLevel from modules.datamodels.datamodelRbac import AccessRuleContext, AccessRule, Role +from modules.datamodels.datamodelMembership import UserMandate from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict from modules.interfaces.interfaceDbApp import getInterface, getRootInterface @@ -77,11 +78,13 @@ async def get_permissions( ) # MULTI-TENANT: Get permissions using context (mandateId/featureInstanceId) - # For now, pass user - RBAC will be extended to use context in later phases + # Pass mandateId and featureInstanceId to load Feature-Instance roles permissions = interface.rbac.getUserPermissions( reqContext.user, accessContext, - item or "" + item or "", + mandateId=reqContext.mandateId, + featureInstanceId=reqContext.featureInstanceId ) return permissions @@ -166,32 +169,92 @@ async def get_all_permissions( result: Dict[str, Any] = {} - # MULTI-TENANT: Get role IDs from context (computed from mandateId/featureInstanceId) - roleIds = reqContext.roleIds or [] + # For UI/RESOURCE permissions: These are GLOBAL (not mandate-specific) + # System roles (admin, user, viewer) have global UI rules that apply without mandate context + + rootInterface = getRootInterface() + + # Start with roleIds from current mandate context (if any) + roleIds = list(reqContext.roleIds or []) + + # For UI/RESOURCE: Load system roles the user has across ALL their mandates + # This allows users to access system UI elements without needing a specific mandate header + userMandates = rootInterface.db.getRecordset( + UserMandate, + recordFilter={"userId": str(reqContext.user.id), "enabled": True} + ) + + # Collect all role IDs the user has across all mandates + for userMandate in userMandates: + mandateRoleIds = rootInterface.getRoleIdsForUserMandate(userMandate.get("id")) + for rid in mandateRoleIds: + if rid not in roleIds: + roleIds.append(rid) + + logger.debug(f"UI/RESOURCE permissions: User has {len(roleIds)} roles across all mandates") + if not roleIds and not reqContext.isSysAdmin: - # User has no roles, return empty permissions + # No roles at all, return empty permissions for ctx in contextsToFetch: result[ctx.value.lower()] = {} return result # Get all access rules for user's roles and requested contexts + # IMPORTANT: Use direct DB access without RBAC filtering! + # Otherwise we have a chicken-and-egg problem: need AccessRule read permission to calculate permissions allRules: Dict[AccessRuleContext, List[AccessRule]] = {} for ctx in contextsToFetch: allRules[ctx] = [] - # Get all rules for user's roles in this context + # Get all rules for user's roles - bypass RBAC filtering for roleId in roleIds: - rules = interface.getAccessRules( - roleId=str(roleId), - context=ctx, - pagination=None + ruleRecords = rootInterface.db.getRecordset( + AccessRule, + recordFilter={"roleId": str(roleId), "context": ctx.value} ) - allRules[ctx].extend(rules) + for ruleRecord in ruleRecords: + # Convert dict to AccessRule object + cleanedRule = {k: v for k, v in ruleRecord.items() if not k.startswith("_")} + allRules[ctx].append(AccessRule(**cleanedRule)) # Build result: for each context, collect all unique items and calculate permissions for ctx in contextsToFetch: result[ctx.value.lower()] = {} - # Collect all unique items from rules + # Check for global rule (item=None) first - this grants access to ALL UI/RESOURCE items + # Calculate permissions directly from loaded rules (don't call getUserPermissions - it requires mandateId) + hasGlobalRule = False + globalView = False + globalRead = None + globalCreate = None + globalUpdate = None + globalDelete = None + + for rule in allRules[ctx]: + if rule.item is None: + hasGlobalRule = True + if rule.view: + globalView = True + if rule.read: + globalRead = rule.read.value if hasattr(rule.read, 'value') else rule.read + if rule.create: + globalCreate = rule.create.value if hasattr(rule.create, 'value') else rule.create + if rule.update: + globalUpdate = rule.update.value if hasattr(rule.update, 'value') else rule.update + if rule.delete: + globalDelete = rule.delete.value if hasattr(rule.delete, 'value') else rule.delete + + # If there's a global rule with view permission, add "_global" key + if hasGlobalRule and globalView: + logger.debug(f"Adding _global key for context {ctx.value} with view={globalView}") + result[ctx.value.lower()]["_global"] = { + "view": globalView, + "read": globalRead, + "create": globalCreate, + "update": globalUpdate, + "delete": globalDelete + } + + # Collect all unique items from rules (specific rules) items = set() for rule in allRules[ctx]: if rule.item: @@ -199,7 +262,11 @@ async def get_all_permissions( # For each item, calculate user permissions for item in sorted(items): - permissions = interface.rbac.getUserPermissions(reqContext.user, ctx, item) + permissions = interface.rbac.getUserPermissions( + reqContext.user, ctx, item, + mandateId=reqContext.mandateId, + featureInstanceId=reqContext.featureInstanceId + ) # Only include if user has view permission if permissions.view: result[ctx.value.lower()][item] = { @@ -1007,3 +1074,129 @@ async def delete_role( status_code=500, detail=f"Failed to delete role: {str(e)}" ) + + +# ============================================================================ +# RBAC Catalog Endpoints +# ============================================================================ + +@router.get("/catalog/objects", response_model=Dict[str, Any]) +@limiter.limit("60/minute") +async def getCatalogObjects( + request: Request, + context: Optional[str] = Query(None, description="Filter by context (DATA, UI, RESOURCE)"), + featureCode: Optional[str] = Query(None, description="Filter by feature code"), + mandateId: Optional[str] = Query(None, description="Filter by mandate's active features"), + reqContext: RequestContext = Depends(getRequestContext) # Available to all authenticated users +) -> Dict[str, Any]: + """ + Get available RBAC catalog objects. + Returns all registered DATA, UI and RESOURCE objects that can be used in AccessRules. + + Query Parameters: + - context: Optional filter by context type (DATA, UI, RESOURCE) + - featureCode: Optional filter by feature (e.g., "trustee") + - mandateId: Optional filter to only include objects from features active in this mandate + + Returns: + - Dictionary with objects grouped by context type, each with: + - objectKey: Dot-notation identifier (e.g., "data.feature.trustee.TrusteeContract") + - label: Multilingual label + - featureCode: Owning feature + - meta: Additional metadata (table name, fields, etc.) + + Examples: + - GET /api/rbac/catalog/objects → all objects + - GET /api/rbac/catalog/objects?context=DATA → only DATA objects + - GET /api/rbac/catalog/objects?featureCode=trustee → only trustee objects + - GET /api/rbac/catalog/objects?mandateId=xxx → objects from mandate's active features + """ + try: + from modules.security.rbacCatalog import getCatalogService + + catalog = getCatalogService() + + # If mandateId is provided, get active features for that mandate + activeFeatures = None + if mandateId: + try: + interface = getRootInterface() + # Get all feature instances for this mandate + from modules.datamodels.datamodelFeatures import FeatureInstance + instances = interface.db.getRecordset( + FeatureInstance, + recordFilter={"mandateId": mandateId, "enabled": True} + ) + activeFeatures = set(inst.get("featureCode") for inst in instances) + # Always include "system" feature + activeFeatures.add("system") + except Exception as e: + logger.warning(f"Could not get active features for mandate {mandateId}: {e}") + + if context: + # Single context filter + try: + accessContext = AccessRuleContext(context.upper()) + except ValueError: + raise HTTPException( + status_code=400, + detail=f"Invalid context '{context}'. Must be one of: DATA, UI, RESOURCE" + ) + + if accessContext == AccessRuleContext.DATA: + objects = catalog.getDataObjects(featureCode) + elif accessContext == AccessRuleContext.UI: + objects = catalog.getUiObjects(featureCode) + else: + objects = catalog.getResourceObjects(featureCode) + + # Filter by active features if mandateId was provided + if activeFeatures: + objects = [obj for obj in objects if obj.get("featureCode") in activeFeatures] + + return {context.upper(): objects} + else: + # All contexts + result = catalog.getAllCatalogObjects(featureCode) + + # Filter by active features if mandateId was provided + if activeFeatures: + for ctxKey in result: + result[ctxKey] = [obj for obj in result[ctxKey] if obj.get("featureCode") in activeFeatures] + + return result + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error getting catalog objects: {str(e)}") + raise HTTPException( + status_code=500, + detail=f"Failed to get catalog objects: {str(e)}" + ) + + +@router.get("/catalog/stats", response_model=Dict[str, Any]) +@limiter.limit("60/minute") +async def getCatalogStats( + request: Request, + currentUser: User = Depends(requireSysAdmin) +) -> Dict[str, Any]: + """ + Get statistics about the RBAC catalog. + + Returns: + - Statistics about registered features, objects, and roles + """ + try: + from modules.security.rbacCatalog import getCatalogService + + catalog = getCatalogService() + return catalog.getCatalogStats() + + except Exception as e: + logger.error(f"Error getting catalog stats: {str(e)}") + raise HTTPException( + status_code=500, + detail=f"Failed to get catalog stats: {str(e)}" + ) diff --git a/modules/security/rbacCatalog.py b/modules/security/rbacCatalog.py index f52adf21..a913a095 100644 --- a/modules/security/rbacCatalog.py +++ b/modules/security/rbacCatalog.py @@ -37,6 +37,7 @@ class RbacCatalogService: self._uiObjects: Dict[str, Dict[str, Any]] = {} self._resourceObjects: Dict[str, Dict[str, Any]] = {} + self._dataObjects: Dict[str, Dict[str, Any]] = {} # DATA objects (tables/entities) self._featureDefinitions: Dict[str, Dict[str, Any]] = {} self._templateRoles: Dict[str, List[Dict[str, Any]]] = {} self._initialized = True @@ -60,6 +61,29 @@ class RbacCatalogService: logger.error(f"Failed to register RESOURCE object {objectKey}: {e}") return False + def registerDataObject(self, featureCode: str, objectKey: str, label: Dict[str, str], meta: Optional[Dict[str, Any]] = None) -> bool: + """ + Register a DATA object (table/entity) for a feature. + + Args: + featureCode: Feature code (e.g., "trustee", "system") + objectKey: Dot-notation key (e.g., "data.feature.trustee.TrusteeContract") + label: Multilingual label dict + meta: Optional metadata (e.g., table name, fields list) + """ + try: + self._dataObjects[objectKey] = { + "objectKey": objectKey, + "featureCode": featureCode, + "label": label, + "meta": meta or {}, + "type": "DATA" + } + return True + except Exception as e: + logger.error(f"Failed to register DATA object {objectKey}: {e}") + return False + def registerFeatureDefinition(self, featureCode: str, label: Dict[str, str], icon: str) -> bool: """Register a feature definition.""" try: @@ -90,9 +114,23 @@ class RbacCatalogService: return [obj for obj in self._resourceObjects.values() if obj["featureCode"] == featureCode] return list(self._resourceObjects.values()) + def getDataObjects(self, featureCode: Optional[str] = None) -> List[Dict[str, Any]]: + """Get all DATA objects (tables/entities), optionally filtered by feature.""" + if featureCode: + return [obj for obj in self._dataObjects.values() if obj["featureCode"] == featureCode] + return list(self._dataObjects.values()) + def getAllObjects(self, featureCode: Optional[str] = None) -> List[Dict[str, Any]]: - """Get all RBAC objects (UI + RESOURCE), optionally filtered by feature.""" - return self.getUiObjects(featureCode) + self.getResourceObjects(featureCode) + """Get all RBAC objects (UI + RESOURCE + DATA), optionally filtered by feature.""" + return self.getUiObjects(featureCode) + self.getResourceObjects(featureCode) + self.getDataObjects(featureCode) + + def getAllCatalogObjects(self, featureCode: Optional[str] = None) -> Dict[str, List[Dict[str, Any]]]: + """Get all catalog objects grouped by type (DATA, UI, RESOURCE).""" + return { + "DATA": self.getDataObjects(featureCode), + "UI": self.getUiObjects(featureCode), + "RESOURCE": self.getResourceObjects(featureCode) + } def getFeatureDefinitions(self) -> List[Dict[str, Any]]: """Get all registered feature definitions.""" @@ -121,6 +159,8 @@ class RbacCatalogService: del self._uiObjects[key] for key in [k for k, v in self._resourceObjects.items() if v["featureCode"] == featureCode]: del self._resourceObjects[key] + for key in [k for k, v in self._dataObjects.items() if v["featureCode"] == featureCode]: + del self._dataObjects[key] self._featureDefinitions.pop(featureCode, None) self._templateRoles.pop(featureCode, None) logger.info(f"Unregistered feature: {featureCode}") @@ -135,6 +175,7 @@ class RbacCatalogService: "features": len(self._featureDefinitions), "uiObjects": len(self._uiObjects), "resourceObjects": len(self._resourceObjects), + "dataObjects": len(self._dataObjects), "templateRoles": sum(len(roles) for roles in self._templateRoles.values()) } diff --git a/modules/system/__init__.py b/modules/system/__init__.py new file mode 100644 index 00000000..7c14ddfa --- /dev/null +++ b/modules/system/__init__.py @@ -0,0 +1,14 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +""" +System Module - Contains system-level infrastructure: +- registry.py: Feature container discovery and loading +- mainSystem.py: System-level RBAC objects (UI, DATA, RESOURCE) +""" + +from modules.system.registry import ( + loadFeatureRouters, + loadFeatureMainModules, + discoverFeatureContainers, + registerAllFeaturesInCatalog, +) diff --git a/modules/system/mainSystem.py b/modules/system/mainSystem.py new file mode 100644 index 00000000..cbb33a52 --- /dev/null +++ b/modules/system/mainSystem.py @@ -0,0 +1,423 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +""" +System Module - Main Module. +Registers system-level RBAC objects (UI, DATA, RESOURCE) that are not part of any feature. +These are global system pages and resources available to all users with appropriate roles. + +Also defines the navigation structure for the frontend. +""" + +import logging +from typing import Dict, List, Any, Optional + +logger = logging.getLogger(__name__) + +# System metadata +FEATURE_CODE = "system" +FEATURE_LABEL = {"en": "System", "de": "System", "fr": "Système"} +FEATURE_ICON = "mdi-cog" + +# ============================================================================= +# Navigation Structure (Single Source of Truth) +# ============================================================================= +# +# Block Order (gemäss Navigation-API-Konzept): +# - System: 10 +# - : 15 (wird in routeSystem.py eingefügt) +# - Workflows: 20 +# - Basisdaten: 30 +# - Migrate: 40 +# - Administration: 200 +# +# Item Order: Default-Abstand 10 pro Item +# uiComponent: Abgeleitet von objectKey (ui.system.home -> page.system.home) +# icon: Wird intern gehalten aber NICHT in der API Response zurückgegeben + +NAVIGATION_SECTIONS = [ + { + "id": "system", + "title": {"en": "SYSTEM", "de": "SYSTEM", "fr": "SYSTÈME"}, + "order": 10, + "items": [ + { + "id": "home", + "objectKey": "ui.system.home", + "label": {"en": "Home", "de": "Übersicht", "fr": "Accueil"}, + "icon": "FaHome", + "path": "/", + "order": 10, + "public": True, + }, + { + "id": "settings", + "objectKey": "ui.system.settings", + "label": {"en": "Settings", "de": "Einstellungen", "fr": "Paramètres"}, + "icon": "FaCog", + "path": "/settings", + "order": 20, + "public": True, + }, + ], + }, + { + "id": "workflows", + "title": {"en": "WORKFLOWS", "de": "WORKFLOWS", "fr": "WORKFLOWS"}, + "order": 20, + "items": [ + { + "id": "playground", + "objectKey": "ui.system.playground", + "label": {"en": "Chat Playground", "de": "Chat Playground", "fr": "Chat Playground"}, + "icon": "FaPlay", + "path": "/workflows/playground", + "order": 10, + }, + { + "id": "chats", + "objectKey": "ui.system.chats", + "label": {"en": "Chats", "de": "Chats", "fr": "Chats"}, + "icon": "FaListAlt", + "path": "/workflows/list", + "order": 20, + }, + { + "id": "automations", + "objectKey": "ui.system.automations", + "label": {"en": "Automations", "de": "Automatisierungen", "fr": "Automatisations"}, + "icon": "FaCogs", + "path": "/workflows/automations", + "order": 30, + }, + ], + }, + { + "id": "basedata", + "title": {"en": "BASE DATA", "de": "BASISDATEN", "fr": "DONNÉES DE BASE"}, + "order": 30, + "items": [ + { + "id": "prompts", + "objectKey": "ui.system.prompts", + "label": {"en": "Prompts", "de": "Prompts", "fr": "Prompts"}, + "icon": "FaLightbulb", + "path": "/basedata/prompts", + "order": 10, + }, + { + "id": "files", + "objectKey": "ui.system.files", + "label": {"en": "Files", "de": "Dateien", "fr": "Fichiers"}, + "icon": "FaRegFileAlt", + "path": "/basedata/files", + "order": 20, + }, + { + "id": "connections", + "objectKey": "ui.system.connections", + "label": {"en": "Connections", "de": "Verbindungen", "fr": "Connexions"}, + "icon": "FaLink", + "path": "/basedata/connections", + "order": 30, + }, + ], + }, + { + "id": "migrate", + "title": {"en": "MIGRATE TO FEATURES", "de": "MIGRATE TO FEATURES", "fr": "MIGRER VERS FEATURES"}, + "order": 40, + "deprecated": True, + "items": [ + { + "id": "chatbot", + "objectKey": "ui.system.chatbot", + "label": {"en": "Chatbot", "de": "Chatbot", "fr": "Chatbot"}, + "icon": "FaComments", + "path": "/chatbot", + "order": 10, + "deprecated": True, + }, + { + "id": "pek", + "objectKey": "ui.system.pek", + "label": {"en": "PEK", "de": "PEK", "fr": "PEK"}, + "icon": "FaChartBar", + "path": "/pek", + "order": 20, + "deprecated": True, + }, + { + "id": "speech", + "objectKey": "ui.system.speech", + "label": {"en": "Speech", "de": "Sprache", "fr": "Parole"}, + "icon": "FaMicrophone", + "path": "/speech", + "order": 30, + "deprecated": True, + }, + ], + }, + { + "id": "admin", + "title": {"en": "ADMINISTRATION", "de": "ADMINISTRATION", "fr": "ADMINISTRATION"}, + "order": 200, + "adminOnly": True, + "items": [ + { + "id": "admin-users", + "objectKey": "ui.admin.users", + "label": {"en": "Users", "de": "Benutzer", "fr": "Utilisateurs"}, + "icon": "FaUsers", + "path": "/admin/users", + "order": 10, + "adminOnly": True, + }, + { + "id": "admin-invitations", + "objectKey": "ui.admin.invitations", + "label": {"en": "Invitations", "de": "Einladungen", "fr": "Invitations"}, + "icon": "FaEnvelopeOpenText", + "path": "/admin/invitations", + "order": 20, + "adminOnly": True, + }, + { + "id": "admin-mandates", + "objectKey": "ui.admin.mandates", + "label": {"en": "Mandates", "de": "Mandanten", "fr": "Mandats"}, + "icon": "FaBuilding", + "path": "/admin/mandates", + "order": 30, + "adminOnly": True, + }, + { + "id": "admin-roles", + "objectKey": "ui.admin.roles", + "label": {"en": "Roles", "de": "Rollen", "fr": "Rôles"}, + "icon": "FaKey", + "path": "/admin/mandate-roles", + "order": 40, + "adminOnly": True, + }, + { + "id": "admin-role-permissions", + "objectKey": "ui.admin.role-permissions", + "label": {"en": "Role Permissions", "de": "Rollen-Berechtigungen", "fr": "Permissions des rôles"}, + "icon": "FaShieldAlt", + "path": "/admin/mandate-role-permissions", + "order": 50, + "adminOnly": True, + }, + { + "id": "admin-user-mandates", + "objectKey": "ui.admin.user-mandates", + "label": {"en": "Mandate Members", "de": "Mandanten-Mitglieder", "fr": "Membres du mandat"}, + "icon": "FaUserTag", + "path": "/admin/user-mandates", + "order": 60, + "adminOnly": True, + }, + { + "id": "admin-feature-roles", + "objectKey": "ui.admin.feature-roles", + "label": {"en": "Feature Roles & Permissions", "de": "Feature Rollen & Rechte", "fr": "Rôles et permissions des features"}, + "icon": "FaCube", + "path": "/admin/feature-roles", + "order": 70, + "adminOnly": True, + }, + { + "id": "admin-feature-instances", + "objectKey": "ui.admin.feature-instances", + "label": {"en": "Feature Instances", "de": "Feature-Instanzen", "fr": "Instances de feature"}, + "icon": "FaCubes", + "path": "/admin/feature-instances", + "order": 80, + "adminOnly": True, + }, + { + "id": "admin-feature-users", + "objectKey": "ui.admin.feature-users", + "label": {"en": "Feature Instance Users", "de": "Feature Instanz Benutzer", "fr": "Utilisateurs d'instance de feature"}, + "icon": "FaUsersCog", + "path": "/admin/feature-users", + "order": 90, + "adminOnly": True, + }, + ], + }, +] + + +def _objectKeyToUiComponent(objectKey: str) -> str: + """ + Convert objectKey to uiComponent. + + Example: ui.system.home -> page.system.home + ui.admin.users -> page.admin.users + ui.feature.trustee.dashboard -> page.feature.trustee.dashboard + """ + if objectKey.startswith("ui."): + return "page." + objectKey[3:] + return objectKey + + +def _buildUiObjectsFromNavigation() -> List[Dict[str, Any]]: + """Build UI_OBJECTS list from NAVIGATION_SECTIONS for RBAC registration.""" + uiObjects = [] + for section in NAVIGATION_SECTIONS: + for item in section.get("items", []): + uiObjects.append({ + "objectKey": item["objectKey"], + "label": item["label"], + "meta": { + "area": section["id"], + "public": item.get("public", False), + "adminOnly": item.get("adminOnly", False), + "deprecated": item.get("deprecated", False), + "path": item["path"], + "icon": item["icon"], + } + }) + return uiObjects + + +# Generate UI_OBJECTS from navigation structure +UI_OBJECTS = _buildUiObjectsFromNavigation() + +# ============================================================================= +# System DATA Objects +# ============================================================================= + +DATA_OBJECTS = [ + { + "objectKey": "data.system.User", + "label": {"en": "User", "de": "Benutzer", "fr": "Utilisateur"}, + "meta": {"table": "UserInDB"} + }, + { + "objectKey": "data.system.Mandate", + "label": {"en": "Mandate", "de": "Mandant", "fr": "Mandat"}, + "meta": {"table": "Mandate"} + }, + { + "objectKey": "data.system.Role", + "label": {"en": "Role", "de": "Rolle", "fr": "Rôle"}, + "meta": {"table": "Role"} + }, + { + "objectKey": "data.system.AccessRule", + "label": {"en": "Access Rule", "de": "Zugriffsregel", "fr": "Règle d'accès"}, + "meta": {"table": "AccessRule"} + }, + { + "objectKey": "data.system.UserMandate", + "label": {"en": "User Mandate", "de": "Benutzer-Mandant", "fr": "Mandat utilisateur"}, + "meta": {"table": "UserMandate"} + }, + { + "objectKey": "data.system.Prompt", + "label": {"en": "Prompt", "de": "Prompt", "fr": "Prompt"}, + "meta": {"table": "Prompt"} + }, + { + "objectKey": "data.system.ChatWorkflow", + "label": {"en": "Chat Workflow", "de": "Chat-Workflow", "fr": "Workflow de chat"}, + "meta": {"table": "ChatWorkflow"} + }, + { + "objectKey": "data.system.FileItem", + "label": {"en": "File", "de": "Datei", "fr": "Fichier"}, + "meta": {"table": "FileItem"} + }, + { + "objectKey": "data.system.UserConnection", + "label": {"en": "Connection", "de": "Verbindung", "fr": "Connexion"}, + "meta": {"table": "UserConnection"} + }, + { + "objectKey": "data.system.FeatureInstance", + "label": {"en": "Feature Instance", "de": "Feature-Instanz", "fr": "Instance de feature"}, + "meta": {"table": "FeatureInstance"} + }, +] + +# ============================================================================= +# System RESOURCE Objects +# ============================================================================= + +RESOURCE_OBJECTS = [ + { + "objectKey": "resource.system.api.auth", + "label": {"en": "Authentication API", "de": "Authentifizierungs-API", "fr": "API d'authentification"}, + "meta": {"endpoint": "/api/auth/*"} + }, + { + "objectKey": "resource.system.api.users", + "label": {"en": "Users API", "de": "Benutzer-API", "fr": "API des utilisateurs"}, + "meta": {"endpoint": "/api/users/*"} + }, + { + "objectKey": "resource.system.api.mandates", + "label": {"en": "Mandates API", "de": "Mandanten-API", "fr": "API des mandats"}, + "meta": {"endpoint": "/api/mandates/*"} + }, + { + "objectKey": "resource.system.api.rbac", + "label": {"en": "RBAC API", "de": "RBAC-API", "fr": "API RBAC"}, + "meta": {"endpoint": "/api/rbac/*"} + }, +] + + +def registerFeature(catalogService) -> bool: + """ + Register system RBAC objects in the catalog. + + Args: + catalogService: The RBAC catalog service instance + + Returns: + True if registration was successful + """ + try: + # Register UI objects + for uiObj in UI_OBJECTS: + catalogService.registerUiObject( + featureCode=FEATURE_CODE, + objectKey=uiObj["objectKey"], + label=uiObj["label"], + meta=uiObj.get("meta") + ) + + # Register DATA objects + for dataObj in DATA_OBJECTS: + catalogService.registerDataObject( + featureCode=FEATURE_CODE, + objectKey=dataObj["objectKey"], + label=dataObj["label"], + meta=dataObj.get("meta") + ) + + # Register RESOURCE objects + for resObj in RESOURCE_OBJECTS: + catalogService.registerResourceObject( + featureCode=FEATURE_CODE, + objectKey=resObj["objectKey"], + label=resObj["label"], + meta=resObj.get("meta") + ) + + # Register feature definition + catalogService.registerFeatureDefinition( + featureCode=FEATURE_CODE, + label=FEATURE_LABEL, + icon=FEATURE_ICON + ) + + logger.info(f"Registered system RBAC objects: {len(UI_OBJECTS)} UI, {len(DATA_OBJECTS)} DATA, {len(RESOURCE_OBJECTS)} RESOURCE") + return True + + except Exception as e: + logger.error(f"Failed to register system RBAC objects: {e}") + return False diff --git a/modules/features/featureRegistry.py b/modules/system/registry.py similarity index 82% rename from modules/features/featureRegistry.py rename to modules/system/registry.py index 4bf6d82e..5431b706 100644 --- a/modules/features/featureRegistry.py +++ b/modules/system/registry.py @@ -3,6 +3,8 @@ """ Feature Registry for Plug&Play Feature Container Loading. Dynamically discovers and loads feature containers from the features directory. + +Note: This module is in modules/system/ but manages modules/features/. """ import os @@ -14,8 +16,8 @@ from fastapi import FastAPI logger = logging.getLogger(__name__) -# Path to the features directory -FEATURES_DIR = os.path.dirname(os.path.abspath(__file__)) +# Path to the features directory (relative to this file's location) +FEATURES_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "features") def discoverFeatureContainers() -> List[str]: @@ -109,10 +111,26 @@ def loadFeatureMainModules() -> Dict[str, Any]: def registerAllFeaturesInCatalog(catalogService) -> Dict[str, bool]: """ Register all features' RBAC objects in the catalog. + Also registers system-level RBAC objects. """ - mainModules = loadFeatureMainModules() results = {} + # Register system-level RBAC objects first + try: + from modules.system.mainSystem import registerFeature as registerSystemFeature + success = registerSystemFeature(catalogService) + results["system"] = success + if success: + logger.info("Registered RBAC objects: system") + except ImportError as e: + logger.warning(f"System module not found, skipping system RBAC registration: {e}") + except Exception as e: + logger.error(f"Error registering system RBAC objects: {e}") + results["system"] = False + + # Register feature modules + mainModules = loadFeatureMainModules() + for featureName, module in mainModules.items(): if hasattr(module, "registerFeature"): try: diff --git a/modules/system/routeSystem.py b/modules/system/routeSystem.py new file mode 100644 index 00000000..4e2f9f8f --- /dev/null +++ b/modules/system/routeSystem.py @@ -0,0 +1,515 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +""" +System Routes - Navigation and system-level API endpoints. + +Navigation API Konzept: +- Single Source of Truth für Navigation im Gateway +- UI rendert nur was es erhält (keine Permission-Logik im UI) +- Keine Icons in API Response - UI mappt selbst via uiComponent +- Blocks statt Sections mit order auf allen Ebenen +""" + +import logging +from typing import Dict, List, Any, Optional +from fastapi import APIRouter, Depends, Request, Query +from slowapi import Limiter +from slowapi.util import get_remote_address + +from modules.auth.authentication import getRequestContext, RequestContext +from modules.system.mainSystem import NAVIGATION_SECTIONS, _objectKeyToUiComponent +from modules.interfaces.interfaceDbApp import getRootInterface +from modules.interfaces.interfaceFeatures import getFeatureInterface +from modules.datamodels.datamodelRbac import AccessRule, AccessRuleContext +from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole, FeatureAccess, FeatureAccessRole + +logger = logging.getLogger(__name__) +limiter = Limiter(key_func=get_remote_address) + +# Main system router (for other system endpoints if needed) +router = APIRouter(prefix="/api/system", tags=["System"]) + +# Navigation router at /api/navigation (gemäss Navigation-API-Konzept) +navigationRouter = APIRouter(prefix="/api", tags=["Navigation"]) + + +def _getUserRoleIds(userId: str) -> List[str]: + """Get all role IDs for a user across all their mandates.""" + rootInterface = getRootInterface() + roleIds = [] + + userMandates = rootInterface.db.getRecordset( + UserMandate, + recordFilter={"userId": userId, "enabled": True} + ) + + for um in userMandates: + mandateRoleIds = rootInterface.getRoleIdsForUserMandate(um.get("id")) + for rid in mandateRoleIds: + if rid not in roleIds: + roleIds.append(rid) + + return roleIds + + +def _checkUiPermission(roleIds: List[str], objectKey: str) -> bool: + """Check if any of the given roles has view permission for the UI object.""" + if not roleIds: + return False + + rootInterface = getRootInterface() + + for roleId in roleIds: + # Get UI rules for this role + rules = rootInterface.db.getRecordset( + AccessRule, + recordFilter={"roleId": roleId, "context": "UI"} + ) + + for rule in rules: + ruleItem = rule.get("item") + ruleView = rule.get("view", False) + + if not ruleView: + continue + + # Global rule (item=None) grants access to all UI + if ruleItem is None: + return True + + # Exact match + if ruleItem == objectKey: + return True + + # Wildcard match (e.g., ui.system.* matches ui.system.playground) + if ruleItem.endswith(".*"): + prefix = ruleItem[:-2] + if objectKey.startswith(prefix): + return True + + return False + + +# ============================================================================= +# Navigation API (gemäss Navigation-API-Konzept) +# Endpoint: GET /api/navigation +# ============================================================================= + +def _getFeatureUiObjects(featureCode: str) -> List[Dict[str, Any]]: + """ + Get UI objects for a feature from its main module. + Returns list of UI objects with objectKey, label, meta (including path). + """ + try: + # Dynamic import based on feature code + if featureCode == "trustee": + from modules.features.trustee.mainTrustee import UI_OBJECTS + return UI_OBJECTS + elif featureCode == "realestate": + from modules.features.realestate.mainRealEstate import UI_OBJECTS + return UI_OBJECTS + else: + logger.warning(f"Unknown feature code: {featureCode}") + return [] + except ImportError as e: + logger.error(f"Failed to import UI_OBJECTS for feature {featureCode}: {e}") + return [] + + +def _buildDynamicBlock( + userId: str, + language: str, + isSysAdmin: bool +) -> Optional[Dict[str, Any]]: + """ + Build the dynamic features block with mandates, features, and instances. + + Returns None if user has no feature instances. + """ + try: + rootInterface = getRootInterface() + featureInterface = getFeatureInterface(rootInterface.db) + + # Get all feature accesses for this user + featureAccesses = rootInterface.getFeatureAccessesForUser(userId) + + if not featureAccesses: + return None + + # Build hierarchical structure: mandate -> feature -> instances + mandatesMap: Dict[str, Dict[str, Any]] = {} + featuresMap: Dict[str, Dict[str, Any]] = {} # key: mandateId_featureCode + + mandateOrder = 10 + for access in featureAccesses: + if not access.enabled: + continue + + instance = featureInterface.getFeatureInstance(str(access.featureInstanceId)) + if not instance or not instance.enabled: + continue + + # Get mandate info + mandateId = str(instance.mandateId) + if mandateId not in mandatesMap: + mandate = rootInterface.getMandate(mandateId) + mandateName = mandate.name if mandate and hasattr(mandate, 'name') else mandateId + mandatesMap[mandateId] = { + "id": mandateId, + "uiLabel": mandateName, + "order": mandateOrder, + "features": [] + } + mandateOrder += 10 + + # Get feature info + featureKey = f"{mandateId}_{instance.featureCode}" + if featureKey not in featuresMap: + feature = featureInterface.getFeature(instance.featureCode) + + # Handle featureLabel - could be a dict or a Pydantic model (TextMultilingual) + if feature and hasattr(feature, 'label'): + featureLabel = feature.label + # Convert Pydantic model to dict if needed + if hasattr(featureLabel, 'model_dump'): + featureLabel = featureLabel.model_dump() + elif hasattr(featureLabel, 'dict'): + featureLabel = featureLabel.dict() + elif not isinstance(featureLabel, dict): + # Fallback: try to access as attributes + featureLabel = {"de": getattr(featureLabel, 'de', instance.featureCode), "en": getattr(featureLabel, 'en', instance.featureCode)} + else: + featureLabel = {"de": instance.featureCode, "en": instance.featureCode} + + featuresMap[featureKey] = { + "uiComponent": f"feature.{instance.featureCode}", + "uiLabel": featureLabel.get(language, featureLabel.get("en", instance.featureCode)), + "order": 10, + "instances": [], + "_mandateId": mandateId, + "_featureCode": instance.featureCode + } + + # Get user's permissions for this instance to filter views + permissions = _getInstanceViewPermissions(rootInterface, userId, str(instance.id), isSysAdmin) + + # Get feature UI objects to build views + featureUiObjects = _getFeatureUiObjects(instance.featureCode) + + # Build views for this instance + views = [] + viewOrder = 10 + for uiObj in featureUiObjects: + objectKey = uiObj.get("objectKey", "") + # Extract view name from objectKey for path building + viewName = objectKey.split(".")[-1] if objectKey else "" + + # Check permission using full objectKey (as per Navigation-API-Konzept) + if not isSysAdmin and not permissions.get("_all") and not permissions.get(objectKey, False): + continue + + # Skip admin-only views for non-admins + meta = uiObj.get("meta", {}) + if meta.get("admin_only") and not isSysAdmin and not permissions.get("isAdmin", False): + continue + + # Build path for this view + viewPath = f"/mandates/{mandateId}/{instance.featureCode}/{instance.id}/{viewName}" + + # Get label in requested language + label = uiObj.get("label", {}) + uiLabel = label.get(language, label.get("en", viewName)) + + views.append({ + "uiComponent": f"page.feature.{instance.featureCode}.{viewName}", + "uiLabel": uiLabel, + "uiPath": viewPath, + "order": viewOrder, + "objectKey": objectKey + }) + viewOrder += 10 + + # Sort views by order + views.sort(key=lambda v: v["order"]) + + # Add instance to feature + featuresMap[featureKey]["instances"].append({ + "id": str(instance.id), + "uiLabel": instance.label, + "order": 10, + "views": views + }) + + # Build final structure + for featureKey, featureData in featuresMap.items(): + mandateId = featureData.pop("_mandateId") + featureData.pop("_featureCode") + mandatesMap[mandateId]["features"].append(featureData) + + # Sort features within each mandate + for mandate in mandatesMap.values(): + mandate["features"].sort(key=lambda f: f["order"]) + + # Convert to list and sort by order + mandatesList = list(mandatesMap.values()) + mandatesList.sort(key=lambda m: m["order"]) + + if not mandatesList: + return None + + return { + "type": "dynamic", + "id": "features", + "title": "MEINE FEATURES", + "order": 15, # Between system (10) and workflows (20) + "mandates": mandatesList + } + + except Exception as e: + logger.error(f"Error building dynamic block: {e}") + return None + + +def _getInstanceViewPermissions( + rootInterface, + userId: str, + instanceId: str, + isSysAdmin: bool +) -> Dict[str, Any]: + """ + Get view permissions for a user in a feature instance. + Returns dict with view names as keys and True/False as values. + Also includes "_all" if user has global view access. + """ + if isSysAdmin: + return {"_all": True, "isAdmin": True} + + permissions = {"_all": False, "isAdmin": False} + + try: + from modules.datamodels.datamodelRbac import AccessRule, AccessRuleContext, Role + + # Get FeatureAccess for this user and instance + featureAccesses = rootInterface.db.getRecordset( + FeatureAccess, + recordFilter={"userId": userId, "featureInstanceId": instanceId} + ) + + if not featureAccesses: + return permissions + + # Get role IDs via FeatureAccessRole junction table + featureAccessId = featureAccesses[0].get("id") + featureAccessRoles = rootInterface.db.getRecordset( + FeatureAccessRole, + recordFilter={"featureAccessId": featureAccessId} + ) + roleIds = [far.get("roleId") for far in featureAccessRoles] + + if not roleIds: + return permissions + + # Check if user has admin role + for roleId in roleIds: + roles = rootInterface.db.getRecordset(Role, recordFilter={"id": roleId}) + if roles: + roleLabel = roles[0].get("roleLabel", "").lower() + if "admin" in roleLabel: + permissions["isAdmin"] = True + break + + # Get UI permissions from AccessRules + # Permissions are stored with full objectKey (e.g., ui.feature.trustee.dashboard) + for roleId in roleIds: + accessRules = rootInterface.db.getRecordset( + AccessRule, + recordFilter={"roleId": roleId, "context": "UI"} + ) + + logger.debug(f"_getInstanceViewPermissions: roleId={roleId}, UI rules count={len(accessRules)}") + + for rule in accessRules: + if not rule.get("view", False): + continue + + item = rule.get("item") + logger.debug(f"_getInstanceViewPermissions: rule item={item}, view={rule.get('view')}") + + if item is None: + # item=None means all views + permissions["_all"] = True + else: + # Store full objectKey as per Navigation-API-Konzept + permissions[item] = True + + logger.debug(f"_getInstanceViewPermissions: final permissions={permissions}") + return permissions + + except Exception as e: + logger.debug(f"Error getting instance view permissions: {e}") + return permissions + + +def _buildStaticBlocks( + language: str, + isSysAdmin: bool, + roleIds: List[str], + hasGlobalPermission: bool +) -> List[Dict[str, Any]]: + """ + Build static navigation blocks from NAVIGATION_SECTIONS. + + Returns list of blocks with items filtered by permissions. + """ + blocks = [] + + for section in NAVIGATION_SECTIONS: + # Skip admin-only sections for non-admins + if section.get("adminOnly") and not isSysAdmin: + continue + + # Filter items based on permissions + filteredItems = [] + for item in section.get("items", []): + # Skip admin-only items for non-admins + if item.get("adminOnly") and not isSysAdmin: + continue + + # Public items are always visible + if item.get("public"): + filteredItems.append(_formatBlockItem(item, language)) + continue + + # SysAdmin sees everything + if isSysAdmin: + filteredItems.append(_formatBlockItem(item, language)) + continue + + # Check permission for this item + if hasGlobalPermission or _checkUiPermission(roleIds, item["objectKey"]): + filteredItems.append(_formatBlockItem(item, language)) + + # Only include section if it has visible items + if filteredItems: + # Sort items by order + filteredItems.sort(key=lambda i: i["order"]) + + blocks.append({ + "type": "static", + "id": section["id"], + "title": section["title"].get(language, section["title"].get("en", section["id"])), + "order": section.get("order", 50), + "items": filteredItems, + }) + + return blocks + + +def _formatBlockItem(item: Dict[str, Any], language: str) -> Dict[str, Any]: + """ + Format a navigation item for the new API response. + + Uses new field names: uiComponent, uiLabel, uiPath + Does NOT include icon (UI maps via uiComponent) + """ + objectKey = item["objectKey"] + uiComponent = _objectKeyToUiComponent(objectKey) + + return { + "uiComponent": uiComponent, + "uiLabel": item["label"].get(language, item["label"].get("en", item["id"])), + "uiPath": item["path"], + "order": item.get("order", 50), + "objectKey": objectKey, + } + + +@navigationRouter.get("/navigation") +@limiter.limit("60/minute") +async def get_navigation( + request: Request, + language: str = Query("de", description="Language for labels (en, de, fr)"), + reqContext: RequestContext = Depends(getRequestContext) +) -> Dict[str, Any]: + """ + Get unified navigation structure with blocks. + + Single Source of Truth für Navigation - UI rendert nur was es erhält. + + Endpoint: GET /api/navigation + + Block order: + - System (10) + - Dynamic/Features (15) - only if user has feature instances + - Workflows (20) + - Basisdaten (30) + - Migrate (40) + - Administration (200) + + Response format: + { + "language": "de", + "blocks": [ + { + "type": "static", + "id": "system", + "title": "SYSTEM", + "order": 10, + "items": [ + { + "uiComponent": "page.system.home", + "uiLabel": "Übersicht", + "uiPath": "/", + "order": 10, + "objectKey": "ui.system.home" + } + ] + }, + { + "type": "dynamic", + "id": "features", + "title": "MEINE FEATURES", + "order": 15, + "mandates": [...] + } + ] + } + """ + try: + isSysAdmin = reqContext.isSysAdmin + userId = str(reqContext.user.id) if reqContext.user else None + + # Get user's role IDs for permission checking + roleIds = [] + if userId and not isSysAdmin: + roleIds = _getUserRoleIds(userId) + + # Check if user has global UI permission + hasGlobalPermission = isSysAdmin + if not hasGlobalPermission and roleIds: + hasGlobalPermission = _checkUiPermission(roleIds, "_global_check") + + # Build static blocks from NAVIGATION_SECTIONS + blocks = _buildStaticBlocks(language, isSysAdmin, roleIds, hasGlobalPermission) + + # Build dynamic block (features) if user has feature instances + if userId: + dynamicBlock = _buildDynamicBlock(userId, language, isSysAdmin) + if dynamicBlock: + blocks.append(dynamicBlock) + + # Sort all blocks by order + blocks.sort(key=lambda b: b["order"]) + + return { + "language": language, + "blocks": blocks, + } + + except Exception as e: + logger.error(f"Error getting navigation: {e}") + return { + "language": language, + "blocks": [], + "error": str(e), + } diff --git a/scripts/script_db_migrate_accessrules_objectkeys.py b/scripts/script_db_migrate_accessrules_objectkeys.py new file mode 100644 index 00000000..840367e5 --- /dev/null +++ b/scripts/script_db_migrate_accessrules_objectkeys.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Migration Script: Migrate AccessRules to Vollqualifizierte ObjectKeys + +This script migrates existing AccessRules in the database from short item names +(e.g., "dashboard", "positions") to fully qualified ObjectKeys +(e.g., "ui.feature.trustee.dashboard", "ui.feature.trustee.positions"). + +This is required for the Navigation-API-Konzept implementation. + +Usage: + python script_db_migrate_accessrules_objectkeys.py [--dry-run] + +Options: + --dry-run Show what would be changed without making actual changes +""" + +import sys +import os +import logging +from typing import Dict, List, Any, Optional + +# Add parent directory to path for imports +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + + +# Mapping of short item names to fully qualified ObjectKeys per feature +MIGRATION_MAP: Dict[str, Dict[str, str]] = { + "trustee": { + # UI items + "dashboard": "ui.feature.trustee.dashboard", + "positions": "ui.feature.trustee.positions", + "documents": "ui.feature.trustee.documents", + "position-documents": "ui.feature.trustee.position-documents", + "instance-roles": "ui.feature.trustee.instance-roles", + # RESOURCE items + "instance-roles.manage": "resource.feature.trustee.instance-roles.manage", + }, + "realestate": { + # UI items + "projects": "ui.feature.realestate.projects", + "parcels": "ui.feature.realestate.parcels", + # RESOURCE items + "project.create": "resource.feature.realestate.project.create", + "project.delete": "resource.feature.realestate.project.delete", + }, +} + + +def migrateAccessRules(dryRun: bool = False) -> Dict[str, int]: + """ + Migrate AccessRules from short item names to fully qualified ObjectKeys. + + Args: + dryRun: If True, don't make actual changes, just show what would be done + + Returns: + Dictionary with migration statistics + """ + from modules.interfaces.interfaceDbApp import getRootInterface + from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext + + stats = { + "total_rules": 0, + "migrated": 0, + "already_qualified": 0, + "skipped_null": 0, + "errors": 0, + } + + try: + rootInterface = getRootInterface() + db = rootInterface.db + + # Get all AccessRules + allRules = db.getRecordset(AccessRule, recordFilter=None) + stats["total_rules"] = len(allRules) + + logger.info(f"Found {len(allRules)} AccessRules to check") + + for rule in allRules: + ruleId = rule.get("id") + context = rule.get("context") + item = rule.get("item") + roleId = rule.get("roleId") + + # Skip rules without item (wildcard rules) + if item is None: + stats["skipped_null"] += 1 + continue + + # Skip if already fully qualified + if item.startswith("ui.") or item.startswith("resource.") or item.startswith("data."): + stats["already_qualified"] += 1 + continue + + # Get the role to determine feature code + roles = db.getRecordset(Role, recordFilter={"id": roleId}) + if not roles or len(roles) == 0: + logger.warning(f"Rule {ruleId}: Role {roleId} not found, skipping") + stats["errors"] += 1 + continue + + role = roles[0] + featureCode = role.get("featureCode") + if not featureCode or featureCode not in MIGRATION_MAP: + logger.debug(f"Rule {ruleId}: Feature '{featureCode}' not in migration map, skipping") + continue + + # Lookup the new ObjectKey + featureMap = MIGRATION_MAP[featureCode] + if item not in featureMap: + logger.warning(f"Rule {ruleId}: Item '{item}' not in migration map for feature '{featureCode}'") + stats["errors"] += 1 + continue + + newItem = featureMap[item] + + if dryRun: + logger.info(f"[DRY-RUN] Would migrate rule {ruleId}: '{item}' -> '{newItem}' (feature: {featureCode})") + else: + # Update the rule using recordModify + try: + db.recordModify(AccessRule, ruleId, {"item": newItem}) + logger.info(f"Migrated rule {ruleId}: '{item}' -> '{newItem}' (feature: {featureCode})") + except Exception as e: + logger.error(f"Failed to migrate rule {ruleId}: {e}") + stats["errors"] += 1 + continue + + stats["migrated"] += 1 + + return stats + + except Exception as e: + logger.error(f"Migration failed: {e}", exc_info=True) + raise + + +def main(): + """Main entry point.""" + dryRun = "--dry-run" in sys.argv + forceRun = "--force" in sys.argv + + if dryRun: + logger.info("=" * 60) + logger.info("DRY RUN MODE - No changes will be made") + logger.info("=" * 60) + else: + logger.info("=" * 60) + logger.info("LIVE MODE - Changes will be applied to the database") + logger.info("=" * 60) + + if not forceRun: + # Confirm before proceeding + confirm = input("Are you sure you want to proceed? (yes/no): ") + if confirm.lower() != "yes": + logger.info("Migration cancelled") + return + + try: + stats = migrateAccessRules(dryRun=dryRun) + + logger.info("=" * 60) + logger.info("Migration completed!") + logger.info(f" Total rules checked: {stats['total_rules']}") + logger.info(f" Rules migrated: {stats['migrated']}") + logger.info(f" Already qualified: {stats['already_qualified']}") + logger.info(f" Skipped (null item): {stats['skipped_null']}") + logger.info(f" Errors: {stats['errors']}") + logger.info("=" * 60) + + except Exception as e: + logger.error(f"Migration failed: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/scripts/script_export_accessrules.py b/scripts/script_export_accessrules.py new file mode 100644 index 00000000..6d5aeec7 --- /dev/null +++ b/scripts/script_export_accessrules.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Export Script: Generate Access Rules per Role Report + +Usage: + python script_export_accessrules.py > output.md + python script_export_accessrules.py --file ../docs/reports/access-rules.md +""" + +import sys +import os +from datetime import datetime + +# Add parent directory to path for imports +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from modules.interfaces.interfaceDbApp import getRootInterface +from modules.datamodels.datamodelRbac import Role, AccessRule + +def main(): + rootInterface = getRootInterface() + db = rootInterface.db + + # Get all roles and rules + roles = db.getRecordset(Role, recordFilter=None) + rules = db.getRecordset(AccessRule, recordFilter=None) + + # Group rules by role + rulesByRole = {} + for rule in rules: + roleId = rule.get('roleId') + if roleId not in rulesByRole: + rulesByRole[roleId] = [] + rulesByRole[roleId].append(rule) + + # Build markdown + lines = [] + lines.append('# Access Rules per Role') + lines.append('') + lines.append(f'Generated: {datetime.now().isoformat()}') + lines.append('') + lines.append(f'Total Roles: {len(roles)}') + lines.append(f'Total Rules: {len(rules)}') + lines.append('') + lines.append('---') + lines.append('') + + for role in sorted(roles, key=lambda r: (r.get('featureCode') or '', r.get('code') or '')): + roleId = role.get('id') + roleName = role.get('name') or role.get('code') + featureCode = role.get('featureCode') or 'system' + roleRules = rulesByRole.get(roleId, []) + + lines.append(f'## {featureCode} / {roleName}') + lines.append('') + lines.append(f'- **Role ID:** `{roleId}`') + lines.append(f'- **Code:** `{role.get("code")}`') + lines.append(f'- **Feature:** `{featureCode}`') + lines.append(f'- **Rules Count:** {len(roleRules)}') + lines.append('') + + if roleRules: + lines.append('| Context | Item | Access |') + lines.append('|---------|------|--------|') + for rule in sorted(roleRules, key=lambda r: (r.get('context') or '', r.get('item') or '')): + ctx = rule.get('context') or '*' + item = rule.get('item') or '*' + access = rule.get('access') or 'allow' + lines.append(f'| {ctx} | `{item}` | {access} |') + lines.append('') + else: + lines.append('*No rules defined*') + lines.append('') + + lines.append('---') + lines.append('') + + md = '\n'.join(lines) + + # Check for --file argument + if '--file' in sys.argv: + idx = sys.argv.index('--file') + if idx + 1 < len(sys.argv): + filePath = sys.argv[idx + 1] + with open(filePath, 'w', encoding='utf-8') as f: + f.write(md) + print(f'Written to {filePath}') + return + + # Output to stdout + sys.stdout.reconfigure(encoding='utf-8') + print(md) + +if __name__ == "__main__": + main()