From 5c0ab3f8935dfdfacb8735fdcaddf5a428c19680 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Sat, 17 Jan 2026 02:17:58 +0100 Subject: [PATCH] implemented multimandate --- app.py | 24 +- env_dev.env | 32 +- env_int.env | 32 +- env_prod.env | 32 +- modules/aicore/aicorePluginTavily.py | 3 +- modules/auth/__init__.py | 24 +- modules/auth/authentication.py | 206 ++- modules/connectors/connectorDbPostgre.py | 6 +- modules/datamodels/datamodelFeatures.py | 83 ++ modules/datamodels/datamodelInvitation.py | 120 ++ modules/datamodels/datamodelMembership.py | 150 ++ modules/datamodels/datamodelRbac.py | 174 ++- modules/datamodels/datamodelSecurity.py | 72 +- modules/datamodels/datamodelUam.py | 184 ++- modules/features/chatbot/mainChatbot.py | 15 +- .../dynamicOptions/mainDynamicOptions.py | 2 +- .../mainNeutralizePlayground.py | 7 +- modules/features/realEstate/mainRealEstate.py | 53 +- modules/features/workflow/mainWorkflow.py | 24 +- modules/interfaces/interfaceBootstrap.py | 1283 ++++++----------- modules/interfaces/interfaceDbAppObjects.py | 475 +++++- modules/interfaces/interfaceDbChatObjects.py | 89 +- .../interfaces/interfaceDbComponentObjects.py | 45 +- .../interfaceDbRealEstateObjects.py | 82 +- .../interfaces/interfaceDbTrusteeObjects.py | 235 +-- modules/interfaces/interfaceFeatures.py | 478 ++++++ modules/interfaces/interfaceRbac.py | 46 +- modules/interfaces/interfaceVoiceObjects.py | 32 +- modules/routes/routeAdminRbacRoles.py | 465 +++--- modules/routes/routeDataAutomation.py | 6 +- modules/routes/routeDataMandates.py | 548 ++++++- modules/routes/routeDataUsers.py | 280 +++- ...ionEvents.py => routeFeatureAutomation.py} | 32 +- ...ayground.py => routeFeatureChatDynamic.py} | 19 +- ...routeChatbot.py => routeFeatureChatbot.py} | 28 +- ...ation.py => routeFeatureNeutralization.py} | 43 +- ...ealEstate.py => routeFeatureRealEstate.py} | 141 +- ...eDataTrustee.py => routeFeatureTrustee.py} | 237 ++- modules/routes/routeFeatures.py | 625 ++++++++ modules/routes/routeGdpr.py | 514 +++++++ modules/routes/routeInvitations.py | 812 +++++++++++ modules/routes/routeMessaging.py | 53 +- modules/routes/routeRbac.py | 275 +--- modules/routes/routeRbacExport.py | 608 ++++++++ modules/routes/routeSecurityAdmin.py | 376 ++--- modules/routes/routeSecurityGoogle.py | 11 +- modules/routes/routeSecurityLocal.py | 21 +- modules/routes/routeSecurityMsft.py | 11 +- modules/security/rbac.py | 387 ++++- modules/security/rootAccess.py | 49 +- modules/services/__init__.py | 27 +- modules/shared/dbMultiTenantOptimizations.py | 438 ++++++ modules/workflows/workflowManager.py | 2 +- 53 files changed, 7558 insertions(+), 2458 deletions(-) create mode 100644 modules/datamodels/datamodelFeatures.py create mode 100644 modules/datamodels/datamodelInvitation.py create mode 100644 modules/datamodels/datamodelMembership.py create mode 100644 modules/interfaces/interfaceFeatures.py rename modules/routes/{routeAdminAutomationEvents.py => routeFeatureAutomation.py} (85%) rename modules/routes/{routeChatPlayground.py => routeFeatureChatDynamic.py} (86%) rename modules/routes/{routeChatbot.py => routeFeatureChatbot.py} (96%) rename modules/routes/{routeDataNeutralization.py => routeFeatureNeutralization.py} (85%) rename modules/routes/{routeRealEstate.py => routeFeatureRealEstate.py} (91%) rename modules/routes/{routeDataTrustee.py => routeFeatureTrustee.py} (79%) create mode 100644 modules/routes/routeFeatures.py create mode 100644 modules/routes/routeGdpr.py create mode 100644 modules/routes/routeInvitations.py create mode 100644 modules/routes/routeRbacExport.py create mode 100644 modules/shared/dbMultiTenantOptimizations.py diff --git a/app.py b/app.py index 8ddd806e..7ed57ed9 100644 --- a/app.py +++ b/app.py @@ -405,7 +405,7 @@ app.include_router(userRouter) from modules.routes.routeDataFiles import router as fileRouter app.include_router(fileRouter) -from modules.routes.routeDataNeutralization import router as neutralizationRouter +from modules.routes.routeFeatureNeutralization import router as neutralizationRouter app.include_router(neutralizationRouter) from modules.routes.routeDataPrompts import router as promptRouter @@ -417,10 +417,10 @@ app.include_router(connectionsRouter) from modules.routes.routeWorkflows import router as workflowRouter app.include_router(workflowRouter) -from modules.routes.routeChatPlayground import router as chatPlaygroundRouter +from modules.routes.routeFeatureChatDynamic import router as chatPlaygroundRouter app.include_router(chatPlaygroundRouter) -from modules.routes.routeRealEstate import router as realEstateRouter +from modules.routes.routeFeatureRealEstate import router as realEstateRouter app.include_router(realEstateRouter) from modules.routes.routeSecurityLocal import router as localRouter @@ -444,7 +444,7 @@ app.include_router(sharepointRouter) from modules.routes.routeDataAutomation import router as automationRouter app.include_router(automationRouter) -from modules.routes.routeAdminAutomationEvents import router as adminAutomationEventsRouter +from modules.routes.routeFeatureAutomation import router as adminAutomationEventsRouter app.include_router(adminAutomationEventsRouter) from modules.routes.routeRbac import router as rbacRouter @@ -456,9 +456,21 @@ app.include_router(optionsRouter) from modules.routes.routeMessaging import router as messagingRouter app.include_router(messagingRouter) -from modules.routes.routeChatbot import router as chatbotRouter +from modules.routes.routeFeatureChatbot import router as chatbotRouter app.include_router(chatbotRouter) -from modules.routes.routeDataTrustee import router as trusteeRouter +from modules.routes.routeFeatureTrustee import router as trusteeRouter app.include_router(trusteeRouter) +# Phase 8: New Feature Routes +from modules.routes.routeFeatures import router as featuresRouter +app.include_router(featuresRouter) + +from modules.routes.routeInvitations import router as invitationsRouter +app.include_router(invitationsRouter) + +from modules.routes.routeRbacExport import router as rbacExportRouter +app.include_router(rbacExportRouter) + +from modules.routes.routeGdpr import router as gdprRouter +app.include_router(gdprRouter) diff --git a/env_dev.env b/env_dev.env index 19d718d1..93523018 100644 --- a/env_dev.env +++ b/env_dev.env @@ -8,33 +8,11 @@ APP_KEY_SYSVAR = D:/Athi/Local/Web/poweron/local/key.txt APP_INIT_PASS_ADMIN_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEeFFtRGtQeVUtcjlrU3dab1ZxUm9WSks0MlJVYUtERFlqUElHemZrOGNENk1tcmJNX3Vxc01UMDhlNU40VzZZRVBpUGNmT3podzZrOGhOeEJIUEt4eVlSWG5UYXA3d09DVXlLT21Kb1JYSUU9 APP_INIT_PASS_EVENT_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERzZjNm56WGVBdjJTeG5Udjd6OGQwUVotYXUzQjJ1YVNyVXVBa3NZVml3ODU0MVNkZjhWWmJwNUFkc19BcHlHMTU1Q3BRcHU0cDBoZkFlR2l6UEZQU3d2U3MtMDh5UDZteGFoQ0EyMUE1ckE9 -# PostgreSQL Storage (new) -DB_APP_HOST=localhost -DB_APP_DATABASE=poweron_app -DB_APP_USER=poweron_dev -DB_APP_PASSWORD_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEcUIxNEFfQ2xnS0RrSC1KNnUxTlVvTGZoMHgzaEI4Z3NlVzVROTVLak5Ubi1vaEZubFZaMTFKMGd6MXAxekN2d2NvMy1hRjg2UVhybktlcFA5anZ1WjFlQmZhcXdwaGhWdzRDc3ExeUhzWTg9 -DB_APP_PORT=5432 - -# PostgreSQL Storage (new) -DB_CHAT_HOST=localhost -DB_CHAT_DATABASE=poweron_chat -DB_CHAT_USER=poweron_dev -DB_CHAT_PASSWORD_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERFNzNVhoalpCR0QxYXAwdEpXWXVVOTdZdWtqWW5FNXFGcFl2amNYLWYwYl9STXltRlFxLWNzVWlMVnNYdXk0RklnRExFT0FaQjg2aGswNnhhSGhCN29KN2VEb2FlUV9NTlV3b0tLelplSVU9 -DB_CHAT_PORT=5432 - -# PostgreSQL Storage (new) -DB_MANAGEMENT_HOST=localhost -DB_MANAGEMENT_DATABASE=poweron_management -DB_MANAGEMENT_USER=poweron_dev -DB_MANAGEMENT_PASSWORD_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEUldqSTVpUnFqdGhITDYzT3RScGlMYVdTMmZhOXdudDRCc3dhdllOd3l6MS1vWHY2MjVsTUF1Sk9saEJOSk9ONUlBZjQwb2c2T1gtWWJhcXFzVVVXd01xc0U0b0lJX0JyVDRxaDhNS01JcWs9 -DB_MANAGEMENT_PORT=5432 - -# PostgreSQL Storage (new) -DB_REALESTATE_HOST=localhost -DB_REALESTATE_DATABASE=poweron_realestate -DB_REALESTATE_USER=poweron_dev -DB_REALESTATE_PASSWORD_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEcUIxNEFfQ2xnS0RrSC1KNnUxTlVvTGZoMHgzaEI4Z3NlVzVROTVLak5Ubi1vaEZubFZaMTFKMGd6MXAxekN2d2NvMy1hRjg2UVhybktlcFA5anZ1WjFlQmZhcXdwaGhWdzRDc3ExeUhzWTg9 -DB_REALESTATE_PORT=5432 +# PostgreSQL DB Host +DB_HOST=localhost +DB_USER=poweron_dev +DB_PASSWORD_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEcUIxNEFfQ2xnS0RrSC1KNnUxTlVvTGZoMHgzaEI4Z3NlVzVROTVLak5Ubi1vaEZubFZaMTFKMGd6MXAxekN2d2NvMy1hRjg2UVhybktlcFA5anZ1WjFlQmZhcXdwaGhWdzRDc3ExeUhzWTg9 +DB_PORT=5432 # Security Configuration APP_JWT_KEY_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERjlrSktmZHVuQnJ1VVJDdndLaUcxZGJsT2ZlUFRlcFdOZ001RnlzM2FhLWhRV2tjWWFhaWQwQ3hkcUFvbThMcndxSjFpYTdfRV9OZGhTcksxbXFTZWg5MDZvOHpCVXBHcDJYaHlJM0tyNWRZckZsVHpQcmxTZHJoZUs1M3lfU2ljRnJaTmNSQ0w0X085OXI0QW80M2xfQnJqZmZ6VEh3TUltX0xzeE42SGtZPQ== diff --git a/env_int.env b/env_int.env index 7047dc6e..05313802 100644 --- a/env_int.env +++ b/env_int.env @@ -8,33 +8,11 @@ APP_KEY_SYSVAR = CONFIG_KEY APP_INIT_PASS_ADMIN_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjWm41MWZ4TUZGaVlrX3pWZWNwakJsY3Facm0wLVZDd1VKeTFoZEVZQnItcEdUUnVJS1NXeDBpM2xKbGRsYmxOSmRhc29PZjJSU2txQjdLbUVrTTE1NEJjUXBHbV9NOVJWZUR3QlJkQnJvTEU9 APP_INIT_PASS_EVENT_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjdmtrakgxa0djekZVNGtTZV8wM2I5UUpCZllveVBMWXROYk5yS3BiV3JEelJSM09VYTRONHpnY3VtMGxDRk5JTEZSRFhtcDZ0RVRmZ1RicTFhb3c5dVZRQ1o4SmlkLVpPTW5MMTU2eTQ0Vkk9 -# PostgreSQL Storage (new) -DB_APP_HOST=gateway-int-server.postgres.database.azure.com -DB_APP_DATABASE=poweron_app -DB_APP_USER=heeshkdlby -DB_APP_PASSWORD_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjb2dka2pnN0tUbW1EU0w1Rk1jNERKQ0Z1U3JkVDhuZWZDM0g5M0kwVDE5VHdubkZna3gtZVAxTnl4MDdrR1c1ZXJ3ejJHYkZvcGUwbHJaajBGOWJob0EzRXVHc0JnZkJyNGhHZTZHOXBxd2c9 -DB_APP_PORT=5432 - -# PostgreSQL Storage (new) -DB_CHAT_HOST=gateway-int-server.postgres.database.azure.com -DB_CHAT_DATABASE=poweron_chat -DB_CHAT_USER=heeshkdlby -DB_CHAT_PASSWORD_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjczYzOUtTa21MMGJVTUQ5UmFfdWc3YlhCbWZOeXFaNEE1QzdJV3BLVjhnalBkLVVCMm5BZzdxdlFXQXc2RHYzLWtPSFZkZE1iWG9rQ1NkVWlpRnF5TURVbnl1cm9iYXlSMGYxd1BGYVc0VDA9 -DB_CHAT_PORT=5432 - -# PostgreSQL Storage (new) -DB_MANAGEMENT_HOST=gateway-int-server.postgres.database.azure.com -DB_MANAGEMENT_DATABASE=poweron_management -DB_MANAGEMENT_USER=heeshkdlby -DB_MANAGEMENT_PASSWORD_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjTnJKNlJMNmEwQ0Y5dVNrR3pkZk9SQXVvLTRTNW9lQ1g3TTE5cFhBNTd5UENqWW9qdWd3NWNseWhnUHJveDJyd1Z3X1czS3VuZnAwZHBXYVNQWlZsRy12ME42NndEVlR5X3ZPdFBNNmhLYm89 -DB_MANAGEMENT_PORT=5432 - -# PostgreSQL Storage (new) -DB_REALESTATE_HOST=localhost -DB_REALESTATE_DATABASE=poweron_realestate -DB_REALESTATE_USER=poweron_dev -DB_REALESTATE_PASSWORD_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjTnJKNlJMNmEwQ0Y5dVNrR3pkZk9SQXVvLTRTNW9lQ1g3TTE5cFhBNTd5UENqWW9qdWd3NWNseWhnUHJveDJyd1Z3X1czS3VuZnAwZHBXYVNQWlZsRy12ME42NndEVlR5X3ZPdFBNNmhLYm89 -DB_REALESTATE_PORT=5432 +# PostgreSQL DB Host +DB_HOST=gateway-int-server.postgres.database.azure.com +DB_USER=heeshkdlby +DB_PASSWORD_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjczYzOUtTa21MMGJVTUQ5UmFfdWc3YlhCbWZOeXFaNEE1QzdJV3BLVjhnalBkLVVCMm5BZzdxdlFXQXc2RHYzLWtPSFZkZE1iWG9rQ1NkVWlpRnF5TURVbnl1cm9iYXlSMGYxd1BGYVc0VDA9 +DB_PORT=5432 # Security Configuration APP_JWT_KEY_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNUctb2RwU25iR3ZnanBOdHZhWUtIajZ1RnZzTEp4aDR0MktWRjNoeVBrY1Npd1R0VE9YVHp3M2w1cXRzbUxNaU82QUJvaDNFeVQyN05KblRWblBvbWtoT0VXbkNBbDQ5OHhwSUFnaDZGRG10Vmgtdm1YUkRsYUhFMzRVZURmSFlDTFIzVWg4MXNueDZyMGc5aVpFdWRxY3dkTExGM093ZTVUZVl5LUhGWnlRPQ== diff --git a/env_prod.env b/env_prod.env index 32cfe3ad..57a4e83c 100644 --- a/env_prod.env +++ b/env_prod.env @@ -8,33 +8,11 @@ APP_INIT_PASS_ADMIN_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3UnJRV0sySFlDblpXUlREclREaW APP_INIT_PASS_EVENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3QVpIY19DQVZSSzJmc2F0VEZvQlU1cHBhTEgxdHdnR3g4eW01aTEzYTUxc1gxTDR1RVVpSHRXYjV6N1BLZUdCUGlfOW1qdy0xSHFVRkNBcGZvaGlSSkZycXRuUllaWnpyVGRoeFg1dGEyNUk9 APP_API_URL = https://gateway-prod.poweron-center.net -# PostgreSQL Storage (new) -DB_APP_HOST=gateway-prod-server.postgres.database.azure.com -DB_APP_DATABASE=poweron_app -DB_APP_USER=gzxxmcrdhn -DB_APP_PASSWORD_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3cm5LQWV1OURQanVyTklVaVhJbDI2Y1Itb29pTWFmR2RYM0pyYUhhRUpWZ29tWWwzSmdQeVhScHlHQWVyY0xUTElIdVBJUjh5Zm9ZMzg1ZERNQXZ6TXlGb2tYOGpDX1gzXzB3UUlCM1ZaYWM9 -DB_APP_PORT=5432 - -# PostgreSQL Storage (new) -DB_CHAT_HOST=gateway-prod-server.postgres.database.azure.com -DB_CHAT_DATABASE=poweron_chat -DB_CHAT_USER=gzxxmcrdhn -DB_CHAT_PASSWORD_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3Y1JScGxjZG9TdUkwaHRzSHZhRHpNcDV3N1U2TnIwZ21PRG5TWFFfR1k0N3BiRk5WelVadjlnXzVSTDZ6NXFQNFpqbnJ1R3dNVkJocm1zVEgtSk0xaDRiR19zNDBEbVIzSk51ekNlQ0Z3b0U9 -DB_CHAT_PORT=5432 - -# PostgreSQL Storage (new) -DB_MANAGEMENT_HOST=gateway-prod-server.postgres.database.azure.com -DB_MANAGEMENT_DATABASE=poweron_management -DB_MANAGEMENT_USER=gzxxmcrdhn -DB_MANAGEMENT_PASSWORD_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3ZWpySThqdlVmWWd5dGxmWE91RVBsenZrQmNhSzVxbktmYzZ1RlM3cXhTMUdXRV9wX1lfLTJXLTFzeUo0R3pWLXlmUWdrZ2x6QkFlZVRXaEF6aUdRbDlzb1FfcWtub0dxSGp3OVVQWGg3enM9 -DB_MANAGEMENT_PORT=5432 - -# PostgreSQL Storage (new) -DB_REALESTATE_HOST=localhost -DB_REALESTATE_DATABASE=poweron_realestate -DB_REALESTATE_USER=poweron_dev -DB_REALESTATE_PASSWORD_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3ZWpySThqdlVmWWd5dGxmWE91RVBsenZrQmNhSzVxbktmYzZ1RlM3cXhTMUdXRV9wX1lfLTJXLTFzeUo0R3pWLXlmUWdrZ2x6QkFlZVRXaEF6aUdRbDlzb1FfcWtub0dxSGp3OVVQWGg3enM9 -DB_REALESTATE_PORT=5432 +# PostgreSQL DB Host +DB_HOST=gateway-prod-server.postgres.database.azure.com +DB_USER=gzxxmcrdhn +DB_PASSWORD_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3Y1JScGxjZG9TdUkwaHRzSHZhRHpNcDV3N1U2TnIwZ21PRG5TWFFfR1k0N3BiRk5WelVadjlnXzVSTDZ6NXFQNFpqbnJ1R3dNVkJocm1zVEgtSk0xaDRiR19zNDBEbVIzSk51ekNlQ0Z3b0U9 +DB_PORT=5432 # Security Configuration APP_JWT_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3elhfV0Rnd2pQRjlMdkVwX1FnSmRhSzNZUlV5SVpaWXBNX1hpa2xPZGdMSWpnN2ZINHQxeGZnNHJweU5pZjlyYlY5Qm9zOUZEbl9wUEgtZHZXd1NhR19JSG9kbFU4MnFGQnllbFhRQVphRGQyNHlFVWR5VHQyUUpqN0stUmRuY2QyTi1oalczRHpLTEJqWURjZWs4YjZvT2U5YnFqcXEwdEpxV05fX05QMmtrPQ== diff --git a/modules/aicore/aicorePluginTavily.py b/modules/aicore/aicorePluginTavily.py index d66c3005..1a5cbc65 100644 --- a/modules/aicore/aicorePluginTavily.py +++ b/modules/aicore/aicorePluginTavily.py @@ -728,8 +728,7 @@ class AiTavily(BaseConnectorAi): maxBreadth=webCrawlPrompt.maxWidth or 40 # Use same as limit for breadth ) - # If we got multiple pages from the crawl, we need to format them differently - # Return the first result for backwards compatibility, but include total page count + # Format multiple pages from the crawl into a single response if crawlResults and len(crawlResults) > 0: # Get all pages content with error handling allContent = "" diff --git a/modules/auth/__init__.py b/modules/auth/__init__.py index b375ef15..49024b66 100644 --- a/modules/auth/__init__.py +++ b/modules/auth/__init__.py @@ -3,9 +3,23 @@ """ Authentication and authorization modules for routes and services. High-level security functionality that depends on FastAPI and interfaces. + +Multi-Tenant Design: +- RequestContext: Per-request context with user, mandate, feature instance, roles +- getRequestContext: FastAPI dependency to extract context from X-Mandate-Id header +- requireSysAdmin: FastAPI dependency for system-level admin operations """ -from .authentication import getCurrentUser, limiter, SECRET_KEY, ALGORITHM, cookieAuth +from .authentication import ( + getCurrentUser, + limiter, + SECRET_KEY, + ALGORITHM, + cookieAuth, + RequestContext, + getRequestContext, + requireSysAdmin, +) from .jwtService import ( createAccessToken, createRefreshToken, @@ -20,22 +34,30 @@ from .tokenRefreshMiddleware import TokenRefreshMiddleware, ProactiveTokenRefres from .csrf import CSRFMiddleware __all__ = [ + # Authentication "getCurrentUser", "limiter", "SECRET_KEY", "ALGORITHM", "cookieAuth", + # Multi-Tenant Context + "RequestContext", + "getRequestContext", + "requireSysAdmin", + # JWT Service "createAccessToken", "createRefreshToken", "setAccessTokenCookie", "setRefreshTokenCookie", "clearAccessTokenCookie", "clearRefreshTokenCookie", + # Token Management "TokenManager", "token_refresh_service", "TokenRefreshService", "TokenRefreshMiddleware", "ProactiveTokenRefreshMiddleware", + # CSRF "CSRFMiddleware", ] diff --git a/modules/auth/authentication.py b/modules/auth/authentication.py index 2759a07a..f6cf0f0d 100644 --- a/modules/auth/authentication.py +++ b/modules/auth/authentication.py @@ -3,10 +3,16 @@ """ Authentication module for backend API. Handles JWT-based authentication, token generation, and user context. + +Multi-Tenant Design: +- Token ist NICHT an einen Mandanten gebunden +- User arbeitet parallel in mehreren Mandanten (z.B. mehrere Browser-Tabs) +- Mandant-Kontext wird per Request-Header (X-Mandate-Id) bestimmt +- Request-Context kapselt User + Mandant + Feature-Instanz + geladene Rollen """ -from typing import Optional, Dict, Any, Tuple -from fastapi import Depends, HTTPException, status, Request, Response +from typing import Optional, Dict, Any, Tuple, List +from fastapi import Depends, HTTPException, status, Request, Response, Header from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from jose import JWTError, jwt import logging @@ -15,9 +21,10 @@ from slowapi.util import get_remote_address from modules.shared.configuration import APP_CONFIG from modules.security.rootAccess import getRootDbAppConnector, getRootUser -from modules.interfaces.interfaceDbAppObjects import getInterface -from modules.datamodels.datamodelUam import User, AuthAuthority +from modules.interfaces.interfaceDbAppObjects import getInterface, getRootInterface +from modules.datamodels.datamodelUam import User, AuthAuthority, AccessLevel from modules.datamodels.datamodelSecurity import Token +from modules.datamodels.datamodelRbac import AccessRule # Get Config Data SECRET_KEY = APP_CONFIG.get("APP_JWT_KEY_SECRET") @@ -98,15 +105,16 @@ def _getUserBase(token: str = Depends(cookieAuth)) -> User: if username is None: raise credentialsException - # Extract mandate ID and user ID from token - mandateId: str = payload.get("mandateId") + # Extract user ID from token + # MULTI-TENANT: mandateId is NO LONGER in the token - it comes from X-Mandate-Id header userId: str = payload.get("userId") authority: str = payload.get("authenticationAuthority") tokenId: Optional[str] = payload.get("jti") sessionId: Optional[str] = payload.get("sid") or payload.get("sessionId") - if not mandateId or not userId: - logger.error(f"Missing context in token: mandateId={mandateId}, userId={userId}") + # Only userId is required in token now (no mandateId) + if not userId: + logger.error(f"Missing userId in token") raise credentialsException except JWTError: @@ -129,9 +137,10 @@ def _getUserBase(token: str = Depends(cookieAuth)) -> User: logger.warning(f"User {username} is disabled") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is disabled") - # Ensure the user has the correct context - if str(user.mandateId) != str(mandateId) or str(user.id) != str(userId): - logger.error(f"User context mismatch: token(mandateId={mandateId}, userId={userId}) vs user(mandateId={user.mandateId}, id={user.id})") + # Ensure the user ID in token matches the user in database + # MULTI-TENANT: mandateId is NO LONGER checked here - it comes from headers + if str(user.id) != str(userId): + logger.error(f"User ID mismatch: token(userId={userId}) vs user(id={user.id})") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="User context has changed. Please log in again.", @@ -166,17 +175,18 @@ def _getUserBase(token: str = Depends(cookieAuth)) -> User: db_token = db_tokens[0] token_authority = str(db_token.get("authority", "")).lower() if token_authority == str(AuthAuthority.LOCAL.value): - # Must be active and match user/session/mandate + # Must be active and match user/session + # MULTI-TENANT: mandateId is NOT checked here - tokens are no longer mandate-bound active_token = appInterface.findActiveTokenById( tokenId=tokenId, userId=user.id, authority=AuthAuthority.LOCAL, sessionId=sessionId, - mandateId=str(mandateId) if mandateId else None, + mandateId=None, # Token is no longer mandate-bound ) if not active_token: logger.info( - f"Local JWT db record not active/valid: jti={tokenId}, userId={user.id}, mandateId={mandateId}, sessionId={sessionId}" + f"Local JWT db record not active/valid: jti={tokenId}, userId={user.id}, sessionId={sessionId}" ) raise credentialsException else: @@ -203,3 +213,171 @@ def getCurrentUser(currentUser: User = Depends(_getUserBase)) -> User: return currentUser +# ============================================================================= +# MULTI-TENANT: Request Context System +# ============================================================================= + +class RequestContext: + """ + Request context for multi-tenant operations. + + Contains user, mandate context, feature instance context, and loaded role IDs. + This context is per-request (not persisted) - follows stateless design. + + IMPORTANT: SysAdmin also needs explicit membership for mandate context! + isSysAdmin flag does NOT give implicit access to mandate data. + """ + + def __init__(self, user: User): + self.user: User = user + self.mandateId: Optional[str] = None + self.featureInstanceId: Optional[str] = None + self.roleIds: List[str] = [] + + # Request-scoped cache: rules loaded only once per request + self._cachedRules: Optional[List[tuple]] = None + + def getRules(self) -> List[tuple]: + """ + Loads rules once per request (not across requests). + Returns list of (priority, AccessRule) tuples. + """ + if self._cachedRules is None: + if not self.mandateId: + # No mandate context = no rules + self._cachedRules = [] + else: + try: + rootUser = getRootUser() + appInterface = getInterface(rootUser) + self._cachedRules = appInterface.rbac.getRulesForUserBulk( + self.user.id, + self.mandateId, + self.featureInstanceId + ) + except Exception as e: + logger.error(f"Error loading RBAC rules: {e}") + self._cachedRules = [] + return self._cachedRules + + @property + def isSysAdmin(self) -> bool: + """Convenience property to check if user is a system admin.""" + return getattr(self.user, 'isSysAdmin', False) + + +def getRequestContext( + request: Request, + mandateId: Optional[str] = Header(None, alias="X-Mandate-Id"), + featureInstanceId: Optional[str] = Header(None, alias="X-Instance-Id"), + currentUser: User = Depends(getCurrentUser) +) -> RequestContext: + """ + 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. + + Args: + request: FastAPI Request object + mandateId: Mandate ID from X-Mandate-Id header + featureInstanceId: Feature instance ID from X-Instance-Id header + currentUser: Current authenticated user + + Returns: + RequestContext with user, mandate, roles + + Raises: + HTTPException 403: If user is not member of mandate or has no feature access + """ + ctx = RequestContext(user=currentUser) + + # Get root interface for membership checks + rootInterface = getRootInterface() + + if mandateId: + # Check mandate membership - ALSO for SysAdmin! + # SysAdmin must be explicitly added to the mandate + membership = rootInterface.getUserMandate(currentUser.id, mandateId) + if not membership: + # No implicit access for SysAdmin - Fail-Fast! + 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! + access = rootInterface.getFeatureAccess(currentUser.id, featureInstanceId) + if not access: + 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 + + +def requireSysAdmin(currentUser: User = Depends(getCurrentUser)) -> User: + """ + SysAdmin check for system-level operations. + + Use this dependency for endpoints that require SysAdmin privileges. + SysAdmin has access to system-level operations, but NOT to mandate data. + + Args: + currentUser: Current authenticated user + + Returns: + User if they are a SysAdmin + + Raises: + HTTPException 403: If user is not a SysAdmin + """ + if not getattr(currentUser, 'isSysAdmin', False): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="SysAdmin privileges required" + ) + + # Audit for all SysAdmin actions + try: + from modules.shared.auditLogger import audit_logger + audit_logger.logSecurityEvent( + userId=str(currentUser.id), + mandateId="system", + action="sysadmin_action", + details="System-level operation" + ) + except Exception: + # Don't fail if audit logging fails + pass + + return currentUser + diff --git a/modules/connectors/connectorDbPostgre.py b/modules/connectors/connectorDbPostgre.py index ba452891..5cf2dc62 100644 --- a/modules/connectors/connectorDbPostgre.py +++ b/modules/connectors/connectorDbPostgre.py @@ -638,14 +638,12 @@ class DatabaseConnector: # Only set _createdBy if userId is valid (not None or empty string) if self.userId: record["_createdBy"] = self.userId - else: - logger.warning(f"Attempting to create record with empty userId - _createdBy will not be set") + # No warning - empty userId is normal during bootstrap # Also ensure _createdBy is set even if _createdAt exists but _createdBy is missing/empty elif "_createdBy" not in record or not record.get("_createdBy"): if self.userId: record["_createdBy"] = self.userId - else: - logger.warning(f"Attempting to set _createdBy with empty userId for record {recordId}") + # No warning - empty userId is normal during bootstrap # Always update modification metadata record["_modifiedAt"] = currentTime if self.userId: diff --git a/modules/datamodels/datamodelFeatures.py b/modules/datamodels/datamodelFeatures.py new file mode 100644 index 00000000..e772a19e --- /dev/null +++ b/modules/datamodels/datamodelFeatures.py @@ -0,0 +1,83 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +"""Feature models: Feature, FeatureInstance.""" + +import uuid +from typing import Optional +from pydantic import BaseModel, Field +from modules.shared.attributeUtils import registerModelLabels + + +class Feature(BaseModel): + """ + Feature-Definition (global, z.B. 'trustee', 'chatbot'). + Features sind die verfügbaren Funktionalitäten der Plattform. + """ + code: str = Field( + description="Unique feature code (Primary Key), z.B. 'trustee', 'chatbot'", + json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True} + ) + label: dict = Field( + default_factory=dict, + description="Feature label in multiple languages (I18n)", + json_schema_extra={"frontend_type": "multilingual", "frontend_readonly": False, "frontend_required": True} + ) + icon: str = Field( + default="", + description="Icon identifier for the feature", + json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False} + ) + + +registerModelLabels( + "Feature", + {"en": "Feature", "de": "Feature", "fr": "Fonctionnalité"}, + { + "code": {"en": "Code", "de": "Code", "fr": "Code"}, + "label": {"en": "Label", "de": "Bezeichnung", "fr": "Libellé"}, + "icon": {"en": "Icon", "de": "Symbol", "fr": "Icône"}, + }, +) + + +class FeatureInstance(BaseModel): + """ + Instanz eines Features in einem Mandanten. + Ein Mandant kann mehrere Instanzen desselben Features haben. + """ + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Unique ID of the feature instance", + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} + ) + featureCode: str = Field( + description="FK → Feature.code", + json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": True} + ) + mandateId: str = Field( + description="FK → Mandate.id (CASCADE DELETE)", + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True} + ) + label: str = Field( + default="", + description="Instance label, z.B. 'Buchhaltung 2025'", + json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True} + ) + enabled: bool = Field( + default=True, + description="Whether this feature instance is enabled", + json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False} + ) + + +registerModelLabels( + "FeatureInstance", + {"en": "Feature Instance", "de": "Feature-Instanz", "fr": "Instance de fonctionnalité"}, + { + "id": {"en": "ID", "de": "ID", "fr": "ID"}, + "featureCode": {"en": "Feature", "de": "Feature", "fr": "Fonctionnalité"}, + "mandateId": {"en": "Mandate", "de": "Mandant", "fr": "Mandat"}, + "label": {"en": "Label", "de": "Bezeichnung", "fr": "Libellé"}, + "enabled": {"en": "Enabled", "de": "Aktiviert", "fr": "Activé"}, + }, +) diff --git a/modules/datamodels/datamodelInvitation.py b/modules/datamodels/datamodelInvitation.py new file mode 100644 index 00000000..a35dfb09 --- /dev/null +++ b/modules/datamodels/datamodelInvitation.py @@ -0,0 +1,120 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +""" +Invitation model for self-service onboarding. +Token-basierte Einladungen für neue User zu Mandanten/Features. +""" + +import uuid +import secrets +from typing import Optional, List +from pydantic import BaseModel, Field +from modules.shared.attributeUtils import registerModelLabels +from modules.shared.timeUtils import getUtcTimestamp + + +class Invitation(BaseModel): + """ + Einladungs-Token für neue User. + Ermöglicht Self-Service Onboarding zu Mandanten und Feature-Instanzen. + """ + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Unique ID of the invitation", + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} + ) + token: str = Field( + default_factory=lambda: secrets.token_urlsafe(32), + description="Secure invitation token", + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} + ) + + # Ziel der Einladung + mandateId: str = Field( + description="FK → Mandate.id - Target mandate for the invitation", + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True} + ) + featureInstanceId: Optional[str] = Field( + default=None, + description="Optional FK → FeatureInstance.id - Direct access to specific feature", + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} + ) + roleIds: List[str] = Field( + default_factory=list, + description="List of Role IDs to assign to the invited user", + json_schema_extra={"frontend_type": "multiselect", "frontend_readonly": False, "frontend_required": True} + ) + + # Einladungs-Details + email: Optional[str] = Field( + default=None, + description="Target email address (optional, for tracking)", + json_schema_extra={"frontend_type": "email", "frontend_readonly": False, "frontend_required": False} + ) + createdBy: str = Field( + description="User ID of the person who created the invitation", + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True} + ) + createdAt: float = Field( + default_factory=getUtcTimestamp, + description="When the invitation was created (UTC timestamp)", + json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False} + ) + expiresAt: float = Field( + description="When the invitation expires (UTC timestamp)", + json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": True} + ) + + # Status + usedBy: Optional[str] = Field( + default=None, + description="User ID of the person who used the invitation", + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} + ) + usedAt: Optional[float] = Field( + default=None, + description="When the invitation was used (UTC timestamp)", + json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False} + ) + revokedAt: Optional[float] = Field( + default=None, + description="When the invitation was revoked (UTC timestamp)", + json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False} + ) + + # Einschränkungen + maxUses: int = Field( + default=1, + ge=1, + le=100, + description="Maximum number of times this invitation can be used", + json_schema_extra={"frontend_type": "number", "frontend_readonly": False, "frontend_required": False} + ) + currentUses: int = Field( + default=0, + ge=0, + description="Current number of times this invitation has been used", + json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False} + ) + + +registerModelLabels( + "Invitation", + {"en": "Invitation", "de": "Einladung", "fr": "Invitation"}, + { + "id": {"en": "ID", "de": "ID", "fr": "ID"}, + "token": {"en": "Token", "de": "Token", "fr": "Jeton"}, + "mandateId": {"en": "Mandate", "de": "Mandant", "fr": "Mandat"}, + "featureInstanceId": {"en": "Feature Instance", "de": "Feature-Instanz", "fr": "Instance"}, + "roleIds": {"en": "Roles", "de": "Rollen", "fr": "Rôles"}, + "email": {"en": "Email", "de": "E-Mail", "fr": "Email"}, + "createdBy": {"en": "Created By", "de": "Erstellt von", "fr": "Créé par"}, + "createdAt": {"en": "Created At", "de": "Erstellt am", "fr": "Créé le"}, + "expiresAt": {"en": "Expires At", "de": "Gültig bis", "fr": "Expire le"}, + "usedBy": {"en": "Used By", "de": "Verwendet von", "fr": "Utilisé par"}, + "usedAt": {"en": "Used At", "de": "Verwendet am", "fr": "Utilisé le"}, + "revokedAt": {"en": "Revoked At", "de": "Widerrufen am", "fr": "Révoqué le"}, + "maxUses": {"en": "Max Uses", "de": "Max. Verwendungen", "fr": "Utilisations max"}, + "currentUses": {"en": "Current Uses", "de": "Aktuelle Verwendungen", "fr": "Utilisations actuelles"}, + }, +) diff --git a/modules/datamodels/datamodelMembership.py b/modules/datamodels/datamodelMembership.py new file mode 100644 index 00000000..e2cdb0b6 --- /dev/null +++ b/modules/datamodels/datamodelMembership.py @@ -0,0 +1,150 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +""" +Membership models: UserMandate, FeatureAccess, and Junction Tables. + +Diese Models definieren die m:n Beziehungen zwischen User, Mandate und FeatureInstance. +Rollen werden über Junction Tables verknüpft für saubere CASCADE DELETE. +""" + +import uuid +from pydantic import BaseModel, Field +from modules.shared.attributeUtils import registerModelLabels + + +class UserMandate(BaseModel): + """ + User-Mitgliedschaft in einem Mandanten. + Kein User gehört direkt zu einem Mandanten - Zugehörigkeit wird über dieses Model gesteuert. + """ + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Unique ID of the user-mandate membership", + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} + ) + userId: str = Field( + description="FK → User.id (CASCADE DELETE)", + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True} + ) + mandateId: str = Field( + description="FK → Mandate.id (CASCADE DELETE)", + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True} + ) + enabled: bool = Field( + default=True, + description="Whether this membership is enabled", + json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False} + ) + # Rollen werden via Junction Table UserMandateRole verknüpft + + +registerModelLabels( + "UserMandate", + {"en": "User Mandate", "de": "Benutzer-Mandant", "fr": "Mandat utilisateur"}, + { + "id": {"en": "ID", "de": "ID", "fr": "ID"}, + "userId": {"en": "User", "de": "Benutzer", "fr": "Utilisateur"}, + "mandateId": {"en": "Mandate", "de": "Mandant", "fr": "Mandat"}, + "enabled": {"en": "Enabled", "de": "Aktiviert", "fr": "Activé"}, + }, +) + + +class FeatureAccess(BaseModel): + """ + User-Zugriff auf eine Feature-Instanz. + Definiert welche User auf welche Feature-Instanzen zugreifen können. + """ + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Unique ID of the feature access", + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} + ) + userId: str = Field( + description="FK → User.id (CASCADE DELETE)", + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True} + ) + featureInstanceId: str = Field( + description="FK → FeatureInstance.id (CASCADE DELETE)", + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True} + ) + enabled: bool = Field( + default=True, + description="Whether this feature access is enabled", + json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False} + ) + # Rollen werden via Junction Table FeatureAccessRole verknüpft + + +registerModelLabels( + "FeatureAccess", + {"en": "Feature Access", "de": "Feature-Zugang", "fr": "Accès fonctionnalité"}, + { + "id": {"en": "ID", "de": "ID", "fr": "ID"}, + "userId": {"en": "User", "de": "Benutzer", "fr": "Utilisateur"}, + "featureInstanceId": {"en": "Feature Instance", "de": "Feature-Instanz", "fr": "Instance"}, + "enabled": {"en": "Enabled", "de": "Aktiviert", "fr": "Activé"}, + }, +) + + +class UserMandateRole(BaseModel): + """ + Junction Table: UserMandate zu Role. + Ermöglicht CASCADE DELETE auf Datenbankebene. + """ + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Unique ID of the junction record", + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} + ) + userMandateId: str = Field( + description="FK → UserMandate.id (CASCADE DELETE)", + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True} + ) + roleId: str = Field( + description="FK → Role.id (CASCADE DELETE)", + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True} + ) + + +registerModelLabels( + "UserMandateRole", + {"en": "User Mandate Role", "de": "Benutzer-Mandant-Rolle", "fr": "Rôle mandat utilisateur"}, + { + "id": {"en": "ID", "de": "ID", "fr": "ID"}, + "userMandateId": {"en": "User Mandate", "de": "Benutzer-Mandant", "fr": "Mandat utilisateur"}, + "roleId": {"en": "Role", "de": "Rolle", "fr": "Rôle"}, + }, +) + + +class FeatureAccessRole(BaseModel): + """ + Junction Table: FeatureAccess zu Role. + Ermöglicht CASCADE DELETE auf Datenbankebene. + """ + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Unique ID of the junction record", + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} + ) + featureAccessId: str = Field( + description="FK → FeatureAccess.id (CASCADE DELETE)", + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True} + ) + roleId: str = Field( + description="FK → Role.id (CASCADE DELETE)", + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True} + ) + + +registerModelLabels( + "FeatureAccessRole", + {"en": "Feature Access Role", "de": "Feature-Zugang-Rolle", "fr": "Rôle accès fonctionnalité"}, + { + "id": {"en": "ID", "de": "ID", "fr": "ID"}, + "featureAccessId": {"en": "Feature Access", "de": "Feature-Zugang", "fr": "Accès fonctionnalité"}, + "roleId": {"en": "Role", "de": "Rolle", "fr": "Rôle"}, + }, +) diff --git a/modules/datamodels/datamodelRbac.py b/modules/datamodels/datamodelRbac.py index c9be666f..666470c8 100644 --- a/modules/datamodels/datamodelRbac.py +++ b/modules/datamodels/datamodelRbac.py @@ -1,9 +1,16 @@ # Copyright (c) 2025 Patrick Motsch # All rights reserved. -"""RBAC models: AccessRule, AccessRuleContext, Role.""" +""" +RBAC models: AccessRule, AccessRuleContext, Role. + +Multi-Tenant Design: +- Role hat einen unveränderlichen Kontext (mandateId, featureInstanceId, featureCode) +- AccessRule referenziert Role via roleId (FK), nicht via roleLabel +- Kontext-Felder sind IMMUTABLE nach Erstellung +""" import uuid -from typing import Optional, Dict +from typing import Optional from enum import Enum from pydantic import BaseModel, Field from modules.shared.attributeUtils import registerModelLabels @@ -19,7 +26,17 @@ class AccessRuleContext(str, Enum): class Role(BaseModel): - """Data model for RBAC roles""" + """ + Data model for RBAC roles. + + Kernkonzept: Eine Rolle existiert immer in einem spezifischen Kontext. + Der Kontext ist IMMUTABLE nach Erstellung. + + Kontext-Typen: + - mandateId=None, featureInstanceId=None → GLOBAL (Template-Rolle) + - mandateId=X, featureInstanceId=None → MANDATE-Rolle + - mandateId=X, featureInstanceId=Y → INSTANCE-Rolle + """ id: str = Field( default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the role", @@ -33,106 +50,163 @@ class Role(BaseModel): description="Role description in multiple languages", json_schema_extra={"frontend_type": "multilingual", "frontend_readonly": False, "frontend_required": True} ) + + # KONTEXT - IMMUTABLE nach Create (nur Create/Delete, kein Update!) + mandateId: Optional[str] = Field( + default=None, + description="FK → Mandate.id (CASCADE DELETE). Null = Global/Template role.", + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} + ) + featureInstanceId: Optional[str] = Field( + default=None, + description="FK → FeatureInstance.id (CASCADE DELETE). Null = Mandate-level or Global role.", + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} + ) + featureCode: Optional[str] = Field( + default=None, + description="Feature code (z.B. 'trustee') - für Template-Rollen", + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} + ) + isSystemRole: bool = Field( - False, + default=False, description="Whether this is a system role that cannot be deleted", json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": False} ) + registerModelLabels( "Role", - {"en": "Role", "fr": "Rôle"}, + {"en": "Role", "de": "Rolle", "fr": "Rôle"}, { - "id": {"en": "ID", "fr": "ID"}, - "roleLabel": {"en": "Role Label", "fr": "Label du rôle"}, - "description": {"en": "Description", "fr": "Description"}, - "isSystemRole": {"en": "System Role", "fr": "Rôle système"}, + "id": {"en": "ID", "de": "ID", "fr": "ID"}, + "roleLabel": {"en": "Role Label", "de": "Rollen-Label", "fr": "Label du rôle"}, + "description": {"en": "Description", "de": "Beschreibung", "fr": "Description"}, + "mandateId": {"en": "Mandate", "de": "Mandant", "fr": "Mandat"}, + "featureInstanceId": {"en": "Feature Instance", "de": "Feature-Instanz", "fr": "Instance"}, + "featureCode": {"en": "Feature Code", "de": "Feature-Code", "fr": "Code fonctionnalité"}, + "isSystemRole": {"en": "System Role", "de": "System-Rolle", "fr": "Rôle système"}, }, ) class AccessRule(BaseModel): - """Data model for access control rules""" + """ + Data model for access control rules. + + WICHTIG: roleId referenziert die Role via FK (nicht mehr roleLabel!) + Die Felder 'context' und 'roleId' sind IMMUTABLE nach Erstellung. + """ id: str = Field( default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the access rule", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} ) - roleLabel: str = Field( - description="Role label this rule applies to", - json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_options": "user.role"} + roleId: str = Field( + description="FK → Role.id (CASCADE DELETE!)", + json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": True} ) context: AccessRuleContext = Field( - description="Context type: DATA (database), UI (interface), RESOURCE (system resources)", - json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_options": [ - {"value": "DATA", "label": {"en": "Data", "fr": "Données"}}, - {"value": "UI", "label": {"en": "UI", "fr": "Interface"}}, - {"value": "RESOURCE", "label": {"en": "Resource", "fr": "Ressource"}} + description="Context type: DATA (database), UI (interface), RESOURCE (system resources). IMMUTABLE!", + json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": True, "frontend_options": [ + {"value": "DATA", "label": {"en": "Data", "de": "Daten", "fr": "Données"}}, + {"value": "UI", "label": {"en": "UI", "de": "Oberfläche", "fr": "Interface"}}, + {"value": "RESOURCE", "label": {"en": "Resource", "de": "Ressource", "fr": "Ressource"}} ]} ) item: Optional[str] = Field( - None, + default=None, description="Item identifier (null = all items in context). Format: DATA: '' or '
.', UI: cascading string (e.g., 'playground.voice.settings'), RESOURCE: cascading string (e.g., 'ai.model.anthropic')", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False} ) view: bool = Field( - False, + default=False, description="View permission: if true, item is visible/enabled. Only objects with view=true are shown.", json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": True} ) read: Optional[AccessLevel] = Field( - None, + default=None, description="Read permission level (only for DATA context)", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [ - {"value": "a", "label": {"en": "All Records", "fr": "Tous les enregistrements"}}, - {"value": "m", "label": {"en": "My Records", "fr": "Mes enregistrements"}}, - {"value": "g", "label": {"en": "Group Records", "fr": "Enregistrements du groupe"}}, - {"value": "n", "label": {"en": "No Access", "fr": "Aucun accès"}} + {"value": "a", "label": {"en": "All Records", "de": "Alle Datensätze", "fr": "Tous les enregistrements"}}, + {"value": "m", "label": {"en": "My Records", "de": "Meine Datensätze", "fr": "Mes enregistrements"}}, + {"value": "g", "label": {"en": "Group Records", "de": "Gruppen-Datensätze", "fr": "Enregistrements du groupe"}}, + {"value": "n", "label": {"en": "No Access", "de": "Kein Zugriff", "fr": "Aucun accès"}} ]} ) create: Optional[AccessLevel] = Field( - None, + default=None, description="Create permission level (only for DATA context)", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [ - {"value": "a", "label": {"en": "All Records", "fr": "Tous les enregistrements"}}, - {"value": "m", "label": {"en": "My Records", "fr": "Mes enregistrements"}}, - {"value": "g", "label": {"en": "Group Records", "fr": "Enregistrements du groupe"}}, - {"value": "n", "label": {"en": "No Access", "fr": "Aucun accès"}} + {"value": "a", "label": {"en": "All Records", "de": "Alle Datensätze", "fr": "Tous les enregistrements"}}, + {"value": "m", "label": {"en": "My Records", "de": "Meine Datensätze", "fr": "Mes enregistrements"}}, + {"value": "g", "label": {"en": "Group Records", "de": "Gruppen-Datensätze", "fr": "Enregistrements du groupe"}}, + {"value": "n", "label": {"en": "No Access", "de": "Kein Zugriff", "fr": "Aucun accès"}} ]} ) update: Optional[AccessLevel] = Field( - None, + default=None, description="Update permission level (only for DATA context)", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [ - {"value": "a", "label": {"en": "All Records", "fr": "Tous les enregistrements"}}, - {"value": "m", "label": {"en": "My Records", "fr": "Mes enregistrements"}}, - {"value": "g", "label": {"en": "Group Records", "fr": "Enregistrements du groupe"}}, - {"value": "n", "label": {"en": "No Access", "fr": "Aucun accès"}} + {"value": "a", "label": {"en": "All Records", "de": "Alle Datensätze", "fr": "Tous les enregistrements"}}, + {"value": "m", "label": {"en": "My Records", "de": "Meine Datensätze", "fr": "Mes enregistrements"}}, + {"value": "g", "label": {"en": "Group Records", "de": "Gruppen-Datensätze", "fr": "Enregistrements du groupe"}}, + {"value": "n", "label": {"en": "No Access", "de": "Kein Zugriff", "fr": "Aucun accès"}} ]} ) delete: Optional[AccessLevel] = Field( - None, + default=None, description="Delete permission level (only for DATA context)", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [ - {"value": "a", "label": {"en": "All Records", "fr": "Tous les enregistrements"}}, - {"value": "m", "label": {"en": "My Records", "fr": "Mes enregistrements"}}, - {"value": "g", "label": {"en": "Group Records", "fr": "Enregistrements du groupe"}}, - {"value": "n", "label": {"en": "No Access", "fr": "Aucun accès"}} + {"value": "a", "label": {"en": "All Records", "de": "Alle Datensätze", "fr": "Tous les enregistrements"}}, + {"value": "m", "label": {"en": "My Records", "de": "Meine Datensätze", "fr": "Mes enregistrements"}}, + {"value": "g", "label": {"en": "Group Records", "de": "Gruppen-Datensätze", "fr": "Enregistrements du groupe"}}, + {"value": "n", "label": {"en": "No Access", "de": "Kein Zugriff", "fr": "Aucun accès"}} ]} ) + registerModelLabels( "AccessRule", - {"en": "Access Rule", "fr": "Règle d'accès"}, + {"en": "Access Rule", "de": "Zugriffsregel", "fr": "Règle d'accès"}, { - "id": {"en": "ID", "fr": "ID"}, - "roleLabel": {"en": "Role Label", "fr": "Label du rôle"}, - "context": {"en": "Context", "fr": "Contexte"}, - "item": {"en": "Item", "fr": "Élément"}, - "view": {"en": "View", "fr": "Vue"}, - "read": {"en": "Read", "fr": "Lecture"}, - "create": {"en": "Create", "fr": "Créer"}, - "update": {"en": "Update", "fr": "Mettre à jour"}, - "delete": {"en": "Delete", "fr": "Supprimer"}, + "id": {"en": "ID", "de": "ID", "fr": "ID"}, + "roleId": {"en": "Role", "de": "Rolle", "fr": "Rôle"}, + "context": {"en": "Context", "de": "Kontext", "fr": "Contexte"}, + "item": {"en": "Item", "de": "Element", "fr": "Élément"}, + "view": {"en": "View", "de": "Anzeigen", "fr": "Vue"}, + "read": {"en": "Read", "de": "Lesen", "fr": "Lecture"}, + "create": {"en": "Create", "de": "Erstellen", "fr": "Créer"}, + "update": {"en": "Update", "de": "Aktualisieren", "fr": "Mettre à jour"}, + "delete": {"en": "Delete", "de": "Löschen", "fr": "Supprimer"}, }, ) + + +# IMMUTABLE Fields Definition - für Enforcement auf Application-Level +IMMUTABLE_FIELDS = { + "Role": ["mandateId", "featureInstanceId", "featureCode"], + "AccessRule": ["context", "roleId"] +} + + +def validateUpdateNotImmutable(model: str, updateData: dict): + """ + Blockiert Updates auf immutable Felder. + Wirft ValueError wenn versucht wird, Kontext-Felder zu ändern. + + Args: + model: Model name (z.B. "Role", "AccessRule") + updateData: Dictionary mit Update-Daten + + Raises: + ValueError: Wenn immutable Felder im Update enthalten sind + """ + forbidden = IMMUTABLE_FIELDS.get(model, []) + violations = [f for f in forbidden if f in updateData] + + if violations: + raise ValueError( + f"Cannot update immutable fields on {model}: {violations}. " + f"Delete and recreate instead." + ) diff --git a/modules/datamodels/datamodelSecurity.py b/modules/datamodels/datamodelSecurity.py index eac51430..9fed9fa4 100644 --- a/modules/datamodels/datamodelSecurity.py +++ b/modules/datamodels/datamodelSecurity.py @@ -1,6 +1,13 @@ # Copyright (c) 2025 Patrick Motsch # All rights reserved. -"""Security models: Token and AuthEvent.""" +""" +Security models: Token and AuthEvent. + +Multi-Tenant Design: +- Token ist NICHT an einen Mandanten gebunden +- User arbeitet parallel in mehreren Mandanten (z.B. mehrere Browser-Tabs) +- Mandant-Kontext wird per Request-Header (X-Mandate-Id) bestimmt +""" from typing import Optional from pydantic import BaseModel, Field, ConfigDict @@ -17,6 +24,14 @@ class TokenStatus(str, Enum): class Token(BaseModel): + """ + Authentication Token model. + + Multi-Tenant Design: + - Token ist User-gebunden, NICHT Mandant-gebunden + - Ermöglicht parallele Arbeit in mehreren Mandanten + - Mandant-Kontext wird per Request-Header bestimmt + """ id: Optional[str] = None userId: str authority: AuthAuthority @@ -45,37 +60,36 @@ class Token(BaseModel): sessionId: Optional[str] = Field( None, description="Logical session grouping for logout revocation" ) - mandateId: Optional[str] = Field( - None, description="Mandate ID for tenant scoping of the token" - ) + # ENTFERNT: mandateId - Token ist nicht mehr Mandant-spezifisch + # Mandant-Kontext wird per Request-Header (X-Mandate-Id) bestimmt model_config = ConfigDict(use_enum_values=True) registerModelLabels( "Token", - {"en": "Token", "fr": "Jeton"}, + {"en": "Token", "de": "Token", "fr": "Jeton"}, { - "id": {"en": "ID", "fr": "ID"}, - "userId": {"en": "User ID", "fr": "ID utilisateur"}, - "authority": {"en": "Authority", "fr": "Autorité"}, - "connectionId": {"en": "Connection ID", "fr": "ID de connexion"}, - "tokenAccess": {"en": "Access Token", "fr": "Jeton d'accès"}, - "tokenType": {"en": "Token Type", "fr": "Type de jeton"}, - "expiresAt": {"en": "Expires At", "fr": "Expire le"}, - "tokenRefresh": {"en": "Refresh Token", "fr": "Jeton de rafraîchissement"}, - "createdAt": {"en": "Created At", "fr": "Créé le"}, - "status": {"en": "Status", "fr": "Statut"}, - "revokedAt": {"en": "Revoked At", "fr": "Révoqué le"}, - "revokedBy": {"en": "Revoked By", "fr": "Révoqué par"}, - "reason": {"en": "Reason", "fr": "Raison"}, - "sessionId": {"en": "Session ID", "fr": "ID de session"}, - "mandateId": {"en": "Mandate ID", "fr": "ID de mandat"}, + "id": {"en": "ID", "de": "ID", "fr": "ID"}, + "userId": {"en": "User ID", "de": "Benutzer-ID", "fr": "ID utilisateur"}, + "authority": {"en": "Authority", "de": "Autorität", "fr": "Autorité"}, + "connectionId": {"en": "Connection ID", "de": "Verbindungs-ID", "fr": "ID de connexion"}, + "tokenAccess": {"en": "Access Token", "de": "Zugriffstoken", "fr": "Jeton d'accès"}, + "tokenType": {"en": "Token Type", "de": "Token-Typ", "fr": "Type de jeton"}, + "expiresAt": {"en": "Expires At", "de": "Läuft ab am", "fr": "Expire le"}, + "tokenRefresh": {"en": "Refresh Token", "de": "Refresh-Token", "fr": "Jeton de rafraîchissement"}, + "createdAt": {"en": "Created At", "de": "Erstellt am", "fr": "Créé le"}, + "status": {"en": "Status", "de": "Status", "fr": "Statut"}, + "revokedAt": {"en": "Revoked At", "de": "Widerrufen am", "fr": "Révoqué le"}, + "revokedBy": {"en": "Revoked By", "de": "Widerrufen von", "fr": "Révoqué par"}, + "reason": {"en": "Reason", "de": "Grund", "fr": "Raison"}, + "sessionId": {"en": "Session ID", "de": "Sitzungs-ID", "fr": "ID de session"}, }, ) class AuthEvent(BaseModel): + """Authentication event for audit logging.""" id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the auth event", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) userId: str = Field(description="ID of the user this event belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}) eventType: str = Field(description="Type of authentication event (e.g., 'login', 'logout', 'token_refresh')", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}) @@ -88,15 +102,15 @@ class AuthEvent(BaseModel): registerModelLabels( "AuthEvent", - {"en": "Authentication Event", "fr": "Événement d'authentification"}, + {"en": "Authentication Event", "de": "Authentifizierungsereignis", "fr": "Événement d'authentification"}, { - "id": {"en": "ID", "fr": "ID"}, - "userId": {"en": "User ID", "fr": "ID utilisateur"}, - "eventType": {"en": "Event Type", "fr": "Type d'événement"}, - "timestamp": {"en": "Timestamp", "fr": "Horodatage"}, - "ipAddress": {"en": "IP Address", "fr": "Adresse IP"}, - "userAgent": {"en": "User Agent", "fr": "Agent utilisateur"}, - "success": {"en": "Success", "fr": "Succès"}, - "details": {"en": "Details", "fr": "Détails"}, + "id": {"en": "ID", "de": "ID", "fr": "ID"}, + "userId": {"en": "User ID", "de": "Benutzer-ID", "fr": "ID utilisateur"}, + "eventType": {"en": "Event Type", "de": "Ereignistyp", "fr": "Type d'événement"}, + "timestamp": {"en": "Timestamp", "de": "Zeitstempel", "fr": "Horodatage"}, + "ipAddress": {"en": "IP Address", "de": "IP-Adresse", "fr": "Adresse IP"}, + "userAgent": {"en": "User Agent", "de": "User-Agent", "fr": "Agent utilisateur"}, + "success": {"en": "Success", "de": "Erfolgreich", "fr": "Succès"}, + "details": {"en": "Details", "de": "Details", "fr": "Détails"}, }, ) diff --git a/modules/datamodels/datamodelUam.py b/modules/datamodels/datamodelUam.py index adea38b0..f1c5da33 100644 --- a/modules/datamodels/datamodelUam.py +++ b/modules/datamodels/datamodelUam.py @@ -1,11 +1,18 @@ # Copyright (c) 2025 Patrick Motsch # All rights reserved. -"""UAM models: User, Mandate, UserConnection.""" +""" +UAM models: User, Mandate, UserConnection. + +Multi-Tenant Design: +- User gehört NICHT direkt zu einem Mandanten +- Zugehörigkeit wird über UserMandate gesteuert (siehe datamodelMembership.py) +- isSysAdmin ist globales Admin-Flag für System-Zugriff (KEIN Daten-Zugriff!) +""" import uuid from typing import Optional, List from enum import Enum -from pydantic import BaseModel, Field, EmailStr +from pydantic import BaseModel, Field, EmailStr, field_validator from modules.shared.attributeUtils import registerModelLabels from modules.shared.timeUtils import getUtcTimestamp @@ -51,7 +58,12 @@ class UserPermissions(BaseModel): description="Delete permission level" ) + class Mandate(BaseModel): + """ + Mandate (Mandant/Tenant) model. + Ein Mandant ist ein isolierter Bereich für Daten und Berechtigungen. + """ id: str = Field( default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the mandate", @@ -61,37 +73,24 @@ class Mandate(BaseModel): description="Name of the mandate", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True} ) - language: str = Field( - default="en", - description="Default language of the mandate", - json_schema_extra={ - "frontend_type": "select", - "frontend_readonly": False, - "frontend_required": True, - "frontend_options": [ - {"value": "de", "label": {"en": "Deutsch", "fr": "Allemand"}}, - {"value": "en", "label": {"en": "English", "fr": "Anglais"}}, - {"value": "fr", "label": {"en": "Français", "fr": "Français"}}, - {"value": "it", "label": {"en": "Italiano", "fr": "Italien"}}, - ] - } - ) enabled: bool = Field( default=True, description="Indicates whether the mandate is enabled", json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False} ) + + registerModelLabels( "Mandate", - {"en": "Mandate", "fr": "Mandat"}, + {"en": "Mandate", "de": "Mandant", "fr": "Mandat"}, { - "id": {"en": "ID", "fr": "ID"}, - "name": {"en": "Name", "fr": "Nom"}, - "language": {"en": "Language", "fr": "Langue"}, - "enabled": {"en": "Enabled", "fr": "Activé"}, + "id": {"en": "ID", "de": "ID", "fr": "ID"}, + "name": {"en": "Name", "de": "Name", "fr": "Nom"}, + "enabled": {"en": "Enabled", "de": "Aktiviert", "fr": "Activé"}, }, ) + class UserConnection(BaseModel): id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the connection", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) userId: str = Field(description="ID of the user this connection belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) @@ -109,70 +108,123 @@ class UserConnection(BaseModel): {"value": "none", "label": {"en": "None", "fr": "Aucun"}}, ]}) tokenExpiresAt: Optional[float] = Field(None, description="When the current token expires (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}) + + registerModelLabels( "UserConnection", - {"en": "User Connection", "fr": "Connexion utilisateur"}, + {"en": "User Connection", "de": "Benutzerverbindung", "fr": "Connexion utilisateur"}, { - "id": {"en": "ID", "fr": "ID"}, - "userId": {"en": "User ID", "fr": "ID utilisateur"}, - "authority": {"en": "Authority", "fr": "Autorité"}, - "externalId": {"en": "External ID", "fr": "ID externe"}, - "externalUsername": {"en": "External Username", "fr": "Nom d'utilisateur externe"}, - "externalEmail": {"en": "External Email", "fr": "Email externe"}, - "status": {"en": "Status", "fr": "Statut"}, - "connectedAt": {"en": "Connected At", "fr": "Connecté le"}, - "lastChecked": {"en": "Last Checked", "fr": "Dernière vérification"}, - "expiresAt": {"en": "Expires At", "fr": "Expire le"}, - "tokenStatus": {"en": "Connection Status", "fr": "Statut de connexion"}, - "tokenExpiresAt": {"en": "Expires At", "fr": "Expire le"}, + "id": {"en": "ID", "de": "ID", "fr": "ID"}, + "userId": {"en": "User ID", "de": "Benutzer-ID", "fr": "ID utilisateur"}, + "authority": {"en": "Authority", "de": "Autorität", "fr": "Autorité"}, + "externalId": {"en": "External ID", "de": "Externe ID", "fr": "ID externe"}, + "externalUsername": {"en": "External Username", "de": "Externer Benutzername", "fr": "Nom d'utilisateur externe"}, + "externalEmail": {"en": "External Email", "de": "Externe E-Mail", "fr": "Email externe"}, + "status": {"en": "Status", "de": "Status", "fr": "Statut"}, + "connectedAt": {"en": "Connected At", "de": "Verbunden am", "fr": "Connecté le"}, + "lastChecked": {"en": "Last Checked", "de": "Zuletzt geprüft", "fr": "Dernière vérification"}, + "expiresAt": {"en": "Expires At", "de": "Läuft ab am", "fr": "Expire le"}, + "tokenStatus": {"en": "Connection Status", "de": "Verbindungsstatus", "fr": "Statut de connexion"}, + "tokenExpiresAt": {"en": "Expires At", "de": "Läuft ab am", "fr": "Expire le"}, }, ) + class User(BaseModel): - id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the user", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) - username: str = Field(description="Username for login", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True}) - email: Optional[EmailStr] = Field(None, description="Email address of the user", json_schema_extra={"frontend_type": "email", "frontend_readonly": False, "frontend_required": True}) - fullName: Optional[str] = Field(None, description="Full name of the user", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False}) - language: str = Field(default="en", description="Preferred language of the user", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_options": [ - {"value": "de", "label": {"en": "Deutsch", "fr": "Allemand"}}, - {"value": "en", "label": {"en": "English", "fr": "Anglais"}}, - {"value": "fr", "label": {"en": "Français", "fr": "Français"}}, - {"value": "it", "label": {"en": "Italiano", "fr": "Italien"}}, - ]}) - enabled: bool = Field(default=True, description="Indicates whether the user is enabled", json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}) - roleLabels: List[str] = Field( - default_factory=list, - description="List of role labels assigned to this user. All roles are opening roles (union) - if one role enables something, it is enabled.", - json_schema_extra={"frontend_type": "multiselect", "frontend_readonly": False, "frontend_required": True, "frontend_options": "user.role"} + """ + User model. + + Multi-Tenant Design: + - User gehört NICHT direkt zu einem Mandanten + - Zugehörigkeit wird über UserMandate gesteuert (siehe datamodelMembership.py) + - Rollen werden über UserMandateRole gesteuert + - isSysAdmin = System-Zugriff, KEIN Daten-Zugriff + """ + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Unique ID of the user", + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} ) - authenticationAuthority: AuthAuthority = Field(default=AuthAuthority.LOCAL, description="Primary authentication authority", json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": False, "frontend_options": "auth.authority"}) - mandateId: Optional[str] = Field(None, description="ID of the mandate this user belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) + username: str = Field( + description="Username for login", + json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True} + ) + email: Optional[EmailStr] = Field( + default=None, + description="Email address of the user", + json_schema_extra={"frontend_type": "email", "frontend_readonly": False, "frontend_required": True} + ) + fullName: Optional[str] = Field( + default=None, + description="Full name of the user", + json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False} + ) + language: str = Field( + default="en", + description="Preferred language of the user", + json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_options": [ + {"value": "de", "label": {"en": "Deutsch", "fr": "Allemand"}}, + {"value": "en", "label": {"en": "English", "fr": "Anglais"}}, + {"value": "fr", "label": {"en": "Français", "fr": "Français"}}, + {"value": "it", "label": {"en": "Italiano", "fr": "Italien"}}, + ]} + ) + enabled: bool = Field( + default=True, + description="Indicates whether the user is enabled", + json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False} + ) + + isSysAdmin: bool = Field( + default=False, + description="Global SysAdmin flag. SysAdmin = System-Zugriff, KEIN Daten-Zugriff!", + json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False} + ) + + @field_validator('isSysAdmin', mode='before') + @classmethod + def _coerceIsSysAdmin(cls, v): + """Konvertiert None zu False (für bestehende DB-Einträge ohne isSysAdmin Feld).""" + if v is None: + return False + return v + + authenticationAuthority: AuthAuthority = Field( + default=AuthAuthority.LOCAL, + description="Primary authentication authority", + json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": False, "frontend_options": "auth.authority"} + ) + + registerModelLabels( "User", - {"en": "User", "fr": "Utilisateur"}, + {"en": "User", "de": "Benutzer", "fr": "Utilisateur"}, { - "id": {"en": "ID", "fr": "ID"}, - "username": {"en": "Username", "fr": "Nom d'utilisateur"}, - "email": {"en": "Email", "fr": "Email"}, - "fullName": {"en": "Full Name", "fr": "Nom complet"}, - "language": {"en": "Language", "fr": "Langue"}, - "enabled": {"en": "Enabled", "fr": "Activé"}, - "roleLabels": {"en": "Role Labels", "fr": "Labels de rôle"}, - "authenticationAuthority": {"en": "Auth Authority", "fr": "Autorité d'authentification"}, - "mandateId": {"en": "Mandate ID", "fr": "ID de mandat"}, + "id": {"en": "ID", "de": "ID", "fr": "ID"}, + "username": {"en": "Username", "de": "Benutzername", "fr": "Nom d'utilisateur"}, + "email": {"en": "Email", "de": "E-Mail", "fr": "Email"}, + "fullName": {"en": "Full Name", "de": "Vollständiger Name", "fr": "Nom complet"}, + "language": {"en": "Language", "de": "Sprache", "fr": "Langue"}, + "enabled": {"en": "Enabled", "de": "Aktiviert", "fr": "Activé"}, + "isSysAdmin": {"en": "System Admin", "de": "System-Admin", "fr": "Admin système"}, + "authenticationAuthority": {"en": "Auth Authority", "de": "Authentifizierung", "fr": "Autorité d'authentification"}, }, ) + class UserInDB(User): + """User model with password hash for database storage.""" hashedPassword: Optional[str] = Field(None, description="Hash of the user password") resetToken: Optional[str] = Field(None, description="Password reset token (UUID)") resetTokenExpires: Optional[float] = Field(None, description="Reset token expiration (UTC timestamp in seconds)") + + registerModelLabels( "UserInDB", - {"en": "User Access", "fr": "Accès de l'utilisateur"}, + {"en": "User Access", "de": "Benutzerzugang", "fr": "Accès de l'utilisateur"}, { - "hashedPassword": {"en": "Password hash", "fr": "Hachage de mot de passe"}, - "resetToken": {"en": "Reset Token", "fr": "Jeton de réinitialisation"}, - "resetTokenExpires": {"en": "Reset Token Expires", "fr": "Expiration du jeton"}, + "hashedPassword": {"en": "Password hash", "de": "Passwort-Hash", "fr": "Hachage de mot de passe"}, + "resetToken": {"en": "Reset Token", "de": "Reset-Token", "fr": "Jeton de réinitialisation"}, + "resetTokenExpires": {"en": "Reset Token Expires", "de": "Token läuft ab", "fr": "Expiration du jeton"}, }, ) diff --git a/modules/features/chatbot/mainChatbot.py b/modules/features/chatbot/mainChatbot.py index eaaf8b43..43503339 100644 --- a/modules/features/chatbot/mainChatbot.py +++ b/modules/features/chatbot/mainChatbot.py @@ -62,6 +62,7 @@ def _extractJsonFromResponse(content: str) -> Optional[dict]: async def chatProcess( currentUser: User, + mandateId: str, userInput: UserInputRequest, workflowId: Optional[str] = None ) -> ChatWorkflow: @@ -76,6 +77,7 @@ async def chatProcess( Args: currentUser: Current user + mandateId: Mandate context (from RequestContext / X-Mandate-Id header) userInput: User input request workflowId: Optional workflow ID to continue existing conversation @@ -83,8 +85,8 @@ async def chatProcess( ChatWorkflow instance """ try: - # Get services - services = getServices(currentUser, None) + # Get services with mandate context + services = getServices(currentUser, None, mandateId=mandateId) interfaceDbChat = services.interfaceDbChat # Get event manager and create queue if needed @@ -120,7 +122,7 @@ async def chatProcess( # Create new workflow workflowData = { "id": str(uuid.uuid4()), - "mandateId": currentUser.mandateId, + "mandateId": mandateId, "status": "running", "name": conversation_name, "currentRound": 1, @@ -687,12 +689,13 @@ async def _convert_file_ids_to_document_references( # Search database if not found in messages if not document_id: try: - from modules.shared.databaseUtils import getRecordsetWithRBAC + from modules.interfaces.interfaceRbac import getRecordsetWithRBAC documents = getRecordsetWithRBAC( services.interfaceDbChat.db, ChatDocument, - services.currentUser, - recordFilter={"fileId": file_id} + services.user, + recordFilter={"fileId": file_id}, + mandateId=services.mandateId ) if documents: workflow_message_ids = {msg.id for msg in workflow.messages} if workflow.messages else set() diff --git a/modules/features/dynamicOptions/mainDynamicOptions.py b/modules/features/dynamicOptions/mainDynamicOptions.py index b75e906b..e8c9a4ff 100644 --- a/modules/features/dynamicOptions/mainDynamicOptions.py +++ b/modules/features/dynamicOptions/mainDynamicOptions.py @@ -126,7 +126,7 @@ def getOptions(optionsName: str, services, currentUser: Optional[User] = None) - return [] try: - users = services.interfaceDbApp.getUsersByMandate(currentUser.mandateId) + users = services.interfaceDbApp.getUsersByMandate(services.mandateId) # Handle both list and PaginatedResult if hasattr(users, 'items'): diff --git a/modules/features/neutralizePlayground/mainNeutralizePlayground.py b/modules/features/neutralizePlayground/mainNeutralizePlayground.py index 80e0e03b..d10dc8ec 100644 --- a/modules/features/neutralizePlayground/mainNeutralizePlayground.py +++ b/modules/features/neutralizePlayground/mainNeutralizePlayground.py @@ -15,9 +15,10 @@ logger = logging.getLogger(__name__) class NeutralizationPlayground: """Feature/UI wrapper around NeutralizationService for playground & routes.""" - def __init__(self, currentUser: User): + def __init__(self, currentUser: User, mandateId: str): self.currentUser = currentUser - self.services = getServices(currentUser, None) + self.mandateId = mandateId + self.services = getServices(currentUser, None, mandateId=mandateId) def processText(self, text: str) -> Dict[str, Any]: return self.services.neutralization.processText(text) @@ -81,7 +82,7 @@ class NeutralizationPlayground: 'total_attributes': len(allAttributes), 'unique_files': len(uniqueFiles), 'pattern_counts': patternCounts, - 'mandate_id': self.currentUser.mandateId if self.currentUser else None, + 'mandate_id': self.mandateId, } except Exception as e: logger.error(f"Error getting stats: {str(e)}") diff --git a/modules/features/realEstate/mainRealEstate.py b/modules/features/realEstate/mainRealEstate.py index 149fd8d8..37c4a3cd 100644 --- a/modules/features/realEstate/mainRealEstate.py +++ b/modules/features/realEstate/mainRealEstate.py @@ -346,6 +346,7 @@ async def fetch_parcel_polygon_from_swisstopo( async def executeDirectQuery( currentUser: User, + mandateId: str, queryText: str, parameters: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: @@ -354,6 +355,7 @@ async def executeDirectQuery( Args: currentUser: Current authenticated user + mandateId: Mandate context (from RequestContext / X-Mandate-Id header) queryText: SQL query text parameters: Optional parameters for parameterized queries @@ -364,16 +366,15 @@ async def executeDirectQuery( - No session or query history is saved - Query is executed directly and result is returned - For production, validate and sanitize queries before execution - - TODO: Implement actual database query execution via interface """ try: - logger.info(f"Executing direct query for user {currentUser.id} (mandate: {currentUser.mandateId})") + logger.info(f"Executing direct query for user {currentUser.id} (mandate: {mandateId})") logger.debug(f"Query text: {queryText}") if parameters: logger.debug(f"Query parameters: {parameters}") # Execute query via Real Estate interface (stateless) - realEstateInterface = getRealEstateInterface(currentUser) + realEstateInterface = getRealEstateInterface(currentUser, mandateId=mandateId) result = realEstateInterface.executeQuery(queryText, parameters) logger.info( @@ -529,6 +530,7 @@ def _formatEntitySummary(entity_type: str, items: List[Dict[str, Any]], filters: async def processNaturalLanguageCommand( currentUser: User, + mandateId: str, userInput: str, ) -> Dict[str, Any]: """ @@ -539,6 +541,7 @@ async def processNaturalLanguageCommand( Args: currentUser: Current authenticated user + mandateId: Mandate context (from RequestContext / X-Mandate-Id header) userInput: Natural language command from user Returns: @@ -552,11 +555,11 @@ async def processNaturalLanguageCommand( - "SELECT * FROM Projekt WHERE plz = '8000'" """ try: - logger.info(f"Processing natural language command for user {currentUser.id} (mandate: {currentUser.mandateId})") + logger.info(f"Processing natural language command for user {currentUser.id} (mandate: {mandateId})") logger.debug(f"User input: {userInput}") # Initialize services for AI access - services = getServices(currentUser, workflow=None) + services = getServices(currentUser, workflow=None, mandateId=mandateId) aiService = services.ai # Step 1: Analyze user intent with AI @@ -567,6 +570,7 @@ async def processNaturalLanguageCommand( # Step 2: Execute CRUD operation based on intent result = await executeIntentBasedOperation( currentUser=currentUser, + mandateId=mandateId, intent=intentAnalysis["intent"], entity=intentAnalysis.get("entity"), parameters=intentAnalysis.get("parameters", {}), @@ -839,6 +843,7 @@ IMPORTANT EXTRACTION RULES: async def executeIntentBasedOperation( currentUser: User, + mandateId: str, intent: str, entity: Optional[str], parameters: Dict[str, Any], @@ -848,6 +853,7 @@ async def executeIntentBasedOperation( Args: currentUser: Current authenticated user + mandateId: Mandate context (from RequestContext / X-Mandate-Id header) intent: Intent from AI analysis (CREATE, READ, UPDATE, DELETE, QUERY) entity: Entity type from AI analysis parameters: Extracted parameters from AI analysis @@ -856,8 +862,8 @@ async def executeIntentBasedOperation( Operation result Note: - - TODO: Implement actual interface calls once datamodels are ready - - Currently returns test responses showing what would be executed + - Supports CREATE, READ, UPDATE, DELETE, QUERY intents + - Entity types: Projekt, Parzelle, Gemeinde, Kanton, Land, Dokument """ try: logger.info(f"Executing intent-based operation: intent={intent}, entity={entity}") @@ -872,6 +878,7 @@ async def executeIntentBasedOperation( result = await executeDirectQuery( currentUser=currentUser, + mandateId=mandateId, queryText=queryText, parameters=parameters.get("queryParameters"), ) @@ -879,12 +886,12 @@ async def executeIntentBasedOperation( elif intent == "CREATE": # Create new entity - realEstateInterface = getRealEstateInterface(currentUser) + realEstateInterface = getRealEstateInterface(currentUser, mandateId=mandateId) if entity == "Projekt": # Create Projekt from parameters projekt = Projekt( - mandateId=currentUser.mandateId, + mandateId=mandateId, label=parameters.get("label", ""), statusProzess=StatusProzess(parameters.get("statusProzess", "EINGANG")) if parameters.get("statusProzess") else None, ) @@ -902,7 +909,7 @@ async def executeIntentBasedOperation( # Build parzelle data with all extracted parameters parzelle_data = { - "mandateId": currentUser.mandateId, + "mandateId": mandateId, "label": parameters.get("label", ""), } @@ -985,7 +992,7 @@ async def executeIntentBasedOperation( # Create Gemeinde from parameters from modules.datamodels.datamodelRealEstate import Gemeinde gemeinde = Gemeinde( - mandateId=currentUser.mandateId, + mandateId=mandateId, label=parameters.get("label", ""), id_kanton=parameters.get("id_kanton"), plz=parameters.get("plz"), @@ -1000,7 +1007,7 @@ async def executeIntentBasedOperation( # Create Kanton from parameters from modules.datamodels.datamodelRealEstate import Kanton kanton = Kanton( - mandateId=currentUser.mandateId, + mandateId=mandateId, label=parameters.get("label", ""), id_land=parameters.get("id_land"), abk=parameters.get("abk"), @@ -1015,7 +1022,7 @@ async def executeIntentBasedOperation( # Create Land from parameters from modules.datamodels.datamodelRealEstate import Land land = Land( - mandateId=currentUser.mandateId, + mandateId=mandateId, label=parameters.get("label", ""), abk=parameters.get("abk"), ) @@ -1029,7 +1036,7 @@ async def executeIntentBasedOperation( # Create Dokument from parameters from modules.datamodels.datamodelRealEstate import Dokument dokument = Dokument( - mandateId=currentUser.mandateId, + mandateId=mandateId, label=parameters.get("label", ""), dokumentReferenz=parameters.get("dokumentReferenz", ""), versionsbezeichnung=parameters.get("versionsbezeichnung"), @@ -1474,6 +1481,7 @@ async def executeIntentBasedOperation( async def create_project_with_parcel_data( currentUser: User, + mandateId: str, projekt_label: str, parzellen_data: List[Dict[str, Any]], status_prozess: Optional[str] = None, @@ -1483,6 +1491,7 @@ async def create_project_with_parcel_data( Args: currentUser: Current authenticated user + mandateId: Mandate context (from RequestContext / X-Mandate-Id header) projekt_label: Label for the Projekt parzellen_data: List of dictionaries containing parcel information from request status_prozess: Optional project status (defaults to "Eingang") @@ -1496,8 +1505,8 @@ async def create_project_with_parcel_data( try: logger.info(f"Creating project '{projekt_label}' with {len(parzellen_data)} parcel(s) for user {currentUser.id}") - # Get interface - realEstateInterface = getRealEstateInterface(currentUser) + # Get interface with mandate context + realEstateInterface = getRealEstateInterface(currentUser, mandateId=mandateId) # Validate required fields if not projekt_label: @@ -1587,7 +1596,7 @@ async def create_project_with_parcel_data( # Check if Parzelle with this label already exists existing_parzellen = realEstateInterface.getParzellen( - recordFilter={"label": parcel_label, "mandateId": currentUser.mandateId} + recordFilter={"label": parcel_label, "mandateId": mandateId} ) if existing_parzellen and len(existing_parzellen) > 0: @@ -1630,7 +1639,7 @@ async def create_project_with_parcel_data( if not laender: logger.info("Creating Land 'Schweiz'") land = Land( - mandateId=currentUser.mandateId, + mandateId=mandateId, label="Schweiz", abk="CH" ) @@ -1648,7 +1657,7 @@ async def create_project_with_parcel_data( logger.info(f"Kanton '{canton_abk}' not found, creating it") kanton_label = canton_names.get(canton_abk, canton_abk) # Use mapping or fallback to abk kanton = Kanton( - mandateId=currentUser.mandateId, + mandateId=mandateId, label=kanton_label, abk=canton_abk, id_land=land.id @@ -1668,7 +1677,7 @@ async def create_project_with_parcel_data( if not gemeinden: logger.info(f"Gemeinde '{municipality_name}' in Kanton '{canton_abk}' not found, creating it") gemeinde = Gemeinde( - mandateId=currentUser.mandateId, + mandateId=mandateId, label=municipality_name, id_kanton=kanton.id, plz=parzelle_data.get("plz") # Use PLZ directly from Swiss Topo API @@ -1837,7 +1846,7 @@ async def create_project_with_parcel_data( # Build Parzelle data parzelle_create_data = { - "mandateId": currentUser.mandateId, + "mandateId": mandateId, "label": parcel_label, # Use the label we determined earlier for uniqueness check "parzellenAliasTags": alias_tags, "eigentuemerschaft": None, @@ -1979,7 +1988,7 @@ async def create_project_with_parcel_data( project_perimeter = created_parzellen[0].perimeter if created_parzellen else None projekt_create_data = { - "mandateId": currentUser.mandateId, + "mandateId": mandateId, "label": projekt_label, "statusProzess": status_prozess_enum, "perimeter": project_perimeter, # Use first parcel perimeter as project perimeter diff --git a/modules/features/workflow/mainWorkflow.py b/modules/features/workflow/mainWorkflow.py index 08205fcc..70a2e9aa 100644 --- a/modules/features/workflow/mainWorkflow.py +++ b/modules/features/workflow/mainWorkflow.py @@ -83,8 +83,8 @@ async def executeAutomation(automationId: str, services) -> ChatWorkflow: executionLog["messages"].append(f"Started execution at {executionStartTime}") # 2. Replace placeholders in template to generate plan - template = automation.get("template", "") - placeholders = automation.get("placeholders", {}) + template = automation.template or "" + placeholders = automation.placeholders or {} planJson = replacePlaceholders(template, placeholders) try: plan = json.loads(planJson) @@ -102,7 +102,7 @@ async def executeAutomation(automationId: str, services) -> ChatWorkflow: executionLog["messages"].append("Template placeholders replaced successfully") # 3. Get user who created automation - creatorUserId = automation.get("_createdBy") + creatorUserId = getattr(automation, "_createdBy", None) # CRITICAL: Automation MUST run as creator user only, or fail if not creatorUserId: @@ -147,13 +147,13 @@ async def executeAutomation(automationId: str, services) -> ChatWorkflow: logger.info(f"Started workflow {workflow.id} with plan containing {len(plan.get('tasks', []))} tasks (plan embedded in userInput)") # Set workflow name with "automated" prefix - automationLabel = automation.get("label", "Unknown Automation") + automationLabel = automation.label or "Unknown Automation" workflowName = f"automated: {automationLabel}" workflow = services.interfaceDbChat.updateWorkflow(workflow.id, {"name": workflowName}) logger.info(f"Set workflow {workflow.id} name to: {workflowName}") # Update automation with execution log - executionLogs = automation.get("executionLogs", []) + executionLogs = list(automation.executionLogs or []) executionLogs.append(executionLog) # Keep only last 50 executions if len(executionLogs) > 50: @@ -174,7 +174,7 @@ async def executeAutomation(automationId: str, services) -> ChatWorkflow: try: automation = services.interfaceDbChat.getAutomationDefinition(automationId) if automation: - executionLogs = automation.get("executionLogs", []) + executionLogs = list(automation.executionLogs or []) executionLogs.append(executionLog) if len(executionLogs) > 50: executionLogs = executionLogs[-50:] @@ -204,10 +204,10 @@ async def syncAutomationEvents(services, eventUser) -> Dict[str, Any]: registeredEvents = {} for automation in filtered: - automationId = automation.get("id") - isActive = automation.get("active", False) - currentEventId = automation.get("eventId") - schedule = automation.get("schedule") + automationId = automation.id + isActive = automation.active if hasattr(automation, 'active') else False + currentEventId = automation.eventId if hasattr(automation, 'eventId') else None + schedule = automation.schedule if hasattr(automation, 'schedule') else None if not schedule: logger.warning(f"Automation {automationId} has no schedule, skipping") @@ -288,12 +288,12 @@ def createAutomationEventHandler(automationId: str, eventUser): # Load automation using event user context automation = eventServices.interfaceDbChat.getAutomationDefinition(automationId) - if not automation or not automation.get("active"): + if not automation or not getattr(automation, "active", False): logger.warning(f"Automation {automationId} not found or not active, skipping execution") return # Get creator user - creatorUserId = automation.get("_createdBy") + creatorUserId = getattr(automation, "_createdBy", None) if not creatorUserId: logger.error(f"Automation {automationId} has no creator user") return diff --git a/modules/interfaces/interfaceBootstrap.py b/modules/interfaces/interfaceBootstrap.py index f17f2cd7..95b660ec 100644 --- a/modules/interfaces/interfaceBootstrap.py +++ b/modules/interfaces/interfaceBootstrap.py @@ -3,10 +3,15 @@ """ Centralized bootstrap interface for system initialization. Contains all bootstrap logic including mandate, users, and RBAC rules. + +Multi-Tenant Design: +- Rollen werden mit Kontext erstellt (mandateId=None für globale Template-Rollen) +- AccessRules referenzieren roleId (FK), nicht roleLabel +- Admin-User bekommt isSysAdmin=True statt roleLabels """ import logging -from typing import Optional, List, Dict, Any +from typing import Optional, Dict from passlib.context import CryptContext from modules.connectors.connectorDbPostgre import DatabaseConnector from modules.shared.configuration import APP_CONFIG @@ -21,12 +26,19 @@ from modules.datamodels.datamodelRbac import ( Role, ) from modules.datamodels.datamodelUam import AccessLevel +from modules.datamodels.datamodelMembership import ( + UserMandate, + UserMandateRole, +) logger = logging.getLogger(__name__) # Password-Hashing pwdContext = CryptContext(schemes=["argon2"], deprecated="auto") +# Cache für Role-IDs (roleLabel -> roleId) +_roleIdCache: Dict[str, str] = {} + def initBootstrap(db: DatabaseConnector) -> None: """ @@ -40,21 +52,24 @@ def initBootstrap(db: DatabaseConnector) -> None: # Initialize root mandate mandateId = initRootMandate(db) + # Initialize roles FIRST (needed for AccessRules) + initRoles(db) + + # Initialize RBAC rules (uses roleIds from roles) + initRbacRules(db) + # Initialize admin user adminUserId = initAdminUser(db, mandateId) # Initialize event user eventUserId = initEventUser(db, mandateId) - # Initialize roles - initRoles(db) + # Assign initial user memberships (via UserMandate + UserMandateRole) + if adminUserId and eventUserId and mandateId: + assignInitialUserMemberships(db, mandateId, adminUserId, eventUserId) - # Initialize RBAC rules - initRbacRules(db) - - # Assign initial user roles - if adminUserId and eventUserId: - assignInitialUserRoles(db, adminUserId, eventUserId) + # Apply multi-tenant database optimizations (indexes, triggers, FKs) + _applyDatabaseOptimizations(db) logger.info("System bootstrap completed") @@ -76,7 +91,7 @@ def initRootMandate(db: DatabaseConnector) -> Optional[str]: return mandateId logger.info("Creating Root mandate") - rootMandate = Mandate(name="Root", language="en", enabled=True) + rootMandate = Mandate(name="Root", enabled=True) createdMandate = db.recordCreate(Mandate, rootMandate) mandateId = createdMandate.get("id") logger.info(f"Root mandate created with ID {mandateId}") @@ -86,10 +101,12 @@ def initRootMandate(db: DatabaseConnector) -> Optional[str]: def initAdminUser(db: DatabaseConnector, mandateId: Optional[str]) -> Optional[str]: """ Creates the Admin user if it doesn't exist. + Admin user gets isSysAdmin=True for system-level access. + Role assignment is done via UserMandate + UserMandateRole in assignInitialUserMemberships(). Args: db: Database connector instance - mandateId: Root mandate ID + mandateId: Root mandate ID (for membership assignment, not on User) Returns: User ID if created or found, None otherwise @@ -102,16 +119,14 @@ def initAdminUser(db: DatabaseConnector, mandateId: Optional[str]) -> Optional[s logger.info("Creating Admin user") adminUser = UserInDB( - mandateId=mandateId, username="admin", email="admin@example.com", fullName="Administrator", enabled=True, language="en", - roleLabels=["sysadmin"], + isSysAdmin=True, authenticationAuthority=AuthAuthority.LOCAL, hashedPassword=_getPasswordHash(APP_CONFIG.get("APP_INIT_PASS_ADMIN_SECRET")), - connections=[], ) createdUser = db.recordCreate(UserInDB, adminUser) userId = createdUser.get("id") @@ -122,10 +137,12 @@ def initAdminUser(db: DatabaseConnector, mandateId: Optional[str]) -> Optional[s def initEventUser(db: DatabaseConnector, mandateId: Optional[str]) -> Optional[str]: """ Creates the Event user if it doesn't exist. + Event user gets isSysAdmin=True for system operations. + Role assignment is done via UserMandate + UserMandateRole in assignInitialUserMemberships(). Args: db: Database connector instance - mandateId: Root mandate ID + mandateId: Root mandate ID (for membership assignment, not on User) Returns: User ID if created or found, None otherwise @@ -138,16 +155,14 @@ def initEventUser(db: DatabaseConnector, mandateId: Optional[str]) -> Optional[s logger.info("Creating Event user") eventUser = UserInDB( - mandateId=mandateId, username="event", email="event@example.com", fullName="Event", enabled=True, language="en", - roleLabels=["sysadmin"], + isSysAdmin=True, authenticationAuthority=AuthAuthority.LOCAL, hashedPassword=_getPasswordHash(APP_CONFIG.get("APP_INIT_PASS_EVENT_SECRET")), - connections=[], ) createdUser = db.recordCreate(UserInDB, eventUser) userId = createdUser.get("id") @@ -158,56 +173,99 @@ def initEventUser(db: DatabaseConnector, mandateId: Optional[str]) -> Optional[s def initRoles(db: DatabaseConnector) -> None: """ Initialize standard roles if they don't exist. + Roles are created as GLOBAL (mandateId=None) template roles. Args: db: Database connector instance """ logger.info("Initializing roles") + global _roleIdCache + _roleIdCache = {} standardRoles = [ Role( roleLabel="sysadmin", - description={"en": "System Administrator - Full access to all system resources", "fr": "Administrateur système - Accès complet à toutes les ressources"}, + description={"en": "System Administrator - Full access to all system resources", "de": "System-Administrator - Vollzugriff auf alle System-Ressourcen", "fr": "Administrateur système - Accès complet à toutes les ressources"}, + mandateId=None, # Global template role + featureInstanceId=None, + featureCode=None, isSystemRole=True ), Role( roleLabel="admin", - description={"en": "Administrator - Manage users and resources within mandate scope", "fr": "Administrateur - Gérer les utilisateurs et ressources dans le périmètre du mandat"}, + description={"en": "Administrator - Manage users and resources within mandate scope", "de": "Administrator - Benutzer und Ressourcen im Mandanten verwalten", "fr": "Administrateur - Gérer les utilisateurs et ressources dans le périmètre du mandat"}, + mandateId=None, # Global template role + featureInstanceId=None, + featureCode=None, isSystemRole=True ), Role( roleLabel="user", - description={"en": "User - Standard user with access to own records", "fr": "Utilisateur - Utilisateur standard avec accès à ses propres enregistrements"}, + description={"en": "User - Standard user with access to own records", "de": "Benutzer - Standard-Benutzer mit Zugriff auf eigene Datensätze", "fr": "Utilisateur - Utilisateur standard avec accès à ses propres enregistrements"}, + mandateId=None, # Global template role + featureInstanceId=None, + featureCode=None, isSystemRole=True ), Role( roleLabel="viewer", - description={"en": "Viewer - Read-only access to group records", "fr": "Visualiseur - Accès en lecture seule aux enregistrements du groupe"}, + description={"en": "Viewer - Read-only access to group records", "de": "Betrachter - Nur-Lese-Zugriff auf Gruppen-Datensätze", "fr": "Visualiseur - Accès en lecture seule aux enregistrements du groupe"}, + mandateId=None, # Global template role + featureInstanceId=None, + featureCode=None, isSystemRole=True ), ] existingRoles = db.getRecordset(Role) - existingRoleLabels = {role.get("roleLabel") for role in existingRoles} + existingRoleLabels = {role.get("roleLabel"): role.get("id") for role in existingRoles} for role in standardRoles: if role.roleLabel not in existingRoleLabels: try: - db.recordCreate(Role, role) - logger.info(f"Created role: {role.roleLabel}") + createdRole = db.recordCreate(Role, role) + _roleIdCache[role.roleLabel] = createdRole.get("id") + logger.info(f"Created role: {role.roleLabel} with ID {createdRole.get('id')}") except Exception as e: logger.warning(f"Error creating role {role.roleLabel}: {e}") else: - logger.debug(f"Role {role.roleLabel} already exists") + _roleIdCache[role.roleLabel] = existingRoleLabels[role.roleLabel] + logger.debug(f"Role {role.roleLabel} already exists with ID {existingRoleLabels[role.roleLabel]}") logger.info("Roles initialization completed") +def _getRoleId(db: DatabaseConnector, roleLabel: str) -> Optional[str]: + """ + Get role ID by label, using cache or database lookup. + + Args: + db: Database connector + roleLabel: Role label to look up + + Returns: + Role ID or None if not found + """ + global _roleIdCache + + if roleLabel in _roleIdCache: + return _roleIdCache[roleLabel] + + # Lookup from database + roles = db.getRecordset(Role, recordFilter={"roleLabel": roleLabel}) + if roles: + roleId = roles[0].get("id") + _roleIdCache[roleLabel] = roleId + return roleId + + logger.warning(f"Role not found: {roleLabel}") + return None + + def initRbacRules(db: DatabaseConnector) -> None: """ Initialize RBAC rules if they don't exist. - Converts all UAM logic from interface*Access.py modules to RBAC rules. - Also checks for and adds missing rules for new tables. + AccessRules now reference roleId (FK) instead of roleLabel. Args: db: Database connector instance @@ -215,41 +273,40 @@ def initRbacRules(db: DatabaseConnector) -> None: existingRules = db.getRecordset(AccessRule) if existingRules: logger.info(f"RBAC rules already exist ({len(existingRules)} rules)") - # Check for missing rules for ChatWorkflow and Prompt tables - _addMissingTableRules(db, existingRules) return logger.info("Initializing RBAC rules") # Create default role rules - createDefaultRoleRules(db) + _createDefaultRoleRules(db) # Create table-specific rules (converted from UAM logic) - createTableSpecificRules(db) + _createTableSpecificRules(db) # Create UI context rules - createUiContextRules(db) + _createUiContextRules(db) # Create RESOURCE context rules - createResourceContextRules(db) - - # Create Action-specific RBAC rules - createActionRules(db) + _createResourceContextRules(db) logger.info("RBAC rules initialization completed") -def createDefaultRoleRules(db: DatabaseConnector) -> None: +def _createDefaultRoleRules(db: DatabaseConnector) -> None: """ Create default role rules for generic access (item = null). + Uses roleId instead of roleLabel. Args: db: Database connector instance """ - defaultRules = [ - # SysAdmin Role - Full access to all - AccessRule( - roleLabel="sysadmin", + defaultRules = [] + + # SysAdmin Role - Full access to all + sysadminId = _getRoleId(db, "sysadmin") + if sysadminId: + defaultRules.append(AccessRule( + roleId=sysadminId, context=AccessRuleContext.DATA, item=None, view=True, @@ -257,10 +314,13 @@ def createDefaultRoleRules(db: DatabaseConnector) -> None: create=AccessLevel.ALL, update=AccessLevel.ALL, delete=AccessLevel.ALL, - ), - # Admin Role - Group-level access - AccessRule( - roleLabel="admin", + )) + + # Admin Role - Group-level access + adminId = _getRoleId(db, "admin") + if adminId: + defaultRules.append(AccessRule( + roleId=adminId, context=AccessRuleContext.DATA, item=None, view=True, @@ -268,10 +328,13 @@ def createDefaultRoleRules(db: DatabaseConnector) -> None: create=AccessLevel.GROUP, update=AccessLevel.GROUP, delete=AccessLevel.NONE, - ), - # User Role - My records only - AccessRule( - roleLabel="user", + )) + + # User Role - My records only + userId = _getRoleId(db, "user") + if userId: + defaultRules.append(AccessRule( + roleId=userId, context=AccessRuleContext.DATA, item=None, view=True, @@ -279,10 +342,13 @@ def createDefaultRoleRules(db: DatabaseConnector) -> None: create=AccessLevel.MY, update=AccessLevel.MY, delete=AccessLevel.MY, - ), - # Viewer Role - Read-only group access - AccessRule( - roleLabel="viewer", + )) + + # Viewer Role - Read-only group access + viewerId = _getRoleId(db, "viewer") + if viewerId: + defaultRules.append(AccessRule( + roleId=viewerId, context=AccessRuleContext.DATA, item=None, view=True, @@ -290,8 +356,7 @@ def createDefaultRoleRules(db: DatabaseConnector) -> None: create=AccessLevel.NONE, update=AccessLevel.NONE, delete=AccessLevel.NONE, - ), - ] + )) for rule in defaultRules: db.recordCreate(AccessRule, rule) @@ -299,393 +364,209 @@ def createDefaultRoleRules(db: DatabaseConnector) -> None: logger.info(f"Created {len(defaultRules)} default role rules") -def createTableSpecificRules(db: DatabaseConnector) -> None: +def _createTableSpecificRules(db: DatabaseConnector) -> None: """ Create table-specific rules converted from UAM logic. These rules override generic rules for specific tables. + Uses roleId instead of roleLabel. Args: db: Database connector instance """ tableRules = [] + # Get role IDs + sysadminId = _getRoleId(db, "sysadmin") + adminId = _getRoleId(db, "admin") + userId = _getRoleId(db, "user") + viewerId = _getRoleId(db, "viewer") + # Mandate table - Only sysadmin can access - tableRules.append(AccessRule( - roleLabel="sysadmin", - context=AccessRuleContext.DATA, - item="Mandate", - view=True, - read=AccessLevel.ALL, - create=AccessLevel.ALL, - update=AccessLevel.ALL, - delete=AccessLevel.ALL, - )) - tableRules.append(AccessRule( - roleLabel="admin", - context=AccessRuleContext.DATA, - item="Mandate", - view=False, - read=AccessLevel.NONE, - create=AccessLevel.NONE, - update=AccessLevel.NONE, - delete=AccessLevel.NONE, - )) - tableRules.append(AccessRule( - roleLabel="user", - context=AccessRuleContext.DATA, - item="Mandate", - view=False, - read=AccessLevel.NONE, - create=AccessLevel.NONE, - update=AccessLevel.NONE, - delete=AccessLevel.NONE, - )) - tableRules.append(AccessRule( - roleLabel="viewer", - context=AccessRuleContext.DATA, - item="Mandate", - view=False, - read=AccessLevel.NONE, - create=AccessLevel.NONE, - update=AccessLevel.NONE, - delete=AccessLevel.NONE, - )) - - # UserInDB table - tableRules.append(AccessRule( - roleLabel="sysadmin", - context=AccessRuleContext.DATA, - item="UserInDB", - view=True, - read=AccessLevel.ALL, - create=AccessLevel.ALL, - update=AccessLevel.ALL, - delete=AccessLevel.ALL, - )) - tableRules.append(AccessRule( - roleLabel="admin", - context=AccessRuleContext.DATA, - item="UserInDB", - view=True, - read=AccessLevel.GROUP, - create=AccessLevel.GROUP, - update=AccessLevel.GROUP, - delete=AccessLevel.GROUP, - )) - tableRules.append(AccessRule( - roleLabel="user", - context=AccessRuleContext.DATA, - item="UserInDB", - view=True, - read=AccessLevel.MY, - create=AccessLevel.NONE, - update=AccessLevel.MY, - delete=AccessLevel.NONE, - )) - tableRules.append(AccessRule( - roleLabel="viewer", - context=AccessRuleContext.DATA, - item="UserInDB", - view=True, - read=AccessLevel.MY, - create=AccessLevel.NONE, - update=AccessLevel.NONE, - delete=AccessLevel.NONE, - )) - - # UserConnection table - tableRules.append(AccessRule( - roleLabel="sysadmin", - context=AccessRuleContext.DATA, - item="UserConnection", - view=True, - read=AccessLevel.ALL, - create=AccessLevel.ALL, - update=AccessLevel.ALL, - delete=AccessLevel.ALL, - )) - tableRules.append(AccessRule( - roleLabel="admin", - context=AccessRuleContext.DATA, - item="UserConnection", - view=True, - read=AccessLevel.GROUP, - create=AccessLevel.GROUP, - update=AccessLevel.GROUP, - delete=AccessLevel.GROUP, - )) - tableRules.append(AccessRule( - roleLabel="user", - context=AccessRuleContext.DATA, - item="UserConnection", - view=True, - read=AccessLevel.MY, - create=AccessLevel.MY, - update=AccessLevel.MY, - delete=AccessLevel.MY, - )) - tableRules.append(AccessRule( - roleLabel="viewer", - context=AccessRuleContext.DATA, - item="UserConnection", - view=True, - read=AccessLevel.MY, - create=AccessLevel.NONE, - update=AccessLevel.NONE, - delete=AccessLevel.NONE, - )) - - # DataNeutraliserConfig table - tableRules.append(AccessRule( - roleLabel="sysadmin", - context=AccessRuleContext.DATA, - item="DataNeutraliserConfig", - view=True, - read=AccessLevel.ALL, - create=AccessLevel.ALL, - update=AccessLevel.ALL, - delete=AccessLevel.ALL, - )) - tableRules.append(AccessRule( - roleLabel="admin", - context=AccessRuleContext.DATA, - item="DataNeutraliserConfig", - view=True, - read=AccessLevel.GROUP, - create=AccessLevel.GROUP, - update=AccessLevel.GROUP, - delete=AccessLevel.GROUP, - )) - tableRules.append(AccessRule( - roleLabel="user", - context=AccessRuleContext.DATA, - item="DataNeutraliserConfig", - view=True, - read=AccessLevel.MY, - create=AccessLevel.MY, - update=AccessLevel.MY, - delete=AccessLevel.MY, - )) - tableRules.append(AccessRule( - roleLabel="viewer", - context=AccessRuleContext.DATA, - item="DataNeutraliserConfig", - view=True, - read=AccessLevel.MY, - create=AccessLevel.NONE, - update=AccessLevel.NONE, - delete=AccessLevel.NONE, - )) - - # DataNeutralizerAttributes table - tableRules.append(AccessRule( - roleLabel="sysadmin", - context=AccessRuleContext.DATA, - item="DataNeutralizerAttributes", - view=True, - read=AccessLevel.ALL, - create=AccessLevel.ALL, - update=AccessLevel.ALL, - delete=AccessLevel.ALL, - )) - tableRules.append(AccessRule( - roleLabel="admin", - context=AccessRuleContext.DATA, - item="DataNeutralizerAttributes", - view=True, - read=AccessLevel.GROUP, - create=AccessLevel.GROUP, - update=AccessLevel.GROUP, - delete=AccessLevel.GROUP, - )) - tableRules.append(AccessRule( - roleLabel="user", - context=AccessRuleContext.DATA, - item="DataNeutralizerAttributes", - view=True, - read=AccessLevel.MY, - create=AccessLevel.MY, - update=AccessLevel.MY, - delete=AccessLevel.MY, - )) - tableRules.append(AccessRule( - roleLabel="viewer", - context=AccessRuleContext.DATA, - item="DataNeutralizerAttributes", - view=True, - read=AccessLevel.MY, - create=AccessLevel.NONE, - update=AccessLevel.NONE, - delete=AccessLevel.NONE, - )) - - # AuthEvent table - tableRules.append(AccessRule( - roleLabel="sysadmin", - context=AccessRuleContext.DATA, - item="AuthEvent", - view=True, - read=AccessLevel.ALL, - create=AccessLevel.NONE, - update=AccessLevel.NONE, - delete=AccessLevel.ALL, - )) - tableRules.append(AccessRule( - roleLabel="admin", - context=AccessRuleContext.DATA, - item="AuthEvent", - view=True, - read=AccessLevel.ALL, - create=AccessLevel.NONE, - update=AccessLevel.NONE, - delete=AccessLevel.ALL, - )) - tableRules.append(AccessRule( - roleLabel="user", - context=AccessRuleContext.DATA, - item="AuthEvent", - view=True, - read=AccessLevel.MY, - create=AccessLevel.NONE, - update=AccessLevel.NONE, - delete=AccessLevel.NONE, - )) - tableRules.append(AccessRule( - roleLabel="viewer", - context=AccessRuleContext.DATA, - item="AuthEvent", - view=True, - read=AccessLevel.MY, - create=AccessLevel.NONE, - update=AccessLevel.NONE, - delete=AccessLevel.NONE, - )) - - # ChatWorkflow table - Users can access their own workflows - tableRules.append(AccessRule( - roleLabel="sysadmin", - context=AccessRuleContext.DATA, - item="ChatWorkflow", - view=True, - read=AccessLevel.ALL, - create=AccessLevel.ALL, - update=AccessLevel.ALL, - delete=AccessLevel.ALL, - )) - tableRules.append(AccessRule( - roleLabel="admin", - context=AccessRuleContext.DATA, - item="ChatWorkflow", - view=True, - read=AccessLevel.GROUP, - create=AccessLevel.GROUP, - update=AccessLevel.GROUP, - delete=AccessLevel.GROUP, - )) - tableRules.append(AccessRule( - roleLabel="user", - context=AccessRuleContext.DATA, - item="ChatWorkflow", - view=True, - read=AccessLevel.MY, - create=AccessLevel.MY, - update=AccessLevel.MY, - delete=AccessLevel.MY, - )) - tableRules.append(AccessRule( - roleLabel="viewer", - context=AccessRuleContext.DATA, - item="ChatWorkflow", - view=True, - read=AccessLevel.MY, - create=AccessLevel.NONE, - update=AccessLevel.NONE, - delete=AccessLevel.NONE, - )) - - # Prompt table - Users can access their own prompts - tableRules.append(AccessRule( - roleLabel="sysadmin", - context=AccessRuleContext.DATA, - item="Prompt", - view=True, - read=AccessLevel.ALL, - create=AccessLevel.ALL, - update=AccessLevel.ALL, - delete=AccessLevel.ALL, - )) - tableRules.append(AccessRule( - roleLabel="admin", - context=AccessRuleContext.DATA, - item="Prompt", - view=True, - read=AccessLevel.GROUP, - create=AccessLevel.GROUP, - update=AccessLevel.GROUP, - delete=AccessLevel.GROUP, - )) - tableRules.append(AccessRule( - roleLabel="user", - context=AccessRuleContext.DATA, - item="Prompt", - view=True, - read=AccessLevel.MY, - create=AccessLevel.MY, - update=AccessLevel.MY, - delete=AccessLevel.MY, - )) - tableRules.append(AccessRule( - roleLabel="viewer", - context=AccessRuleContext.DATA, - item="Prompt", - view=True, - read=AccessLevel.MY, - create=AccessLevel.NONE, - update=AccessLevel.NONE, - delete=AccessLevel.NONE, - )) - - # Real Estate tables - Projekt, Parzelle, Dokument, Gemeinde, Kanton, Land - realEstateTables = ["Projekt", "Parzelle", "Dokument", "Gemeinde", "Kanton", "Land"] - for table in realEstateTables: - # Sysadmin - full access + if sysadminId: tableRules.append(AccessRule( - roleLabel="sysadmin", + roleId=sysadminId, context=AccessRuleContext.DATA, - item=table, + item="Mandate", view=True, read=AccessLevel.ALL, create=AccessLevel.ALL, update=AccessLevel.ALL, delete=AccessLevel.ALL, )) - # Admin - group access + if adminId: tableRules.append(AccessRule( - roleLabel="admin", + roleId=adminId, context=AccessRuleContext.DATA, - item=table, + item="Mandate", + view=False, + read=AccessLevel.NONE, + create=AccessLevel.NONE, + update=AccessLevel.NONE, + delete=AccessLevel.NONE, + )) + if userId: + tableRules.append(AccessRule( + roleId=userId, + context=AccessRuleContext.DATA, + item="Mandate", + view=False, + read=AccessLevel.NONE, + create=AccessLevel.NONE, + update=AccessLevel.NONE, + delete=AccessLevel.NONE, + )) + if viewerId: + tableRules.append(AccessRule( + roleId=viewerId, + context=AccessRuleContext.DATA, + item="Mandate", + view=False, + read=AccessLevel.NONE, + create=AccessLevel.NONE, + update=AccessLevel.NONE, + delete=AccessLevel.NONE, + )) + + # UserInDB table + if sysadminId: + tableRules.append(AccessRule( + roleId=sysadminId, + context=AccessRuleContext.DATA, + item="UserInDB", + view=True, + read=AccessLevel.ALL, + create=AccessLevel.ALL, + update=AccessLevel.ALL, + delete=AccessLevel.ALL, + )) + if adminId: + tableRules.append(AccessRule( + roleId=adminId, + context=AccessRuleContext.DATA, + item="UserInDB", view=True, read=AccessLevel.GROUP, create=AccessLevel.GROUP, update=AccessLevel.GROUP, delete=AccessLevel.GROUP, )) - # User - my records only + if userId: tableRules.append(AccessRule( - roleLabel="user", + roleId=userId, context=AccessRuleContext.DATA, - item=table, + item="UserInDB", view=True, read=AccessLevel.MY, - create=AccessLevel.MY, + create=AccessLevel.NONE, update=AccessLevel.MY, - delete=AccessLevel.MY, + delete=AccessLevel.NONE, )) - # Viewer - read-only my records + if viewerId: tableRules.append(AccessRule( - roleLabel="viewer", + roleId=viewerId, context=AccessRuleContext.DATA, - item=table, + item="UserInDB", + view=True, + read=AccessLevel.MY, + create=AccessLevel.NONE, + update=AccessLevel.NONE, + delete=AccessLevel.NONE, + )) + + # Standard tables with typical access patterns + standardTables = [ + "UserConnection", "DataNeutraliserConfig", "DataNeutralizerAttributes", + "ChatWorkflow", "Prompt", "Projekt", "Parzelle", "Dokument", + "Gemeinde", "Kanton", "Land", + "TrusteeOrganisation", "TrusteeRole", "TrusteeAccess", "TrusteeContract", + "TrusteeDocument", "TrusteePosition", "TrusteePositionDocument" + ] + + for table in standardTables: + if sysadminId: + tableRules.append(AccessRule( + roleId=sysadminId, + context=AccessRuleContext.DATA, + item=table, + view=True, + read=AccessLevel.ALL, + create=AccessLevel.ALL, + update=AccessLevel.ALL, + delete=AccessLevel.ALL, + )) + 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, + )) + if userId: + tableRules.append(AccessRule( + roleId=userId, + context=AccessRuleContext.DATA, + item=table, + 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=table, + view=True, + read=AccessLevel.MY, + create=AccessLevel.NONE, + update=AccessLevel.NONE, + delete=AccessLevel.NONE, + )) + + # AuthEvent table - special handling + if sysadminId: + tableRules.append(AccessRule( + roleId=sysadminId, + context=AccessRuleContext.DATA, + item="AuthEvent", + view=True, + read=AccessLevel.ALL, + create=AccessLevel.NONE, + update=AccessLevel.NONE, + delete=AccessLevel.ALL, + )) + if adminId: + tableRules.append(AccessRule( + roleId=adminId, + context=AccessRuleContext.DATA, + item="AuthEvent", + view=True, + read=AccessLevel.ALL, + create=AccessLevel.NONE, + update=AccessLevel.NONE, + delete=AccessLevel.ALL, + )) + if userId: + tableRules.append(AccessRule( + roleId=userId, + context=AccessRuleContext.DATA, + item="AuthEvent", + view=True, + read=AccessLevel.MY, + create=AccessLevel.NONE, + update=AccessLevel.NONE, + delete=AccessLevel.NONE, + )) + if viewerId: + tableRules.append(AccessRule( + roleId=viewerId, + context=AccessRuleContext.DATA, + item="AuthEvent", view=True, read=AccessLevel.MY, create=AccessLevel.NONE, @@ -700,509 +581,124 @@ def createTableSpecificRules(db: DatabaseConnector) -> None: logger.info(f"Created {len(tableRules)} table-specific rules") -def createUiContextRules(db: DatabaseConnector) -> None: +def _createUiContextRules(db: DatabaseConnector) -> None: """ Create UI context rules for controlling UI element visibility. - These rules control which UI components users can see based on their roles. + Uses roleId instead of roleLabel. Args: db: Database connector instance """ uiRules = [] - # Generic UI rules - all roles can view UI by default - # Specific UI elements can override these with more restrictive rules + # All roles get full UI access by default + for roleLabel in ["sysadmin", "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, + )) - # Sysadmin - full UI access - uiRules.append(AccessRule( - roleLabel="sysadmin", - context=AccessRuleContext.UI, - item=None, - view=True, - read=None, - create=None, - update=None, - delete=None, - )) - - # Admin - full UI access - uiRules.append(AccessRule( - roleLabel="admin", - context=AccessRuleContext.UI, - item=None, - view=True, - read=None, - create=None, - update=None, - delete=None, - )) - - # User - full UI access - uiRules.append(AccessRule( - roleLabel="user", - context=AccessRuleContext.UI, - item=None, - view=True, - read=None, - create=None, - update=None, - delete=None, - )) - - # Viewer - full UI access (can view but may have restricted actions) - uiRules.append(AccessRule( - roleLabel="viewer", - context=AccessRuleContext.UI, - item=None, - view=True, - read=None, - create=None, - update=None, - delete=None, - )) - - # Create all UI context rules for rule in uiRules: db.recordCreate(AccessRule, rule) logger.info(f"Created {len(uiRules)} UI context rules") -def createResourceContextRules(db: DatabaseConnector) -> None: +def _createResourceContextRules(db: DatabaseConnector) -> None: """ - Create RESOURCE context rules for controlling resource access (AI models, actions, etc.). - These rules control which resources users can access based on their roles. + Create RESOURCE context rules for controlling resource access. + Uses roleId instead of roleLabel. Args: db: Database connector instance """ resourceRules = [] - # Generic resource rules - all roles can access resources by default - # Specific resources can override these with more restrictive rules + # All roles get full resource access by default + for roleLabel in ["sysadmin", "admin", "user", "viewer"]: + roleId = _getRoleId(db, roleLabel) + if roleId: + resourceRules.append(AccessRule( + roleId=roleId, + context=AccessRuleContext.RESOURCE, + item=None, + view=True, + read=None, + create=None, + update=None, + delete=None, + )) - # Sysadmin - full resource access - resourceRules.append(AccessRule( - roleLabel="sysadmin", - context=AccessRuleContext.RESOURCE, - item=None, - view=True, - read=None, - create=None, - update=None, - delete=None, - )) - - # Admin - full resource access - resourceRules.append(AccessRule( - roleLabel="admin", - context=AccessRuleContext.RESOURCE, - item=None, - view=True, - read=None, - create=None, - update=None, - delete=None, - )) - - # User - full resource access - resourceRules.append(AccessRule( - roleLabel="user", - context=AccessRuleContext.RESOURCE, - item=None, - view=True, - read=None, - create=None, - update=None, - delete=None, - )) - - # Viewer - full resource access (can view but may have restricted actions) - resourceRules.append(AccessRule( - roleLabel="viewer", - context=AccessRuleContext.RESOURCE, - item=None, - view=True, - read=None, - create=None, - update=None, - delete=None, - )) - - # Create all RESOURCE context rules for rule in resourceRules: db.recordCreate(AccessRule, rule) logger.info(f"Created {len(resourceRules)} RESOURCE context rules") -def createActionRules(db: DatabaseConnector) -> None: +def assignInitialUserMemberships( + db: DatabaseConnector, + mandateId: str, + adminUserId: str, + eventUserId: str +) -> None: """ - Create default RBAC rules for workflow actions. - - This function dynamically discovers all available actions from all methods - and creates RBAC rules for them. Actions are protected via RESOURCE context - with actionId as the item identifier (format: 'module.actionName'). - - Args: - db: Database connector instance - """ - try: - # Import method discovery to get all actions - from modules.workflows.processing.shared.methodDiscovery import discoverMethods - from modules.services import getInterface as getServices - from modules.datamodels.datamodelUam import User - - # Create a temporary user context for discovery (will be filtered by RBAC later) - # We need to discover methods, but we'll use a minimal user context - # In production, this should use a system user or admin user - try: - # Try to get an admin user for discovery - adminUsers = db.getRecordset("User", recordFilter={"roleLabel": "sysadmin"}, limit=1) - if adminUsers: - tempUser = User(**adminUsers[0]) - else: - # Fallback: create minimal user context - tempUser = User(id="system", roleLabel="sysadmin") - except: - # Fallback: create minimal user context - tempUser = User(id="system", roleLabel="sysadmin") - - # Get services and discover methods - services = getServices(tempUser, None) - discoverMethods(services) - - # Import methods catalog - from modules.workflows.processing.shared.methodDiscovery import methods - - # Collect all action IDs - allActionIds = [] - for methodName, methodInfo in methods.items(): - # Skip duplicate entries (same method stored with full and short name) - if methodName.startswith('Method'): - continue - - methodInstance = methodInfo['instance'] - methodActions = methodInstance.actions - - for actionName in methodActions.keys(): - actionId = f"{methodInstance.name}.{actionName}" - allActionIds.append(actionId) - - logger.info(f"Discovered {len(allActionIds)} actions for RBAC rule creation") - - # Define default action access by role - # SysAdmin and Admin: Access to all actions - # User: Access to common actions (read, search, process, etc.) - # Viewer: Read-only actions - - actionRules = [] - - # All roles: Generic access to all actions - # Using item=None grants access to all resources (all actions) in RESOURCE context - - # SysAdmin: Access to all actions - actionRules.append(AccessRule( - roleLabel="sysadmin", - context=AccessRuleContext.RESOURCE, - item=None, # All resources (covers all actions) - view=True - )) - - # Admin: Access to all actions - actionRules.append(AccessRule( - roleLabel="admin", - context=AccessRuleContext.RESOURCE, - item=None, # All resources (covers all actions) - view=True - )) - - # User: Access to all actions (generic rights) - actionRules.append(AccessRule( - roleLabel="user", - context=AccessRuleContext.RESOURCE, - item=None, # All resources (covers all actions) - view=True - )) - - - # Create all action rules - for rule in actionRules: - db.recordCreate(AccessRule, rule) - - logger.info(f"Created {len(actionRules)} action RBAC rules") - - except Exception as e: - logger.error(f"Error creating action RBAC rules: {str(e)}", exc_info=True) - # Don't fail bootstrap if action rules can't be created - # They can be created manually or via migration script - - -def _addMissingTableRules(db: DatabaseConnector, existingRules: List[Dict[str, Any]]) -> None: - """ - Add missing RBAC rules for tables that were added after initial bootstrap. - - Args: - db: Database connector instance - existingRules: List of existing AccessRule records - """ - # Check which tables already have rules - existingItems = {rule.get("item") for rule in existingRules if rule.get("context") == AccessRuleContext.DATA} - existingRoles = {rule.get("roleLabel") for rule in existingRules} - - # Tables that need rules - requiredTables = [ - "ChatWorkflow", "Prompt", "Projekt", "Parzelle", "Dokument", "Gemeinde", "Kanton", "Land", - # Trustee tables - "TrusteeOrganisation", "TrusteeRole", "TrusteeAccess", "TrusteeContract", - "TrusteeDocument", "TrusteePosition", "TrusteePositionDocument" - ] - requiredRoles = ["sysadmin", "admin", "user", "viewer"] - - newRules = [] - - for table in requiredTables: - if table not in existingItems: - logger.info(f"Adding missing RBAC rules for table {table}") - # ChatWorkflow rules - if table == "ChatWorkflow": - for roleLabel in requiredRoles: - if roleLabel == "sysadmin": - newRules.append(AccessRule( - roleLabel=roleLabel, - context=AccessRuleContext.DATA, - item=table, - view=True, - read=AccessLevel.ALL, - create=AccessLevel.ALL, - update=AccessLevel.ALL, - delete=AccessLevel.ALL, - )) - elif roleLabel == "admin": - newRules.append(AccessRule( - roleLabel=roleLabel, - context=AccessRuleContext.DATA, - item=table, - view=True, - read=AccessLevel.GROUP, - create=AccessLevel.GROUP, - update=AccessLevel.GROUP, - delete=AccessLevel.GROUP, - )) - elif roleLabel == "user": - newRules.append(AccessRule( - roleLabel=roleLabel, - context=AccessRuleContext.DATA, - item=table, - view=True, - read=AccessLevel.MY, - create=AccessLevel.MY, - update=AccessLevel.MY, - delete=AccessLevel.MY, - )) - elif roleLabel == "viewer": - newRules.append(AccessRule( - roleLabel=roleLabel, - context=AccessRuleContext.DATA, - item=table, - view=True, - read=AccessLevel.MY, - create=AccessLevel.NONE, - update=AccessLevel.NONE, - delete=AccessLevel.NONE, - )) - # Prompt rules (same as ChatWorkflow) - elif table == "Prompt": - for roleLabel in requiredRoles: - if roleLabel == "sysadmin": - newRules.append(AccessRule( - roleLabel=roleLabel, - context=AccessRuleContext.DATA, - item=table, - view=True, - read=AccessLevel.ALL, - create=AccessLevel.ALL, - update=AccessLevel.ALL, - delete=AccessLevel.ALL, - )) - elif roleLabel == "admin": - newRules.append(AccessRule( - roleLabel=roleLabel, - context=AccessRuleContext.DATA, - item=table, - view=True, - read=AccessLevel.GROUP, - create=AccessLevel.GROUP, - update=AccessLevel.GROUP, - delete=AccessLevel.GROUP, - )) - elif roleLabel == "user": - newRules.append(AccessRule( - roleLabel=roleLabel, - context=AccessRuleContext.DATA, - item=table, - view=True, - read=AccessLevel.MY, - create=AccessLevel.MY, - update=AccessLevel.MY, - delete=AccessLevel.MY, - )) - elif roleLabel == "viewer": - newRules.append(AccessRule( - roleLabel=roleLabel, - context=AccessRuleContext.DATA, - item=table, - view=True, - read=AccessLevel.MY, - create=AccessLevel.NONE, - update=AccessLevel.NONE, - delete=AccessLevel.NONE, - )) - # Real Estate tables rules (Projekt, Parzelle, Dokument, Gemeinde, Kanton, Land) - elif table in ["Projekt", "Parzelle", "Dokument", "Gemeinde", "Kanton", "Land"]: - for roleLabel in requiredRoles: - if roleLabel == "sysadmin": - newRules.append(AccessRule( - roleLabel=roleLabel, - context=AccessRuleContext.DATA, - item=table, - view=True, - read=AccessLevel.ALL, - create=AccessLevel.ALL, - update=AccessLevel.ALL, - delete=AccessLevel.ALL, - )) - elif roleLabel == "admin": - newRules.append(AccessRule( - roleLabel=roleLabel, - context=AccessRuleContext.DATA, - item=table, - view=True, - read=AccessLevel.GROUP, - create=AccessLevel.GROUP, - update=AccessLevel.GROUP, - delete=AccessLevel.GROUP, - )) - elif roleLabel == "user": - newRules.append(AccessRule( - roleLabel=roleLabel, - context=AccessRuleContext.DATA, - item=table, - view=True, - read=AccessLevel.MY, - create=AccessLevel.MY, - update=AccessLevel.MY, - delete=AccessLevel.MY, - )) - elif roleLabel == "viewer": - newRules.append(AccessRule( - roleLabel=roleLabel, - context=AccessRuleContext.DATA, - item=table, - view=True, - read=AccessLevel.MY, - create=AccessLevel.NONE, - update=AccessLevel.NONE, - delete=AccessLevel.NONE, - )) - # Trustee tables rules - elif table in ["TrusteeOrganisation", "TrusteeRole", "TrusteeAccess", "TrusteeContract", - "TrusteeDocument", "TrusteePosition", "TrusteePositionDocument"]: - for roleLabel in requiredRoles: - if roleLabel == "sysadmin": - newRules.append(AccessRule( - roleLabel=roleLabel, - context=AccessRuleContext.DATA, - item=table, - view=True, - read=AccessLevel.ALL, - create=AccessLevel.ALL, - update=AccessLevel.ALL, - delete=AccessLevel.ALL, - )) - elif roleLabel == "admin": - newRules.append(AccessRule( - roleLabel=roleLabel, - context=AccessRuleContext.DATA, - item=table, - view=True, - read=AccessLevel.GROUP, - create=AccessLevel.GROUP, - update=AccessLevel.GROUP, - delete=AccessLevel.GROUP, - )) - elif roleLabel == "user": - # User role: Access to MY records (feature-specific access via trustee.access) - newRules.append(AccessRule( - roleLabel=roleLabel, - context=AccessRuleContext.DATA, - item=table, - view=True, - read=AccessLevel.MY, - create=AccessLevel.MY, - update=AccessLevel.MY, - delete=AccessLevel.MY, - )) - elif roleLabel == "viewer": - newRules.append(AccessRule( - roleLabel=roleLabel, - context=AccessRuleContext.DATA, - item=table, - view=True, - read=AccessLevel.MY, - create=AccessLevel.NONE, - update=AccessLevel.NONE, - delete=AccessLevel.NONE, - )) - - # Create missing rules - if newRules: - for rule in newRules: - db.recordCreate(AccessRule, rule) - logger.info(f"Added {len(newRules)} missing RBAC rules") - - -def assignInitialUserRoles(db: DatabaseConnector, adminUserId: str, eventUserId: str) -> None: - """ - Assign initial roles to admin and event users. + Assign initial memberships to admin and event users via UserMandate + UserMandateRole. + This is the NEW multi-tenant way of assigning roles. Args: db: Database connector instance + mandateId: Root mandate ID adminUserId: Admin user ID eventUserId: Event user ID """ - # Set context to admin user for bootstrap operations - originalUserId = db.userId if hasattr(db, 'userId') else None - try: - if adminUserId: - db.updateContext(adminUserId) + sysadminRoleId = _getRoleId(db, "sysadmin") + if not sysadminRoleId: + logger.warning("Sysadmin role not found, skipping membership assignment") + return + + for userId, userName in [(adminUserId, "admin"), (eventUserId, "event")]: + # Check if UserMandate already exists + existingMemberships = db.getRecordset( + UserMandate, + recordFilter={"userId": userId, "mandateId": mandateId} + ) - # Update admin user with sysadmin role - adminUser = db.getRecordset(UserInDB, recordFilter={"id": adminUserId}) - if adminUser: - adminUserData = adminUser[0] - roleLabels = adminUserData.get("roleLabels") or [] - if "sysadmin" not in roleLabels: - adminUserData["roleLabels"] = roleLabels + ["sysadmin"] - db.recordModify(UserInDB, adminUserId, adminUserData) - logger.info(f"Assigned sysadmin role to admin user {adminUserId}") + if existingMemberships: + userMandateId = existingMemberships[0].get("id") + logger.debug(f"UserMandate already exists for {userName} user") + else: + # Create UserMandate + userMandate = UserMandate( + userId=userId, + mandateId=mandateId, + enabled=True + ) + createdMembership = db.recordCreate(UserMandate, userMandate) + userMandateId = createdMembership.get("id") + logger.info(f"Created UserMandate for {userName} user with ID {userMandateId}") - # Update event user with sysadmin role - eventUser = db.getRecordset(UserInDB, recordFilter={"id": eventUserId}) - if eventUser: - eventUserData = eventUser[0] - roleLabels = eventUserData.get("roleLabels") or [] - if "sysadmin" not in roleLabels: - eventUserData["roleLabels"] = roleLabels + ["sysadmin"] - db.recordModify(UserInDB, eventUserId, eventUserData) - logger.info(f"Assigned sysadmin role to event user {eventUserId}") - finally: - # Restore original context if it existed - if originalUserId: - db.updateContext(originalUserId) - elif hasattr(db, 'userId'): - # If original was None/empty, just set it directly - db.userId = originalUserId + # Check if UserMandateRole already exists + existingRoles = db.getRecordset( + UserMandateRole, + recordFilter={"userMandateId": userMandateId, "roleId": sysadminRoleId} + ) + + if not existingRoles: + # Create UserMandateRole + userMandateRole = UserMandateRole( + userMandateId=userMandateId, + roleId=sysadminRoleId + ) + db.recordCreate(UserMandateRole, userMandateRole) + logger.info(f"Assigned sysadmin role to {userName} user in mandate") def _getPasswordHash(password: Optional[str]) -> Optional[str]: @@ -1218,3 +714,42 @@ def _getPasswordHash(password: Optional[str]) -> Optional[str]: if password is None: return None return pwdContext.hash(password) + + +def _applyDatabaseOptimizations(db: DatabaseConnector) -> None: + """ + Apply multi-tenant database optimizations after bootstrap. + + Creates indexes, immutable triggers, and foreign key constraints + for the multi-tenant junction tables. All operations are idempotent. + + Args: + db: Database connector instance + """ + try: + from modules.shared.dbMultiTenantOptimizations import applyMultiTenantOptimizations + + result = applyMultiTenantOptimizations(db) + + if result.get("errors"): + for error in result["errors"]: + logger.warning(f"DB optimization error: {error}") + else: + totalCreated = ( + result.get("indexesCreated", 0) + + result.get("triggersCreated", 0) + + result.get("foreignKeysCreated", 0) + ) + if totalCreated > 0: + logger.info( + f"Applied DB optimizations: {result['indexesCreated']} indexes, " + f"{result['triggersCreated']} triggers, " + f"{result['foreignKeysCreated']} foreign keys" + ) + # If nothing created, optimizations were already applied (idempotent) + + except ImportError as e: + logger.warning(f"DB optimizations module not available: {e}") + except Exception as e: + # Don't fail bootstrap if optimizations fail + logger.warning(f"Failed to apply DB optimizations (non-critical): {e}") diff --git a/modules/interfaces/interfaceDbAppObjects.py b/modules/interfaces/interfaceDbAppObjects.py index ab792b08..d661e603 100644 --- a/modules/interfaces/interfaceDbAppObjects.py +++ b/modules/interfaces/interfaceDbAppObjects.py @@ -3,6 +3,10 @@ """ Interface to the Gateway system. Manages users and mandates for authentication. + +Multi-Tenant Design: +- User gehört nicht mehr direkt zu einem Mandanten +- mandateId wird aus Request-Context übergeben (X-Mandate-Id Header) """ import logging @@ -37,6 +41,14 @@ from modules.datamodels.datamodelNeutralizer import ( DataNeutralizerAttributes, ) from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResult +from modules.datamodels.datamodelMembership import ( + UserMandate, + UserMandateRole, + FeatureAccess, + FeatureAccessRole, +) +from modules.datamodels.datamodelFeatures import Feature, FeatureInstance +from modules.datamodels.datamodelInvitation import Invitation logger = logging.getLogger(__name__) @@ -61,7 +73,7 @@ class AppObjects: # Initialize variables self.currentUser = currentUser # Store User object directly self.userId = currentUser.id if currentUser else None - self.mandateId = currentUser.mandateId if currentUser else None + self.mandateId = None # mandateId comes from setUserContext, not from User # Initialize database self._initializeDatabase() @@ -73,25 +85,42 @@ class AppObjects: if currentUser: self.setUserContext(currentUser) - def setUserContext(self, currentUser: User): - """Sets the user context for the interface.""" + def setUserContext(self, currentUser: User, mandateId: Optional[str] = None): + """ + Sets the user context for the interface. + + Multi-Tenant Design: + - mandateId wird explizit übergeben (aus Request-Context / X-Mandate-Id Header) + - isSysAdmin User brauchen kein mandateId für System-Operationen + + Args: + currentUser: User object + mandateId: Explicit mandate context (from request header). Required for non-sysadmin. + """ if not currentUser: logger.info("Initializing interface without user context") return self.currentUser = currentUser # Store User object directly self.userId = currentUser.id - self.mandateId = currentUser.mandateId + + # mandateId comes from parameter only + self.mandateId = mandateId - if not self.userId or not self.mandateId: - raise ValueError("Invalid user context: id and mandateId are required") + # Validate: userId is always required + if not self.userId: + raise ValueError("Invalid user context: id is required") + + # mandateId is optional for isSysAdmin users doing system-level operations + isSysAdmin = getattr(currentUser, 'isSysAdmin', False) + if not self.mandateId and not isSysAdmin: + # Non-sysadmin users MUST have a mandateId for tenant-scoped operations + logger.warning(f"User {self.userId} has no mandateId context") # Add language settings self.userLanguage = currentUser.language # Default user language # Initialize RBAC interface - if not currentUser: - raise ValueError("User context is required for RBAC") # Pass self.db as dbApp since this interface uses DbApp database self.rbac = RbacClass(self.db, dbApp=self.db) @@ -110,11 +139,11 @@ class AppObjects: """Initializes the database connection directly.""" try: # Get configuration values with defaults - dbHost = APP_CONFIG.get("DB_APP_HOST", "_no_config_default_data") - dbDatabase = APP_CONFIG.get("DB_APP_DATABASE", "app") - dbUser = APP_CONFIG.get("DB_APP_USER") - dbPassword = APP_CONFIG.get("DB_APP_PASSWORD_SECRET") - dbPort = int(APP_CONFIG.get("DB_APP_PORT", 5432)) + dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data") + dbDatabase = "poweron_app" + dbUser = APP_CONFIG.get("DB_USER") + dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET") + dbPort = int(APP_CONFIG.get("DB_PORT", 5432)) # Create database connector directly self.db = DatabaseConnector( @@ -615,13 +644,17 @@ class AppObjects: fullName: str = None, language: str = "en", enabled: bool = True, - roleLabels: List[str] = None, authenticationAuthority: AuthAuthority = AuthAuthority.LOCAL, externalId: str = None, externalUsername: str = None, externalEmail: str = None, + isSysAdmin: bool = False, ) -> User: - """Create a new user with optional external connection""" + """ + Create a new user. + + Note: Role assignment is done via createUserMandate(), not via User fields. + """ try: # Ensure username is a string username = str(username).strip() @@ -638,28 +671,17 @@ class AppObjects: if not password.strip(): raise ValueError("Password cannot be empty") - # Ensure mandateId is set - use self.mandateId or default mandate - mandateId = self.mandateId - if not mandateId: - mandateId = self._getDefaultMandateId() - logger.warning(f"Using default mandate ID {mandateId} for new user {username}") - - # Default roleLabels to ["user"] if not provided - if roleLabels is None or not roleLabels: - roleLabels = ["user"] - # Create user data using UserInDB model + # Note: mandateId and roleLabels are REMOVED - use UserMandate + UserMandateRole userData = UserInDB( username=username, email=email, fullName=fullName, language=language, - mandateId=mandateId, enabled=enabled, - roleLabels=roleLabels, + isSysAdmin=isSysAdmin, authenticationAuthority=authenticationAuthority, hashedPassword=self._getPasswordHash(password) if password else None, - connections=[], ) # Create user record @@ -712,25 +734,11 @@ class AppObjects: # Remove id field from updateDict if present - we'll use userId from parameter updateDict.pop("id", None) - # Ensure mandateId is set - if missing or None, use default mandate - if "mandateId" not in updateDict or not updateDict.get("mandateId"): - if not user.mandateId: - # User has no mandateId, set to default - defaultMandateId = self._getDefaultMandateId() - updateDict["mandateId"] = defaultMandateId - logger.warning(f"Setting default mandate ID {defaultMandateId} for user {userId}") - else: - # Keep existing mandateId if update doesn't provide one - updateDict["mandateId"] = user.mandateId - # Update user data using model updatedData = user.model_dump() updatedData.update(updateDict) # Ensure ID matches userId parameter updatedData["id"] = userId - # Ensure mandateId is set in final data - if not updatedData.get("mandateId"): - updatedData["mandateId"] = self._getDefaultMandateId() updatedUser = User(**updatedData) # Update user record @@ -1382,6 +1390,325 @@ class AppObjects: logger.error(f"Error deleting mandate: {str(e)}") raise ValueError(f"Failed to delete mandate: {str(e)}") + # ============================================ + # User-Mandate Membership Methods (Multi-Tenant) + # ============================================ + + def getUserMandate(self, userId: str, mandateId: str) -> Optional[UserMandate]: + """ + Get UserMandate record for a user in a specific mandate. + + Args: + userId: User ID + mandateId: Mandate ID + + Returns: + UserMandate object or None + """ + try: + records = self.db.getRecordset( + UserMandate, + recordFilter={"userId": userId, "mandateId": mandateId} + ) + if not records: + return None + cleanedRecord = {k: v for k, v in records[0].items() if not k.startswith("_")} + return UserMandate(**cleanedRecord) + except Exception as e: + logger.error(f"Error getting UserMandate: {e}") + return None + + def getUserMandates(self, userId: str) -> List[UserMandate]: + """ + Get all mandates a user is member of. + + Args: + userId: User ID + + Returns: + List of UserMandate objects + """ + try: + records = self.db.getRecordset( + UserMandate, + recordFilter={"userId": userId, "enabled": True} + ) + result = [] + for record in records: + cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_")} + result.append(UserMandate(**cleanedRecord)) + return result + except Exception as e: + logger.error(f"Error getting UserMandates: {e}") + return [] + + def createUserMandate(self, userId: str, mandateId: str, roleIds: List[str] = None) -> UserMandate: + """ + Create a UserMandate record (add user to mandate). + + Args: + userId: User ID + mandateId: Mandate ID + roleIds: Optional list of role IDs to assign + + Returns: + Created UserMandate object + """ + try: + # Check if already exists + existing = self.getUserMandate(userId, mandateId) + if existing: + raise ValueError(f"User {userId} is already member of mandate {mandateId}") + + # Create UserMandate + userMandate = UserMandate( + userId=userId, + mandateId=mandateId, + enabled=True + ) + createdRecord = self.db.recordCreate(UserMandate, userMandate.model_dump()) + + # Assign roles via junction table + if roleIds and createdRecord: + userMandateId = createdRecord.get("id") + for roleId in roleIds: + userMandateRole = UserMandateRole( + userMandateId=userMandateId, + roleId=roleId + ) + self.db.recordCreate(UserMandateRole, userMandateRole.model_dump()) + + cleanedRecord = {k: v for k, v in createdRecord.items() if not k.startswith("_")} + return UserMandate(**cleanedRecord) + except Exception as e: + logger.error(f"Error creating UserMandate: {e}") + raise ValueError(f"Failed to create UserMandate: {e}") + + def deleteUserMandate(self, userId: str, mandateId: str) -> bool: + """ + Delete a UserMandate record (remove user from mandate). + CASCADE will delete UserMandateRole entries. + + Args: + userId: User ID + mandateId: Mandate ID + + Returns: + True if deleted, False if not found + """ + try: + existing = self.getUserMandate(userId, mandateId) + if not existing: + return False + + return self.db.recordDelete(UserMandate, existing.id) + except Exception as e: + logger.error(f"Error deleting UserMandate: {e}") + raise ValueError(f"Failed to delete UserMandate: {e}") + + def getRoleIdsForUserMandate(self, userMandateId: str) -> List[str]: + """ + Get all role IDs assigned to a UserMandate. + + Args: + userMandateId: UserMandate ID + + Returns: + List of role IDs + """ + try: + records = self.db.getRecordset( + UserMandateRole, + recordFilter={"userMandateId": userMandateId} + ) + return [r.get("roleId") for r in records if r.get("roleId")] + except Exception as e: + logger.error(f"Error getting role IDs for UserMandate: {e}") + return [] + + def addRoleToUserMandate(self, userMandateId: str, roleId: str) -> UserMandateRole: + """ + Add a role to a UserMandate. + + Args: + userMandateId: UserMandate ID + roleId: Role ID to add + + Returns: + Created UserMandateRole object + """ + try: + # Check if already exists + existing = self.db.getRecordset( + UserMandateRole, + recordFilter={"userMandateId": userMandateId, "roleId": roleId} + ) + if existing: + cleanedRecord = {k: v for k, v in existing[0].items() if not k.startswith("_")} + return UserMandateRole(**cleanedRecord) + + userMandateRole = UserMandateRole( + userMandateId=userMandateId, + roleId=roleId + ) + createdRecord = self.db.recordCreate(UserMandateRole, userMandateRole.model_dump()) + cleanedRecord = {k: v for k, v in createdRecord.items() if not k.startswith("_")} + return UserMandateRole(**cleanedRecord) + except Exception as e: + logger.error(f"Error adding role to UserMandate: {e}") + raise ValueError(f"Failed to add role: {e}") + + def removeRoleFromUserMandate(self, userMandateId: str, roleId: str) -> bool: + """ + Remove a role from a UserMandate. + If no roles remain, the UserMandate is deleted (Application-Level Cleanup). + + Args: + userMandateId: UserMandate ID + roleId: Role ID to remove + + Returns: + True if removed + """ + try: + # Find and delete the junction record + records = self.db.getRecordset( + UserMandateRole, + recordFilter={"userMandateId": userMandateId, "roleId": roleId} + ) + if not records: + return False + + self.db.recordDelete(UserMandateRole, records[0].get("id")) + + # Application-Level Cleanup: Delete UserMandate if no roles remain + remainingRoles = self.db.getRecordset( + UserMandateRole, + recordFilter={"userMandateId": userMandateId} + ) + if not remainingRoles: + self.db.recordDelete(UserMandate, userMandateId) + logger.info(f"Deleted empty UserMandate {userMandateId}") + + return True + except Exception as e: + logger.error(f"Error removing role from UserMandate: {e}") + raise ValueError(f"Failed to remove role: {e}") + + # ============================================ + # Feature Access Methods (Multi-Tenant) + # ============================================ + + def getFeatureAccess(self, userId: str, featureInstanceId: str) -> Optional[FeatureAccess]: + """ + Get FeatureAccess record for a user to a specific feature instance. + + Args: + userId: User ID + featureInstanceId: FeatureInstance ID + + Returns: + FeatureAccess object or None + """ + try: + records = self.db.getRecordset( + FeatureAccess, + recordFilter={"userId": userId, "featureInstanceId": featureInstanceId} + ) + if not records: + return None + cleanedRecord = {k: v for k, v in records[0].items() if not k.startswith("_")} + return FeatureAccess(**cleanedRecord) + except Exception as e: + logger.error(f"Error getting FeatureAccess: {e}") + return None + + def getFeatureAccessesForUser(self, userId: str) -> List[FeatureAccess]: + """ + Get all feature accesses for a user. + + Args: + userId: User ID + + Returns: + List of FeatureAccess objects + """ + try: + records = self.db.getRecordset( + FeatureAccess, + recordFilter={"userId": userId, "enabled": True} + ) + result = [] + for record in records: + cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_")} + result.append(FeatureAccess(**cleanedRecord)) + return result + except Exception as e: + logger.error(f"Error getting FeatureAccesses: {e}") + return [] + + def createFeatureAccess(self, userId: str, featureInstanceId: str, roleIds: List[str] = None) -> FeatureAccess: + """ + Create a FeatureAccess record (grant user access to feature instance). + + Args: + userId: User ID + featureInstanceId: FeatureInstance ID + roleIds: Optional list of role IDs to assign + + Returns: + Created FeatureAccess object + """ + try: + # Check if already exists + existing = self.getFeatureAccess(userId, featureInstanceId) + if existing: + raise ValueError(f"User {userId} already has access to feature instance {featureInstanceId}") + + # Create FeatureAccess + featureAccess = FeatureAccess( + userId=userId, + featureInstanceId=featureInstanceId, + enabled=True + ) + createdRecord = self.db.recordCreate(FeatureAccess, featureAccess.model_dump()) + + # Assign roles via junction table + if roleIds and createdRecord: + featureAccessId = createdRecord.get("id") + for roleId in roleIds: + featureAccessRole = FeatureAccessRole( + featureAccessId=featureAccessId, + roleId=roleId + ) + self.db.recordCreate(FeatureAccessRole, featureAccessRole.model_dump()) + + cleanedRecord = {k: v for k, v in createdRecord.items() if not k.startswith("_")} + return FeatureAccess(**cleanedRecord) + except Exception as e: + logger.error(f"Error creating FeatureAccess: {e}") + raise ValueError(f"Failed to create FeatureAccess: {e}") + + def getRoleIdsForFeatureAccess(self, featureAccessId: str) -> List[str]: + """ + Get all role IDs assigned to a FeatureAccess. + + Args: + featureAccessId: FeatureAccess ID + + Returns: + List of role IDs + """ + try: + records = self.db.getRecordset( + FeatureAccessRole, + recordFilter={"featureAccessId": featureAccessId} + ) + return [r.get("roleId") for r in records if r.get("roleId")] + except Exception as e: + logger.error(f"Error getting role IDs for FeatureAccess: {e}") + return [] + # Token methods def saveAccessToken(self, token: Token, replace_existing: bool = True) -> None: @@ -1902,6 +2229,7 @@ class AppObjects: def getAccessRules( self, roleLabel: Optional[str] = None, + roleId: Optional[str] = None, context: Optional[AccessRuleContext] = None, item: Optional[str] = None, pagination: Optional[PaginationParams] = None @@ -1910,7 +2238,8 @@ class AppObjects: Get access rules with optional filters and pagination. Args: - roleLabel: Optional role label filter + roleLabel: Optional role label filter (deprecated, use roleId) + roleId: Optional role ID filter context: Optional context filter item: Optional item filter pagination: Optional pagination parameters. If None, returns all items. @@ -1921,7 +2250,9 @@ class AppObjects: """ try: recordFilter = {} - if roleLabel: + if roleId: + recordFilter["roleId"] = roleId + elif roleLabel: recordFilter["roleLabel"] = roleLabel if context: recordFilter["context"] = context.value @@ -2134,6 +2465,29 @@ class AppObjects: else: return PaginatedResult(items=[], totalItems=0, totalPages=0) + def countRoleAssignments(self) -> Dict[str, int]: + """ + Count the number of user assignments per role from UserMandateRole table. + + Returns: + Dictionary mapping roleId to count of user assignments + """ + try: + # Get all UserMandateRole records + assignments = self.db.getRecordset(UserMandateRole) + + # Count assignments per roleId + roleCounts: Dict[str, int] = {} + for assignment in assignments: + roleId = str(assignment.get("roleId", "")) + if roleId: + roleCounts[roleId] = roleCounts.get(roleId, 0) + 1 + + return roleCounts + except Exception as e: + logger.error(f"Error counting role assignments: {str(e)}") + return {} + def updateRole(self, roleId: str, role: Role) -> Role: """ Update an existing role. @@ -2185,14 +2539,13 @@ class AppObjects: if role.isSystemRole: raise ValueError(f"Cannot delete system role '{role.roleLabel}'") - # Check if role is assigned to any users - allUsers = self.getUsersByMandate(None) # Get all users across all mandates - for user in allUsers: - if role.roleLabel in (user.roleLabels or []): - raise ValueError(f"Cannot delete role '{role.roleLabel}' - it is assigned to users") + # Check if role is assigned to any users via UserMandateRole + roleAssignments = self.db.getRecordset(UserMandateRole, recordFilter={"roleId": roleId}) + if roleAssignments: + raise ValueError(f"Cannot delete role '{role.roleLabel}' - it is assigned to users") # Check if role is used in any access rules - accessRules = self.getAccessRules(roleLabel=role.roleLabel) + accessRules = self.getAccessRules(roleId=roleId) if accessRules: raise ValueError(f"Cannot delete role '{role.roleLabel}' - it is used in access rules") @@ -2207,20 +2560,34 @@ class AppObjects: # Public Methods -def getInterface(currentUser: User) -> AppObjects: +def getInterface(currentUser: User, mandateId: Optional[str] = None) -> AppObjects: """ Returns a AppObjects instance for the current user. Handles initialization of database and records. + + Multi-Tenant Design: + - mandateId wird explizit übergeben (aus Request-Context / X-Mandate-Id Header) + + Args: + currentUser: User object + mandateId: Explicit mandate context (from request header). Required for non-sysadmin. + + Returns: + AppObjects instance for the user context """ if not currentUser: raise ValueError("Invalid user context: user is required") - # Create context key - contextKey = f"{currentUser.mandateId}_{currentUser.id}" + effectiveMandateId = mandateId + + # Create context key (user + mandate combination) + contextKey = f"{effectiveMandateId}_{currentUser.id}" # Create new instance if not exists if contextKey not in _gatewayInterfaces: - _gatewayInterfaces[contextKey] = AppObjects(currentUser) + instance = AppObjects(currentUser) + instance.setUserContext(currentUser, mandateId=effectiveMandateId) + _gatewayInterfaces[contextKey] = instance return _gatewayInterfaces[contextKey] diff --git a/modules/interfaces/interfaceDbChatObjects.py b/modules/interfaces/interfaceDbChatObjects.py index 4c15ba89..10b202da 100644 --- a/modules/interfaces/interfaceDbChatObjects.py +++ b/modules/interfaces/interfaceDbChatObjects.py @@ -178,12 +178,18 @@ class ChatObjects: Uses the JSON connector for data access with added language support. """ - def __init__(self, currentUser: Optional[User] = None): - """Initializes the Chat Interface.""" + def __init__(self, currentUser: Optional[User] = None, mandateId: Optional[str] = None): + """Initializes the Chat Interface. + + Args: + currentUser: The authenticated user + mandateId: The mandate ID from RequestContext (X-Mandate-Id header) + """ # Initialize variables self.currentUser = currentUser # Store User object directly self.userId = currentUser.id if currentUser else None - self.mandateId = currentUser.mandateId if currentUser else None + # Use mandateId from parameter (Request-Context), not from user object + self.mandateId = mandateId self.rbac = None # RBAC interface # Initialize services @@ -194,7 +200,7 @@ class ChatObjects: # Set user context if provided if currentUser: - self.setUserContext(currentUser) + self.setUserContext(currentUser, mandateId=mandateId) # ===== Generic Utility Methods ===== @@ -257,14 +263,24 @@ class ChatObjects: def _initializeServices(self): pass - def setUserContext(self, currentUser: User): - """Sets the user context for the interface.""" + def setUserContext(self, currentUser: User, mandateId: Optional[str] = None): + """Sets the user context for the interface. + + Args: + currentUser: The authenticated user + mandateId: The mandate ID from RequestContext (X-Mandate-Id header) + """ self.currentUser = currentUser # Store User object directly self.userId = currentUser.id - self.mandateId = currentUser.mandateId + # Use mandateId from parameter (Request-Context), not from user object + self.mandateId = mandateId - if not self.userId or not self.mandateId: - raise ValueError("Invalid user context: id and mandateId are required") + if not self.userId: + raise ValueError("Invalid user context: id is required") + + # mandateId can be None for sysadmins performing cross-mandate operations + if not self.mandateId and not getattr(currentUser, 'isSysAdmin', False): + raise ValueError("Invalid user context: mandateId is required for non-sysadmin users") # Add language settings self.userLanguage = currentUser.language # Default user language @@ -293,11 +309,11 @@ class ChatObjects: """Initializes the database connection directly.""" try: # Get configuration values with defaults - dbHost = APP_CONFIG.get("DB_CHAT_HOST", "_no_config_default_data") - dbDatabase = APP_CONFIG.get("DB_CHAT_DATABASE", "chat") - dbUser = APP_CONFIG.get("DB_CHAT_USER") - dbPassword = APP_CONFIG.get("DB_CHAT_PASSWORD_SECRET") - dbPort = int(APP_CONFIG.get("DB_CHAT_PORT", 5432)) + dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data") + dbDatabase = "poweron_chat" + dbUser = APP_CONFIG.get("DB_USER") + dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET") + dbPort = int(APP_CONFIG.get("DB_PORT", 5432)) # Create database connector directly self.db = DatabaseConnector( @@ -654,7 +670,7 @@ class ChatObjects: logs=logs, messages=messages, stats=stats, - mandateId=workflow.get("mandateId", self.currentUser.mandateId) + mandateId=workflow.get("mandateId", self.mandateId) ) except Exception as e: logger.error(f"Error validating workflow data: {str(e)}") @@ -695,7 +711,7 @@ class ChatObjects: logs=[], messages=[], stats=[], - mandateId=created.get("mandateId", self.currentUser.mandateId), + mandateId=created.get("mandateId", self.mandateId), workflowMode=created["workflowMode"], maxSteps=created.get("maxSteps", 1) ) @@ -1088,7 +1104,7 @@ class ChatObjects: logger.error(f"Error creating workflow message: {str(e)}") return None - def updateMessage(self, messageId: str, messageData: Dict[str, Any]) -> Dict[str, Any]: + def updateMessage(self, messageId: str, messageData: Dict[str, Any]) -> Optional[ChatMessage]: """Updates a workflow message if user has access to the workflow.""" try: @@ -1174,8 +1190,10 @@ class ChatObjects: logger.error(f"Error updating message documents: {str(e)}") if not updatedMessage: logger.warning(f"Failed to update message {messageId}") - - return updatedMessage + return None + + # Convert to ChatMessage model + return ChatMessage(**updatedMessage) except Exception as e: logger.error(f"Error updating message {messageId}: {str(e)}", exc_info=True) raise ValueError(f"Error updating message {messageId}: {str(e)}") @@ -1716,7 +1734,7 @@ class ChatObjects: totalPages=totalPages ) - def getAutomationDefinition(self, automationId: str) -> Optional[Dict[str, Any]]: + def getAutomationDefinition(self, automationId: str) -> Optional[AutomationDefinition]: """Returns an automation definition by ID if user has access, with computed status.""" try: # Use RBAC filtering @@ -1736,12 +1754,14 @@ class ChatObjects: automation["executionLogs"] = [] # Enrich with user and mandate names self._enrichAutomationWithUserAndMandate(automation) - return automation + # Clean metadata fields and return Pydantic model + cleanedRecord = {k: v for k, v in automation.items() if not k.startswith("_")} + return AutomationDefinition(**cleanedRecord) except Exception as e: logger.error(f"Error getting automation definition: {str(e)}") return None - def createAutomationDefinition(self, automationData: Dict[str, Any]) -> Dict[str, Any]: + def createAutomationDefinition(self, automationData: Dict[str, Any]) -> AutomationDefinition: """Creates a new automation definition, then triggers sync.""" try: # Ensure ID is present @@ -1777,12 +1797,14 @@ class ChatObjects: # Trigger automation change callback (async, don't wait) asyncio.create_task(self._notifyAutomationChanged()) - return createdAutomation + # Clean metadata fields and return Pydantic model + cleanedRecord = {k: v for k, v in createdAutomation.items() if not k.startswith("_")} + return AutomationDefinition(**cleanedRecord) except Exception as e: logger.error(f"Error creating automation definition: {str(e)}") raise - def updateAutomationDefinition(self, automationId: str, automationData: Dict[str, Any]) -> Dict[str, Any]: + def updateAutomationDefinition(self, automationId: str, automationData: Dict[str, Any]) -> AutomationDefinition: """Updates an automation definition, then triggers sync.""" try: # Check access @@ -1808,7 +1830,9 @@ class ChatObjects: # Trigger automation change callback (async, don't wait) asyncio.create_task(self._notifyAutomationChanged()) - return updatedAutomation + # Clean metadata fields and return Pydantic model + cleanedRecord = {k: v for k, v in updatedAutomation.items() if not k.startswith("_")} + return AutomationDefinition(**cleanedRecord) except Exception as e: logger.error(f"Error updating automation definition: {str(e)}") raise @@ -1870,19 +1894,28 @@ class ChatObjects: logger.error(f"Error notifying automation change: {str(e)}") -def getInterface(currentUser: Optional[User] = None) -> 'ChatObjects': +def getInterface(currentUser: Optional[User] = None, mandateId: Optional[str] = None) -> 'ChatObjects': """ Returns a ChatObjects instance for the current user. Handles initialization of database and records. + + Args: + currentUser: The authenticated user + mandateId: The mandate ID from RequestContext (X-Mandate-Id header). Required. """ if not currentUser: raise ValueError("Invalid user context: user is required") + effectiveMandateId = str(mandateId) if mandateId else None + # Create context key - contextKey = f"{currentUser.mandateId}_{currentUser.id}" + contextKey = f"{effectiveMandateId}_{currentUser.id}" # Create new instance if not exists if contextKey not in _chatInterfaces: - _chatInterfaces[contextKey] = ChatObjects(currentUser) + _chatInterfaces[contextKey] = ChatObjects(currentUser, mandateId=effectiveMandateId) + else: + # Update user context if needed + _chatInterfaces[contextKey].setUserContext(currentUser, mandateId=effectiveMandateId) return _chatInterfaces[contextKey] diff --git a/modules/interfaces/interfaceDbComponentObjects.py b/modules/interfaces/interfaceDbComponentObjects.py index fee78fef..07cf235c 100644 --- a/modules/interfaces/interfaceDbComponentObjects.py +++ b/modules/interfaces/interfaceDbComponentObjects.py @@ -76,14 +76,21 @@ class ComponentObjects: # Initialize standard records if needed self._initRecords() - def setUserContext(self, currentUser: User): - """Sets the user context for the interface.""" + def setUserContext(self, currentUser: User, mandateId: Optional[str] = None): + """Sets the user context for the interface. + + Args: + currentUser: The authenticated user + mandateId: The mandate ID from RequestContext (X-Mandate-Id header) + """ if not currentUser: logger.info("Initializing interface without user context") return self.currentUser = currentUser # Store User object directly self.userId = currentUser.id + # Use mandateId from parameter (Request-Context), not from user object + self.mandateId = mandateId if not self.userId: raise ValueError("Invalid user context: id is required") @@ -116,11 +123,11 @@ class ComponentObjects: """Initializes the database connection directly.""" try: # Get configuration values with defaults - dbHost = APP_CONFIG.get("DB_MANAGEMENT_HOST", "_no_config_default_data") - dbDatabase = APP_CONFIG.get("DB_MANAGEMENT_DATABASE", "management") - dbUser = APP_CONFIG.get("DB_MANAGEMENT_USER") - dbPassword = APP_CONFIG.get("DB_MANAGEMENT_PASSWORD_SECRET") - dbPort = int(APP_CONFIG.get("DB_MANAGEMENT_PORT")) + dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data") + dbDatabase = "poweron_management" + dbUser = APP_CONFIG.get("DB_USER") + dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET") + dbPort = int(APP_CONFIG.get("DB_PORT", 5432)) # Create database connector directly self.db = DatabaseConnector( @@ -979,8 +986,8 @@ class ComponentObjects: fileSize = len(content) fileHash = hashlib.sha256(content).hexdigest() - # Ensure mandateId is valid - mandateId = self.currentUser.mandateId or "default" + # Use mandateId from context + mandateId = self.mandateId # Create FileItem instance fileItem = FileItem( @@ -1320,9 +1327,9 @@ class ComponentObjects: if "userId" not in settingsData: settingsData["userId"] = self.userId - # Ensure mandateId is set + # Ensure mandateId is set from context if "mandateId" not in settingsData: - settingsData["mandateId"] = self.currentUser.mandateId if self.currentUser else "default" + settingsData["mandateId"] = self.mandateId # Check if settings already exist for this user existingSettings = self.getVoiceSettings(settingsData["userId"]) @@ -1406,7 +1413,7 @@ class ComponentObjects: # Create default settings defaultSettings = { "userId": targetUserId, - "mandateId": self.currentUser.mandateId if self.currentUser else "default", + "mandateId": self.mandateId, "sttLanguage": "de-DE", "ttsLanguage": "de-DE", "ttsVoice": "de-DE-KatjaNeural", @@ -1494,9 +1501,9 @@ class ComponentObjects: if not all(c.isalpha() or c == "_" for c in subscriptionId): raise ValueError("subscriptionId must contain only letters and underscores") - # Set mandateId if not provided + # Set mandateId from context if "mandateId" not in subscriptionData: - subscriptionData["mandateId"] = self.currentUser.mandateId if self.currentUser else "default" + subscriptionData["mandateId"] = self.mandateId createdRecord = self.db.recordCreate(MessagingSubscription, subscriptionData) if not createdRecord or not createdRecord.get("id"): @@ -1741,12 +1748,18 @@ class ComponentObjects: return MessagingDelivery(**filteredDeliveries[0]) if filteredDeliveries else None -def getInterface(currentUser: Optional[User] = None) -> 'ComponentObjects': +def getInterface(currentUser: Optional[User] = None, mandateId: Optional[str] = None) -> 'ComponentObjects': """ Returns a ComponentObjects instance. If currentUser is provided, initializes with user context. Otherwise, returns an instance with only database access. + + Args: + currentUser: The authenticated user + mandateId: The mandate ID from RequestContext (X-Mandate-Id header). Required. """ + effectiveMandateId = str(mandateId) if mandateId else None + # Create new instance if not exists if "default" not in _instancesManagement: _instancesManagement["default"] = ComponentObjects() @@ -1754,7 +1767,7 @@ def getInterface(currentUser: Optional[User] = None) -> 'ComponentObjects': interface = _instancesManagement["default"] if currentUser: - interface.setUserContext(currentUser) + interface.setUserContext(currentUser, mandateId=effectiveMandateId) else: logger.info("Returning interface without user context") diff --git a/modules/interfaces/interfaceDbRealEstateObjects.py b/modules/interfaces/interfaceDbRealEstateObjects.py index df0ac295..d475b8db 100644 --- a/modules/interfaces/interfaceDbRealEstateObjects.py +++ b/modules/interfaces/interfaceDbRealEstateObjects.py @@ -39,11 +39,17 @@ class RealEstateObjects: Handles CRUD operations on Real Estate entities. """ - def __init__(self, currentUser: Optional[User] = None): - """Initializes the Real Estate Interface.""" + def __init__(self, currentUser: Optional[User] = None, mandateId: Optional[str] = None): + """Initializes the Real Estate Interface. + + Args: + currentUser: The authenticated user + mandateId: The mandate ID from RequestContext (X-Mandate-Id header) + """ self.currentUser = currentUser self.userId = currentUser.id if currentUser else None - self.mandateId = currentUser.mandateId if currentUser else None + # Use mandateId from parameter (Request-Context), not from user object + self.mandateId = mandateId self.rbac = None # RBAC interface # Initialize database @@ -51,17 +57,17 @@ class RealEstateObjects: # Set user context if provided if currentUser: - self.setUserContext(currentUser) + self.setUserContext(currentUser, mandateId=mandateId) def _initializeDatabase(self): """Initialize PostgreSQL database connection.""" try: # Get database configuration from environment - dbHost = APP_CONFIG.get("DB_REALESTATE_HOST", "localhost") - dbDatabase = APP_CONFIG.get("DB_REALESTATE_DATABASE", "poweron_realestate") - dbUser = APP_CONFIG.get("DB_REALESTATE_USER") - dbPassword = APP_CONFIG.get("DB_REALESTATE_PASSWORD_SECRET") - dbPort = int(APP_CONFIG.get("DB_REALESTATE_PORT", 5432)) + dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data") + dbDatabase = "poweron_realestate" + dbUser = APP_CONFIG.get("DB_USER") + dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET") + dbPort = int(APP_CONFIG.get("DB_PORT", 5432)) # Initialize database connector self.db = DatabaseConnector( @@ -101,14 +107,24 @@ class RealEstateObjects: logger.warning(f"Error ensuring supporting tables exist: {e}") # Don't raise - tables will be created on-demand anyway - def setUserContext(self, currentUser: User): - """Sets the user context for the interface.""" + def setUserContext(self, currentUser: User, mandateId: Optional[str] = None): + """Sets the user context for the interface. + + Args: + currentUser: The authenticated user + mandateId: The mandate ID from RequestContext (X-Mandate-Id header) + """ self.currentUser = currentUser self.userId = currentUser.id - self.mandateId = currentUser.mandateId + # Use mandateId from parameter (Request-Context), not from user object + self.mandateId = mandateId - if not self.userId or not self.mandateId: - raise ValueError("Invalid user context: id and mandateId are required") + if not self.userId: + raise ValueError("Invalid user context: id is required") + + # mandateId can be None for sysadmins performing cross-mandate operations + if not self.mandateId and not getattr(currentUser, 'isSysAdmin', False): + raise ValueError("Invalid user context: mandateId is required for non-sysadmin users") # Initialize RBAC interface if not self.currentUser: @@ -239,14 +255,8 @@ class RealEstateObjects: def getParzellen(self, recordFilter: Optional[Dict[str, Any]] = None) -> List[Parzelle]: """Get all plots matching the filter.""" - original_gemeinde_value = None - # Resolve location names to IDs if needed if recordFilter: - # Save original value before resolution for fallback search - if "kontextGemeinde" in recordFilter: - original_gemeinde_value = recordFilter["kontextGemeinde"] - recordFilter = self._resolveLocationFilters(recordFilter) records = getRecordsetWithRBAC( @@ -256,23 +266,6 @@ class RealEstateObjects: recordFilter=recordFilter or {} ) - # Fallback: If no records found and we resolved a Gemeinde name, - # try searching with the original name for backwards compatibility - # (handles case where data has string names instead of UUIDs) - if not records and original_gemeinde_value and recordFilter and "kontextGemeinde" in recordFilter: - if recordFilter["kontextGemeinde"] != original_gemeinde_value: - logger.info(f"No results with resolved UUID, trying with original name '{original_gemeinde_value}'") - fallback_filter = recordFilter.copy() - fallback_filter["kontextGemeinde"] = original_gemeinde_value - records = getRecordsetWithRBAC( - self.db, - Parzelle, - self.currentUser, - recordFilter=fallback_filter - ) - if records: - logger.info(f"Found {len(records)} records using original name (legacy data format)") - return [Parzelle(**r) for r in records] def _resolveLocationFilters(self, recordFilter: Dict[str, Any]) -> Dict[str, Any]: @@ -799,15 +792,24 @@ class RealEstateObjects: raise -def getInterface(currentUser: User) -> RealEstateObjects: +def getInterface(currentUser: User, mandateId: Optional[str] = None) -> RealEstateObjects: """ Factory function to get or create a Real Estate interface instance for a user. Uses singleton pattern per user. + + Args: + currentUser: The authenticated user + mandateId: The mandate ID from RequestContext (X-Mandate-Id header). Required. """ - userKey = f"{currentUser.id}_{currentUser.mandateId}" + effectiveMandateId = str(mandateId) if mandateId else None + + userKey = f"{currentUser.id}_{effectiveMandateId}" if userKey not in _realEstateInterfaces: - _realEstateInterfaces[userKey] = RealEstateObjects(currentUser) + _realEstateInterfaces[userKey] = RealEstateObjects(currentUser, mandateId=effectiveMandateId) + else: + # Update user context if needed + _realEstateInterfaces[userKey].setUserContext(currentUser, mandateId=effectiveMandateId) return _realEstateInterfaces[userKey] diff --git a/modules/interfaces/interfaceDbTrusteeObjects.py b/modules/interfaces/interfaceDbTrusteeObjects.py index 54fc25c7..edb085fa 100644 --- a/modules/interfaces/interfaceDbTrusteeObjects.py +++ b/modules/interfaces/interfaceDbTrusteeObjects.py @@ -7,7 +7,8 @@ Manages trustee organisations, roles, access, contracts, documents, and position import logging import math -from typing import Dict, Any, List, Optional +import uuid +from typing import Dict, Any, List, Optional, Union from modules.connectors.connectorDbPostgre import DatabaseConnector from modules.shared.configuration import APP_CONFIG @@ -32,20 +33,27 @@ logger = logging.getLogger(__name__) _trusteeInterfaces = {} -def getInterface(currentUser: User) -> "TrusteeObjects": - """Get or create a TrusteeObjects instance for the given user context.""" +def getInterface(currentUser: User, mandateId: Optional[Union[str, uuid.UUID]] = None) -> "TrusteeObjects": + """Get or create a TrusteeObjects instance for the given user context. + + Args: + currentUser: The authenticated user + mandateId: The mandate ID from RequestContext (X-Mandate-Id header). Required. + """ global _trusteeInterfaces if not currentUser or not currentUser.id: raise ValueError("Valid user context required") - cacheKey = f"{currentUser.id}_{currentUser.mandateId}" + effectiveMandateId = str(mandateId) if mandateId else None + + cacheKey = f"{currentUser.id}_{effectiveMandateId}" if cacheKey not in _trusteeInterfaces: - _trusteeInterfaces[cacheKey] = TrusteeObjects(currentUser) + _trusteeInterfaces[cacheKey] = TrusteeObjects(currentUser, mandateId=effectiveMandateId) else: # Update user context if needed - _trusteeInterfaces[cacheKey].setUserContext(currentUser) + _trusteeInterfaces[cacheKey].setUserContext(currentUser, mandateId=effectiveMandateId) return _trusteeInterfaces[cacheKey] @@ -56,11 +64,17 @@ class TrusteeObjects: Manages trustee organisations, roles, access, contracts, documents, and positions. """ - def __init__(self, currentUser: Optional[User] = None): - """Initializes the Trustee Interface.""" + def __init__(self, currentUser: Optional[User] = None, mandateId: Optional[str] = None): + """Initializes the Trustee Interface. + + Args: + currentUser: The authenticated user + mandateId: The mandate ID from RequestContext (X-Mandate-Id header) + """ self.currentUser = currentUser self.userId = currentUser.id if currentUser else None - self.mandateId = currentUser.mandateId if currentUser else None + # Use mandateId from parameter (Request-Context), not from user object + self.mandateId = mandateId self.rbac = None # Initialize database @@ -68,20 +82,30 @@ class TrusteeObjects: # Set user context if provided if currentUser: - self.setUserContext(currentUser) + self.setUserContext(currentUser, mandateId=mandateId) - def setUserContext(self, currentUser: User): - """Sets the user context for the interface.""" + def setUserContext(self, currentUser: User, mandateId: Optional[str] = None): + """Sets the user context for the interface. + + Args: + currentUser: The authenticated user + mandateId: The mandate ID from RequestContext (X-Mandate-Id header) + """ if not currentUser: logger.info("Initializing interface without user context") return self.currentUser = currentUser self.userId = currentUser.id - self.mandateId = currentUser.mandateId + # Use mandateId from parameter (Request-Context), not from user object + self.mandateId = mandateId - if not self.userId or not self.mandateId: - raise ValueError("Invalid user context: id and mandateId are required") + if not self.userId: + raise ValueError("Invalid user context: id is required") + + # mandateId can be None for sysadmins performing cross-mandate operations + if not self.mandateId and not getattr(currentUser, 'isSysAdmin', False): + raise ValueError("Invalid user context: mandateId is required for non-sysadmin users") self.userLanguage = currentUser.language @@ -104,11 +128,11 @@ class TrusteeObjects: def _initializeDatabase(self): """Initializes the database connection directly.""" try: - dbHost = APP_CONFIG.get("DB_TRUSTEE_HOST", APP_CONFIG.get("DB_CHAT_HOST", "_no_config_default_data")) - dbDatabase = APP_CONFIG.get("DB_TRUSTEE_DATABASE", "trustee") - dbUser = APP_CONFIG.get("DB_TRUSTEE_USER", APP_CONFIG.get("DB_CHAT_USER")) - dbPassword = APP_CONFIG.get("DB_TRUSTEE_PASSWORD_SECRET", APP_CONFIG.get("DB_CHAT_PASSWORD_SECRET")) - dbPort = int(APP_CONFIG.get("DB_TRUSTEE_PORT", APP_CONFIG.get("DB_CHAT_PORT", 5432))) + dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data") + dbDatabase = "poweron_trustee" + dbUser = APP_CONFIG.get("DB_USER") + dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET") + dbPort = int(APP_CONFIG.get("DB_PORT", 5432)) self.db = DatabaseConnector( dbHost=dbHost, @@ -174,7 +198,7 @@ class TrusteeObjects: # ===== Organisation CRUD ===== - def createOrganisation(self, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: + def createOrganisation(self, data: Dict[str, Any]) -> Optional[TrusteeOrganisation]: """Create a new organisation.""" if not self.checkRbacPermission(TrusteeOrganisation, "create"): logger.warning(f"User {self.userId} lacks permission to create organisation") @@ -196,13 +220,15 @@ class TrusteeObjects: createdRecord = self.db.recordCreate(TrusteeOrganisation, data) if createdRecord and createdRecord.get("id"): - return createdRecord + return TrusteeOrganisation(**{k: v for k, v in createdRecord.items() if not k.startswith("_")}) return None - def getOrganisation(self, orgId: str) -> Optional[Dict[str, Any]]: + def getOrganisation(self, orgId: str) -> Optional[TrusteeOrganisation]: """Get a single organisation by ID.""" records = self.db.getRecordset(TrusteeOrganisation, recordFilter={"id": orgId}) - return records[0] if records else None + if not records: + return None + return TrusteeOrganisation(**{k: v for k, v in records[0].items() if not k.startswith("_")}) def getAllOrganisations(self, params: Optional[PaginationParams] = None) -> PaginatedResult: """Get all organisations with RBAC filtering. @@ -214,7 +240,7 @@ class TrusteeObjects: - New organisations wouldn't be visible without an access record """ # Debug: Log user info and permissions - logger.debug(f"getAllOrganisations called for user {self.userId}, roles: {self.currentUser.roleLabels if self.currentUser else 'None'}, mandateId: {self.mandateId}") + logger.debug(f"getAllOrganisations called for user {self.userId}, mandateId: {self.mandateId}") # System RBAC filtering (filters by mandate for GROUP access level) records = getRecordsetWithRBAC( @@ -247,7 +273,7 @@ class TrusteeObjects: totalPages=totalPages ) - def updateOrganisation(self, orgId: str, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: + def updateOrganisation(self, orgId: str, data: Dict[str, Any]) -> Optional[TrusteeOrganisation]: """Update an organisation.""" if not self.checkRbacPermission(TrusteeOrganisation, "update"): logger.warning(f"User {self.userId} lacks permission to update organisation") @@ -260,7 +286,9 @@ class TrusteeObjects: data["id"] = orgId updatedRecord = self.db.recordModify(TrusteeOrganisation, orgId, data) - return updatedRecord + if not updatedRecord: + return None + return TrusteeOrganisation(**{k: v for k, v in updatedRecord.items() if not k.startswith("_")}) def deleteOrganisation(self, orgId: str) -> bool: """Delete an organisation.""" @@ -272,7 +300,7 @@ class TrusteeObjects: # ===== Role CRUD ===== - def createRole(self, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: + def createRole(self, data: Dict[str, Any]) -> Optional[TrusteeRole]: """Create a new role (sysadmin only).""" if not self.checkRbacPermission(TrusteeRole, "create"): logger.warning(f"User {self.userId} lacks permission to create role") @@ -287,13 +315,15 @@ class TrusteeObjects: createdRecord = self.db.recordCreate(TrusteeRole, data) if createdRecord and createdRecord.get("id"): - return createdRecord + return TrusteeRole(**{k: v for k, v in createdRecord.items() if not k.startswith("_")}) return None - def getRole(self, roleId: str) -> Optional[Dict[str, Any]]: + def getRole(self, roleId: str) -> Optional[TrusteeRole]: """Get a single role by ID.""" records = self.db.getRecordset(TrusteeRole, recordFilter={"id": roleId}) - return records[0] if records else None + if not records: + return None + return TrusteeRole(**{k: v for k, v in records[0].items() if not k.startswith("_")}) def getAllRoles(self, params: Optional[PaginationParams] = None) -> PaginatedResult: """Get all roles with RBAC filtering. @@ -338,7 +368,7 @@ class TrusteeObjects: totalPages=totalPages ) - def updateRole(self, roleId: str, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: + def updateRole(self, roleId: str, data: Dict[str, Any]) -> Optional[TrusteeRole]: """Update a role (sysadmin only).""" if not self.checkRbacPermission(TrusteeRole, "update"): logger.warning(f"User {self.userId} lacks permission to update role") @@ -346,7 +376,9 @@ class TrusteeObjects: data["id"] = roleId updatedRecord = self.db.recordModify(TrusteeRole, roleId, data) - return updatedRecord + if not updatedRecord: + return None + return TrusteeRole(**{k: v for k, v in updatedRecord.items() if not k.startswith("_")}) def deleteRole(self, roleId: str) -> bool: """Delete a role (sysadmin only, not if in use).""" @@ -364,7 +396,7 @@ class TrusteeObjects: # ===== Access CRUD ===== - def createAccess(self, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: + def createAccess(self, data: Dict[str, Any]) -> Optional[TrusteeAccess]: """Create a new access record. Requires admin role for the organisation or ALL access level.""" # Check system RBAC first if not self.checkRbacPermission(TrusteeAccess, "create"): @@ -389,13 +421,15 @@ class TrusteeObjects: createdRecord = self.db.recordCreate(TrusteeAccess, data) if createdRecord and createdRecord.get("id"): - return createdRecord + return TrusteeAccess(**{k: v for k, v in createdRecord.items() if not k.startswith("_")}) return None - def getAccess(self, accessId: str) -> Optional[Dict[str, Any]]: + def getAccess(self, accessId: str) -> Optional[TrusteeAccess]: """Get a single access record by ID.""" records = self.db.getRecordset(TrusteeAccess, recordFilter={"id": accessId}) - return records[0] if records else None + if not records: + return None + return TrusteeAccess(**{k: v for k, v in records[0].items() if not k.startswith("_")}) def getAllAccess(self, params: Optional[PaginationParams] = None) -> PaginatedResult: """Get all access records with RBAC filtering + feature-level filtering. @@ -451,7 +485,7 @@ class TrusteeObjects: totalPages=totalPages ) - def getAccessByOrganisation(self, organisationId: str) -> List[Dict[str, Any]]: + def getAccessByOrganisation(self, organisationId: str) -> List[TrusteeAccess]: """Get all access records for a specific organisation. Requires admin role for the organisation. @@ -461,15 +495,16 @@ class TrusteeObjects: logger.warning(f"User {self.userId} lacks admin role for organisation {organisationId}") return [] - return getRecordsetWithRBAC( + records = getRecordsetWithRBAC( connector=self.db, modelClass=TrusteeAccess, currentUser=self.currentUser, recordFilter={"organisationId": organisationId}, orderBy="id" ) + return [TrusteeAccess(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in records] - def getAccessByUser(self, userId: str) -> List[Dict[str, Any]]: + def getAccessByUser(self, userId: str) -> List[TrusteeAccess]: """Get all access records for a specific user. Users with ALL access level see all access records. @@ -486,7 +521,7 @@ class TrusteeObjects: # Users with ALL access level (from system RBAC) see all records accessLevel = self.getRbacAccessLevel(TrusteeAccess, "read") if accessLevel == AccessLevel.ALL: - return records + return [TrusteeAccess(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in records] # Filter to only organisations where current user has admin role userAccess = self.getAllUserAccess(self.userId) @@ -495,9 +530,10 @@ class TrusteeObjects: if access.get("roleId") == "admin": adminOrgs.add(access.get("organisationId")) - return [r for r in records if r.get("organisationId") in adminOrgs] + filtered = [r for r in records if r.get("organisationId") in adminOrgs] + return [TrusteeAccess(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in filtered] - def updateAccess(self, accessId: str, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: + def updateAccess(self, accessId: str, data: Dict[str, Any]) -> Optional[TrusteeAccess]: """Update an access record. Requires admin role for the organisation or ALL access level.""" # Check system RBAC first if not self.checkRbacPermission(TrusteeAccess, "update"): @@ -524,7 +560,9 @@ class TrusteeObjects: data["id"] = accessId updatedRecord = self.db.recordModify(TrusteeAccess, accessId, data) - return updatedRecord + if not updatedRecord: + return None + return TrusteeAccess(**{k: v for k, v in updatedRecord.items() if not k.startswith("_")}) def deleteAccess(self, accessId: str) -> bool: """Delete an access record. Requires admin role for the organisation or ALL access level.""" @@ -555,7 +593,7 @@ class TrusteeObjects: # ===== Contract CRUD ===== - def createContract(self, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: + def createContract(self, data: Dict[str, Any]) -> Optional[TrusteeContract]: """Create a new contract.""" organisationId = data.get("organisationId") @@ -572,13 +610,15 @@ class TrusteeObjects: createdRecord = self.db.recordCreate(TrusteeContract, data) if createdRecord and createdRecord.get("id"): - return createdRecord + return TrusteeContract(**{k: v for k, v in createdRecord.items() if not k.startswith("_")}) return None - def getContract(self, contractId: str) -> Optional[Dict[str, Any]]: + def getContract(self, contractId: str) -> Optional[TrusteeContract]: """Get a single contract by ID.""" records = self.db.getRecordset(TrusteeContract, recordFilter={"id": contractId}) - return records[0] if records else None + if not records: + return None + return TrusteeContract(**{k: v for k, v in records[0].items() if not k.startswith("_")}) def getAllContracts(self, params: Optional[PaginationParams] = None) -> PaginatedResult: """Get all contracts with RBAC filtering + feature-level access filtering.""" @@ -614,7 +654,7 @@ class TrusteeObjects: totalPages=totalPages ) - def getContractsByOrganisation(self, organisationId: str) -> List[Dict[str, Any]]: + def getContractsByOrganisation(self, organisationId: str) -> List[TrusteeContract]: """Get all contracts for a specific organisation.""" # Step 1: System RBAC filtering records = getRecordsetWithRBAC( @@ -626,9 +666,10 @@ class TrusteeObjects: ) # Step 2: Feature-level filtering based on trustee.access - return self.filterRecordsByTrusteeAccess(records, TrusteeContract) + filtered = self.filterRecordsByTrusteeAccess(records, TrusteeContract) + return [TrusteeContract(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in filtered] - def updateContract(self, contractId: str, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: + def updateContract(self, contractId: str, data: Dict[str, Any]) -> Optional[TrusteeContract]: """Update a contract (organisationId is immutable).""" # Get existing contract to check organisation existingRecords = self.db.getRecordset(TrusteeContract, recordFilter={"id": contractId}) @@ -652,7 +693,9 @@ class TrusteeObjects: data["id"] = contractId updatedRecord = self.db.recordModify(TrusteeContract, contractId, data) - return updatedRecord + if not updatedRecord: + return None + return TrusteeContract(**{k: v for k, v in updatedRecord.items() if not k.startswith("_")}) def deleteContract(self, contractId: str) -> bool: """Delete a contract.""" @@ -675,7 +718,7 @@ class TrusteeObjects: # ===== Document CRUD ===== - def createDocument(self, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: + def createDocument(self, data: Dict[str, Any]) -> Optional[TrusteeDocument]: """Create a new document.""" organisationId = data.get("organisationId") contractId = data.get("contractId") @@ -693,20 +736,19 @@ class TrusteeObjects: createdRecord = self.db.recordCreate(TrusteeDocument, data) if createdRecord and createdRecord.get("id"): - # Remove binary data from response - createdRecord.pop("documentData", None) - return createdRecord + # Remove binary data and metadata from Pydantic model + cleanedRecord = {k: v for k, v in createdRecord.items() if not k.startswith("_") and k != "documentData"} + return TrusteeDocument(**cleanedRecord) return None - def getDocument(self, documentId: str) -> Optional[Dict[str, Any]]: + def getDocument(self, documentId: str) -> Optional[TrusteeDocument]: """Get a single document by ID (metadata only).""" records = self.db.getRecordset(TrusteeDocument, recordFilter={"id": documentId}) - if records: - record = records[0] - # Remove binary data from response - record.pop("documentData", None) - return record - return None + if not records: + return None + # Remove binary data and metadata from Pydantic model + cleanedRecord = {k: v for k, v in records[0].items() if not k.startswith("_") and k != "documentData"} + return TrusteeDocument(**cleanedRecord) def getDocumentData(self, documentId: str) -> Optional[bytes]: """Get document binary data.""" @@ -755,7 +797,7 @@ class TrusteeObjects: totalPages=totalPages ) - def getDocumentsByContract(self, contractId: str) -> List[Dict[str, Any]]: + def getDocumentsByContract(self, contractId: str) -> List[TrusteeDocument]: """Get all documents for a specific contract.""" # Step 1: System RBAC filtering records = getRecordsetWithRBAC( @@ -767,13 +809,15 @@ class TrusteeObjects: ) # Step 2: Feature-level filtering based on trustee.access - records = self.filterRecordsByTrusteeAccess(records, TrusteeDocument) + filtered = self.filterRecordsByTrusteeAccess(records, TrusteeDocument) - for record in records: - record.pop("documentData", None) - return records + result = [] + for record in filtered: + cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_") and k != "documentData"} + result.append(TrusteeDocument(**cleanedRecord)) + return result - def updateDocument(self, documentId: str, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: + def updateDocument(self, documentId: str, data: Dict[str, Any]) -> Optional[TrusteeDocument]: """Update a document.""" # Get existing document to check organisation and creator existingRecords = self.db.getRecordset(TrusteeDocument, recordFilter={"id": documentId}) @@ -795,9 +839,10 @@ class TrusteeObjects: data["id"] = documentId updatedRecord = self.db.recordModify(TrusteeDocument, documentId, data) - if updatedRecord: - updatedRecord.pop("documentData", None) - return updatedRecord + if not updatedRecord: + return None + cleanedRecord = {k: v for k, v in updatedRecord.items() if not k.startswith("_") and k != "documentData"} + return TrusteeDocument(**cleanedRecord) def deleteDocument(self, documentId: str) -> bool: """Delete a document.""" @@ -823,7 +868,7 @@ class TrusteeObjects: # ===== Position CRUD ===== - def createPosition(self, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: + def createPosition(self, data: Dict[str, Any]) -> Optional[TrusteePosition]: """Create a new position.""" organisationId = data.get("organisationId") contractId = data.get("contractId") @@ -847,13 +892,15 @@ class TrusteeObjects: createdRecord = self.db.recordCreate(TrusteePosition, data) if createdRecord and createdRecord.get("id"): - return createdRecord + return TrusteePosition(**{k: v for k, v in createdRecord.items() if not k.startswith("_")}) return None - def getPosition(self, positionId: str) -> Optional[Dict[str, Any]]: + def getPosition(self, positionId: str) -> Optional[TrusteePosition]: """Get a single position by ID.""" records = self.db.getRecordset(TrusteePosition, recordFilter={"id": positionId}) - return records[0] if records else None + if not records: + return None + return TrusteePosition(**{k: v for k, v in records[0].items() if not k.startswith("_")}) def getAllPositions(self, params: Optional[PaginationParams] = None) -> PaginatedResult: """Get all positions with RBAC filtering + feature-level access filtering.""" @@ -890,7 +937,7 @@ class TrusteeObjects: totalPages=totalPages ) - def getPositionsByContract(self, contractId: str) -> List[Dict[str, Any]]: + def getPositionsByContract(self, contractId: str) -> List[TrusteePosition]: """Get all positions for a specific contract.""" # Step 1: System RBAC filtering records = getRecordsetWithRBAC( @@ -902,9 +949,10 @@ class TrusteeObjects: ) # Step 2: Feature-level filtering based on trustee.access - return self.filterRecordsByTrusteeAccess(records, TrusteePosition) + filtered = self.filterRecordsByTrusteeAccess(records, TrusteePosition) + return [TrusteePosition(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in filtered] - def getPositionsByOrganisation(self, organisationId: str) -> List[Dict[str, Any]]: + def getPositionsByOrganisation(self, organisationId: str) -> List[TrusteePosition]: """Get all positions for a specific organisation.""" # Step 1: System RBAC filtering records = getRecordsetWithRBAC( @@ -916,9 +964,10 @@ class TrusteeObjects: ) # Step 2: Feature-level filtering based on trustee.access - return self.filterRecordsByTrusteeAccess(records, TrusteePosition) + filtered = self.filterRecordsByTrusteeAccess(records, TrusteePosition) + return [TrusteePosition(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in filtered] - def updatePosition(self, positionId: str, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: + def updatePosition(self, positionId: str, data: Dict[str, Any]) -> Optional[TrusteePosition]: """Update a position.""" # Get existing position to check organisation and creator existingRecords = self.db.getRecordset(TrusteePosition, recordFilter={"id": positionId}) @@ -940,7 +989,9 @@ class TrusteeObjects: data["id"] = positionId updatedRecord = self.db.recordModify(TrusteePosition, positionId, data) - return updatedRecord + if not updatedRecord: + return None + return TrusteePosition(**{k: v for k, v in updatedRecord.items() if not k.startswith("_")}) def deletePosition(self, positionId: str) -> bool: """Delete a position.""" @@ -966,7 +1017,7 @@ class TrusteeObjects: # ===== Position-Document Link CRUD ===== - def createPositionDocument(self, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: + def createPositionDocument(self, data: Dict[str, Any]) -> Optional[TrusteePositionDocument]: """Create a new position-document link.""" organisationId = data.get("organisationId") contractId = data.get("contractId") @@ -984,13 +1035,15 @@ class TrusteeObjects: createdRecord = self.db.recordCreate(TrusteePositionDocument, data) if createdRecord and createdRecord.get("id"): - return createdRecord + return TrusteePositionDocument(**{k: v for k, v in createdRecord.items() if not k.startswith("_")}) return None - def getPositionDocument(self, linkId: str) -> Optional[Dict[str, Any]]: + def getPositionDocument(self, linkId: str) -> Optional[TrusteePositionDocument]: """Get a single position-document link by ID.""" records = self.db.getRecordset(TrusteePositionDocument, recordFilter={"id": linkId}) - return records[0] if records else None + if not records: + return None + return TrusteePositionDocument(**{k: v for k, v in records[0].items() if not k.startswith("_")}) def getAllPositionDocuments(self, params: Optional[PaginationParams] = None) -> PaginatedResult: """Get all position-document links with RBAC filtering + feature-level access filtering.""" @@ -1027,7 +1080,7 @@ class TrusteeObjects: totalPages=totalPages ) - def getDocumentsForPosition(self, positionId: str) -> List[Dict[str, Any]]: + def getDocumentsForPosition(self, positionId: str) -> List[TrusteePositionDocument]: """Get all documents linked to a position.""" # Step 1: System RBAC filtering links = getRecordsetWithRBAC( @@ -1039,9 +1092,10 @@ class TrusteeObjects: ) # Step 2: Feature-level filtering based on trustee.access - return self.filterRecordsByTrusteeAccess(links, TrusteePositionDocument) + filtered = self.filterRecordsByTrusteeAccess(links, TrusteePositionDocument) + return [TrusteePositionDocument(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in filtered] - def getPositionsForDocument(self, documentId: str) -> List[Dict[str, Any]]: + def getPositionsForDocument(self, documentId: str) -> List[TrusteePositionDocument]: """Get all positions linked to a document.""" # Step 1: System RBAC filtering links = getRecordsetWithRBAC( @@ -1053,7 +1107,8 @@ class TrusteeObjects: ) # Step 2: Feature-level filtering based on trustee.access - return self.filterRecordsByTrusteeAccess(links, TrusteePositionDocument) + filtered = self.filterRecordsByTrusteeAccess(links, TrusteePositionDocument) + return [TrusteePositionDocument(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in filtered] def deletePositionDocument(self, linkId: str) -> bool: """Delete a position-document link.""" diff --git a/modules/interfaces/interfaceFeatures.py b/modules/interfaces/interfaceFeatures.py new file mode 100644 index 00000000..697658b3 --- /dev/null +++ b/modules/interfaces/interfaceFeatures.py @@ -0,0 +1,478 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +""" +Feature Instance Management Interface. + +Multi-Tenant Design: +- Feature-Instanzen gehören zu Mandanten +- Template-Rollen werden bei Erstellung kopiert +- Synchronisation von Templates ist explizit (nicht automatisch) +""" + +import logging +import uuid +from typing import List, Dict, Any, Optional + +from modules.datamodels.datamodelFeatures import Feature, FeatureInstance +from modules.datamodels.datamodelRbac import Role, AccessRule +from modules.connectors.connectorDbPostgre import DatabaseConnector + +logger = logging.getLogger(__name__) + + +class FeatureInterface: + """ + Interface for Feature and FeatureInstance management. + + Responsibilities: + - CRUD operations for Features and FeatureInstances + - Template role copying on instance creation + - Template synchronization for existing instances + """ + + def __init__(self, db: DatabaseConnector): + """ + Initialize Feature interface. + + Args: + db: DatabaseConnector instance (DbApp database) + """ + self.db = db + + # ============================================ + # Feature Methods (Global Feature Definitions) + # ============================================ + + def getFeature(self, featureCode: str) -> Optional[Feature]: + """ + Get a feature by code. + + Args: + featureCode: Feature code (e.g., "trustee", "chatbot") + + Returns: + Feature object or None + """ + try: + records = self.db.getRecordset(Feature, recordFilter={"code": featureCode}) + if not records: + return None + cleanedRecord = {k: v for k, v in records[0].items() if not k.startswith("_")} + return Feature(**cleanedRecord) + except Exception as e: + logger.error(f"Error getting feature {featureCode}: {e}") + return None + + def getAllFeatures(self) -> List[Feature]: + """ + Get all available features. + + Returns: + List of Feature objects + """ + try: + records = self.db.getRecordset(Feature) + result = [] + for record in records: + cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_")} + result.append(Feature(**cleanedRecord)) + return result + except Exception as e: + logger.error(f"Error getting all features: {e}") + return [] + + def createFeature(self, code: str, label: Dict[str, str], icon: str = "mdi-puzzle") -> Feature: + """ + Create a new feature definition. + + Args: + code: Unique feature code (e.g., "trustee") + label: I18n labels (e.g., {"en": "Trustee", "de": "Treuhand"}) + icon: Icon identifier + + Returns: + Created Feature object + """ + try: + feature = Feature(code=code, label=label, icon=icon) + createdRecord = self.db.recordCreate(Feature, feature.model_dump()) + cleanedRecord = {k: v for k, v in createdRecord.items() if not k.startswith("_")} + return Feature(**cleanedRecord) + except Exception as e: + logger.error(f"Error creating feature {code}: {e}") + raise ValueError(f"Failed to create feature: {e}") + + # ============================================ + # Feature Instance Methods + # ============================================ + + def getFeatureInstance(self, instanceId: str) -> Optional[FeatureInstance]: + """ + Get a feature instance by ID. + + Args: + instanceId: FeatureInstance ID + + Returns: + FeatureInstance object or None + """ + try: + records = self.db.getRecordset(FeatureInstance, recordFilter={"id": instanceId}) + if not records: + return None + cleanedRecord = {k: v for k, v in records[0].items() if not k.startswith("_")} + return FeatureInstance(**cleanedRecord) + except Exception as e: + logger.error(f"Error getting feature instance {instanceId}: {e}") + return None + + def getFeatureInstancesForMandate(self, mandateId: str, featureCode: Optional[str] = None) -> List[FeatureInstance]: + """ + Get all feature instances for a mandate. + + Args: + mandateId: Mandate ID + featureCode: Optional filter by feature code + + Returns: + List of FeatureInstance objects + """ + try: + recordFilter = {"mandateId": mandateId} + if featureCode: + recordFilter["featureCode"] = featureCode + records = self.db.getRecordset(FeatureInstance, recordFilter=recordFilter) + result = [] + for record in records: + cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_")} + result.append(FeatureInstance(**cleanedRecord)) + return result + except Exception as e: + logger.error(f"Error getting feature instances for mandate {mandateId}: {e}") + return [] + + def createFeatureInstance( + self, + featureCode: str, + mandateId: str, + label: str, + copyTemplateRoles: bool = True + ) -> FeatureInstance: + """ + Create a new feature instance for a mandate. + + Optionally copies global template roles for this feature. + + WICHTIG: Templates werden NUR bei Erstellung kopiert. + Spätere Template-Änderungen werden NICHT automatisch propagiert. + Für manuelle Nachsynchronisation siehe syncRolesFromTemplate(). + + Args: + featureCode: Feature code (e.g., "trustee") + mandateId: Mandate ID + label: Instance label (e.g., "Buchhaltung 2025") + copyTemplateRoles: Whether to copy template roles + + Returns: + Created FeatureInstance object + """ + try: + # Create instance + instance = FeatureInstance( + featureCode=featureCode, + mandateId=mandateId, + label=label, + enabled=True + ) + createdInstance = self.db.recordCreate(FeatureInstance, instance.model_dump()) + + if not createdInstance: + raise ValueError("Failed to create feature instance record") + + instanceId = createdInstance.get("id") + + # Copy template roles if requested + if copyTemplateRoles: + self._copyTemplateRoles(featureCode, mandateId, instanceId) + + cleanedRecord = {k: v for k, v in createdInstance.items() if not k.startswith("_")} + return FeatureInstance(**cleanedRecord) + + except Exception as e: + logger.error(f"Error creating feature instance: {e}") + raise ValueError(f"Failed to create feature instance: {e}") + + def _copyTemplateRoles(self, featureCode: str, mandateId: str, instanceId: str) -> int: + """ + Copy global template roles for a feature to a new instance. + + Args: + featureCode: Feature code + mandateId: Mandate ID + instanceId: New FeatureInstance ID + + Returns: + Number of roles copied + """ + try: + # Find global template roles for this feature (mandateId=None) + globalRoles = self.db.getRecordset( + Role, + recordFilter={"featureCode": featureCode, "mandateId": None} + ) + + if not globalRoles: + logger.debug(f"No template roles found for feature {featureCode}") + return 0 + + templateRoleIds = [r.get("id") for r in globalRoles] + + # BULK: Load all template AccessRules in one query + allTemplateRules = [] + for roleId in templateRoleIds: + rules = self.db.getRecordset(AccessRule, recordFilter={"roleId": roleId}) + allTemplateRules.extend([(roleId, r) for r in rules]) + + # Index for fast lookup: roleId -> rules + rulesByRoleId = {} + for roleId, rule in allTemplateRules: + if roleId not in rulesByRoleId: + rulesByRoleId[roleId] = [] + rulesByRoleId[roleId].append(rule) + + # Copy roles and their AccessRules + copiedCount = 0 + for templateRole in globalRoles: + newRoleId = str(uuid.uuid4()) + + # Create new role for this instance + newRole = Role( + id=newRoleId, + roleLabel=templateRole.get("roleLabel"), + description=templateRole.get("description", {}), + featureCode=featureCode, + mandateId=mandateId, + featureInstanceId=instanceId, + isSystemRole=False + ) + self.db.recordCreate(Role, newRole.model_dump()) + + # Copy AccessRules for this role + templateRulesForRole = rulesByRoleId.get(templateRole.get("id"), []) + for rule in templateRulesForRole: + newRule = AccessRule( + id=str(uuid.uuid4()), + roleId=newRoleId, + context=rule.get("context"), + item=rule.get("item"), + view=rule.get("view", False), + read=rule.get("read"), + create=rule.get("create"), + update=rule.get("update"), + delete=rule.get("delete") + ) + self.db.recordCreate(AccessRule, newRule.model_dump()) + + copiedCount += 1 + + logger.info(f"Copied {copiedCount} template roles for instance {instanceId}") + return copiedCount + + except Exception as e: + logger.error(f"Error copying template roles: {e}") + return 0 + + def syncRolesFromTemplate(self, featureInstanceId: str, addOnly: bool = True) -> Dict[str, int]: + """ + Synchronize roles of a feature instance with current templates. + + WICHTIG: Templates werden NUR bei Erstellung einer neuen FeatureInstance kopiert. + Diese Sync-Funktion ist für manuelle Nachsynchronisation gedacht, nicht für + automatische Propagation von Template-Änderungen. + + Args: + featureInstanceId: ID of the instance to sync + addOnly: If True, only add missing roles. If False, also remove extras. + + Returns: + Dict with added/removed/unchanged counts + """ + try: + instance = self.getFeatureInstance(featureInstanceId) + if not instance: + raise ValueError(f"FeatureInstance {featureInstanceId} not found") + + featureCode = instance.featureCode + mandateId = instance.mandateId + + # Get current template roles + templateRoles = self.db.getRecordset( + Role, + recordFilter={"featureCode": featureCode, "mandateId": None} + ) + templateLabels = {r.get("roleLabel") for r in templateRoles} + + # Get current instance roles + instanceRoles = self.db.getRecordset( + Role, + recordFilter={"featureInstanceId": featureInstanceId} + ) + instanceLabels = {r.get("roleLabel") for r in instanceRoles} + + result = {"added": 0, "removed": 0, "unchanged": 0} + + # Add missing roles + for templateRole in templateRoles: + if templateRole.get("roleLabel") not in instanceLabels: + # Copy this role + newRoleId = str(uuid.uuid4()) + newRole = Role( + id=newRoleId, + roleLabel=templateRole.get("roleLabel"), + description=templateRole.get("description", {}), + featureCode=featureCode, + mandateId=mandateId, + featureInstanceId=featureInstanceId, + isSystemRole=False + ) + self.db.recordCreate(Role, newRole.model_dump()) + + # Copy AccessRules + templateRules = self.db.getRecordset( + AccessRule, + recordFilter={"roleId": templateRole.get("id")} + ) + for rule in templateRules: + newRule = AccessRule( + id=str(uuid.uuid4()), + roleId=newRoleId, + context=rule.get("context"), + item=rule.get("item"), + view=rule.get("view", False), + read=rule.get("read"), + create=rule.get("create"), + update=rule.get("update"), + delete=rule.get("delete") + ) + self.db.recordCreate(AccessRule, newRule.model_dump()) + + result["added"] += 1 + else: + result["unchanged"] += 1 + + # Remove extra roles (optional) + if not addOnly: + from modules.datamodels.datamodelMembership import FeatureAccessRole + + for instanceRole in instanceRoles: + if instanceRole.get("roleLabel") not in templateLabels: + # Check if role is still in use + usages = self.db.getRecordset( + FeatureAccessRole, + recordFilter={"roleId": instanceRole.get("id")} + ) + if not usages: + self.db.recordDelete(Role, instanceRole.get("id")) + result["removed"] += 1 + + logger.info(f"Synced roles for instance {featureInstanceId}: {result}") + return result + + except Exception as e: + logger.error(f"Error syncing roles from template: {e}") + raise ValueError(f"Failed to sync roles: {e}") + + def deleteFeatureInstance(self, instanceId: str) -> bool: + """ + Delete a feature instance. + CASCADE will delete associated roles and access records. + + Args: + instanceId: FeatureInstance ID + + Returns: + True if deleted + """ + try: + instance = self.getFeatureInstance(instanceId) + if not instance: + return False + + return self.db.recordDelete(FeatureInstance, instanceId) + except Exception as e: + logger.error(f"Error deleting feature instance {instanceId}: {e}") + raise ValueError(f"Failed to delete feature instance: {e}") + + # ============================================ + # Template Role Methods (Global) + # ============================================ + + def getTemplateRoles(self, featureCode: Optional[str] = None) -> List[Role]: + """ + Get global template roles (mandateId=None). + + Args: + featureCode: Optional filter by feature code + + Returns: + List of Role objects + """ + try: + recordFilter = {"mandateId": None} + if featureCode: + recordFilter["featureCode"] = featureCode + records = self.db.getRecordset(Role, recordFilter=recordFilter) + result = [] + for record in records: + cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_")} + result.append(Role(**cleanedRecord)) + return result + except Exception as e: + logger.error(f"Error getting template roles: {e}") + return [] + + def createTemplateRole( + self, + roleLabel: str, + featureCode: str, + description: Dict[str, str] = None + ) -> Role: + """ + Create a global template role for a feature. + + Args: + roleLabel: Role label (e.g., "admin", "viewer") + featureCode: Feature code this role belongs to + description: I18n descriptions + + Returns: + Created Role object + """ + try: + role = Role( + roleLabel=roleLabel, + description=description or {}, + featureCode=featureCode, + mandateId=None, # Global template + featureInstanceId=None, + isSystemRole=False + ) + createdRecord = self.db.recordCreate(Role, role.model_dump()) + cleanedRecord = {k: v for k, v in createdRecord.items() if not k.startswith("_")} + return Role(**cleanedRecord) + except Exception as e: + logger.error(f"Error creating template role: {e}") + raise ValueError(f"Failed to create template role: {e}") + + +def getFeatureInterface(db: DatabaseConnector) -> FeatureInterface: + """ + Factory function to get a FeatureInterface instance. + + Args: + db: DatabaseConnector instance (DbApp database) + + Returns: + FeatureInterface instance + """ + return FeatureInterface(db) diff --git a/modules/interfaces/interfaceRbac.py b/modules/interfaces/interfaceRbac.py index e232ae95..26515e94 100644 --- a/modules/interfaces/interfaceRbac.py +++ b/modules/interfaces/interfaceRbac.py @@ -3,6 +3,10 @@ """ RBAC helper functions for interfaces. Provides RBAC filtering for database queries without connectors importing security. + +Multi-Tenant Design: +- mandateId kommt aus Request-Context (X-Mandate-Id Header) +- GROUP-Filter verwendet expliziten mandateId Parameter """ import logging @@ -24,24 +28,33 @@ def getRecordsetWithRBAC( recordFilter: Dict[str, Any] = None, orderBy: str = None, limit: int = None, + mandateId: Optional[str] = None, + featureInstanceId: Optional[str] = None, ) -> List[Dict[str, Any]]: """ Get records with RBAC filtering applied at database level. This function wraps connector.getRecordset() with RBAC logic. + Multi-Tenant Design: + - mandateId wird explizit übergeben (aus Request-Context / X-Mandate-Id Header) + Args: connector: DatabaseConnector instance modelClass: Pydantic model class for the table - currentUser: User object with roleLabels + currentUser: User object recordFilter: Additional record filters orderBy: Field to order by (defaults to "id") limit: Maximum number of records to return + mandateId: Explicit mandate context (from request header). Required for GROUP access. + featureInstanceId: Explicit feature instance context Returns: List of filtered records """ table = modelClass.__name__ + effectiveMandateId = mandateId + try: if not connector._ensureTableExists(modelClass): return [] @@ -53,7 +66,9 @@ def getRecordsetWithRBAC( permissions = rbacInstance.getUserPermissions( currentUser, AccessRuleContext.DATA, - table + table, + mandateId=effectiveMandateId, + featureInstanceId=featureInstanceId ) # Check view permission first @@ -66,7 +81,13 @@ def getRecordsetWithRBAC( whereValues = [] # Add RBAC WHERE clause based on read permission - rbacWhereClause = buildRbacWhereClause(permissions, currentUser, table, connector) + rbacWhereClause = buildRbacWhereClause( + permissions, + currentUser, + table, + connector, + mandateId=effectiveMandateId + ) if rbacWhereClause: whereConditions.append(rbacWhereClause["condition"]) whereValues.extend(rbacWhereClause["values"]) @@ -155,17 +176,21 @@ def buildRbacWhereClause( permissions: UserPermissions, currentUser: User, table: str, - connector # DatabaseConnector instance for connection access + connector, # DatabaseConnector instance for connection access + mandateId: Optional[str] = None ) -> Optional[Dict[str, Any]]: """ Build RBAC WHERE clause based on permissions and access level. - Moved from connector to interfaces. + + Multi-Tenant Design: + - mandateId wird explizit übergeben (aus Request-Context / X-Mandate-Id Header) Args: permissions: UserPermissions object currentUser: User object table: Table name connector: DatabaseConnector instance (needed for GROUP queries) + mandateId: Explicit mandate context (from request header). Required for GROUP access. Returns: Dictionary with "condition" and "values" keys, or None if no filtering needed @@ -201,7 +226,9 @@ def buildRbacWhereClause( # Group records - filter by mandateId if readLevel == AccessLevel.GROUP: - if not currentUser.mandateId: + effectiveMandateId = mandateId + + if not effectiveMandateId: logger.warning(f"User {currentUser.id} has no mandateId for GROUP access") return {"condition": "1 = 0", "values": []} @@ -209,7 +236,7 @@ def buildRbacWhereClause( if table == "UserInDB": return { "condition": '"mandateId" = %s', - "values": [currentUser.mandateId] + "values": [effectiveMandateId] } # For UserConnection, need to join with UserInDB or filter by mandateId in user elif table == "UserConnection": @@ -218,7 +245,7 @@ def buildRbacWhereClause( with connector.connection.cursor() as cursor: cursor.execute( 'SELECT "id" FROM "UserInDB" WHERE "mandateId" = %s', - (currentUser.mandateId,) + (effectiveMandateId,) ) users = cursor.fetchall() userIds = [u["id"] for u in users] @@ -236,8 +263,7 @@ def buildRbacWhereClause( else: return { "condition": '"mandateId" = %s', - "values": [currentUser.mandateId] + "values": [effectiveMandateId] } return None - diff --git a/modules/interfaces/interfaceVoiceObjects.py b/modules/interfaces/interfaceVoiceObjects.py index ee86910f..0c28f81d 100644 --- a/modules/interfaces/interfaceVoiceObjects.py +++ b/modules/interfaces/interfaceVoiceObjects.py @@ -31,19 +31,26 @@ class VoiceObjects: self.userId: Optional[str] = None self._google_speech_connector: Optional[ConnectorGoogleSpeech] = None - def setUserContext(self, currentUser: User): - """Set the user context for the interface.""" + def setUserContext(self, currentUser: User, mandateId: Optional[str] = None): + """Set the user context for the interface. + + Args: + currentUser: The authenticated user + mandateId: The mandate ID from RequestContext (X-Mandate-Id header) + """ if not currentUser: logger.info("Initializing voice interface without user context") return self.currentUser = currentUser self.userId = currentUser.id + # Use mandateId from parameter (Request-Context), not from user object + self.mandateId = mandateId if not self.userId: raise ValueError("Invalid user context: id is required") - logger.debug(f"Voice interface user context set: userId={self.userId}") + logger.debug(f"Voice interface user context set: userId={self.userId}, mandateId={self.mandateId}") def _getGoogleSpeechConnector(self) -> ConnectorGoogleSpeech: """Get or create Google Cloud Speech connector instance.""" @@ -308,11 +315,11 @@ class VoiceObjects: try: logger.info(f"Creating voice settings: {settingsData}") - # Ensure mandateId is set from user context if not provided + # Ensure mandateId is set from context if not provided if "mandateId" not in settingsData or not settingsData["mandateId"]: - if not self.currentUser or not self.currentUser.mandateId: - raise ValueError("mandateId is required but not provided and user context has no mandateId") - settingsData["mandateId"] = self.currentUser.mandateId + if not self.mandateId: + raise ValueError("mandateId is required but not provided and context has no mandateId") + settingsData["mandateId"] = self.mandateId # Add timestamps currentTime = getUtcTimestamp() @@ -376,7 +383,7 @@ class VoiceObjects: # Create default settings if none exist defaultSettings = { "userId": userId, - "mandateId": self.currentUser.mandateId, + "mandateId": self.mandateId, "sttLanguage": "de-DE", "ttsLanguage": "de-DE", "ttsVoice": "de-DE-Wavenet-A", @@ -524,21 +531,22 @@ class VoiceObjects: } -def getVoiceInterface(currentUser: User = None) -> VoiceObjects: +def getVoiceInterface(currentUser: User = None, mandateId: Optional[str] = None) -> VoiceObjects: """ Factory function to get or create Voice interface instance. Args: currentUser: User object for context (optional) + mandateId: The mandate ID from RequestContext (X-Mandate-Id header). Required. Returns: VoiceObjects instance """ - # For now, create a new instance each time - # In the future, this could be enhanced with singleton pattern per user + effectiveMandateId = str(mandateId) if mandateId else None + voiceInterface = VoiceObjects() if currentUser: - voiceInterface.setUserContext(currentUser) + voiceInterface.setUserContext(currentUser, mandateId=effectiveMandateId) return voiceInterface diff --git a/modules/routes/routeAdminRbacRoles.py b/modules/routes/routeAdminRbacRoles.py index e7902b0f..caa39859 100644 --- a/modules/routes/routeAdminRbacRoles.py +++ b/modules/routes/routeAdminRbacRoles.py @@ -3,20 +3,70 @@ """ Admin RBAC Roles Management routes. Provides endpoints for managing roles and role assignments to users. + +MULTI-TENANT: These are SYSTEM-LEVEL operations requiring isSysAdmin=true. +Roles are global system resources, not mandate-specific. +Role assignments are managed via UserMandateRole (not User.roleLabels). """ from fastapi import APIRouter, HTTPException, Depends, Query, Body, Path, Request -from typing import List, Dict, Any, Optional +from typing import List, Dict, Any, Optional, Set import logging -from modules.auth import getCurrentUser, limiter +from modules.auth import limiter, requireSysAdmin, RequestContext from modules.datamodels.datamodelUam import User, UserInDB from modules.datamodels.datamodelRbac import Role -from modules.interfaces.interfaceDbAppObjects import getInterface +from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole +from modules.interfaces.interfaceDbAppObjects import getInterface, getRootInterface # Configure logger logger = logging.getLogger(__name__) + +def _getUserRoleLabels(interface, userId: str) -> List[str]: + """ + Get role labels for a user from UserMandateRole (across all mandates). + + Args: + interface: Database interface + userId: User ID + + Returns: + List of role labels + """ + roleLabels: Set[str] = set() + + # Get all UserMandate records for this user + userMandates = interface.db.getRecordset(UserMandate, recordFilter={"userId": userId}) + + for um in userMandates: + userMandateId = um.get("id") + if not userMandateId: + continue + + # Get all UserMandateRole records for this membership + userMandateRoles = interface.db.getRecordset( + UserMandateRole, + recordFilter={"userMandateId": str(userMandateId)} + ) + + for umr in userMandateRoles: + roleId = umr.get("roleId") + if roleId: + # Get role by ID to get roleLabel + role = interface.getRole(str(roleId)) + if role: + roleLabels.add(role.roleLabel) + + return list(roleLabels) + + +def _hasRoleLabel(interface, userId: str, roleLabel: str) -> bool: + """ + Check if user has a specific role label (across all mandates). + """ + return roleLabel in _getUserRoleLabels(interface, userId) + router = APIRouter( prefix="/api/admin/rbac/roles", tags=["Admin RBAC Roles"], @@ -24,51 +74,27 @@ router = APIRouter( ) -def _ensureAdminAccess(currentUser: User) -> None: - """Ensure current user has admin access to RBAC roles management.""" - interface = getInterface(currentUser) - - # Check if user has admin or sysadmin role - roleLabels = currentUser.roleLabels or [] - if "sysadmin" not in roleLabels and "admin" not in roleLabels: - raise HTTPException( - status_code=403, - detail="Admin or sysadmin role required to manage RBAC roles" - ) - - # Additional RBAC check: verify user has permission to update UserInDB - # This is already covered by admin/sysadmin role check above, but we can add explicit RBAC check if needed - # For now, admin/sysadmin role check is sufficient - - @router.get("/", response_model=List[Dict[str, Any]]) @limiter.limit("60/minute") async def listRoles( request: Request, - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(requireSysAdmin) ) -> List[Dict[str, Any]]: """ Get list of all available roles with metadata. + MULTI-TENANT: SysAdmin-only (roles are system resources). Returns: - List of role dictionaries with role label, description, and user count """ try: - _ensureAdminAccess(currentUser) - - interface = getInterface(currentUser) + interface = getRootInterface() # Get all roles from database dbRoles = interface.getAllRoles() - # Get all users to count role assignments - allUsers = interface.getUsers() - - # Count users per role - roleCounts: Dict[str, int] = {} - for user in allUsers: - for roleLabel in (user.roleLabels or []): - roleCounts[roleLabel] = roleCounts.get(roleLabel, 0) + 1 + # Count role assignments from UserMandateRole table + roleCounts = interface.countRoleAssignments() # Convert Role objects to dictionaries and add user counts result = [] @@ -77,22 +103,10 @@ async def listRoles( "id": role.id, "roleLabel": role.roleLabel, "description": role.description, - "userCount": roleCounts.get(role.roleLabel, 0), + "userCount": roleCounts.get(str(role.id), 0), "isSystemRole": role.isSystemRole }) - # Add any roles found in user assignments that don't exist in database - dbRoleLabels = {role.roleLabel for role in dbRoles} - for roleLabel, count in roleCounts.items(): - if roleLabel not in dbRoleLabels: - result.append({ - "id": None, - "roleLabel": roleLabel, - "description": {"en": f"Custom role: {roleLabel}", "fr": f"Rôle personnalisé : {roleLabel}"}, - "userCount": count, - "isSystemRole": False - }) - return result except HTTPException: @@ -109,19 +123,17 @@ async def listRoles( @limiter.limit("60/minute") async def getRoleOptions( request: Request, - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(requireSysAdmin) ) -> List[Dict[str, Any]]: """ Get role options for select dropdowns. - Returns roles in format suitable for frontend select components. + MULTI-TENANT: SysAdmin-only. Returns: - List of role option dictionaries with value and label """ try: - _ensureAdminAccess(currentUser) - - interface = getInterface(currentUser) + interface = getRootInterface() # Get all roles from database dbRoles = interface.getAllRoles() @@ -153,10 +165,11 @@ async def getRoleOptions( async def createRole( request: Request, role: Role = Body(...), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(requireSysAdmin) ) -> Dict[str, Any]: """ Create a new role. + MULTI-TENANT: SysAdmin-only (roles are system resources). Request Body: - role: Role object to create @@ -165,9 +178,7 @@ async def createRole( - Created role dictionary """ try: - _ensureAdminAccess(currentUser) - - interface = getInterface(currentUser) + interface = getRootInterface() createdRole = interface.createRole(role) @@ -198,10 +209,11 @@ async def createRole( async def getRole( request: Request, roleId: str = Path(..., description="Role ID"), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(requireSysAdmin) ) -> Dict[str, Any]: """ Get a role by ID. + MULTI-TENANT: SysAdmin-only. Path Parameters: - roleId: Role ID @@ -210,9 +222,7 @@ async def getRole( - Role dictionary """ try: - _ensureAdminAccess(currentUser) - - interface = getInterface(currentUser) + interface = getRootInterface() role = interface.getRole(roleId) if not role: @@ -244,10 +254,11 @@ async def updateRole( request: Request, roleId: str = Path(..., description="Role ID"), role: Role = Body(...), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(requireSysAdmin) ) -> Dict[str, Any]: """ Update an existing role. + MULTI-TENANT: SysAdmin-only. Path Parameters: - roleId: Role ID @@ -259,9 +270,7 @@ async def updateRole( - Updated role dictionary """ try: - _ensureAdminAccess(currentUser) - - interface = getInterface(currentUser) + interface = getRootInterface() updatedRole = interface.updateRole(roleId, role) @@ -292,10 +301,11 @@ async def updateRole( async def deleteRole( request: Request, roleId: str = Path(..., description="Role ID"), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(requireSysAdmin) ) -> Dict[str, str]: """ Delete a role. + MULTI-TENANT: SysAdmin-only. Path Parameters: - roleId: Role ID @@ -304,9 +314,7 @@ async def deleteRole( - Success message """ try: - _ensureAdminAccess(currentUser) - - interface = getInterface(currentUser) + interface = getRootInterface() success = interface.deleteRole(roleId) if not success: @@ -337,48 +345,50 @@ async def deleteRole( async def listUsersWithRoles( request: Request, roleLabel: Optional[str] = Query(None, description="Filter by role label"), - mandateId: Optional[str] = Query(None, description="Filter by mandate ID"), - currentUser: User = Depends(getCurrentUser) + mandateId: Optional[str] = Query(None, description="Filter by mandate ID (via UserMandate)"), + context: RequestContext = Depends(requireSysAdmin) ) -> List[Dict[str, Any]]: """ Get list of users with their role assignments. + MULTI-TENANT: SysAdmin-only, can see all users across mandates. Query Parameters: - roleLabel: Optional filter by role label - - mandateId: Optional filter by mandate ID + - mandateId: Optional filter by mandate ID (via UserMandate table) Returns: - List of user dictionaries with role assignments """ try: - _ensureAdminAccess(currentUser) + interface = getRootInterface() - interface = getInterface(currentUser) + # Get all users (SysAdmin sees all) + users = interface.getUsers() - # Get users based on filters + # Filter by mandate if specified (via UserMandate table) if mandateId: - # Filter by mandate (if user has permission) - users = interface.getUsers() - users = [u for u in users if u.mandateId == mandateId] - else: - users = interface.getUsers() + from modules.datamodels.datamodelMembership import UserMandate + userMandates = interface.db.getRecordset(UserMandate, recordFilter={"mandateId": mandateId}) + mandateUserIds = {str(um["userId"]) for um in userMandates} + users = [u for u in users if str(u.id) in mandateUserIds] - # Filter by role if specified + # Filter by role if specified (via UserMandateRole) if roleLabel: - users = [u for u in users if roleLabel in (u.roleLabels or [])] + users = [u for u in users if _hasRoleLabel(interface, str(u.id), roleLabel)] # Format response result = [] for user in users: + userRoleLabels = _getUserRoleLabels(interface, str(user.id)) result.append({ "id": user.id, "username": user.username, "email": user.email, "fullName": user.fullName, - "mandateId": user.mandateId, + "isSysAdmin": user.isSysAdmin, "enabled": user.enabled, - "roleLabels": user.roleLabels or [], - "roleCount": len(user.roleLabels or []) + "roleLabels": userRoleLabels, + "roleCount": len(userRoleLabels) }) return result @@ -398,10 +408,11 @@ async def listUsersWithRoles( async def getUserRoles( request: Request, userId: str = Path(..., description="User ID"), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(requireSysAdmin) ) -> Dict[str, Any]: """ Get role assignments for a specific user. + MULTI-TENANT: SysAdmin-only. Path Parameters: - userId: User ID @@ -410,9 +421,7 @@ async def getUserRoles( - User dictionary with role assignments """ try: - _ensureAdminAccess(currentUser) - - interface = getInterface(currentUser) + interface = getRootInterface() # Get user user = interface.getUser(userId) @@ -422,15 +431,16 @@ async def getUserRoles( detail=f"User {userId} not found" ) + userRoleLabels = _getUserRoleLabels(interface, str(user.id)) return { "id": user.id, "username": user.username, "email": user.email, "fullName": user.fullName, - "mandateId": user.mandateId, + "isSysAdmin": user.isSysAdmin, "enabled": user.enabled, - "roleLabels": user.roleLabels or [], - "roleCount": len(user.roleLabels or []) + "roleLabels": userRoleLabels, + "roleCount": len(userRoleLabels) } except HTTPException: @@ -448,25 +458,24 @@ async def getUserRoles( async def updateUserRoles( request: Request, userId: str = Path(..., description="User ID"), - roleLabels: List[str] = Body(..., description="List of role labels to assign"), - currentUser: User = Depends(getCurrentUser) + newRoleLabels: List[str] = Body(..., description="List of role labels to assign"), + context: RequestContext = Depends(requireSysAdmin) ) -> Dict[str, Any]: """ Update role assignments for a specific user. + MULTI-TENANT: SysAdmin-only. Updates roles in user's first mandate. Path Parameters: - userId: User ID Request Body: - - roleLabels: List of role labels to assign (e.g., ["admin", "user"]) + - newRoleLabels: List of role labels to assign (e.g., ["admin", "user"]) Returns: - Updated user dictionary with role assignments """ try: - _ensureAdminAccess(currentUser) - - interface = getInterface(currentUser) + interface = getRootInterface() # Get user user = interface.getUser(userId) @@ -478,28 +487,57 @@ async def updateUserRoles( # Validate role labels (basic validation - check against standard roles) standardRoles = ["sysadmin", "admin", "user", "viewer"] - for roleLabel in roleLabels: + for roleLabel in newRoleLabels: if roleLabel not in standardRoles: logger.warning(f"Non-standard role label assigned: {roleLabel}") - # Update user roles - userData = { - "roleLabels": roleLabels - } + # Get user's first mandate (for role assignment) + userMandates = interface.db.getRecordset(UserMandate, recordFilter={"userId": userId}) + if not userMandates: + raise HTTPException( + status_code=400, + detail=f"User {userId} has no mandate memberships. Add to mandate first." + ) - updatedUser = interface.updateUser(userId, userData) + userMandateId = str(userMandates[0].get("id")) - logger.info(f"Updated roles for user {userId}: {roleLabels}") + # Get current roles for this mandate + existingRoles = interface.db.getRecordset( + UserMandateRole, + recordFilter={"userMandateId": userMandateId} + ) + existingRoleIds = {str(r.get("roleId")) for r in existingRoles} + # Convert roleLabels to roleIds + newRoleIds = set() + for roleLabel in newRoleLabels: + role = interface.getRoleByLabel(roleLabel) + if role: + newRoleIds.add(str(role.id)) + + # Remove roles that are no longer needed + for existingRole in existingRoles: + if str(existingRole.get("roleId")) not in newRoleIds: + interface.db.recordDelete(UserMandateRole, str(existingRole.get("id"))) + + # Add new roles + for roleId in newRoleIds: + if roleId not in existingRoleIds: + newRole = UserMandateRole(userMandateId=userMandateId, roleId=roleId) + interface.db.recordCreate(UserMandateRole, newRole.model_dump()) + + logger.info(f"Updated roles for user {userId}: {newRoleLabels} by SysAdmin {context.user.id}") + + userRoleLabels = _getUserRoleLabels(interface, userId) return { - "id": updatedUser.id, - "username": updatedUser.username, - "email": updatedUser.email, - "fullName": updatedUser.fullName, - "mandateId": updatedUser.mandateId, - "enabled": updatedUser.enabled, - "roleLabels": updatedUser.roleLabels or [], - "roleCount": len(updatedUser.roleLabels or []) + "id": user.id, + "username": user.username, + "email": user.email, + "fullName": user.fullName, + "isSysAdmin": user.isSysAdmin, + "enabled": user.enabled, + "roleLabels": userRoleLabels, + "roleCount": len(userRoleLabels) } except HTTPException: @@ -518,10 +556,11 @@ async def addUserRole( request: Request, userId: str = Path(..., description="User ID"), roleLabel: str = Path(..., description="Role label to add"), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(requireSysAdmin) ) -> Dict[str, Any]: """ Add a role to a user (if not already assigned). + MULTI-TENANT: SysAdmin-only. Adds role to user's first mandate. Path Parameters: - userId: User ID @@ -531,9 +570,7 @@ async def addUserRole( - Updated user dictionary with role assignments """ try: - _ensureAdminAccess(currentUser) - - interface = getInterface(currentUser) + interface = getRootInterface() # Get user user = interface.getUser(userId) @@ -543,33 +580,46 @@ async def addUserRole( detail=f"User {userId} not found" ) - # Get current roles - currentRoles = list(user.roleLabels or []) + # Get role by label + role = interface.getRoleByLabel(roleLabel) + if not role: + raise HTTPException( + status_code=404, + detail=f"Role '{roleLabel}' not found" + ) - # Add role if not already present - if roleLabel not in currentRoles: - currentRoles.append(roleLabel) - - # Update user roles - userData = { - "roleLabels": currentRoles - } - - updatedUser = interface.updateUser(userId, userData) - - logger.info(f"Added role {roleLabel} to user {userId}") - else: - updatedUser = user + # Get user's first mandate + userMandates = interface.db.getRecordset(UserMandate, recordFilter={"userId": userId}) + if not userMandates: + raise HTTPException( + status_code=400, + detail=f"User {userId} has no mandate memberships. Add to mandate first." + ) + userMandateId = str(userMandates[0].get("id")) + + # Check if role is already assigned + existingAssignment = interface.db.getRecordset( + UserMandateRole, + recordFilter={"userMandateId": userMandateId, "roleId": str(role.id)} + ) + + if not existingAssignment: + # Add the role + newRole = UserMandateRole(userMandateId=userMandateId, roleId=str(role.id)) + interface.db.recordCreate(UserMandateRole, newRole.model_dump()) + logger.info(f"Added role {roleLabel} to user {userId} by SysAdmin {context.user.id}") + + userRoleLabels = _getUserRoleLabels(interface, userId) return { - "id": updatedUser.id, - "username": updatedUser.username, - "email": updatedUser.email, - "fullName": updatedUser.fullName, - "mandateId": updatedUser.mandateId, - "enabled": updatedUser.enabled, - "roleLabels": updatedUser.roleLabels or [], - "roleCount": len(updatedUser.roleLabels or []) + "id": user.id, + "username": user.username, + "email": user.email, + "fullName": user.fullName, + "isSysAdmin": user.isSysAdmin, + "enabled": user.enabled, + "roleLabels": userRoleLabels, + "roleCount": len(userRoleLabels) } except HTTPException: @@ -588,10 +638,11 @@ async def removeUserRole( request: Request, userId: str = Path(..., description="User ID"), roleLabel: str = Path(..., description="Role label to remove"), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(requireSysAdmin) ) -> Dict[str, Any]: """ Remove a role from a user. + MULTI-TENANT: SysAdmin-only. Removes role from all user's mandates. Path Parameters: - userId: User ID @@ -601,9 +652,7 @@ async def removeUserRole( - Updated user dictionary with role assignments """ try: - _ensureAdminAccess(currentUser) - - interface = getInterface(currentUser) + interface = getRootInterface() # Get user user = interface.getUser(userId) @@ -613,38 +662,44 @@ async def removeUserRole( detail=f"User {userId} not found" ) - # Get current roles - currentRoles = list(user.roleLabels or []) + # Get role by label + role = interface.getRoleByLabel(roleLabel) + if not role: + raise HTTPException( + status_code=404, + detail=f"Role '{roleLabel}' not found" + ) - # Remove role if present - if roleLabel in currentRoles: - currentRoles.remove(roleLabel) - - # Ensure user has at least one role (default to "user") - if not currentRoles: - currentRoles = ["user"] - logger.warning(f"User {userId} had all roles removed, defaulting to 'user' role") - - # Update user roles - userData = { - "roleLabels": currentRoles - } - - updatedUser = interface.updateUser(userId, userData) - - logger.info(f"Removed role {roleLabel} from user {userId}") - else: - updatedUser = user + # Remove role from all user's mandates + userMandates = interface.db.getRecordset(UserMandate, recordFilter={"userId": userId}) + roleRemoved = False + for um in userMandates: + userMandateId = str(um.get("id")) + + # Find and delete the role assignment + assignments = interface.db.getRecordset( + UserMandateRole, + recordFilter={"userMandateId": userMandateId, "roleId": str(role.id)} + ) + + for assignment in assignments: + interface.db.recordDelete(UserMandateRole, str(assignment.get("id"))) + roleRemoved = True + + if roleRemoved: + logger.info(f"Removed role {roleLabel} from user {userId} by SysAdmin {context.user.id}") + + userRoleLabels = _getUserRoleLabels(interface, userId) return { - "id": updatedUser.id, - "username": updatedUser.username, - "email": updatedUser.email, - "fullName": updatedUser.fullName, - "mandateId": updatedUser.mandateId, - "enabled": updatedUser.enabled, - "roleLabels": updatedUser.roleLabels or [], - "roleCount": len(updatedUser.roleLabels or []) + "id": user.id, + "username": user.username, + "email": user.email, + "fullName": user.fullName, + "isSysAdmin": user.isSysAdmin, + "enabled": user.enabled, + "roleLabels": userRoleLabels, + "roleCount": len(userRoleLabels) } except HTTPException: @@ -662,49 +717,69 @@ async def removeUserRole( async def getUsersWithRole( request: Request, roleLabel: str = Path(..., description="Role label"), - mandateId: Optional[str] = Query(None, description="Filter by mandate ID"), - currentUser: User = Depends(getCurrentUser) + mandateId: Optional[str] = Query(None, description="Filter by mandate ID (via UserMandate)"), + context: RequestContext = Depends(requireSysAdmin) ) -> List[Dict[str, Any]]: """ Get all users with a specific role. + MULTI-TENANT: SysAdmin-only. Path Parameters: - roleLabel: Role label Query Parameters: - - mandateId: Optional filter by mandate ID + - mandateId: Optional filter by mandate ID (via UserMandate table) Returns: - List of users with the specified role """ try: - _ensureAdminAccess(currentUser) + interface = getRootInterface() - interface = getInterface(currentUser) + # Get role by label + role = interface.getRoleByLabel(roleLabel) + if not role: + raise HTTPException( + status_code=404, + detail=f"Role '{roleLabel}' not found" + ) - # Get all users - users = interface.getUsers() + # Get all UserMandateRole assignments for this role + roleAssignments = interface.db.getRecordset( + UserMandateRole, + recordFilter={"roleId": str(role.id)} + ) - # Filter by role - users = [u for u in users if roleLabel in (u.roleLabels or [])] + # Get unique userMandateIds + userMandateIds = {str(ra.get("userMandateId")) for ra in roleAssignments} - # Filter by mandate if specified - if mandateId: - users = [u for u in users if u.mandateId == mandateId] + # Get userIds from UserMandate records + userIds: Set[str] = set() + for userMandateId in userMandateIds: + umRecords = interface.db.getRecordset(UserMandate, recordFilter={"id": userMandateId}) + if umRecords: + um = umRecords[0] + # Filter by mandate if specified + if mandateId and str(um.get("mandateId")) != mandateId: + continue + userIds.add(str(um.get("userId"))) - # Format response + # Get users and format response result = [] - for user in users: - result.append({ - "id": user.id, - "username": user.username, - "email": user.email, - "fullName": user.fullName, - "mandateId": user.mandateId, - "enabled": user.enabled, - "roleLabels": user.roleLabels or [], - "roleCount": len(user.roleLabels or []) - }) + for userId in userIds: + user = interface.getUser(userId) + if user: + userRoleLabels = _getUserRoleLabels(interface, userId) + result.append({ + "id": user.id, + "username": user.username, + "email": user.email, + "fullName": user.fullName, + "isSysAdmin": user.isSysAdmin, + "enabled": user.enabled, + "roleLabels": userRoleLabels, + "roleCount": len(userRoleLabels) + }) return result diff --git a/modules/routes/routeDataAutomation.py b/modules/routes/routeDataAutomation.py index 12ed265c..3b5515df 100644 --- a/modules/routes/routeDataAutomation.py +++ b/modules/routes/routeDataAutomation.py @@ -117,7 +117,7 @@ async def create_automation( chatInterface = getChatInterface(currentUser) automationData = automation.model_dump() created = chatInterface.createAutomationDefinition(automationData) - return AutomationDefinition(**created) + return created except HTTPException: raise except Exception as e: @@ -171,7 +171,7 @@ async def get_automation( detail=f"Automation {automationId} not found" ) - return AutomationDefinition(**automation) + return automation except HTTPException: raise except Exception as e: @@ -194,7 +194,7 @@ async def update_automation( chatInterface = getChatInterface(currentUser) automationData = automation.model_dump() updated = chatInterface.updateAutomationDefinition(automationId, automationData) - return AutomationDefinition(**updated) + return updated except HTTPException: raise except PermissionError as e: diff --git a/modules/routes/routeDataMandates.py b/modules/routes/routeDataMandates.py index 8a0c310a..a118869a 100644 --- a/modules/routes/routeDataMandates.py +++ b/modules/routes/routeDataMandates.py @@ -3,6 +3,10 @@ """ Mandate routes for the backend API. Implements the endpoints for mandate management. + +MULTI-TENANT: +- Mandate CRUD is SysAdmin-only (mandates are system resources) +- User management within mandates is Mandate-Admin (add/remove users) """ from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Response, Query @@ -10,18 +14,53 @@ from typing import List, Dict, Any, Optional from fastapi import status import logging import json +from pydantic import BaseModel, Field # Import auth module -from modules.auth import limiter, getCurrentUser +from modules.auth import limiter, requireSysAdmin, getRequestContext, RequestContext # Import interfaces import modules.interfaces.interfaceDbAppObjects as interfaceDbAppObjects from modules.shared.attributeUtils import getModelAttributeDefinitions +from modules.shared.auditLogger import audit_logger # Import the model classes from modules.datamodels.datamodelUam import Mandate, User +from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole +from modules.datamodels.datamodelRbac import Role from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict + +# ============================================================================= +# Request/Response Models for User Management +# ============================================================================= + +class UserMandateCreate(BaseModel): + """Request model for adding a user to a mandate""" + targetUserId: str = Field(..., description="User ID to add to the mandate") + roleIds: List[str] = Field(..., description="Role IDs to assign to the user") + + +class UserMandateResponse(BaseModel): + """Response model for user mandate membership""" + userMandateId: str + userId: str + mandateId: str + roleIds: List[str] + enabled: bool + + +class MandateUserInfo(BaseModel): + """User info within a mandate context""" + userId: str + username: str + email: Optional[str] + firstname: Optional[str] + lastname: Optional[str] + userMandateId: str + roleIds: List[str] + enabled: bool + # Configure logger logger = logging.getLogger(__name__) @@ -40,10 +79,11 @@ router = APIRouter( async def get_mandates( request: Request, pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(requireSysAdmin) ) -> PaginatedResponse[Mandate]: """ Get mandates with optional pagination, sorting, and filtering. + MULTI-TENANT: SysAdmin-only (mandates are system resources). Query Parameters: - pagination: JSON-encoded PaginationParams object, or None for no pagination @@ -67,7 +107,7 @@ async def get_mandates( detail=f"Invalid pagination parameter: {str(e)}" ) - appInterface = interfaceDbAppObjects.getInterface(currentUser) + appInterface = interfaceDbAppObjects.getRootInterface() result = appInterface.getAllMandates(pagination=paginationParams) # If pagination was requested, result is PaginatedResult @@ -103,11 +143,14 @@ async def get_mandates( async def get_mandate( request: Request, mandateId: str = Path(..., description="ID of the mandate"), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(requireSysAdmin) ) -> Mandate: - """Get a specific mandate by ID""" + """ + Get a specific mandate by ID. + MULTI-TENANT: SysAdmin-only. + """ try: - appInterface = interfaceDbAppObjects.getInterface(currentUser) + appInterface = interfaceDbAppObjects.getRootInterface() mandate = appInterface.getMandate(mandateId) if not mandate: @@ -131,9 +174,12 @@ async def get_mandate( async def create_mandate( request: Request, mandateData: dict = Body(..., description="Mandate data with at least 'name' field"), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(requireSysAdmin) ) -> Mandate: - """Create a new mandate""" + """ + Create a new mandate. + MULTI-TENANT: SysAdmin-only. + """ try: logger.debug(f"Creating mandate with data: {mandateData}") @@ -148,7 +194,7 @@ async def create_mandate( # Get optional fields with defaults language = mandateData.get('language', 'en') - appInterface = interfaceDbAppObjects.getInterface(currentUser) + appInterface = interfaceDbAppObjects.getRootInterface() # Create mandate newMandate = appInterface.createMandate( @@ -161,6 +207,8 @@ async def create_mandate( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to create mandate" ) + + logger.info(f"Mandate {newMandate.id} created by SysAdmin {context.user.id}") return newMandate except HTTPException: @@ -178,13 +226,16 @@ async def update_mandate( request: Request, mandateId: str = Path(..., description="ID of the mandate to update"), mandateData: dict = Body(..., description="Mandate update data"), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(requireSysAdmin) ) -> Mandate: - """Update an existing mandate""" + """ + Update an existing mandate. + MULTI-TENANT: SysAdmin-only. + """ try: logger.debug(f"Updating mandate {mandateId} with data: {mandateData}") - appInterface = interfaceDbAppObjects.getInterface(currentUser) + appInterface = interfaceDbAppObjects.getRootInterface() # Check if mandate exists existingMandate = appInterface.getMandate(mandateId) @@ -202,6 +253,8 @@ async def update_mandate( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to update mandate" ) + + logger.info(f"Mandate {mandateId} updated by SysAdmin {context.user.id}") return updatedMandate except HTTPException: @@ -218,11 +271,14 @@ async def update_mandate( async def delete_mandate( request: Request, mandateId: str = Path(..., description="ID of the mandate to delete"), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(requireSysAdmin) ) -> Dict[str, Any]: - """Delete a mandate""" + """ + Delete a mandate. + MULTI-TENANT: SysAdmin-only. + """ try: - appInterface = interfaceDbAppObjects.getInterface(currentUser) + appInterface = interfaceDbAppObjects.getRootInterface() # Check if mandate exists existingMandate = appInterface.getMandate(mandateId) @@ -231,6 +287,13 @@ async def delete_mandate( status_code=status.HTTP_404_NOT_FOUND, detail=f"Mandate {mandateId} not found" ) + + # MULTI-TENANT: Delete all UserMandate entries for this mandate first + from modules.datamodels.datamodelMembership import UserMandate + userMandates = appInterface.db.getRecordset(UserMandate, recordFilter={"mandateId": mandateId}) + for um in userMandates: + appInterface.db.deleteRecord(UserMandate, um["id"]) + logger.info(f"Deleted {len(userMandates)} UserMandate entries for mandate {mandateId}") # Delete mandate try: @@ -240,6 +303,8 @@ async def delete_mandate( status_code=status.HTTP_400_BAD_REQUEST, detail=str(e) ) + + logger.info(f"Mandate {mandateId} deleted by SysAdmin {context.user.id}") return {"message": f"Mandate {mandateId} deleted successfully"} except HTTPException: @@ -250,3 +315,456 @@ async def delete_mandate( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to delete mandate: {str(e)}" ) + + +# ============================================================================= +# User Management within Mandates (Mandate-Admin) +# ============================================================================= + +@router.get("/{mandateId}/users", response_model=List[MandateUserInfo]) +@limiter.limit("60/minute") +async def listMandateUsers( + request: Request, + mandateId: str = Path(..., description="ID of the mandate"), + context: RequestContext = Depends(getRequestContext) +) -> List[MandateUserInfo]: + """ + List all users in a mandate. + + Requires Mandate-Admin role or SysAdmin. + """ + # Check permission + if not _hasMandateAdminRole(context, mandateId) and not context.isSysAdmin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Mandate-Admin role required" + ) + + try: + rootInterface = interfaceDbAppObjects.getRootInterface() + + # Verify mandate exists + mandate = rootInterface.getMandate(mandateId) + if not mandate: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Mandate {mandateId} not found" + ) + + # Get all UserMandate entries for this mandate + userMandates = rootInterface.db.getRecordset( + UserMandate, + recordFilter={"mandateId": mandateId} + ) + + result = [] + for um in userMandates: + # Get user info + user = rootInterface.getUserById(um.get("userId")) + if not user: + continue + + # Get roles for this membership + roleIds = rootInterface.getRoleIdsForUserMandate(um.get("id")) + + result.append(MandateUserInfo( + userId=str(user.id), + username=user.username, + email=user.email, + firstname=user.firstname, + lastname=user.lastname, + userMandateId=um.get("id"), + roleIds=roleIds, + enabled=um.get("enabled", True) + )) + + return result + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error listing users for mandate {mandateId}: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to list users: {str(e)}" + ) + + +@router.post("/{mandateId}/users", response_model=UserMandateResponse) +@limiter.limit("30/minute") +async def addUserToMandate( + request: Request, + mandateId: str = Path(..., description="ID of the mandate"), + data: UserMandateCreate = Body(...), + context: RequestContext = Depends(getRequestContext) +) -> UserMandateResponse: + """ + Add a user to a mandate with specified roles. + + Requires Mandate-Admin role. + SysAdmin cannot add themselves (Self-Eskalation Prevention). + + Args: + mandateId: Target mandate ID + data: User ID and role IDs to assign + """ + # 1. SysAdmin Self-Eskalation Prevention + if context.isSysAdmin and data.targetUserId == str(context.user.id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="SysAdmin cannot add themselves to a mandate. A Mandate-Admin must grant access." + ) + + # 2. Check Mandate-Admin permission + if not _hasMandateAdminRole(context, mandateId): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Mandate-Admin role required to add users" + ) + + try: + rootInterface = interfaceDbAppObjects.getRootInterface() + + # 3. Verify mandate exists + mandate = rootInterface.getMandate(mandateId) + if not mandate: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Mandate {mandateId} not found" + ) + + # 4. Verify target user exists + targetUser = rootInterface.getUserById(data.targetUserId) + if not targetUser: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"User {data.targetUserId} not found" + ) + + # 5. Check if user is already a member + existingMembership = rootInterface.getUserMandate(data.targetUserId, mandateId) + if existingMembership: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"User {data.targetUserId} is already a member of this mandate" + ) + + # 6. Validate roles (must exist and belong to this mandate or be global) + for roleId in data.roleIds: + roleRecords = rootInterface.db.getRecordset(Role, recordFilter={"id": roleId}) + if not roleRecords: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Role {roleId} not found" + ) + role = roleRecords[0] + roleMandateId = role.get("mandateId") + if roleMandateId and str(roleMandateId) != str(mandateId): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Role {roleId} belongs to a different mandate" + ) + + # 7. Create UserMandate + userMandate = rootInterface.createUserMandate( + userId=data.targetUserId, + mandateId=mandateId, + roleIds=data.roleIds + ) + + # 8. Audit + audit_logger.logSecurityEvent( + userId=str(context.user.id), + mandateId=mandateId, + action="user_added_to_mandate", + details=f"targetUser={data.targetUserId}, roles={data.roleIds}" + ) + + logger.info( + f"User {context.user.id} added user {data.targetUserId} to mandate {mandateId} " + f"with roles {data.roleIds}" + ) + + return UserMandateResponse( + userMandateId=str(userMandate.id), + userId=data.targetUserId, + mandateId=mandateId, + roleIds=data.roleIds, + enabled=True + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error adding user to mandate: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to add user to mandate: {str(e)}" + ) + + +@router.delete("/{mandateId}/users/{targetUserId}", response_model=Dict[str, str]) +@limiter.limit("30/minute") +async def removeUserFromMandate( + request: Request, + mandateId: str = Path(..., description="ID of the mandate"), + targetUserId: str = Path(..., description="ID of the user to remove"), + context: RequestContext = Depends(getRequestContext) +) -> Dict[str, str]: + """ + Remove a user from a mandate. + + Requires Mandate-Admin role. + Cannot remove the last admin from a mandate (orphan prevention). + + Args: + mandateId: Target mandate ID + targetUserId: User ID to remove + """ + # Check Mandate-Admin permission + if not _hasMandateAdminRole(context, mandateId): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Mandate-Admin role required" + ) + + try: + rootInterface = interfaceDbAppObjects.getRootInterface() + + # Verify mandate exists + mandate = rootInterface.getMandate(mandateId) + if not mandate: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Mandate {mandateId} not found" + ) + + # Get user's membership + membership = rootInterface.getUserMandate(targetUserId, mandateId) + if not membership: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"User {targetUserId} is not a member of this mandate" + ) + + # Check if this is the last admin (orphan prevention) + if _isLastMandateAdmin(rootInterface, mandateId, targetUserId): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot remove the last admin from a mandate. Assign another admin first." + ) + + # Delete UserMandate (CASCADE will delete UserMandateRole entries) + rootInterface.deleteUserMandate(targetUserId, mandateId) + + # Audit + audit_logger.logSecurityEvent( + userId=str(context.user.id), + mandateId=mandateId, + action="user_removed_from_mandate", + details=f"targetUser={targetUserId}" + ) + + logger.info(f"User {context.user.id} removed user {targetUserId} from mandate {mandateId}") + + return {"message": "User removed from mandate", "userId": targetUserId} + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error removing user from mandate: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to remove user from mandate: {str(e)}" + ) + + +@router.put("/{mandateId}/users/{targetUserId}/roles", response_model=UserMandateResponse) +@limiter.limit("30/minute") +async def updateUserRolesInMandate( + request: Request, + mandateId: str = Path(..., description="ID of the mandate"), + targetUserId: str = Path(..., description="ID of the user"), + roleIds: List[str] = Body(..., description="New role IDs to assign"), + context: RequestContext = Depends(getRequestContext) +) -> UserMandateResponse: + """ + Update a user's roles within a mandate. + + Replaces all existing roles with the new set. + Requires Mandate-Admin role. + + Args: + mandateId: Target mandate ID + targetUserId: User ID to update + roleIds: New set of role IDs + """ + # Check Mandate-Admin permission + if not _hasMandateAdminRole(context, mandateId): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Mandate-Admin role required" + ) + + try: + rootInterface = interfaceDbAppObjects.getRootInterface() + + # Get user's membership + membership = rootInterface.getUserMandate(targetUserId, mandateId) + if not membership: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"User {targetUserId} is not a member of this mandate" + ) + + # Validate new roles + for roleId in roleIds: + roleRecords = rootInterface.db.getRecordset(Role, recordFilter={"id": roleId}) + if not roleRecords: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Role {roleId} not found" + ) + role = roleRecords[0] + roleMandateId = role.get("mandateId") + if roleMandateId and str(roleMandateId) != str(mandateId): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Role {roleId} belongs to a different mandate" + ) + + # Check if removing admin role would leave mandate without admins + currentRoleIds = rootInterface.getRoleIdsForUserMandate(str(membership.id)) + isCurrentlyAdmin = _hasAdminRoleInList(rootInterface, currentRoleIds, mandateId) + willBeAdmin = _hasAdminRoleInList(rootInterface, roleIds, mandateId) + + if isCurrentlyAdmin and not willBeAdmin: + if _isLastMandateAdmin(rootInterface, mandateId, targetUserId): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot remove admin role from the last admin. Assign another admin first." + ) + + # Remove existing role assignments + existingRoles = rootInterface.db.getRecordset( + UserMandateRole, + recordFilter={"userMandateId": str(membership.id)} + ) + for er in existingRoles: + rootInterface.db.recordDelete(UserMandateRole, er.get("id")) + + # Add new role assignments + for roleId in roleIds: + rootInterface.addRoleToUserMandate(str(membership.id), roleId) + + # Audit + audit_logger.logSecurityEvent( + userId=str(context.user.id), + mandateId=mandateId, + action="user_roles_updated_in_mandate", + details=f"targetUser={targetUserId}, newRoles={roleIds}" + ) + + logger.info( + f"User {context.user.id} updated roles for user {targetUserId} " + f"in mandate {mandateId} to {roleIds}" + ) + + return UserMandateResponse( + userMandateId=str(membership.id), + userId=targetUserId, + mandateId=mandateId, + roleIds=roleIds, + enabled=membership.enabled + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error updating user roles in mandate: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to update user roles: {str(e)}" + ) + + +# ============================================================================= +# Helper Functions +# ============================================================================= + +def _hasMandateAdminRole(context: RequestContext, mandateId: str) -> bool: + """ + Check if the user has mandate admin role for the specified mandate. + """ + if context.isSysAdmin: + return True + + # Must be in the same mandate context + if str(context.mandateId) != str(mandateId): + return False + + if not context.roleIds: + return False + + try: + rootInterface = interfaceDbAppObjects.getRootInterface() + + for roleId in context.roleIds: + roleRecords = rootInterface.db.getRecordset(Role, recordFilter={"id": roleId}) + if roleRecords: + role = roleRecords[0] + roleLabel = role.get("roleLabel", "") + # Admin role at mandate level (not feature-instance level) + if roleLabel == "admin" and role.get("mandateId") and not role.get("featureInstanceId"): + return True + + return False + + except Exception as e: + logger.error(f"Error checking mandate admin role: {e}") + return False + + +def _isLastMandateAdmin(interface, mandateId: str, excludeUserId: str) -> bool: + """ + Check if excluding this user would leave the mandate without any admins. + """ + try: + # Get all UserMandates for this mandate + userMandates = interface.db.getRecordset( + UserMandate, + recordFilter={"mandateId": mandateId, "enabled": True} + ) + + adminCount = 0 + for um in userMandates: + if str(um.get("userId")) == str(excludeUserId): + continue + + # Check if this user has admin role + roleIds = interface.getRoleIdsForUserMandate(um.get("id")) + if _hasAdminRoleInList(interface, roleIds, mandateId): + adminCount += 1 + + return adminCount == 0 + + except Exception as e: + logger.error(f"Error checking last admin: {e}") + return True # Fail-safe: assume they're the last admin + + +def _hasAdminRoleInList(interface, roleIds: List[str], mandateId: str) -> bool: + """ + Check if any of the role IDs is an admin role for the mandate. + """ + for roleId in roleIds: + roleRecords = interface.db.getRecordset(Role, recordFilter={"id": roleId}) + if roleRecords: + role = roleRecords[0] + roleLabel = role.get("roleLabel", "") + roleMandateId = role.get("mandateId") + # Admin role at mandate level + if roleLabel == "admin" and (not roleMandateId or str(roleMandateId) == str(mandateId)): + if not role.get("featureInstanceId"): + return True + return False diff --git a/modules/routes/routeDataUsers.py b/modules/routes/routeDataUsers.py index 525651c7..ae6ab70a 100644 --- a/modules/routes/routeDataUsers.py +++ b/modules/routes/routeDataUsers.py @@ -3,6 +3,10 @@ """ User routes for the backend API. Implements the endpoints for user management. + +MULTI-TENANT: User management requires RequestContext. +- mandateId from X-Mandate-Id header determines which users are visible +- SysAdmin can see all users across mandates """ from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Response, Query @@ -13,7 +17,7 @@ import json # Import interfaces and models import modules.interfaces.interfaceDbAppObjects as interfaceDbAppObjects -from modules.auth import getCurrentUser, limiter +from modules.auth import limiter, getRequestContext, RequestContext # Import the attribute definition and helper functions from modules.datamodels.datamodelUam import User @@ -32,19 +36,19 @@ router = APIRouter( @limiter.limit("30/minute") async def get_users( request: Request, - mandateId: Optional[str] = Query(None, description="Mandate ID to filter users"), pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> PaginatedResponse[User]: """ Get users with optional pagination, sorting, and filtering. + MULTI-TENANT: mandateId from X-Mandate-Id header determines scope. + SysAdmin without mandateId sees all users. Query Parameters: - - mandateId: Optional mandate ID to filter users - pagination: JSON-encoded PaginationParams object, or None for no pagination Examples: - - GET /api/users/ (no pagination - returns all users) + - GET /api/users/ (no pagination - returns all users in mandate) - GET /api/users/?pagination={"page":1,"pageSize":10,"sort":[]} """ try: @@ -62,30 +66,77 @@ async def get_users( detail=f"Invalid pagination parameter: {str(e)}" ) - appInterface = interfaceDbAppObjects.getInterface(currentUser) - # If mandateId is provided, use it, otherwise use the current user's mandate - targetMandateId = mandateId or currentUser.mandateId - # Get users with optional pagination - result = appInterface.getUsersByMandate(targetMandateId, pagination=paginationParams) + appInterface = interfaceDbAppObjects.getInterface(context.user) - # If pagination was requested, result is PaginatedResult - # If no pagination, result is List[User] - if paginationParams: - return PaginatedResponse( - items=result.items, - pagination=PaginationMetadata( - currentPage=paginationParams.page, - pageSize=paginationParams.pageSize, - totalItems=result.totalItems, - totalPages=result.totalPages, - sort=paginationParams.sort, - filters=paginationParams.filters + # MULTI-TENANT: Use mandateId from context (header) + # SysAdmin without mandateId can see all users + if context.mandateId: + # Get users for specific mandate via UserMandate table + from modules.datamodels.datamodelMembership import UserMandate + userMandates = appInterface.db.getRecordset(UserMandate, recordFilter={"mandateId": str(context.mandateId)}) + userIds = [str(um["userId"]) for um in userMandates] + + # Get all users and filter by mandate membership + allUsers = appInterface.getUsers() + users = [u for u in allUsers if str(u.id) in userIds] + + # Apply pagination manually if needed + if paginationParams: + totalItems = len(users) + import math + totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0 + startIdx = (paginationParams.page - 1) * paginationParams.pageSize + endIdx = startIdx + paginationParams.pageSize + paginatedUsers = users[startIdx:endIdx] + + return PaginatedResponse( + items=paginatedUsers, + pagination=PaginationMetadata( + currentPage=paginationParams.page, + pageSize=paginationParams.pageSize, + totalItems=totalItems, + totalPages=totalPages, + sort=paginationParams.sort, + filters=paginationParams.filters + ) + ) + else: + return PaginatedResponse( + items=users, + pagination=None + ) + elif context.isSysAdmin: + # SysAdmin without mandateId sees all users + result = appInterface.getUsers() + if paginationParams: + totalItems = len(result) + import math + totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0 + startIdx = (paginationParams.page - 1) * paginationParams.pageSize + endIdx = startIdx + paginationParams.pageSize + paginatedUsers = result[startIdx:endIdx] + + return PaginatedResponse( + items=paginatedUsers, + pagination=PaginationMetadata( + currentPage=paginationParams.page, + pageSize=paginationParams.pageSize, + totalItems=totalItems, + totalPages=totalPages, + sort=paginationParams.sort, + filters=paginationParams.filters + ) + ) + else: + return PaginatedResponse( + items=result, + pagination=None ) - ) else: - return PaginatedResponse( - items=result, - pagination=None + # Non-SysAdmin without mandateId - should not happen (getRequestContext enforces) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="X-Mandate-Id header is required" ) except HTTPException: raise @@ -101,11 +152,14 @@ async def get_users( async def get_user( request: Request, userId: str = Path(..., description="ID of the user"), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> User: - """Get a specific user by ID""" + """ + Get a specific user by ID. + MULTI-TENANT: User must be in the same mandate (via UserMandate) or caller is SysAdmin. + """ try: - appInterface = interfaceDbAppObjects.getInterface(currentUser) + appInterface = interfaceDbAppObjects.getInterface(context.user) # Get user without filtering by enabled status user = appInterface.getUser(userId) @@ -114,6 +168,19 @@ async def get_user( status_code=status.HTTP_404_NOT_FOUND, detail=f"User with ID {userId} not found" ) + + # MULTI-TENANT: Verify user is in the same mandate (unless SysAdmin) + if context.mandateId and not context.isSysAdmin: + from modules.datamodels.datamodelMembership import UserMandate + userMandate = appInterface.db.getRecordset(UserMandate, recordFilter={ + "userId": userId, + "mandateId": str(context.mandateId) + }) + if not userMandate: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="User not in your mandate" + ) return user except HTTPException: @@ -131,10 +198,13 @@ async def create_user( request: Request, user_data: User = Body(...), password: Optional[str] = Body(None, embed=True), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> User: - """Create a new user""" - appInterface = interfaceDbAppObjects.getInterface(currentUser) + """ + Create a new user. + MULTI-TENANT: User is created and automatically added to the current mandate. + """ + appInterface = interfaceDbAppObjects.getInterface(context.user) # Extract fields from User model and call createUser with individual parameters from modules.datamodels.datamodelUam import AuthAuthority @@ -145,10 +215,22 @@ async def create_user( fullName=user_data.fullName, language=user_data.language, enabled=user_data.enabled, - roleLabels=user_data.roleLabels if user_data.roleLabels else ["user"], authenticationAuthority=user_data.authenticationAuthority ) + # MULTI-TENANT: Add user to current mandate via UserMandate with default "user" role + if context.mandateId: + # Get "user" role ID + userRole = appInterface.getRoleByLabel("user") + roleIds = [str(userRole.id)] if userRole else [] + + appInterface.createUserMandate( + userId=str(newUser.id), + mandateId=str(context.mandateId), + roleIds=roleIds + ) + logger.info(f"Created UserMandate for user {newUser.id} in mandate {context.mandateId}") + return newUser @router.put("/{userId}", response_model=User) @@ -157,10 +239,13 @@ async def update_user( request: Request, userId: str = Path(..., description="ID of the user to update"), userData: User = Body(...), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> User: - """Update an existing user""" - appInterface = interfaceDbAppObjects.getInterface(currentUser) + """ + Update an existing user. + MULTI-TENANT: Can only update users in the same mandate (unless SysAdmin). + """ + appInterface = interfaceDbAppObjects.getInterface(context.user) # Check if the user exists existingUser = appInterface.getUser(userId) @@ -170,6 +255,19 @@ async def update_user( detail=f"User with ID {userId} not found" ) + # MULTI-TENANT: Verify user is in the same mandate (unless SysAdmin) + if context.mandateId and not context.isSysAdmin: + from modules.datamodels.datamodelMembership import UserMandate + userMandate = appInterface.db.getRecordset(UserMandate, recordFilter={ + "userId": userId, + "mandateId": str(context.mandateId) + }) + if not userMandate: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Cannot update user outside your mandate" + ) + # Update user updatedUser = appInterface.updateUser(userId, userData) @@ -187,19 +285,22 @@ async def reset_user_password( request: Request, userId: str = Path(..., description="ID of the user to reset password for"), newPassword: str = Body(..., embed=True), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> Dict[str, Any]: - """Reset user password (Admin only)""" + """ + Reset user password (Admin only). + MULTI-TENANT: Can only reset passwords for users in the same mandate (unless SysAdmin). + """ try: # Check if current user is admin - if "admin" not in (currentUser.roleLabels or []) and "sysadmin" not in (currentUser.roleLabels or []): + if not context.isSysAdmin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Only administrators can reset passwords" ) # Get user interface - appInterface = interfaceDbAppObjects.getInterface(currentUser) + appInterface = interfaceDbAppObjects.getInterface(context.user) # Get target user target_user = appInterface.getUserById(userId) @@ -209,6 +310,19 @@ async def reset_user_password( detail="User not found" ) + # MULTI-TENANT: Verify user is in the same mandate (unless SysAdmin) + if context.mandateId and not context.isSysAdmin: + from modules.datamodels.datamodelMembership import UserMandate + userMandate = appInterface.db.getRecordset(UserMandate, recordFilter={ + "userId": userId, + "mandateId": str(context.mandateId) + }) + if not userMandate: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Cannot reset password for user outside your mandate" + ) + # Validate password strength if len(newPassword) < 8: raise HTTPException( @@ -231,7 +345,7 @@ async def reset_user_password( userId=userId, authority=None, # Revoke all authorities mandateId=None, # Revoke across all mandates - revokedBy=currentUser.id, + revokedBy=context.user.id, reason="password_reset" ) logger.info(f"Revoked {revoked_count} tokens for user {userId} after password reset") @@ -243,8 +357,8 @@ async def reset_user_password( try: from modules.shared.auditLogger import audit_logger audit_logger.logSecurityEvent( - userId=str(currentUser.id), - mandateId=str(currentUser.mandateId), + userId=str(context.user.id), + mandateId=str(context.mandateId) if context.mandateId else "system", action="password_reset", details=f"Reset password for user {userId}" ) @@ -271,15 +385,18 @@ async def change_password( request: Request, currentPassword: str = Body(..., embed=True), newPassword: str = Body(..., embed=True), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> Dict[str, Any]: - """Change current user's password""" + """ + Change current user's password. + MULTI-TENANT: User changes their own password (no mandate restriction). + """ try: # Get user interface - appInterface = interfaceDbAppObjects.getInterface(currentUser) + appInterface = interfaceDbAppObjects.getInterface(context.user) # Verify current password - if not appInterface.verifyPassword(currentPassword, currentUser.passwordHash): + if not appInterface.verifyPassword(currentPassword, context.user.passwordHash): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Current password is incorrect" @@ -293,7 +410,7 @@ async def change_password( ) # Change password - success = appInterface.resetUserPassword(str(currentUser.id), newPassword) + success = appInterface.resetUserPassword(str(context.user.id), newPassword) if not success: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -304,23 +421,23 @@ async def change_password( try: from modules.datamodels.datamodelUam import AuthAuthority revoked_count = appInterface.revokeTokensByUser( - userId=str(currentUser.id), + userId=str(context.user.id), authority=None, # Revoke all authorities mandateId=None, # Revoke across all mandates - revokedBy=currentUser.id, + revokedBy=context.user.id, reason="password_change" ) - logger.info(f"Revoked {revoked_count} tokens for user {currentUser.id} after password change") + logger.info(f"Revoked {revoked_count} tokens for user {context.user.id} after password change") except Exception as e: - logger.error(f"Failed to revoke tokens after password change for user {currentUser.id}: {str(e)}") + logger.error(f"Failed to revoke tokens after password change for user {context.user.id}: {str(e)}") # Don't fail the password change if token revocation fails # Log password change try: from modules.shared.auditLogger import audit_logger audit_logger.logSecurityEvent( - userId=str(currentUser.id), - mandateId=str(currentUser.mandateId), + userId=str(context.user.id), + mandateId=str(context.mandateId) if context.mandateId else "system", action="password_change", details="User changed their own password" ) @@ -346,9 +463,11 @@ async def sendPasswordLink( request: Request, userId: str = Path(..., description="ID of the user to send password setup link"), frontendUrl: str = Body(..., embed=True), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> Dict[str, Any]: - """Send password setup/reset link to a user (admin function). + """ + Send password setup/reset link to a user (admin function). + MULTI-TENANT: Can only send to users in the same mandate (unless SysAdmin). This allows admins to send a magic link to users to set or reset their password. Used when creating users without password or to help users who forgot their password. @@ -362,7 +481,7 @@ async def sendPasswordLink( from modules.interfaces.interfaceDbAppObjects import getRootInterface # Get user interface - appInterface = interfaceDbAppObjects.getInterface(currentUser) + appInterface = interfaceDbAppObjects.getInterface(context.user) # Get target user targetUser = appInterface.getUser(userId) @@ -372,6 +491,19 @@ async def sendPasswordLink( detail="User not found" ) + # MULTI-TENANT: Verify user is in the same mandate (unless SysAdmin) + if context.mandateId and not context.isSysAdmin: + from modules.datamodels.datamodelMembership import UserMandate + userMandate = appInterface.db.getRecordset(UserMandate, recordFilter={ + "userId": userId, + "mandateId": str(context.mandateId) + }) + if not userMandate: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Cannot send password link to user outside your mandate" + ) + # Check if user has an email if not targetUser.email: raise HTTPException( @@ -440,15 +572,15 @@ Falls Sie diese Anforderung nicht erwartet haben, kontaktieren Sie bitte Ihren A try: from modules.shared.auditLogger import audit_logger audit_logger.logSecurityEvent( - userId=str(currentUser.id), - mandateId=str(currentUser.mandateId), + userId=str(context.user.id), + mandateId=str(context.mandateId) if context.mandateId else "system", action="send_password_link", details=f"Sent password setup link to user {userId} ({targetUser.email})" ) except Exception: pass - logger.info(f"Password setup link sent to {targetUser.email} for user {targetUser.username} by admin {currentUser.username}") + logger.info(f"Password setup link sent to {targetUser.email} for user {targetUser.username} by admin {context.user.username}") return { "message": f"Password setup link sent to {targetUser.email}", @@ -470,10 +602,13 @@ Falls Sie diese Anforderung nicht erwartet haben, kontaktieren Sie bitte Ihren A async def delete_user( request: Request, userId: str = Path(..., description="ID of the user to delete"), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> Dict[str, Any]: - """Delete a user""" - appInterface = interfaceDbAppObjects.getInterface(currentUser) + """ + Delete a user. + MULTI-TENANT: Can only delete users in the same mandate (unless SysAdmin). + """ + appInterface = interfaceDbAppObjects.getInterface(context.user) # Check if the user exists existingUser = appInterface.getUser(userId) @@ -483,6 +618,25 @@ async def delete_user( detail=f"User with ID {userId} not found" ) + # MULTI-TENANT: Verify user is in the same mandate (unless SysAdmin) + if context.mandateId and not context.isSysAdmin: + from modules.datamodels.datamodelMembership import UserMandate + userMandate = appInterface.db.getRecordset(UserMandate, recordFilter={ + "userId": userId, + "mandateId": str(context.mandateId) + }) + if not userMandate: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Cannot delete user outside your mandate" + ) + + # Delete UserMandate entries for this user first + from modules.datamodels.datamodelMembership import UserMandate + userMandates = appInterface.db.getRecordset(UserMandate, recordFilter={"userId": userId}) + for um in userMandates: + appInterface.db.deleteRecord(UserMandate, um["id"]) + success = appInterface.deleteUser(userId) if not success: raise HTTPException( diff --git a/modules/routes/routeAdminAutomationEvents.py b/modules/routes/routeFeatureAutomation.py similarity index 85% rename from modules/routes/routeAdminAutomationEvents.py rename to modules/routes/routeFeatureAutomation.py index a24c7839..ea58e271 100644 --- a/modules/routes/routeAdminAutomationEvents.py +++ b/modules/routes/routeFeatureAutomation.py @@ -12,8 +12,7 @@ import logging # Import interfaces and models import modules.interfaces.interfaceDbChatObjects as interfaceDbChatObjects -from modules.auth import getCurrentUser, limiter -from modules.datamodels.datamodelUam import User +from modules.auth import limiter, getRequestContext, requireSysAdmin, RequestContext # Configure logger logger = logging.getLogger(__name__) @@ -31,26 +30,16 @@ router = APIRouter( } ) -def requireSysadmin(currentUser: User): - """Require sysadmin role""" - if "sysadmin" not in (currentUser.roleLabels or []): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Sysadmin role required" - ) - @router.get("") @limiter.limit("30/minute") async def get_all_automation_events( request: Request, - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(requireSysAdmin) ) -> List[Dict[str, Any]]: """ Get all automation events across all mandates (sysadmin only). Returns list of all registered events with their automation IDs and schedules. """ - requireSysadmin(currentUser) - try: from modules.shared.eventManagement import eventManager @@ -79,20 +68,18 @@ async def get_all_automation_events( @limiter.limit("5/minute") async def sync_all_automation_events( request: Request, - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(requireSysAdmin) ) -> Dict[str, Any]: """ Manually trigger sync for all automations (sysadmin only). This will register/remove events based on active flags. """ - requireSysadmin(currentUser) - try: from modules.interfaces.interfaceDbChatObjects import getInterface as getChatInterface from modules.interfaces.interfaceDbAppObjects import getRootInterface from modules.features.workflow import syncAutomationEvents - chatInterface = getChatInterface(currentUser) + chatInterface = getChatInterface(context.user) # Get event user for sync operation (routes can import from interfaces) rootInterface = getRootInterface() eventUser = rootInterface.getUserByUsername("event") @@ -103,7 +90,7 @@ async def sync_all_automation_events( ) from modules.services import getInterface as getServices - services = getServices(currentUser, None) + services = getServices(context.user, None) result = await syncAutomationEvents(services, eventUser) return { "success": True, @@ -124,14 +111,12 @@ async def sync_all_automation_events( async def remove_event( request: Request, eventId: str = Path(..., description="Event ID to remove"), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(requireSysAdmin) ) -> Dict[str, Any]: """ Manually remove a specific event from scheduler (sysadmin only). Used for debugging and manual event cleanup. """ - requireSysadmin(currentUser) - try: from modules.shared.eventManagement import eventManager @@ -141,9 +126,9 @@ async def remove_event( # Update automation's eventId if it exists if eventId.startswith("automation."): automation_id = eventId.replace("automation.", "") - chatInterface = interfaceDbChatObjects.getInterface(currentUser) + chatInterface = interfaceDbChatObjects.getInterface(context.user) automation = chatInterface.getAutomationDefinition(automation_id) - if automation and automation.get("eventId") == eventId: + if automation and getattr(automation, "eventId", None) == eventId: chatInterface.updateAutomationDefinition(automation_id, {"eventId": None}) return { @@ -157,4 +142,3 @@ async def remove_event( status_code=500, detail=f"Error removing event: {str(e)}" ) - diff --git a/modules/routes/routeChatPlayground.py b/modules/routes/routeFeatureChatDynamic.py similarity index 86% rename from modules/routes/routeChatPlayground.py rename to modules/routes/routeFeatureChatDynamic.py index 287543c2..f2955b61 100644 --- a/modules/routes/routeChatPlayground.py +++ b/modules/routes/routeFeatureChatDynamic.py @@ -10,14 +10,13 @@ from typing import Optional, Dict, Any from fastapi import APIRouter, HTTPException, Depends, Body, Path, Query, Request # Import auth modules -from modules.auth import limiter, getCurrentUser +from modules.auth import limiter, getRequestContext, RequestContext # Import interfaces import modules.interfaces.interfaceDbChatObjects as interfaceDbChatObjects # Import models from modules.datamodels.datamodelChat import ChatWorkflow, UserInputRequest, WorkflowModeEnum -from modules.datamodels.datamodelUam import User # Import workflow control functions from modules.features.workflow import chatStart, chatStop @@ -32,8 +31,8 @@ router = APIRouter( responses={404: {"description": "Not found"}} ) -def getServiceChat(currentUser: User): - return interfaceDbChatObjects.getInterface(currentUser) +def _getServiceChat(context: RequestContext): + return interfaceDbChatObjects.getInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None) # Workflow start endpoint @router.post("/start", response_model=ChatWorkflow) @@ -43,7 +42,7 @@ async def start_workflow( workflowId: Optional[str] = Query(None, description="Optional ID of the workflow to continue"), workflowMode: WorkflowModeEnum = Query(..., description="Workflow mode: 'Dynamic' or 'Automation' (mandatory)"), userInput: UserInputRequest = Body(...), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> ChatWorkflow: """ Starts a new workflow or continues an existing one. @@ -54,7 +53,7 @@ async def start_workflow( """ try: # Start or continue workflow using playground controller - workflow = await chatStart(currentUser, userInput, workflowMode, workflowId) + workflow = await chatStart(context.user, userInput, workflowMode, workflowId) return workflow @@ -71,12 +70,12 @@ async def start_workflow( async def stop_workflow( request: Request, workflowId: str = Path(..., description="ID of the workflow to stop"), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> ChatWorkflow: """Stops a running workflow.""" try: # Stop workflow using playground controller - workflow = await chatStop(currentUser, workflowId) + workflow = await chatStop(context.user, workflowId) return workflow @@ -94,7 +93,7 @@ async def get_workflow_chat_data( request: Request, workflowId: str = Path(..., description="ID of the workflow"), afterTimestamp: Optional[float] = Query(None, description="Unix timestamp to get data after"), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> Dict[str, Any]: """ Get unified chat data (messages, logs, stats) for a workflow with timestamp-based selective data transfer. @@ -102,7 +101,7 @@ async def get_workflow_chat_data( """ try: # Get service center - interfaceDbChat = getServiceChat(currentUser) + interfaceDbChat = _getServiceChat(context) # Verify workflow exists workflow = interfaceDbChat.getWorkflow(workflowId) diff --git a/modules/routes/routeChatbot.py b/modules/routes/routeFeatureChatbot.py similarity index 96% rename from modules/routes/routeChatbot.py rename to modules/routes/routeFeatureChatbot.py index 11814313..0505a752 100644 --- a/modules/routes/routeChatbot.py +++ b/modules/routes/routeFeatureChatbot.py @@ -15,7 +15,7 @@ from fastapi.responses import StreamingResponse from modules.shared.timeUtils import parseTimestamp # Import auth modules -from modules.auth import limiter, getCurrentUser +from modules.auth import limiter, getRequestContext, RequestContext # Import interfaces import modules.interfaces.interfaceDbChatObjects as interfaceDbChatObjects @@ -23,7 +23,6 @@ from modules.interfaces.interfaceRbac import getRecordsetWithRBAC # Import models from modules.datamodels.datamodelChat import ChatWorkflow, UserInputRequest, WorkflowModeEnum -from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse # Import chatbot feature @@ -43,8 +42,8 @@ router = APIRouter( responses={404: {"description": "Not found"}} ) -def getServiceChat(currentUser: User): - return interfaceDbChatObjects.getInterface(currentUser) +def _getServiceChat(context: RequestContext): + return interfaceDbChatObjects.getInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None) # Chatbot streaming endpoint (SSE) @router.post("/start/stream") @@ -53,7 +52,7 @@ async def stream_chatbot_start( request: Request, workflowId: Optional[str] = Query(None, description="Optional ID of the workflow to continue (can also be in request body)"), userInput: UserInputRequest = Body(...), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> StreamingResponse: """ Starts a new chatbot workflow or continues an existing one with SSE streaming. @@ -71,7 +70,7 @@ async def stream_chatbot_start( final_workflow_id = workflowId or userInput.workflowId # Start background processing (this will create the workflow and event queue) - workflow = await chatProcess(currentUser, userInput, final_workflow_id) + workflow = await chatProcess(context.user, str(context.mandateId), userInput, final_workflow_id) # Get event queue for the workflow queue = event_manager.get_queue(workflow.id) @@ -83,7 +82,7 @@ async def stream_chatbot_start( """Async generator for SSE events - pure event-driven streaming (no polling).""" try: # Get interface for initial data and status checks - interfaceDbChat = getServiceChat(currentUser) + interfaceDbChat = _getServiceChat(context) # Get current workflow to check if resuming and get current round current_workflow = interfaceDbChat.getWorkflow(workflow.id) @@ -239,11 +238,11 @@ async def stream_chatbot_start( async def stop_chatbot( request: Request, workflowId: str = Path(..., description="ID of the workflow to stop"), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> ChatWorkflow: """Stops a running chatbot workflow.""" try: - workflow = await chatStop(currentUser, workflowId) + workflow = await chatStop(context.user, workflowId) # Emit stopped event to active streams event_manager = get_event_manager() @@ -272,18 +271,18 @@ async def stop_chatbot( async def delete_chatbot( request: Request, workflowId: str = Path(..., description="ID of the workflow to delete"), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> Dict[str, Any]: """Deletes a chatbot workflow and its associated data.""" try: # Get service center - interfaceDbChat = getServiceChat(currentUser) + interfaceDbChat = _getServiceChat(context) # Check workflow access and permission using RBAC workflows = getRecordsetWithRBAC( interfaceDbChat.db, ChatWorkflow, - currentUser, + context.user, recordFilter={"id": workflowId} ) if not workflows: @@ -337,7 +336,7 @@ async def get_chatbot_threads( request: Request, workflowId: Optional[str] = Query(None, description="Optional workflow ID to get details and chat data for a specific thread"), pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object (only used when workflowId is not provided)"), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> Union[PaginatedResponse[ChatWorkflow], Dict[str, Any]]: """ List all chatbot workflows (threads) for the current user, or get details and chat data for a specific thread. @@ -346,7 +345,7 @@ async def get_chatbot_threads( - If workflowId is not provided: Returns a paginated list of all workflows """ try: - interfaceDbChat = getServiceChat(currentUser) + interfaceDbChat = _getServiceChat(context) # If workflowId is provided, return single workflow with chat data if workflowId: @@ -456,4 +455,3 @@ async def get_chatbot_threads( status_code=500, detail=f"Error getting chatbot threads: {str(e)}" ) - diff --git a/modules/routes/routeDataNeutralization.py b/modules/routes/routeFeatureNeutralization.py similarity index 85% rename from modules/routes/routeDataNeutralization.py rename to modules/routes/routeFeatureNeutralization.py index 7826d96c..04d034dc 100644 --- a/modules/routes/routeDataNeutralization.py +++ b/modules/routes/routeFeatureNeutralization.py @@ -5,10 +5,9 @@ from typing import List, Dict, Any, Optional import logging # Import auth module -from modules.auth import limiter, getCurrentUser +from modules.auth import limiter, getRequestContext, RequestContext # Import interfaces -from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelNeutralizer import DataNeutraliserConfig, DataNeutralizerAttributes from modules.features.neutralizePlayground.mainNeutralizePlayground import NeutralizationPlayground @@ -32,18 +31,18 @@ router = APIRouter( @limiter.limit("30/minute") async def get_neutralization_config( request: Request, - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> DataNeutraliserConfig: """Get data neutralization configuration""" try: - service = NeutralizationPlayground(currentUser) + service = NeutralizationPlayground(context.user, str(context.mandateId)) config = service.getConfig() if not config: # Return default config instead of 404 return DataNeutraliserConfig( - mandateId=currentUser.mandateId, - userId=currentUser.id, + mandateId=context.mandateId, + userId=context.user.id, enabled=True, namesToParse="", sharepointSourcePath="", @@ -66,11 +65,11 @@ async def get_neutralization_config( async def save_neutralization_config( request: Request, config_data: Dict[str, Any] = Body(...), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> DataNeutraliserConfig: """Save or update data neutralization configuration""" try: - service = NeutralizationPlayground(currentUser) + service = NeutralizationPlayground(context.user, str(context.mandateId)) config = service.saveConfig(config_data) return config @@ -87,7 +86,7 @@ async def save_neutralization_config( async def neutralize_text( request: Request, text_data: Dict[str, Any] = Body(...), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> Dict[str, Any]: """Neutralize text content""" try: @@ -100,7 +99,7 @@ async def neutralize_text( detail="Text content is required" ) - service = NeutralizationPlayground(currentUser) + service = NeutralizationPlayground(context.user, str(context.mandateId)) result = service.neutralizeText(text, file_id) return result @@ -119,7 +118,7 @@ async def neutralize_text( async def resolve_text( request: Request, text_data: Dict[str, str] = Body(...), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> Dict[str, str]: """Resolve UIDs in neutralized text back to original text""" try: @@ -131,7 +130,7 @@ async def resolve_text( detail="Text content is required" ) - service = NeutralizationPlayground(currentUser) + service = NeutralizationPlayground(context.user, str(context.mandateId)) resolved_text = service.resolveText(text) return {"resolved_text": resolved_text} @@ -150,11 +149,11 @@ async def resolve_text( async def get_neutralization_attributes( request: Request, fileId: Optional[str] = Query(None, description="Filter by file ID"), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> List[DataNeutralizerAttributes]: """Get neutralization attributes, optionally filtered by file ID""" try: - service = NeutralizationPlayground(currentUser) + service = NeutralizationPlayground(context.user, str(context.mandateId)) attributes = service.getAttributes(fileId) return attributes @@ -171,7 +170,7 @@ async def get_neutralization_attributes( async def process_sharepoint_files( request: Request, paths_data: Dict[str, str] = Body(...), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> Dict[str, Any]: """Process files from SharePoint source path and store neutralized files in target path""" try: @@ -184,7 +183,7 @@ async def process_sharepoint_files( detail="Both source and target paths are required" ) - service = NeutralizationPlayground(currentUser) + service = NeutralizationPlayground(context.user, str(context.mandateId)) result = await service.processSharepointFiles(source_path, target_path) return result @@ -203,7 +202,7 @@ async def process_sharepoint_files( async def batch_process_files( request: Request, files_data: List[Dict[str, Any]] = Body(...), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> Dict[str, Any]: """Process multiple files for neutralization""" try: @@ -213,7 +212,7 @@ async def batch_process_files( detail="Files data is required" ) - service = NeutralizationPlayground(currentUser) + service = NeutralizationPlayground(context.user, str(context.mandateId)) result = service.batchNeutralizeFiles(files_data) return result @@ -231,11 +230,11 @@ async def batch_process_files( @limiter.limit("30/minute") async def get_neutralization_stats( request: Request, - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> Dict[str, Any]: """Get neutralization processing statistics""" try: - service = NeutralizationPlayground(currentUser) + service = NeutralizationPlayground(context.user, str(context.mandateId)) stats = service.getProcessingStats() return stats @@ -252,11 +251,11 @@ async def get_neutralization_stats( async def cleanup_file_attributes( request: Request, fileId: str = Path(..., description="File ID to cleanup attributes for"), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> Dict[str, str]: """Clean up neutralization attributes for a specific file""" try: - service = NeutralizationPlayground(currentUser) + service = NeutralizationPlayground(context.user, str(context.mandateId)) success = service.cleanupFileAttributes(fileId) if success: diff --git a/modules/routes/routeRealEstate.py b/modules/routes/routeFeatureRealEstate.py similarity index 91% rename from modules/routes/routeRealEstate.py rename to modules/routes/routeFeatureRealEstate.py index a554ce7d..fe7544de 100644 --- a/modules/routes/routeRealEstate.py +++ b/modules/routes/routeFeatureRealEstate.py @@ -10,10 +10,9 @@ from typing import Optional, Dict, Any, List, Union from fastapi import APIRouter, HTTPException, Depends, Body, Request, Query, Path, status # Import auth modules -from modules.auth import limiter, getCurrentUser +from modules.auth import limiter, getRequestContext, RequestContext # Import models -from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata from modules.datamodels.datamodelRealEstate import ( Projekt, @@ -63,7 +62,7 @@ router = APIRouter( async def process_command( request: Request, userInput: str = Body(..., embed=True, description="Natural language command"), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> Dict[str, Any]: """ Process natural language command and execute corresponding CRUD operation. @@ -73,9 +72,9 @@ async def process_command( Example user inputs: - "Erstelle ein neues Projekt namens 'Hauptstrasse 42'" - - "Zeige mir alle Projekte in Zürich" + - "Zeige mir alle Projekte in Zuerich" - "Aktualisiere Projekt XYZ mit Status 'Planung'" - - "Lösche Parzelle ABC" + - "Loesche Parzelle ABC" - "SELECT * FROM Projekt WHERE plz = '8000'" Headers: @@ -93,7 +92,7 @@ async def process_command( # Validate CSRF token (middleware also checks, but explicit validation for better error messages) csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token") if not csrf_token: - logger.warning(f"CSRF token missing for POST /api/realestate/command from user {currentUser.id}") + logger.warning(f"CSRF token missing for POST /api/realestate/command from user {context.user.id}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="CSRF token missing. Please include X-CSRF-Token header." @@ -101,7 +100,7 @@ async def process_command( # Basic CSRF token format validation if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64: - logger.warning(f"Invalid CSRF token format for POST /api/realestate/command from user {currentUser.id}") + logger.warning(f"Invalid CSRF token format for POST /api/realestate/command from user {context.user.id}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Invalid CSRF token format" @@ -111,18 +110,19 @@ async def process_command( try: int(csrf_token, 16) except ValueError: - logger.warning(f"CSRF token is not a valid hex string for POST /api/realestate/command from user {currentUser.id}") + logger.warning(f"CSRF token is not a valid hex string for POST /api/realestate/command from user {context.user.id}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Invalid CSRF token format" ) - logger.info(f"Processing command request from user {currentUser.id} (mandate: {currentUser.mandateId})") + logger.info(f"Processing command request from user {context.user.id} (mandate: {context.mandateId})") logger.debug(f"User input: {userInput}") # Process natural language command with AI result = await processNaturalLanguageCommand( - currentUser=currentUser, + currentUser=context.user, + mandateId=str(context.mandateId), userInput=userInput ) @@ -147,7 +147,7 @@ async def process_command( @limiter.limit("120/minute") async def get_available_tables( request: Request, - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> Dict[str, Any]: """ Get all available real estate tables. @@ -164,7 +164,7 @@ async def get_available_tables( # Validate CSRF token if provided csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token") if not csrf_token: - logger.warning(f"CSRF token missing for GET /api/realestate/tables from user {currentUser.id}") + logger.warning(f"CSRF token missing for GET /api/realestate/tables from user {context.user.id}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="CSRF token missing. Please include X-CSRF-Token header." @@ -172,7 +172,7 @@ async def get_available_tables( # Basic CSRF token format validation if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64: - logger.warning(f"Invalid CSRF token format for GET /api/realestate/tables from user {currentUser.id}") + logger.warning(f"Invalid CSRF token format for GET /api/realestate/tables from user {context.user.id}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Invalid CSRF token format" @@ -182,13 +182,13 @@ async def get_available_tables( try: int(csrf_token, 16) except ValueError: - logger.warning(f"CSRF token is not a valid hex string for GET /api/realestate/tables from user {currentUser.id}") + logger.warning(f"CSRF token is not a valid hex string for GET /api/realestate/tables from user {context.user.id}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Invalid CSRF token format" ) - logger.info(f"Getting available tables for user {currentUser.id} (mandate: {currentUser.mandateId})") + logger.info(f"Getting available tables for user {context.user.id} (mandate: {context.mandateId})") # Define available tables with descriptions tables = [ @@ -245,7 +245,7 @@ async def get_table_data( request: Request, table: str = Path(..., description="Table name (Projekt, Parzelle, Dokument, Gemeinde, Kanton, Land)"), pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> PaginatedResponse[Dict[str, Any]]: """ Get all data from a specific real estate table with optional pagination. @@ -273,7 +273,7 @@ async def get_table_data( # Validate CSRF token if provided csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token") if not csrf_token: - logger.warning(f"CSRF token missing for GET /api/realestate/table/{table} from user {currentUser.id}") + logger.warning(f"CSRF token missing for GET /api/realestate/table/{table} from user {context.user.id}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="CSRF token missing. Please include X-CSRF-Token header." @@ -281,7 +281,7 @@ async def get_table_data( # Basic CSRF token format validation if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64: - logger.warning(f"Invalid CSRF token format for GET /api/realestate/table/{table} from user {currentUser.id}") + logger.warning(f"Invalid CSRF token format for GET /api/realestate/table/{table} from user {context.user.id}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Invalid CSRF token format" @@ -291,13 +291,13 @@ async def get_table_data( try: int(csrf_token, 16) except ValueError: - logger.warning(f"CSRF token is not a valid hex string for GET /api/realestate/table/{table} from user {currentUser.id}") + logger.warning(f"CSRF token is not a valid hex string for GET /api/realestate/table/{table} from user {context.user.id}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Invalid CSRF token format" ) - logger.info(f"Getting table data for '{table}' from user {currentUser.id} (mandate: {currentUser.mandateId})") + logger.info(f"Getting table data for '{table}' from user {context.user.id} (mandate: {context.mandateId})") # Map table names to model classes and getter methods table_mapping = { @@ -317,7 +317,7 @@ async def get_table_data( ) # Get interface and fetch data - realEstateInterface = getRealEstateInterface(currentUser) + realEstateInterface = getRealEstateInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None) model_class, method_name = table_mapping[table] getter_method = getattr(realEstateInterface, method_name) @@ -399,7 +399,7 @@ async def create_table_record( request: Request, table: str = Path(..., description="Table name (Projekt, Parzelle, Dokument, Gemeinde, Kanton, Land)"), data: Dict[str, Any] = Body(..., description="Record data to create"), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> Dict[str, Any]: """ Create a new record in a specific real estate table. @@ -442,7 +442,7 @@ async def create_table_record( # Validate CSRF token csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token") if not csrf_token: - logger.warning(f"CSRF token missing for POST /api/realestate/table/{table} from user {currentUser.id}") + logger.warning(f"CSRF token missing for POST /api/realestate/table/{table} from user {context.user.id}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="CSRF token missing. Please include X-CSRF-Token header." @@ -450,7 +450,7 @@ async def create_table_record( # Basic CSRF token format validation if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64: - logger.warning(f"Invalid CSRF token format for POST /api/realestate/table/{table} from user {currentUser.id}") + logger.warning(f"Invalid CSRF token format for POST /api/realestate/table/{table} from user {context.user.id}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Invalid CSRF token format" @@ -460,7 +460,7 @@ async def create_table_record( try: int(csrf_token, 16) except ValueError: - logger.warning(f"CSRF token is not a valid hex string for POST /api/realestate/table/{table} from user {currentUser.id}") + logger.warning(f"CSRF token is not a valid hex string for POST /api/realestate/table/{table} from user {context.user.id}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Invalid CSRF token format" @@ -468,7 +468,7 @@ async def create_table_record( # Special handling for Projekt with parcel data if table == "Projekt" and ("parzelle" in data or "parzellen" in data): - logger.info(f"Creating Projekt with parcel data for user {currentUser.id} (mandate: {currentUser.mandateId})") + logger.info(f"Creating Projekt with parcel data for user {context.user.id} (mandate: {context.mandateId})") # Extract fields label = data.get("label") @@ -491,7 +491,7 @@ async def create_table_record( detail="parzellen must be an array" ) elif "parzelle" in data: - # Single parcel (backward compatibility) + # Single parcel parzelle_data = data.get("parzelle") if parzelle_data: parzellen_data = [parzelle_data] @@ -505,7 +505,8 @@ async def create_table_record( # Use helper function to create project with parcel data try: result = await create_project_with_parcel_data( - currentUser=currentUser, + currentUser=context.user, + mandateId=str(context.mandateId), projekt_label=label, parzellen_data=parzellen_data, status_prozess=status_prozess, @@ -524,7 +525,7 @@ async def create_table_record( ) # Standard handling for other tables or Projekt without parcel data - logger.info(f"Creating record in table '{table}' for user {currentUser.id} (mandate: {currentUser.mandateId})") + logger.info(f"Creating record in table '{table}' for user {context.user.id} (mandate: {context.mandateId})") logger.debug(f"Record data: {data}") # Map table names to model classes and create methods @@ -545,13 +546,13 @@ async def create_table_record( ) # Get interface - realEstateInterface = getRealEstateInterface(currentUser) + realEstateInterface = getRealEstateInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None) model_class, method_name = table_mapping[table] create_method = getattr(realEstateInterface, method_name) - # Ensure mandateId is set (will be set by interface if missing) + # Ensure mandateId is set from context if "mandateId" not in data: - data["mandateId"] = currentUser.mandateId + data["mandateId"] = str(context.mandateId) if context.mandateId else None # Create model instance from data try: @@ -596,7 +597,7 @@ async def search_parcel( request: Request, location: str = Query(..., description="Either coordinates as 'x,y' (LV95) or address string"), include_adjacent: bool = Query(False, description="Include adjacent parcels information"), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> Dict[str, Any]: """ Search for parcel information by address or coordinates. @@ -614,50 +615,18 @@ async def search_parcel( Headers: - X-CSRF-Token: CSRF token (required for security) - - Examples: - - GET /api/realestate/parcel/search?location=2600000,1200000 - - GET /api/realestate/parcel/search?location=Bundesplatz 3, 3003 Bern - - GET /api/realestate/parcel/search?location=Bundesplatz 3, 3003 Bern&include_adjacent=true - - Returns: - { - "parcel": { - "id": "823", - "egrid": "CH294676423526", - "number": "823", - "name": "823", - "identnd": "BE0200000042", - "canton": "BE", - "municipality_code": 351, - "municipality_name": "Bern", - "address": "Bundesplatz 3 3011 Bern", - "plz": "3011", - "perimeter": {...}, - "area_m2": 1234.56, - "centroid": {"x": 2600000, "y": 1200000}, - "geoportal_url": "https://...", - "realestate_type": null - }, - "map_view": { - "center": {"x": 2600000, "y": 1200000}, - "zoom_bounds": {"min_x": ..., "max_x": ..., "min_y": ..., "max_y": ...}, - "geometry_geojson": {...} - }, - "adjacent_parcels": [...] // Optional (only if include_adjacent=true) - } """ try: # Validate CSRF token csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token") if not csrf_token: - logger.warning(f"CSRF token missing for GET /api/realestate/parcel/search from user {currentUser.id}") + logger.warning(f"CSRF token missing for GET /api/realestate/parcel/search from user {context.user.id}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="CSRF token missing. Please include X-CSRF-Token header." ) - logger.info(f"Searching parcel for user {currentUser.id} (mandate: {currentUser.mandateId}) with location: {location}") + logger.info(f"Searching parcel for user {context.user.id} (mandate: {context.mandateId}) with location: {location}") # Initialize connector connector = SwissTopoMapServerConnector() @@ -762,15 +731,14 @@ async def search_parcel( # Basic municipality lookup for common codes common_municipalities = { 351: "Bern", - 261: "Zürich", - 6621: "Genève", + 261: "Zuerich", + 6621: "Geneve", 2701: "Basel", 5586: "Lausanne", 1061: "Luzern", 3203: "Winterthur", 230: "St. Gallen", 5192: "Lugano", - 351: "Bern", 1367: "Schwyz" } @@ -944,7 +912,7 @@ async def add_parcel_to_project( request: Request, projekt_id: str = Path(..., description="Projekt ID"), body: Dict[str, Any] = Body(...), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> Dict[str, Any]: """ Add a parcel to an existing project. @@ -961,7 +929,7 @@ async def add_parcel_to_project( Option 2 - Create new parcel from location: { - "location": "Hauptstrasse 42, 8000 Zürich" + "location": "Hauptstrasse 42, 8000 Zuerich" } Option 3 - Create new parcel with custom data: @@ -988,7 +956,7 @@ async def add_parcel_to_project( # Validate CSRF token csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token") if not csrf_token: - logger.warning(f"CSRF token missing for POST /api/realestate/projekt/{projekt_id}/add-parcel from user {currentUser.id}") + logger.warning(f"CSRF token missing for POST /api/realestate/projekt/{projekt_id}/add-parcel from user {context.user.id}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="CSRF token missing. Please include X-CSRF-Token header." @@ -1008,15 +976,16 @@ async def add_parcel_to_project( detail="Invalid CSRF token format" ) - logger.info(f"Adding parcel to project {projekt_id} for user {currentUser.id} (mandate: {currentUser.mandateId})") + logger.info(f"Adding parcel to project {projekt_id} for user {context.user.id} (mandate: {context.mandateId})") # Get interface - realEstateInterface = getRealEstateInterface(currentUser) + realEstateInterface = getRealEstateInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None) - # Fetch existing Projekt - projekte = realEstateInterface.getProjekte( - recordFilter={"id": projekt_id, "mandateId": currentUser.mandateId} - ) + # Fetch existing Projekt - use mandateId from context + recordFilter = {"id": projekt_id} + if context.mandateId: + recordFilter["mandateId"] = str(context.mandateId) + projekte = realEstateInterface.getProjekte(recordFilter=recordFilter) if not projekte: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -1034,9 +1003,10 @@ async def add_parcel_to_project( # Option 1: Link existing parcel if parcel_id: logger.info(f"Linking existing parcel {parcel_id}") - parcels = realEstateInterface.getParzellen( - recordFilter={"id": parcel_id, "mandateId": currentUser.mandateId} - ) + parcelFilter = {"id": parcel_id} + if context.mandateId: + parcelFilter["mandateId"] = str(context.mandateId) + parcels = realEstateInterface.getParzellen(recordFilter=parcelFilter) if not parcels: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -1062,9 +1032,9 @@ async def add_parcel_to_project( extracted_attributes = connector.extract_parcel_attributes(parcel_data) attributes = parcel_data.get("attributes", {}) - # Create Parzelle + # Create Parzelle with mandateId from context parzelle_create_data = { - "mandateId": currentUser.mandateId, + "mandateId": str(context.mandateId) if context.mandateId else None, "label": extracted_attributes.get("label") or attributes.get("number") or "Unknown", "parzellenAliasTags": [attributes.get("egris_egrid")] if attributes.get("egris_egrid") else [], "eigentuemerschaft": None, @@ -1111,7 +1081,7 @@ async def add_parcel_to_project( # Option 3: Create from custom data elif parcel_data_dict: logger.info(f"Creating parcel from custom data") - parcel_data_dict["mandateId"] = currentUser.mandateId + parcel_data_dict["mandateId"] = str(context.mandateId) if context.mandateId else None parzelle_instance = Parzelle(**parcel_data_dict) parzelle = realEstateInterface.createParzelle(parzelle_instance) @@ -1150,4 +1120,3 @@ async def add_parcel_to_project( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error adding parcel to project: {str(e)}" ) - diff --git a/modules/routes/routeDataTrustee.py b/modules/routes/routeFeatureTrustee.py similarity index 79% rename from modules/routes/routeDataTrustee.py rename to modules/routes/routeFeatureTrustee.py index bca55df4..69fd5918 100644 --- a/modules/routes/routeDataTrustee.py +++ b/modules/routes/routeFeatureTrustee.py @@ -13,7 +13,7 @@ import logging import json import io -from modules.auth import limiter, getCurrentUser +from modules.auth import limiter, getRequestContext, RequestContext from modules.interfaces.interfaceDbTrusteeObjects import getInterface from modules.datamodels.datamodelTrustee import ( TrusteeOrganisation, @@ -24,7 +24,6 @@ from modules.datamodels.datamodelTrustee import ( TrusteePosition, TrusteePositionDocument, ) -from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelPagination import ( PaginationParams, PaginatedResponse, @@ -67,13 +66,13 @@ def _parsePagination(pagination: Optional[str]) -> Optional[PaginationParams]: async def getOrganisations( request: Request, pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams"), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> PaginatedResponse[TrusteeOrganisation]: """Get all organisations with optional pagination.""" logger = logging.getLogger(__name__) - logger.debug(f"getOrganisations called for user {currentUser.id}, roles: {currentUser.roleLabels}") + logger.debug(f"getOrganisations called for user {context.user.id}, mandateId: {context.mandateId}") paginationParams = _parsePagination(pagination) - interface = getInterface(currentUser) + interface = getInterface(context.user, mandateId=context.mandateId) result = interface.getAllOrganisations(paginationParams) logger.debug(f"getOrganisations returned {len(result.items)} items") @@ -97,14 +96,14 @@ async def getOrganisations( async def getOrganisation( request: Request, orgId: str = Path(..., description="Organisation ID"), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> TrusteeOrganisation: """Get a single organisation by ID.""" - interface = getInterface(currentUser) + interface = getInterface(context.user, mandateId=context.mandateId) org = interface.getOrganisation(orgId) if not org: raise HTTPException(status_code=404, detail=f"Organisation {orgId} not found") - return TrusteeOrganisation(**org) + return org @router.post("/organisations", response_model=TrusteeOrganisation, status_code=201) @@ -112,14 +111,14 @@ async def getOrganisation( async def createOrganisation( request: Request, data: TrusteeOrganisation = Body(...), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> TrusteeOrganisation: """Create a new organisation.""" - interface = getInterface(currentUser) + interface = getInterface(context.user, mandateId=context.mandateId) result = interface.createOrganisation(data.model_dump()) if not result: raise HTTPException(status_code=400, detail="Failed to create organisation") - return TrusteeOrganisation(**result) + return result @router.put("/organisations/{orgId}", response_model=TrusteeOrganisation) @@ -128,10 +127,10 @@ async def updateOrganisation( request: Request, orgId: str = Path(..., description="Organisation ID"), data: TrusteeOrganisation = Body(...), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> TrusteeOrganisation: """Update an organisation.""" - interface = getInterface(currentUser) + interface = getInterface(context.user, mandateId=context.mandateId) existing = interface.getOrganisation(orgId) if not existing: raise HTTPException(status_code=404, detail=f"Organisation {orgId} not found") @@ -139,7 +138,7 @@ async def updateOrganisation( result = interface.updateOrganisation(orgId, data.model_dump(exclude={"id"})) if not result: raise HTTPException(status_code=400, detail="Failed to update organisation") - return TrusteeOrganisation(**result) + return result @router.delete("/organisations/{orgId}") @@ -147,10 +146,10 @@ async def updateOrganisation( async def deleteOrganisation( request: Request, orgId: str = Path(..., description="Organisation ID"), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> Dict[str, Any]: """Delete an organisation.""" - interface = getInterface(currentUser) + interface = getInterface(context.user, mandateId=context.mandateId) existing = interface.getOrganisation(orgId) if not existing: raise HTTPException(status_code=404, detail=f"Organisation {orgId} not found") @@ -168,11 +167,11 @@ async def deleteOrganisation( async def getRoles( request: Request, pagination: Optional[str] = Query(None), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> PaginatedResponse[TrusteeRole]: """Get all roles with optional pagination.""" paginationParams = _parsePagination(pagination) - interface = getInterface(currentUser) + interface = getInterface(context.user, mandateId=context.mandateId) result = interface.getAllRoles(paginationParams) if paginationParams: @@ -195,14 +194,14 @@ async def getRoles( async def getRole( request: Request, roleId: str = Path(..., description="Role ID"), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> TrusteeRole: """Get a single role by ID.""" - interface = getInterface(currentUser) + interface = getInterface(context.user, mandateId=context.mandateId) role = interface.getRole(roleId) if not role: raise HTTPException(status_code=404, detail=f"Role {roleId} not found") - return TrusteeRole(**role) + return role @router.post("/roles", response_model=TrusteeRole, status_code=201) @@ -210,14 +209,14 @@ async def getRole( async def createRole( request: Request, data: TrusteeRole = Body(...), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> TrusteeRole: """Create a new role (sysadmin only).""" - interface = getInterface(currentUser) + interface = getInterface(context.user, mandateId=context.mandateId) result = interface.createRole(data.model_dump()) if not result: raise HTTPException(status_code=400, detail="Failed to create role") - return TrusteeRole(**result) + return result @router.put("/roles/{roleId}", response_model=TrusteeRole) @@ -226,10 +225,10 @@ async def updateRole( request: Request, roleId: str = Path(...), data: TrusteeRole = Body(...), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> TrusteeRole: """Update a role (sysadmin only).""" - interface = getInterface(currentUser) + interface = getInterface(context.user, mandateId=context.mandateId) existing = interface.getRole(roleId) if not existing: raise HTTPException(status_code=404, detail=f"Role {roleId} not found") @@ -237,7 +236,7 @@ async def updateRole( result = interface.updateRole(roleId, data.model_dump(exclude={"id"})) if not result: raise HTTPException(status_code=400, detail="Failed to update role") - return TrusteeRole(**result) + return result @router.delete("/roles/{roleId}") @@ -245,10 +244,10 @@ async def updateRole( async def deleteRole( request: Request, roleId: str = Path(...), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> Dict[str, Any]: """Delete a role (sysadmin only, fails if in use).""" - interface = getInterface(currentUser) + interface = getInterface(context.user, mandateId=context.mandateId) existing = interface.getRole(roleId) if not existing: raise HTTPException(status_code=404, detail=f"Role {roleId} not found") @@ -266,11 +265,11 @@ async def deleteRole( async def getAllAccess( request: Request, pagination: Optional[str] = Query(None), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> PaginatedResponse[TrusteeAccess]: """Get all access records with optional pagination.""" paginationParams = _parsePagination(pagination) - interface = getInterface(currentUser) + interface = getInterface(context.user, mandateId=context.mandateId) result = interface.getAllAccess(paginationParams) if paginationParams: @@ -293,14 +292,14 @@ async def getAllAccess( async def getAccess( request: Request, accessId: str = Path(...), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> TrusteeAccess: """Get a single access record by ID.""" - interface = getInterface(currentUser) + interface = getInterface(context.user, mandateId=context.mandateId) access = interface.getAccess(accessId) if not access: raise HTTPException(status_code=404, detail=f"Access {accessId} not found") - return TrusteeAccess(**access) + return access @router.get("/access/organisation/{orgId}", response_model=List[TrusteeAccess]) @@ -308,11 +307,11 @@ async def getAccess( async def getAccessByOrganisation( request: Request, orgId: str = Path(...), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> List[TrusteeAccess]: """Get all access records for an organisation.""" - interface = getInterface(currentUser) - return [TrusteeAccess(**a) for a in interface.getAccessByOrganisation(orgId)] + interface = getInterface(context.user, mandateId=context.mandateId) + return interface.getAccessByOrganisation(orgId) @router.get("/access/user/{userId}", response_model=List[TrusteeAccess]) @@ -320,11 +319,11 @@ async def getAccessByOrganisation( async def getAccessByUser( request: Request, userId: str = Path(...), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> List[TrusteeAccess]: """Get all access records for a user.""" - interface = getInterface(currentUser) - return [TrusteeAccess(**a) for a in interface.getAccessByUser(userId)] + interface = getInterface(context.user, mandateId=context.mandateId) + return interface.getAccessByUser(userId) @router.post("/access", response_model=TrusteeAccess, status_code=201) @@ -332,14 +331,14 @@ async def getAccessByUser( async def createAccess( request: Request, data: TrusteeAccess = Body(...), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> TrusteeAccess: """Create a new access record.""" - interface = getInterface(currentUser) + interface = getInterface(context.user, mandateId=context.mandateId) result = interface.createAccess(data.model_dump()) if not result: raise HTTPException(status_code=400, detail="Failed to create access") - return TrusteeAccess(**result) + return result @router.put("/access/{accessId}", response_model=TrusteeAccess) @@ -348,10 +347,10 @@ async def updateAccess( request: Request, accessId: str = Path(...), data: TrusteeAccess = Body(...), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> TrusteeAccess: """Update an access record.""" - interface = getInterface(currentUser) + interface = getInterface(context.user, mandateId=context.mandateId) existing = interface.getAccess(accessId) if not existing: raise HTTPException(status_code=404, detail=f"Access {accessId} not found") @@ -359,7 +358,7 @@ async def updateAccess( result = interface.updateAccess(accessId, data.model_dump(exclude={"id"})) if not result: raise HTTPException(status_code=400, detail="Failed to update access") - return TrusteeAccess(**result) + return result @router.delete("/access/{accessId}") @@ -367,10 +366,10 @@ async def updateAccess( async def deleteAccess( request: Request, accessId: str = Path(...), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> Dict[str, Any]: """Delete an access record.""" - interface = getInterface(currentUser) + interface = getInterface(context.user, mandateId=context.mandateId) existing = interface.getAccess(accessId) if not existing: raise HTTPException(status_code=404, detail=f"Access {accessId} not found") @@ -388,11 +387,11 @@ async def deleteAccess( async def getContracts( request: Request, pagination: Optional[str] = Query(None), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> PaginatedResponse[TrusteeContract]: """Get all contracts with optional pagination.""" paginationParams = _parsePagination(pagination) - interface = getInterface(currentUser) + interface = getInterface(context.user, mandateId=context.mandateId) result = interface.getAllContracts(paginationParams) if paginationParams: @@ -415,14 +414,14 @@ async def getContracts( async def getContract( request: Request, contractId: str = Path(...), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> TrusteeContract: """Get a single contract by ID.""" - interface = getInterface(currentUser) + interface = getInterface(context.user, mandateId=context.mandateId) contract = interface.getContract(contractId) if not contract: raise HTTPException(status_code=404, detail=f"Contract {contractId} not found") - return TrusteeContract(**contract) + return contract @router.get("/contracts/organisation/{orgId}", response_model=List[TrusteeContract]) @@ -430,11 +429,11 @@ async def getContract( async def getContractsByOrganisation( request: Request, orgId: str = Path(...), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> List[TrusteeContract]: """Get all contracts for an organisation.""" - interface = getInterface(currentUser) - return [TrusteeContract(**c) for c in interface.getContractsByOrganisation(orgId)] + interface = getInterface(context.user, mandateId=context.mandateId) + return interface.getContractsByOrganisation(orgId) @router.post("/contracts", response_model=TrusteeContract, status_code=201) @@ -442,14 +441,14 @@ async def getContractsByOrganisation( async def createContract( request: Request, data: TrusteeContract = Body(...), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> TrusteeContract: """Create a new contract.""" - interface = getInterface(currentUser) + interface = getInterface(context.user, mandateId=context.mandateId) result = interface.createContract(data.model_dump()) if not result: raise HTTPException(status_code=400, detail="Failed to create contract") - return TrusteeContract(**result) + return result @router.put("/contracts/{contractId}", response_model=TrusteeContract) @@ -458,10 +457,10 @@ async def updateContract( request: Request, contractId: str = Path(...), data: TrusteeContract = Body(...), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> TrusteeContract: """Update a contract (organisationId is immutable).""" - interface = getInterface(currentUser) + interface = getInterface(context.user, mandateId=context.mandateId) existing = interface.getContract(contractId) if not existing: raise HTTPException(status_code=404, detail=f"Contract {contractId} not found") @@ -469,7 +468,7 @@ async def updateContract( result = interface.updateContract(contractId, data.model_dump(exclude={"id"})) if not result: raise HTTPException(status_code=400, detail="Failed to update contract (organisationId cannot be changed)") - return TrusteeContract(**result) + return result @router.delete("/contracts/{contractId}") @@ -477,10 +476,10 @@ async def updateContract( async def deleteContract( request: Request, contractId: str = Path(...), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> Dict[str, Any]: """Delete a contract.""" - interface = getInterface(currentUser) + interface = getInterface(context.user, mandateId=context.mandateId) existing = interface.getContract(contractId) if not existing: raise HTTPException(status_code=404, detail=f"Contract {contractId} not found") @@ -498,11 +497,11 @@ async def deleteContract( async def getDocuments( request: Request, pagination: Optional[str] = Query(None), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> PaginatedResponse[TrusteeDocument]: """Get all documents (metadata only) with optional pagination.""" paginationParams = _parsePagination(pagination) - interface = getInterface(currentUser) + interface = getInterface(context.user, mandateId=context.mandateId) result = interface.getAllDocuments(paginationParams) if paginationParams: @@ -525,14 +524,14 @@ async def getDocuments( async def getDocument( request: Request, documentId: str = Path(...), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> TrusteeDocument: """Get document metadata by ID.""" - interface = getInterface(currentUser) + interface = getInterface(context.user, mandateId=context.mandateId) doc = interface.getDocument(documentId) if not doc: raise HTTPException(status_code=404, detail=f"Document {documentId} not found") - return TrusteeDocument(**doc) + return doc @router.get("/documents/{documentId}/data") @@ -540,10 +539,10 @@ async def getDocument( async def getDocumentData( request: Request, documentId: str = Path(...), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ): """Download document binary data.""" - interface = getInterface(currentUser) + interface = getInterface(context.user, mandateId=context.mandateId) doc = interface.getDocument(documentId) if not doc: raise HTTPException(status_code=404, detail=f"Document {documentId} not found") @@ -554,8 +553,8 @@ async def getDocumentData( return StreamingResponse( io.BytesIO(data), - media_type=doc.get("documentMimeType", "application/octet-stream"), - headers={"Content-Disposition": f"attachment; filename={doc.get('documentName', 'document')}"} + media_type=doc.documentMimeType or "application/octet-stream", + headers={"Content-Disposition": f"attachment; filename={doc.documentName or 'document'}"} ) @@ -564,11 +563,11 @@ async def getDocumentData( async def getDocumentsByContract( request: Request, contractId: str = Path(...), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> List[TrusteeDocument]: """Get all documents for a contract.""" - interface = getInterface(currentUser) - return [TrusteeDocument(**d) for d in interface.getDocumentsByContract(contractId)] + interface = getInterface(context.user, mandateId=context.mandateId) + return interface.getDocumentsByContract(contractId) @router.post("/documents", response_model=TrusteeDocument, status_code=201) @@ -576,14 +575,14 @@ async def getDocumentsByContract( async def createDocument( request: Request, data: TrusteeDocument = Body(...), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> TrusteeDocument: """Create a new document.""" - interface = getInterface(currentUser) + interface = getInterface(context.user, mandateId=context.mandateId) result = interface.createDocument(data.model_dump()) if not result: raise HTTPException(status_code=400, detail="Failed to create document") - return TrusteeDocument(**result) + return result @router.put("/documents/{documentId}", response_model=TrusteeDocument) @@ -592,10 +591,10 @@ async def updateDocument( request: Request, documentId: str = Path(...), data: TrusteeDocument = Body(...), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> TrusteeDocument: """Update document metadata.""" - interface = getInterface(currentUser) + interface = getInterface(context.user, mandateId=context.mandateId) existing = interface.getDocument(documentId) if not existing: raise HTTPException(status_code=404, detail=f"Document {documentId} not found") @@ -603,7 +602,7 @@ async def updateDocument( result = interface.updateDocument(documentId, data.model_dump(exclude={"id"})) if not result: raise HTTPException(status_code=400, detail="Failed to update document") - return TrusteeDocument(**result) + return result @router.delete("/documents/{documentId}") @@ -611,10 +610,10 @@ async def updateDocument( async def deleteDocument( request: Request, documentId: str = Path(...), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> Dict[str, Any]: """Delete a document.""" - interface = getInterface(currentUser) + interface = getInterface(context.user, mandateId=context.mandateId) existing = interface.getDocument(documentId) if not existing: raise HTTPException(status_code=404, detail=f"Document {documentId} not found") @@ -632,11 +631,11 @@ async def deleteDocument( async def getPositions( request: Request, pagination: Optional[str] = Query(None), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> PaginatedResponse[TrusteePosition]: """Get all positions with optional pagination.""" paginationParams = _parsePagination(pagination) - interface = getInterface(currentUser) + interface = getInterface(context.user, mandateId=context.mandateId) result = interface.getAllPositions(paginationParams) if paginationParams: @@ -659,14 +658,14 @@ async def getPositions( async def getPosition( request: Request, positionId: str = Path(...), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> TrusteePosition: """Get a single position by ID.""" - interface = getInterface(currentUser) + interface = getInterface(context.user, mandateId=context.mandateId) position = interface.getPosition(positionId) if not position: raise HTTPException(status_code=404, detail=f"Position {positionId} not found") - return TrusteePosition(**position) + return position @router.get("/positions/contract/{contractId}", response_model=List[TrusteePosition]) @@ -674,11 +673,11 @@ async def getPosition( async def getPositionsByContract( request: Request, contractId: str = Path(...), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> List[TrusteePosition]: """Get all positions for a contract.""" - interface = getInterface(currentUser) - return [TrusteePosition(**p) for p in interface.getPositionsByContract(contractId)] + interface = getInterface(context.user, mandateId=context.mandateId) + return interface.getPositionsByContract(contractId) @router.get("/positions/organisation/{orgId}", response_model=List[TrusteePosition]) @@ -686,11 +685,11 @@ async def getPositionsByContract( async def getPositionsByOrganisation( request: Request, orgId: str = Path(...), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> List[TrusteePosition]: """Get all positions for an organisation.""" - interface = getInterface(currentUser) - return [TrusteePosition(**p) for p in interface.getPositionsByOrganisation(orgId)] + interface = getInterface(context.user, mandateId=context.mandateId) + return interface.getPositionsByOrganisation(orgId) @router.post("/positions", response_model=TrusteePosition, status_code=201) @@ -698,14 +697,14 @@ async def getPositionsByOrganisation( async def createPosition( request: Request, data: TrusteePosition = Body(...), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> TrusteePosition: """Create a new position.""" - interface = getInterface(currentUser) + interface = getInterface(context.user, mandateId=context.mandateId) result = interface.createPosition(data.model_dump()) if not result: raise HTTPException(status_code=400, detail="Failed to create position") - return TrusteePosition(**result) + return result @router.put("/positions/{positionId}", response_model=TrusteePosition) @@ -714,10 +713,10 @@ async def updatePosition( request: Request, positionId: str = Path(...), data: TrusteePosition = Body(...), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> TrusteePosition: """Update a position.""" - interface = getInterface(currentUser) + interface = getInterface(context.user, mandateId=context.mandateId) existing = interface.getPosition(positionId) if not existing: raise HTTPException(status_code=404, detail=f"Position {positionId} not found") @@ -725,7 +724,7 @@ async def updatePosition( result = interface.updatePosition(positionId, data.model_dump(exclude={"id"})) if not result: raise HTTPException(status_code=400, detail="Failed to update position") - return TrusteePosition(**result) + return result @router.delete("/positions/{positionId}") @@ -733,10 +732,10 @@ async def updatePosition( async def deletePosition( request: Request, positionId: str = Path(...), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> Dict[str, Any]: """Delete a position.""" - interface = getInterface(currentUser) + interface = getInterface(context.user, mandateId=context.mandateId) existing = interface.getPosition(positionId) if not existing: raise HTTPException(status_code=404, detail=f"Position {positionId} not found") @@ -754,11 +753,11 @@ async def deletePosition( async def getPositionDocuments( request: Request, pagination: Optional[str] = Query(None), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> PaginatedResponse[TrusteePositionDocument]: """Get all position-document links with optional pagination.""" paginationParams = _parsePagination(pagination) - interface = getInterface(currentUser) + interface = getInterface(context.user, mandateId=context.mandateId) result = interface.getAllPositionDocuments(paginationParams) if paginationParams: @@ -781,14 +780,14 @@ async def getPositionDocuments( async def getPositionDocument( request: Request, linkId: str = Path(...), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> TrusteePositionDocument: """Get a single position-document link by ID.""" - interface = getInterface(currentUser) + interface = getInterface(context.user, mandateId=context.mandateId) link = interface.getPositionDocument(linkId) if not link: raise HTTPException(status_code=404, detail=f"Link {linkId} not found") - return TrusteePositionDocument(**link) + return link @router.get("/position-documents/position/{positionId}", response_model=List[TrusteePositionDocument]) @@ -796,11 +795,11 @@ async def getPositionDocument( async def getDocumentsForPosition( request: Request, positionId: str = Path(...), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> List[TrusteePositionDocument]: """Get all document links for a position.""" - interface = getInterface(currentUser) - return [TrusteePositionDocument(**l) for l in interface.getDocumentsForPosition(positionId)] + interface = getInterface(context.user, mandateId=context.mandateId) + return interface.getDocumentsForPosition(positionId) @router.get("/position-documents/document/{documentId}", response_model=List[TrusteePositionDocument]) @@ -808,11 +807,11 @@ async def getDocumentsForPosition( async def getPositionsForDocument( request: Request, documentId: str = Path(...), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> List[TrusteePositionDocument]: """Get all position links for a document.""" - interface = getInterface(currentUser) - return [TrusteePositionDocument(**l) for l in interface.getPositionsForDocument(documentId)] + interface = getInterface(context.user, mandateId=context.mandateId) + return interface.getPositionsForDocument(documentId) @router.post("/position-documents", response_model=TrusteePositionDocument, status_code=201) @@ -820,14 +819,14 @@ async def getPositionsForDocument( async def createPositionDocument( request: Request, data: TrusteePositionDocument = Body(...), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> TrusteePositionDocument: """Create a new position-document link.""" - interface = getInterface(currentUser) + interface = getInterface(context.user, mandateId=context.mandateId) result = interface.createPositionDocument(data.model_dump()) if not result: raise HTTPException(status_code=400, detail="Failed to create link") - return TrusteePositionDocument(**result) + return result @router.delete("/position-documents/{linkId}") @@ -835,10 +834,10 @@ async def createPositionDocument( async def deletePositionDocument( request: Request, linkId: str = Path(...), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> Dict[str, Any]: """Delete a position-document link.""" - interface = getInterface(currentUser) + interface = getInterface(context.user, mandateId=context.mandateId) existing = interface.getPositionDocument(linkId) if not existing: raise HTTPException(status_code=404, detail=f"Link {linkId} not found") diff --git a/modules/routes/routeFeatures.py b/modules/routes/routeFeatures.py new file mode 100644 index 00000000..4d724218 --- /dev/null +++ b/modules/routes/routeFeatures.py @@ -0,0 +1,625 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +""" +Feature management routes for the backend API. +Implements endpoints for Feature and FeatureInstance management. + +Multi-Tenant Design: +- Feature definitions are global (SysAdmin can manage) +- FeatureInstances belong to mandates (Mandate Admin can manage) +- Template roles are copied on instance creation +""" + +from fastapi import APIRouter, HTTPException, Depends, Request, Query +from typing import List, Dict, Any, Optional +from fastapi import status +import logging +from pydantic import BaseModel, Field + +from modules.auth import limiter, getRequestContext, RequestContext, requireSysAdmin +from modules.datamodels.datamodelUam import User +from modules.datamodels.datamodelFeatures import Feature, FeatureInstance +from modules.interfaces.interfaceDbAppObjects import getRootInterface +from modules.interfaces.interfaceFeatures import getFeatureInterface + +logger = logging.getLogger(__name__) + +router = APIRouter( + prefix="/api/features", + tags=["Features"], + responses={404: {"description": "Not found"}} +) + + +# ============================================================================= +# Request/Response Models +# ============================================================================= + +class FeatureInstanceCreate(BaseModel): + """Request model for creating a feature instance""" + featureCode: str = Field(..., description="Feature code (e.g., 'trustee', 'chatbot')") + label: str = Field(..., description="Instance label (e.g., 'Buchhaltung 2025')") + copyTemplateRoles: bool = Field(True, description="Whether to copy template roles on creation") + + +class FeatureInstanceResponse(BaseModel): + """Response model for feature instance""" + id: str + featureCode: str + mandateId: str + label: str + enabled: bool + + +class SyncRolesResult(BaseModel): + """Response model for role synchronization""" + added: int + removed: int + unchanged: int + + +# ============================================================================= +# Feature Endpoints (Global - mostly read-only for non-SysAdmin) +# ============================================================================= + +@router.get("/", response_model=List[Dict[str, Any]]) +@limiter.limit("60/minute") +async def listFeatures( + request: Request, + context: RequestContext = Depends(getRequestContext) +) -> List[Dict[str, Any]]: + """ + List all available features. + + Returns global feature definitions that can be activated for mandates. + Any authenticated user can see available features. + """ + try: + rootInterface = getRootInterface() + featureInterface = getFeatureInterface(rootInterface.db) + + features = featureInterface.getAllFeatures() + return [f.model_dump() for f in features] + + except Exception as e: + logger.error(f"Error listing features: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to list features: {str(e)}" + ) + + +@router.get("/{featureCode}", response_model=Dict[str, Any]) +@limiter.limit("60/minute") +async def getFeature( + request: Request, + featureCode: str, + context: RequestContext = Depends(getRequestContext) +) -> Dict[str, Any]: + """ + Get a specific feature by code. + + Args: + featureCode: Feature code (e.g., 'trustee', 'chatbot') + """ + try: + rootInterface = getRootInterface() + featureInterface = getFeatureInterface(rootInterface.db) + + feature = featureInterface.getFeature(featureCode) + if not feature: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Feature '{featureCode}' not found" + ) + + return feature.model_dump() + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error getting feature {featureCode}: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to get feature: {str(e)}" + ) + + +@router.post("/", response_model=Dict[str, Any]) +@limiter.limit("10/minute") +async def createFeature( + request: Request, + code: str = Query(..., description="Unique feature code"), + label: Dict[str, str] = None, + icon: str = Query("mdi-puzzle", description="Icon identifier"), + sysAdmin: User = Depends(requireSysAdmin) +) -> Dict[str, Any]: + """ + Create a new feature definition. + + SysAdmin only - creates a global feature that can be activated for mandates. + + Args: + code: Unique feature code (e.g., 'trustee') + label: I18n labels (e.g., {"en": "Trustee", "de": "Treuhand"}) + icon: Icon identifier + """ + try: + rootInterface = getRootInterface() + featureInterface = getFeatureInterface(rootInterface.db) + + # Check if feature already exists + existing = featureInterface.getFeature(code) + if existing: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Feature '{code}' already exists" + ) + + feature = featureInterface.createFeature( + code=code, + label=label or {"en": code.title(), "de": code.title()}, + icon=icon + ) + + logger.info(f"SysAdmin {sysAdmin.id} created feature '{code}'") + return feature.model_dump() + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error creating feature: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to create feature: {str(e)}" + ) + + +# ============================================================================= +# Feature Instance Endpoints (Mandate-scoped) +# ============================================================================= + +@router.get("/instances", response_model=List[Dict[str, Any]]) +@limiter.limit("60/minute") +async def listFeatureInstances( + request: Request, + featureCode: Optional[str] = Query(None, description="Filter by feature code"), + context: RequestContext = Depends(getRequestContext) +) -> List[Dict[str, Any]]: + """ + List feature instances for the current mandate. + + Returns instances the user has access to within the selected mandate. + + Args: + featureCode: Optional filter by feature code + """ + if not context.mandateId: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="X-Mandate-Id header is required" + ) + + try: + rootInterface = getRootInterface() + featureInterface = getFeatureInterface(rootInterface.db) + + instances = featureInterface.getFeatureInstancesForMandate( + mandateId=str(context.mandateId), + featureCode=featureCode + ) + + return [inst.model_dump() for inst in instances] + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error listing feature instances: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to list feature instances: {str(e)}" + ) + + +@router.get("/instances/{instanceId}", response_model=Dict[str, Any]) +@limiter.limit("60/minute") +async def getFeatureInstance( + request: Request, + instanceId: str, + context: RequestContext = Depends(getRequestContext) +) -> Dict[str, Any]: + """ + Get a specific feature instance. + + Args: + instanceId: FeatureInstance ID + """ + try: + rootInterface = getRootInterface() + featureInterface = getFeatureInterface(rootInterface.db) + + instance = featureInterface.getFeatureInstance(instanceId) + if not instance: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Feature instance '{instanceId}' not found" + ) + + # Verify mandate access (unless SysAdmin) + if context.mandateId and str(instance.mandateId) != str(context.mandateId): + if not context.isSysAdmin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied to this feature instance" + ) + + return instance.model_dump() + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error getting feature instance {instanceId}: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to get feature instance: {str(e)}" + ) + + +@router.post("/instances", response_model=Dict[str, Any]) +@limiter.limit("10/minute") +async def createFeatureInstance( + request: Request, + data: FeatureInstanceCreate, + context: RequestContext = Depends(getRequestContext) +) -> Dict[str, Any]: + """ + Create a new feature instance for the current mandate. + + Requires Mandate-Admin role. Template roles are optionally copied. + + Args: + data: Feature instance creation data + """ + if not context.mandateId: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="X-Mandate-Id header is required" + ) + + # Check mandate admin permission + if not _hasMandateAdminRole(context): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Mandate-Admin role required to create feature instances" + ) + + try: + rootInterface = getRootInterface() + featureInterface = getFeatureInterface(rootInterface.db) + + # Verify feature exists + feature = featureInterface.getFeature(data.featureCode) + if not feature: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Feature '{data.featureCode}' not found" + ) + + instance = featureInterface.createFeatureInstance( + featureCode=data.featureCode, + mandateId=str(context.mandateId), + label=data.label, + copyTemplateRoles=data.copyTemplateRoles + ) + + logger.info( + f"User {context.user.id} created feature instance '{data.label}' " + f"for feature '{data.featureCode}' in mandate {context.mandateId}" + ) + + return instance.model_dump() + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error creating feature instance: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to create feature instance: {str(e)}" + ) + + +@router.delete("/instances/{instanceId}", response_model=Dict[str, str]) +@limiter.limit("10/minute") +async def deleteFeatureInstance( + request: Request, + instanceId: str, + context: RequestContext = Depends(getRequestContext) +) -> Dict[str, str]: + """ + Delete a feature instance. + + Requires Mandate-Admin role. CASCADE will delete associated roles and access records. + + Args: + instanceId: FeatureInstance ID + """ + try: + rootInterface = getRootInterface() + featureInterface = getFeatureInterface(rootInterface.db) + + # Get instance to verify access + instance = featureInterface.getFeatureInstance(instanceId) + if not instance: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Feature instance '{instanceId}' not found" + ) + + # Verify mandate access + if context.mandateId and str(instance.mandateId) != str(context.mandateId): + if not context.isSysAdmin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied to this feature instance" + ) + + # Check mandate admin permission + if not _hasMandateAdminRole(context) and not context.isSysAdmin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Mandate-Admin role required to delete feature instances" + ) + + featureInterface.deleteFeatureInstance(instanceId) + + logger.info(f"User {context.user.id} deleted feature instance {instanceId}") + + return {"message": "Feature instance deleted", "instanceId": instanceId} + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error deleting feature instance {instanceId}: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to delete feature instance: {str(e)}" + ) + + +@router.post("/instances/{instanceId}/sync-roles", response_model=SyncRolesResult) +@limiter.limit("10/minute") +async def syncInstanceRoles( + request: Request, + instanceId: str, + addOnly: bool = Query(True, description="Only add missing roles, don't remove extras"), + context: RequestContext = Depends(getRequestContext) +) -> SyncRolesResult: + """ + Synchronize roles of a feature instance with current templates. + + IMPORTANT: Templates are only copied when a FeatureInstance is created. + This sync function is for manual re-synchronization, not automatic propagation. + + Args: + instanceId: FeatureInstance ID + addOnly: If True, only add missing roles. If False, also remove extras. + """ + try: + rootInterface = getRootInterface() + featureInterface = getFeatureInterface(rootInterface.db) + + # Get instance to verify access + instance = featureInterface.getFeatureInstance(instanceId) + if not instance: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Feature instance '{instanceId}' not found" + ) + + # Verify mandate access + if context.mandateId and str(instance.mandateId) != str(context.mandateId): + if not context.isSysAdmin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied to this feature instance" + ) + + # Check admin permission (Mandate-Admin or Feature-Admin) + if not _hasMandateAdminRole(context) and not context.isSysAdmin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Admin role required to sync roles" + ) + + result = featureInterface.syncRolesFromTemplate(instanceId, addOnly) + + logger.info( + f"User {context.user.id} synced roles for instance {instanceId}: {result}" + ) + + return SyncRolesResult(**result) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error syncing roles for instance {instanceId}: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to sync roles: {str(e)}" + ) + + +# ============================================================================= +# Template Role Endpoints (SysAdmin only) +# ============================================================================= + +@router.get("/templates/roles", response_model=List[Dict[str, Any]]) +@limiter.limit("60/minute") +async def listTemplateRoles( + request: Request, + featureCode: Optional[str] = Query(None, description="Filter by feature code"), + sysAdmin: User = Depends(requireSysAdmin) +) -> List[Dict[str, Any]]: + """ + List global template roles. + + SysAdmin only - returns template roles that are copied to new feature instances. + + Args: + featureCode: Optional filter by feature code + """ + try: + rootInterface = getRootInterface() + featureInterface = getFeatureInterface(rootInterface.db) + + roles = featureInterface.getTemplateRoles(featureCode) + return [r.model_dump() for r in roles] + + except Exception as e: + logger.error(f"Error listing template roles: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to list template roles: {str(e)}" + ) + + +@router.post("/templates/roles", response_model=Dict[str, Any]) +@limiter.limit("10/minute") +async def createTemplateRole( + request: Request, + roleLabel: str = Query(..., description="Role label (e.g., 'admin', 'viewer')"), + featureCode: str = Query(..., description="Feature code this role belongs to"), + description: Dict[str, str] = None, + sysAdmin: User = Depends(requireSysAdmin) +) -> Dict[str, Any]: + """ + Create a global template role for a feature. + + SysAdmin only - new template roles are NOT automatically propagated to existing instances. + Use the sync-roles endpoint to manually synchronize. + + Args: + roleLabel: Role label + featureCode: Feature code + description: I18n descriptions + """ + try: + rootInterface = getRootInterface() + featureInterface = getFeatureInterface(rootInterface.db) + + # Verify feature exists + feature = featureInterface.getFeature(featureCode) + if not feature: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Feature '{featureCode}' not found" + ) + + role = featureInterface.createTemplateRole( + roleLabel=roleLabel, + featureCode=featureCode, + description=description + ) + + logger.info( + f"SysAdmin {sysAdmin.id} created template role '{roleLabel}' " + f"for feature '{featureCode}'" + ) + + return role.model_dump() + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error creating template role: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to create template role: {str(e)}" + ) + + +# ============================================================================= +# My Feature Instances (No mandate context needed) +# ============================================================================= + +@router.get("/my", response_model=List[Dict[str, Any]]) +@limiter.limit("60/minute") +async def getMyFeatureInstances( + request: Request, + context: RequestContext = Depends(getRequestContext) +) -> List[Dict[str, Any]]: + """ + Get all feature instances the current user has access to. + + Returns instances across all mandates the user is member of. + This endpoint does not require X-Mandate-Id header. + """ + try: + rootInterface = getRootInterface() + + # Get all feature accesses for this user + featureAccesses = rootInterface.getFeatureAccessesForUser(str(context.user.id)) + + if not featureAccesses: + return [] + + featureInterface = getFeatureInterface(rootInterface.db) + result = [] + + for access in featureAccesses: + if not access.enabled: + continue + + instance = featureInterface.getFeatureInstance(str(access.featureInstanceId)) + if instance and instance.enabled: + result.append({ + **instance.model_dump(), + "accessId": str(access.id) + }) + + return result + + except Exception as e: + logger.error(f"Error getting user's feature instances: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to get feature instances: {str(e)}" + ) + + +# ============================================================================= +# Helper Functions +# ============================================================================= + +def _hasMandateAdminRole(context: RequestContext) -> bool: + """ + Check if the user has mandate admin role in the current context. + + A user is mandate admin if they have the 'admin' role at mandate level. + """ + if context.isSysAdmin: + return True + + if not context.roleIds: + return False + + # Check if any of the user's roles is an admin role + try: + rootInterface = getRootInterface() + from modules.datamodels.datamodelRbac import Role + + for roleId in context.roleIds: + roleRecords = rootInterface.db.getRecordset(Role, recordFilter={"id": roleId}) + if roleRecords: + role = roleRecords[0] + roleLabel = role.get("roleLabel", "") + # Admin role at mandate level (not feature-instance level) + if roleLabel == "admin" and role.get("mandateId") and not role.get("featureInstanceId"): + return True + + return False + + except Exception as e: + logger.error(f"Error checking mandate admin role: {e}") + return False diff --git a/modules/routes/routeGdpr.py b/modules/routes/routeGdpr.py new file mode 100644 index 00000000..3eef1980 --- /dev/null +++ b/modules/routes/routeGdpr.py @@ -0,0 +1,514 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +""" +GDPR compliance routes for the backend API. +Implements data subject rights according to GDPR regulations. + +GDPR Articles implemented: +- Article 15: Right of access (data export) +- Article 16: Right to rectification (via existing update endpoints) +- Article 17: Right to erasure (account deletion) +- Article 20: Right to data portability (machine-readable export) +""" + +from fastapi import APIRouter, HTTPException, Depends, Request +from fastapi.responses import JSONResponse +from typing import List, Dict, Any, Optional +from fastapi import status +import logging +import json +from pydantic import BaseModel, Field + +from modules.auth import limiter, getCurrentUser +from modules.datamodels.datamodelUam import User +from modules.interfaces.interfaceDbAppObjects import getRootInterface +from modules.shared.timeUtils import getUtcTimestamp +from modules.shared.auditLogger import audit_logger + +logger = logging.getLogger(__name__) + +router = APIRouter( + prefix="/api/user/me", + tags=["GDPR"], + responses={404: {"description": "Not found"}} +) + + +# ============================================================================= +# Response Models +# ============================================================================= + +class DataExportResponse(BaseModel): + """Response model for GDPR data export""" + exportedAt: float + userId: str + userData: Dict[str, Any] + mandates: List[Dict[str, Any]] + featureAccesses: List[Dict[str, Any]] + invitationsCreated: List[Dict[str, Any]] + invitationsUsed: List[Dict[str, Any]] + + +class DataPortabilityResponse(BaseModel): + """Machine-readable data portability response (JSON-LD format)""" + context: str = Field(alias="@context") + type: str = Field(alias="@type") + identifier: str + exportDate: str + data: Dict[str, Any] + + +class DeletionResult(BaseModel): + """Result of account deletion""" + success: bool + userId: str + deletedAt: float + deletedData: List[str] + message: str + + +# ============================================================================= +# Article 15: Right of Access +# ============================================================================= + +@router.get("/data-export", response_model=DataExportResponse) +@limiter.limit("5/minute") +async def exportUserData( + request: Request, + currentUser: User = Depends(getCurrentUser) +) -> DataExportResponse: + """ + Export all personal data (GDPR Article 15). + + Returns all data associated with the authenticated user including: + - User profile data + - Mandate memberships + - Feature access records + - Invitations created and used + + Note: This exports Gateway-level data only. Feature-specific data + (e.g., chat workflows, trustee contracts) should be exported via + feature-specific endpoints. + """ + try: + rootInterface = getRootInterface() + + # User data (exclude sensitive fields) + userData = { + "id": str(currentUser.id), + "username": currentUser.username, + "email": currentUser.email, + "firstname": currentUser.firstname, + "lastname": currentUser.lastname, + "enabled": currentUser.enabled, + "isSysAdmin": getattr(currentUser, "isSysAdmin", False), + "createdAt": getattr(currentUser, "createdAt", None), + "updatedAt": getattr(currentUser, "updatedAt", None), + "lastLogin": getattr(currentUser, "lastLogin", None), + "language": getattr(currentUser, "language", None), + "authenticationAuthority": str(getattr(currentUser, "authenticationAuthority", "")) + } + + # Mandate memberships + from modules.datamodels.datamodelMembership import UserMandate + userMandates = rootInterface.db.getRecordset( + UserMandate, + recordFilter={"userId": str(currentUser.id)} + ) + + mandates = [] + for um in userMandates: + mandateId = um.get("mandateId") + + # Get mandate details + from modules.datamodels.datamodelUam import Mandate + mandateRecords = rootInterface.db.getRecordset( + Mandate, + recordFilter={"id": mandateId} + ) + mandateName = mandateRecords[0].get("name") if mandateRecords else "Unknown" + + # Get roles for this membership + roleIds = rootInterface.getRoleIdsForUserMandate(um.get("id")) + + mandates.append({ + "userMandateId": um.get("id"), + "mandateId": mandateId, + "mandateName": mandateName, + "enabled": um.get("enabled", True), + "roleIds": roleIds, + "joinedAt": um.get("createdAt") + }) + + # Feature access records + from modules.datamodels.datamodelMembership import FeatureAccess + featureAccesses = rootInterface.db.getRecordset( + FeatureAccess, + recordFilter={"userId": str(currentUser.id)} + ) + + featureAccessList = [] + for fa in featureAccesses: + instanceId = fa.get("featureInstanceId") + + # Get instance details + from modules.datamodels.datamodelFeatures import FeatureInstance + instanceRecords = rootInterface.db.getRecordset( + FeatureInstance, + recordFilter={"id": instanceId} + ) + + instanceInfo = instanceRecords[0] if instanceRecords else {} + roleIds = rootInterface.getRoleIdsForFeatureAccess(fa.get("id")) + + featureAccessList.append({ + "featureAccessId": fa.get("id"), + "featureInstanceId": instanceId, + "featureCode": instanceInfo.get("featureCode"), + "instanceLabel": instanceInfo.get("label"), + "enabled": fa.get("enabled", True), + "roleIds": roleIds + }) + + # Invitations created by user + from modules.datamodels.datamodelInvitation import Invitation + invitationsCreated = rootInterface.db.getRecordset( + Invitation, + recordFilter={"createdBy": str(currentUser.id)} + ) + + invitationsCreatedList = [ + { + "id": inv.get("id"), + "mandateId": inv.get("mandateId"), + "createdAt": inv.get("createdAt"), + "expiresAt": inv.get("expiresAt"), + "maxUses": inv.get("maxUses"), + "currentUses": inv.get("currentUses") + } + for inv in invitationsCreated + ] + + # Invitations used by user + invitationsUsed = rootInterface.db.getRecordset( + Invitation, + recordFilter={"usedBy": str(currentUser.id)} + ) + + invitationsUsedList = [ + { + "id": inv.get("id"), + "mandateId": inv.get("mandateId"), + "usedAt": inv.get("usedAt") + } + for inv in invitationsUsed + ] + + # Audit log + audit_logger.logSecurityEvent( + userId=str(currentUser.id), + mandateId="system", + action="gdpr_data_export", + details="User requested data export (Article 15)" + ) + + logger.info(f"User {currentUser.id} exported personal data (GDPR Art. 15)") + + return DataExportResponse( + exportedAt=getUtcTimestamp(), + userId=str(currentUser.id), + userData=userData, + mandates=mandates, + featureAccesses=featureAccessList, + invitationsCreated=invitationsCreatedList, + invitationsUsed=invitationsUsedList + ) + + except Exception as e: + logger.error(f"Error exporting user data: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to export data: {str(e)}" + ) + + +# ============================================================================= +# Article 20: Right to Data Portability +# ============================================================================= + +@router.get("/data-portability") +@limiter.limit("5/minute") +async def exportPortableData( + request: Request, + currentUser: User = Depends(getCurrentUser) +) -> JSONResponse: + """ + Export data in portable, machine-readable format (GDPR Article 20). + + Returns data in JSON-LD format suitable for transfer to another service. + This is a structured format that can be easily parsed by machines. + """ + try: + # Get full export data first + rootInterface = getRootInterface() + + # Build portable data structure + portableData = { + "@context": "https://schema.org", + "@type": "Person", + "identifier": str(currentUser.id), + "name": f"{currentUser.firstname or ''} {currentUser.lastname or ''}".strip() or currentUser.username, + "email": currentUser.email, + "additionalProperty": [] + } + + # Add profile properties + if currentUser.firstname: + portableData["givenName"] = currentUser.firstname + if currentUser.lastname: + portableData["familyName"] = currentUser.lastname + + # Add mandate memberships as organization affiliations + from modules.datamodels.datamodelMembership import UserMandate + userMandates = rootInterface.db.getRecordset( + UserMandate, + recordFilter={"userId": str(currentUser.id)} + ) + + affiliations = [] + for um in userMandates: + from modules.datamodels.datamodelUam import Mandate + mandateRecords = rootInterface.db.getRecordset( + Mandate, + recordFilter={"id": um.get("mandateId")} + ) + if mandateRecords: + mandate = mandateRecords[0] + affiliations.append({ + "@type": "Organization", + "identifier": um.get("mandateId"), + "name": mandate.get("name"), + "membershipActive": um.get("enabled", True) + }) + + if affiliations: + portableData["affiliation"] = affiliations + + # Wrap in export envelope + exportEnvelope = { + "@context": "https://schema.org", + "@type": "DataDownload", + "identifier": f"export-{currentUser.id}-{int(getUtcTimestamp())}", + "dateCreated": _timestampToIso(getUtcTimestamp()), + "encodingFormat": "application/ld+json", + "about": portableData + } + + # Audit log + audit_logger.logSecurityEvent( + userId=str(currentUser.id), + mandateId="system", + action="gdpr_data_portability", + details="User requested portable data export (Article 20)" + ) + + logger.info(f"User {currentUser.id} exported portable data (GDPR Art. 20)") + + return JSONResponse( + content=exportEnvelope, + media_type="application/ld+json" + ) + + except Exception as e: + logger.error(f"Error exporting portable data: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to export data: {str(e)}" + ) + + +# ============================================================================= +# Article 17: Right to Erasure +# ============================================================================= + +@router.delete("/", response_model=DeletionResult) +@limiter.limit("1/hour") +async def deleteAccount( + request: Request, + confirmDeletion: bool = False, + currentUser: User = Depends(getCurrentUser) +) -> DeletionResult: + """ + Delete own account and all associated data (GDPR Article 17). + + IMPORTANT: This action is irreversible! + - All user data will be permanently deleted + - All mandate memberships will be removed + - All feature accesses will be removed + - All created invitations will be revoked + + Args: + confirmDeletion: Must be True to confirm deletion + """ + if not confirmDeletion: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Deletion not confirmed. Set confirmDeletion=true to proceed." + ) + + # Prevent SysAdmin self-deletion (safety measure) + if getattr(currentUser, "isSysAdmin", False): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="SysAdmin accounts cannot be self-deleted. Contact another SysAdmin." + ) + + try: + rootInterface = getRootInterface() + deletedData = [] + + # 1. Revoke all invitations created by user + from modules.datamodels.datamodelInvitation import Invitation + userInvitations = rootInterface.db.getRecordset( + Invitation, + recordFilter={"createdBy": str(currentUser.id)} + ) + + for inv in userInvitations: + rootInterface.db.recordUpdate( + Invitation, + inv.get("id"), + {"revokedAt": getUtcTimestamp()} + ) + deletedData.append(f"Invitations revoked: {len(userInvitations)}") + + # 2. Delete feature accesses (CASCADE will delete FeatureAccessRoles) + from modules.datamodels.datamodelMembership import FeatureAccess + featureAccesses = rootInterface.db.getRecordset( + FeatureAccess, + recordFilter={"userId": str(currentUser.id)} + ) + + for fa in featureAccesses: + rootInterface.db.recordDelete(FeatureAccess, fa.get("id")) + deletedData.append(f"Feature accesses deleted: {len(featureAccesses)}") + + # 3. Delete mandate memberships (CASCADE will delete UserMandateRoles) + from modules.datamodels.datamodelMembership import UserMandate + userMandates = rootInterface.db.getRecordset( + UserMandate, + recordFilter={"userId": str(currentUser.id)} + ) + + for um in userMandates: + rootInterface.db.recordDelete(UserMandate, um.get("id")) + deletedData.append(f"Mandate memberships deleted: {len(userMandates)}") + + # 4. Delete active tokens + from modules.datamodels.datamodelSecurity import Token + userTokens = rootInterface.db.getRecordset( + Token, + recordFilter={"userId": str(currentUser.id)} + ) + + for token in userTokens: + rootInterface.db.recordDelete(Token, token.get("id")) + deletedData.append(f"Tokens deleted: {len(userTokens)}") + + # 5. Delete user connections (OAuth) + from modules.datamodels.datamodelUam import UserConnection + userConnections = rootInterface.db.getRecordset( + UserConnection, + recordFilter={"userId": str(currentUser.id)} + ) + + for conn in userConnections: + rootInterface.db.recordDelete(UserConnection, conn.get("id")) + deletedData.append(f"Connections deleted: {len(userConnections)}") + + # 6. Finally, delete the user + deletedAt = getUtcTimestamp() + rootInterface.db.recordDelete(User, str(currentUser.id)) + deletedData.append("User account deleted") + + # Audit log (before user is deleted) + audit_logger.logSecurityEvent( + userId=str(currentUser.id), + mandateId="system", + action="gdpr_account_deletion", + details=f"User deleted own account (Article 17). Data: {', '.join(deletedData)}" + ) + + logger.info(f"User {currentUser.id} deleted own account (GDPR Art. 17)") + + return DeletionResult( + success=True, + userId=str(currentUser.id), + deletedAt=deletedAt, + deletedData=deletedData, + message="Account and all associated data have been permanently deleted." + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error deleting account: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to delete account: {str(e)}" + ) + + +# ============================================================================= +# Consent Information Endpoint +# ============================================================================= + +@router.get("/consent-info", response_model=Dict[str, Any]) +@limiter.limit("30/minute") +async def getConsentInfo( + request: Request, + currentUser: User = Depends(getCurrentUser) +) -> Dict[str, Any]: + """ + Get information about data processing and user rights (GDPR transparency). + + Returns information about: + - What data is collected + - How data is processed + - User rights under GDPR + - Contact information for data protection inquiries + """ + return { + "dataCollected": { + "profile": "Name, email, username, language preferences", + "authentication": "Login timestamps, authentication provider", + "memberships": "Mandate and feature access records", + "activity": "Audit logs for security-relevant actions" + }, + "dataProcessing": { + "purpose": "Providing multi-tenant platform services", + "legalBasis": "Contract fulfillment and legitimate interest", + "retention": "Data retained while account is active, deleted upon account deletion" + }, + "userRights": { + "access": "GET /api/user/me/data-export (Article 15)", + "portability": "GET /api/user/me/data-portability (Article 20)", + "erasure": "DELETE /api/user/me (Article 17)", + "rectification": "PUT /api/local/me (Article 16)" + }, + "contact": { + "email": "privacy@example.com", + "note": "For data protection inquiries, please contact us with your user ID" + } + } + + +# ============================================================================= +# Helper Functions +# ============================================================================= + +def _timestampToIso(timestamp: float) -> str: + """Convert Unix timestamp to ISO 8601 format""" + from datetime import datetime, timezone + dt = datetime.fromtimestamp(timestamp, tz=timezone.utc) + return dt.isoformat() diff --git a/modules/routes/routeInvitations.py b/modules/routes/routeInvitations.py new file mode 100644 index 00000000..f649d2b2 --- /dev/null +++ b/modules/routes/routeInvitations.py @@ -0,0 +1,812 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +""" +Invitation routes for the backend API. +Implements token-based user invitations for self-service onboarding. + +Multi-Tenant Design: +- Invitations are mandate-scoped (Mandate Admin creates them) +- Tokens are secure, time-limited, and optionally use-limited +- Users accept invitations to join mandates/features with predefined roles +""" + +from fastapi import APIRouter, HTTPException, Depends, Request, Query +from typing import List, Dict, Any, Optional +from fastapi import status +import logging +from pydantic import BaseModel, Field + +from modules.auth import limiter, getRequestContext, RequestContext, getCurrentUser +from modules.datamodels.datamodelUam import User +from modules.datamodels.datamodelInvitation import Invitation +from modules.interfaces.interfaceDbAppObjects import getRootInterface +from modules.shared.timeUtils import getUtcTimestamp + +logger = logging.getLogger(__name__) + +router = APIRouter( + prefix="/api/invitations", + tags=["Invitations"], + responses={404: {"description": "Not found"}} +) + + +# ============================================================================= +# Request/Response Models +# ============================================================================= + +class InvitationCreate(BaseModel): + """Request model for creating an invitation""" + email: Optional[str] = Field(None, description="Target email address (optional)") + roleIds: List[str] = Field(..., description="Role IDs to assign to the invited user") + featureInstanceId: Optional[str] = Field(None, description="Optional feature instance access") + expiresInHours: int = Field( + 72, + ge=1, + le=720, # Max 30 days + description="Hours until invitation expires" + ) + maxUses: int = Field( + 1, + ge=1, + le=100, + description="Maximum number of times this invitation can be used" + ) + + +class InvitationResponse(BaseModel): + """Response model for invitation""" + id: str + token: str + mandateId: str + featureInstanceId: Optional[str] + roleIds: List[str] + email: Optional[str] + createdBy: str + createdAt: float + expiresAt: float + usedBy: Optional[str] + usedAt: Optional[float] + revokedAt: Optional[float] + maxUses: int + currentUses: int + inviteUrl: str # Full URL for the invitation + + +class InvitationValidation(BaseModel): + """Response model for invitation validation""" + valid: bool + reason: Optional[str] + mandateId: Optional[str] + featureInstanceId: Optional[str] + roleIds: List[str] + + +class RegisterAndAcceptRequest(BaseModel): + """Request model for combined registration + invitation acceptance""" + token: str = Field(..., description="Invitation token") + username: str = Field(..., min_length=3, max_length=50, description="Username for the new account") + email: str = Field(..., description="Email address") + password: str = Field(..., min_length=8, description="Password (min 8 characters)") + firstname: Optional[str] = Field(None, description="First name") + lastname: Optional[str] = Field(None, description="Last name") + + +class RegisterAndAcceptResponse(BaseModel): + """Response model for combined registration + invitation acceptance""" + message: str + userId: str + mandateId: str + userMandateId: str + featureAccessId: Optional[str] + roleIds: List[str] + + +# ============================================================================= +# Invitation CRUD Endpoints +# ============================================================================= + +@router.post("/", response_model=InvitationResponse) +@limiter.limit("30/minute") +async def createInvitation( + request: Request, + data: InvitationCreate, + context: RequestContext = Depends(getRequestContext) +) -> InvitationResponse: + """ + Create a new invitation for the current mandate. + + Requires Mandate-Admin role. Creates a secure token that can be shared + with users to join the mandate with predefined roles. + + Args: + data: Invitation creation data + """ + if not context.mandateId: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="X-Mandate-Id header is required" + ) + + # Check mandate admin permission + if not _hasMandateAdminRole(context): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Mandate-Admin role required to create invitations" + ) + + try: + rootInterface = getRootInterface() + + # Validate role IDs exist and belong to this mandate or are global + for roleId in data.roleIds: + from modules.datamodels.datamodelRbac import Role + roleRecords = rootInterface.db.getRecordset(Role, recordFilter={"id": roleId}) + if not roleRecords: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Role '{roleId}' not found" + ) + role = roleRecords[0] + # Role must be global or belong to this mandate + roleMandateId = role.get("mandateId") + if roleMandateId and str(roleMandateId) != str(context.mandateId): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Role '{roleId}' belongs to a different mandate" + ) + + # Validate feature instance if provided + if data.featureInstanceId: + from modules.datamodels.datamodelFeatures import FeatureInstance + instanceRecords = rootInterface.db.getRecordset( + FeatureInstance, + recordFilter={"id": data.featureInstanceId} + ) + if not instanceRecords: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Feature instance '{data.featureInstanceId}' not found" + ) + instance = instanceRecords[0] + if str(instance.get("mandateId")) != str(context.mandateId): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Feature instance belongs to a different mandate" + ) + + # Calculate expiration time + currentTime = getUtcTimestamp() + expiresAt = currentTime + (data.expiresInHours * 3600) + + # Create invitation + invitation = Invitation( + mandateId=str(context.mandateId), + featureInstanceId=data.featureInstanceId, + roleIds=data.roleIds, + email=data.email, + createdBy=str(context.user.id), + expiresAt=expiresAt, + maxUses=data.maxUses + ) + + createdRecord = rootInterface.db.recordCreate(Invitation, invitation.model_dump()) + if not createdRecord: + raise ValueError("Failed to create invitation record") + + # Build invite URL + from modules.shared.configuration import APP_CONFIG + frontendUrl = APP_CONFIG.get("APP_FRONTEND_URL", "http://localhost:8080") + inviteUrl = f"{frontendUrl}/invite/{invitation.token}" + + logger.info( + f"User {context.user.id} created invitation for mandate {context.mandateId}, " + f"expires in {data.expiresInHours}h" + ) + + return InvitationResponse( + id=str(createdRecord.get("id")), + token=str(createdRecord.get("token")), + mandateId=str(createdRecord.get("mandateId")), + featureInstanceId=createdRecord.get("featureInstanceId"), + roleIds=createdRecord.get("roleIds", []), + email=createdRecord.get("email"), + createdBy=str(createdRecord.get("createdBy")), + createdAt=createdRecord.get("createdAt"), + expiresAt=createdRecord.get("expiresAt"), + usedBy=createdRecord.get("usedBy"), + usedAt=createdRecord.get("usedAt"), + revokedAt=createdRecord.get("revokedAt"), + maxUses=createdRecord.get("maxUses", 1), + currentUses=createdRecord.get("currentUses", 0), + inviteUrl=inviteUrl + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error creating invitation: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to create invitation: {str(e)}" + ) + + +@router.get("/", response_model=List[Dict[str, Any]]) +@limiter.limit("60/minute") +async def listInvitations( + request: Request, + includeUsed: bool = Query(False, description="Include already used invitations"), + includeExpired: bool = Query(False, description="Include expired invitations"), + context: RequestContext = Depends(getRequestContext) +) -> List[Dict[str, Any]]: + """ + List invitations for the current mandate. + + Requires Mandate-Admin role. Returns all invitations created for this mandate. + + Args: + includeUsed: Include invitations that have reached maxUses + includeExpired: Include expired invitations + """ + if not context.mandateId: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="X-Mandate-Id header is required" + ) + + # Check mandate admin permission + if not _hasMandateAdminRole(context): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Mandate-Admin role required to list invitations" + ) + + try: + rootInterface = getRootInterface() + + # Get all invitations for this mandate + allInvitations = rootInterface.db.getRecordset( + Invitation, + recordFilter={"mandateId": str(context.mandateId)} + ) + + currentTime = getUtcTimestamp() + result = [] + + for inv in allInvitations: + # Skip revoked invitations + if inv.get("revokedAt"): + continue + + # Filter by usage + if not includeUsed and inv.get("currentUses", 0) >= inv.get("maxUses", 1): + continue + + # Filter by expiration + if not includeExpired and inv.get("expiresAt", 0) < currentTime: + continue + + # Build invite URL + from modules.shared.configuration import APP_CONFIG + frontendUrl = APP_CONFIG.get("APP_FRONTEND_URL", "http://localhost:8080") + inviteUrl = f"{frontendUrl}/invite/{inv.get('token')}" + + result.append({ + **{k: v for k, v in inv.items() if not k.startswith("_")}, + "inviteUrl": inviteUrl, + "isExpired": inv.get("expiresAt", 0) < currentTime, + "isUsedUp": inv.get("currentUses", 0) >= inv.get("maxUses", 1) + }) + + return result + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error listing invitations: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to list invitations: {str(e)}" + ) + + +@router.delete("/{invitationId}", response_model=Dict[str, str]) +@limiter.limit("30/minute") +async def revokeInvitation( + request: Request, + invitationId: str, + context: RequestContext = Depends(getRequestContext) +) -> Dict[str, str]: + """ + Revoke an invitation. + + Requires Mandate-Admin role. Revoked invitations cannot be used. + + Args: + invitationId: Invitation ID + """ + if not context.mandateId: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="X-Mandate-Id header is required" + ) + + # Check mandate admin permission + if not _hasMandateAdminRole(context): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Mandate-Admin role required to revoke invitations" + ) + + try: + rootInterface = getRootInterface() + + # Get invitation + invitationRecords = rootInterface.db.getRecordset( + Invitation, + recordFilter={"id": invitationId} + ) + + if not invitationRecords: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Invitation '{invitationId}' not found" + ) + + invitation = invitationRecords[0] + + # Verify mandate access + if str(invitation.get("mandateId")) != str(context.mandateId): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied to this invitation" + ) + + # Already revoked? + if invitation.get("revokedAt"): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invitation is already revoked" + ) + + # Revoke invitation + rootInterface.db.recordUpdate( + Invitation, + invitationId, + {"revokedAt": getUtcTimestamp()} + ) + + logger.info(f"User {context.user.id} revoked invitation {invitationId}") + + return {"message": "Invitation revoked", "invitationId": invitationId} + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error revoking invitation {invitationId}: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to revoke invitation: {str(e)}" + ) + + +# ============================================================================= +# Public Invitation Endpoints (No auth required for validation) +# ============================================================================= + +@router.get("/validate/{token}", response_model=InvitationValidation) +@limiter.limit("30/minute") +async def validateInvitation( + request: Request, + token: str +) -> InvitationValidation: + """ + Validate an invitation token (public endpoint). + + Used by the frontend to check if an invitation is valid before + showing the registration/acceptance form. + + Args: + token: Invitation token + """ + try: + rootInterface = getRootInterface() + + # Find invitation by token + invitationRecords = rootInterface.db.getRecordset( + Invitation, + recordFilter={"token": token} + ) + + if not invitationRecords: + return InvitationValidation( + valid=False, + reason="Invitation not found", + mandateId=None, + featureInstanceId=None, + roleIds=[] + ) + + invitation = invitationRecords[0] + + # Check if revoked + if invitation.get("revokedAt"): + return InvitationValidation( + valid=False, + reason="Invitation has been revoked", + mandateId=None, + featureInstanceId=None, + roleIds=[] + ) + + # Check if expired + currentTime = getUtcTimestamp() + if invitation.get("expiresAt", 0) < currentTime: + return InvitationValidation( + valid=False, + reason="Invitation has expired", + mandateId=None, + featureInstanceId=None, + roleIds=[] + ) + + # Check if used up + if invitation.get("currentUses", 0) >= invitation.get("maxUses", 1): + return InvitationValidation( + valid=False, + reason="Invitation has reached maximum uses", + mandateId=None, + featureInstanceId=None, + roleIds=[] + ) + + return InvitationValidation( + valid=True, + reason=None, + mandateId=invitation.get("mandateId"), + featureInstanceId=invitation.get("featureInstanceId"), + roleIds=invitation.get("roleIds", []) + ) + + except Exception as e: + logger.error(f"Error validating invitation token: {e}") + return InvitationValidation( + valid=False, + reason="Validation error", + mandateId=None, + featureInstanceId=None, + roleIds=[] + ) + + +@router.post("/accept/{token}", response_model=Dict[str, Any]) +@limiter.limit("10/minute") +async def acceptInvitation( + request: Request, + token: str, + currentUser: User = Depends(getCurrentUser) +) -> Dict[str, Any]: + """ + Accept an invitation (requires authentication). + + The authenticated user joins the mandate with the predefined roles. + If the user is already a member, their roles are updated. + + Args: + token: Invitation token + """ + try: + rootInterface = getRootInterface() + + # Find invitation by token + invitationRecords = rootInterface.db.getRecordset( + Invitation, + recordFilter={"token": token} + ) + + if not invitationRecords: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Invitation not found" + ) + + invitation = invitationRecords[0] + + # Validate invitation + if invitation.get("revokedAt"): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invitation has been revoked" + ) + + currentTime = getUtcTimestamp() + if invitation.get("expiresAt", 0) < currentTime: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invitation has expired" + ) + + if invitation.get("currentUses", 0) >= invitation.get("maxUses", 1): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invitation has reached maximum uses" + ) + + mandateId = invitation.get("mandateId") + roleIds = invitation.get("roleIds", []) + featureInstanceId = invitation.get("featureInstanceId") + + # Check if user is already a member + existingMembership = rootInterface.getUserMandate(str(currentUser.id), mandateId) + + if existingMembership: + # Update existing membership with additional roles + for roleId in roleIds: + try: + rootInterface.addRoleToUserMandate(str(existingMembership.id), roleId) + except Exception: + pass # Role might already be assigned + + userMandateId = str(existingMembership.id) + message = "Roles updated for existing membership" + else: + # Create new membership + userMandate = rootInterface.createUserMandate( + userId=str(currentUser.id), + mandateId=mandateId, + roleIds=roleIds + ) + userMandateId = str(userMandate.id) + message = "Successfully joined mandate" + + # Grant feature access if specified + featureAccessId = None + if featureInstanceId: + existingAccess = rootInterface.getFeatureAccess(str(currentUser.id), featureInstanceId) + if not existingAccess: + # Create feature access with instance-level roles if any + instanceRoleIds = [r for r in roleIds if _isInstanceRole(rootInterface, r, featureInstanceId)] + featureAccess = rootInterface.createFeatureAccess( + userId=str(currentUser.id), + featureInstanceId=featureInstanceId, + roleIds=instanceRoleIds + ) + featureAccessId = str(featureAccess.id) + + # Update invitation usage + rootInterface.db.recordUpdate( + Invitation, + invitation.get("id"), + { + "currentUses": invitation.get("currentUses", 0) + 1, + "usedBy": str(currentUser.id), + "usedAt": currentTime + } + ) + + logger.info( + f"User {currentUser.id} accepted invitation {invitation.get('id')} " + f"for mandate {mandateId}" + ) + + return { + "message": message, + "mandateId": mandateId, + "userMandateId": userMandateId, + "featureAccessId": featureAccessId, + "roleIds": roleIds + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error accepting invitation: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to accept invitation: {str(e)}" + ) + + +# ============================================================================= +# Combined Registration + Accept Invitation +# ============================================================================= + +@router.post("/register-and-accept", response_model=RegisterAndAcceptResponse) +@limiter.limit("10/minute") # Stricter rate limit for registration +async def registerAndAcceptInvitation( + request: Request, + data: RegisterAndAcceptRequest +) -> RegisterAndAcceptResponse: + """ + Combined endpoint: Register a new user AND accept an invitation in one step. + + This is a PUBLIC endpoint - no authentication required. + + Flow: + 1. Validate invitation token + 2. Check email matches (if invitation has email restriction) + 3. Create new user account + 4. Create UserMandate membership with roles + 5. Optionally grant FeatureAccess + 6. Update invitation usage + + The user can then login with their new credentials. + """ + try: + rootInterface = getRootInterface() + + # 1. Validate invitation + invitation = rootInterface.getInvitationByToken(data.token) + if not invitation: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Invalid invitation token" + ) + + if invitation.get("revokedAt"): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invitation has been revoked" + ) + + currentTime = getUtcTimestamp() + if invitation.get("expiresAt", 0) < currentTime: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invitation has expired" + ) + + if invitation.get("currentUses", 0) >= invitation.get("maxUses", 1): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invitation has reached maximum uses" + ) + + # 2. Check email restriction + invitationEmail = invitation.get("email") + if invitationEmail and invitationEmail.lower() != data.email.lower(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email does not match the invitation" + ) + + # 3. Check if username or email already exists + existingUsername = rootInterface.getUserByUsername(data.username) + if existingUsername: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Username already exists" + ) + + existingEmail = rootInterface.getUserByEmail(data.email) + if existingEmail: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Email already registered. Please login and accept the invitation." + ) + + # 4. Create new user + from modules.security.passwordUtils import hashPassword + hashedPassword = hashPassword(data.password) + + newUser = rootInterface.createUser( + username=data.username, + email=data.email, + passwordHash=hashedPassword, + firstname=data.firstname, + lastname=data.lastname + ) + + if not newUser: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to create user account" + ) + + userId = str(newUser.id) + mandateId = invitation.get("mandateId") + roleIds = invitation.get("roleIds", []) + featureInstanceId = invitation.get("featureInstanceId") + + # 5. Create UserMandate membership + userMandate = rootInterface.createUserMandate( + userId=userId, + mandateId=mandateId, + roleIds=roleIds + ) + userMandateId = str(userMandate.id) + + # 6. Grant feature access if specified + featureAccessId = None + if featureInstanceId: + instanceRoleIds = [r for r in roleIds if _isInstanceRole(rootInterface, r, featureInstanceId)] + featureAccess = rootInterface.createFeatureAccess( + userId=userId, + featureInstanceId=featureInstanceId, + roleIds=instanceRoleIds + ) + featureAccessId = str(featureAccess.id) + + # 7. Update invitation usage + rootInterface.db.recordUpdate( + Invitation, + invitation.get("id"), + { + "currentUses": invitation.get("currentUses", 0) + 1, + "usedBy": userId, + "usedAt": currentTime + } + ) + + logger.info( + f"New user {userId} registered and accepted invitation {invitation.get('id')} " + f"for mandate {mandateId}" + ) + + return RegisterAndAcceptResponse( + message="Account created and invitation accepted successfully", + userId=userId, + mandateId=mandateId, + userMandateId=userMandateId, + featureAccessId=featureAccessId, + roleIds=roleIds + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error in register-and-accept: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to complete registration: {str(e)}" + ) + + +# ============================================================================= +# Helper Functions +# ============================================================================= + +def _hasMandateAdminRole(context: RequestContext) -> bool: + """ + Check if the user has mandate admin role in the current context. + """ + if context.isSysAdmin: + return True + + if not context.roleIds: + return False + + try: + rootInterface = getRootInterface() + from modules.datamodels.datamodelRbac import Role + + for roleId in context.roleIds: + roleRecords = rootInterface.db.getRecordset(Role, recordFilter={"id": roleId}) + if roleRecords: + role = roleRecords[0] + roleLabel = role.get("roleLabel", "") + # Admin role at mandate level + if roleLabel == "admin" and role.get("mandateId") and not role.get("featureInstanceId"): + return True + + return False + + except Exception as e: + logger.error(f"Error checking mandate admin role: {e}") + return False + + +def _isInstanceRole(interface, roleId: str, featureInstanceId: str) -> bool: + """ + Check if a role belongs to a specific feature instance. + """ + try: + from modules.datamodels.datamodelRbac import Role + roleRecords = interface.db.getRecordset(Role, recordFilter={"id": roleId}) + if roleRecords: + role = roleRecords[0] + return str(role.get("featureInstanceId", "")) == str(featureInstanceId) + return False + except Exception: + return False diff --git a/modules/routes/routeMessaging.py b/modules/routes/routeMessaging.py index 9222a4bd..dae6e04e 100644 --- a/modules/routes/routeMessaging.py +++ b/modules/routes/routeMessaging.py @@ -7,7 +7,8 @@ import logging import json # Import auth module -from modules.auth import limiter, getCurrentUser +from modules.auth import limiter, getCurrentUser, getRequestContext, RequestContext +from modules.datamodels.datamodelRbac import Role # Import interfaces import modules.interfaces.interfaceDbComponentObjects as interfaceDbComponentObjects @@ -379,16 +380,23 @@ async def triggerSubscription( request: Request, subscriptionId: str = Path(..., description="ID of the subscription to trigger"), eventParameters: Dict[str, Any] = Body(...), - currentUser: User = Depends(getCurrentUser) + context: RequestContext = Depends(getRequestContext) ) -> MessagingSubscriptionExecutionResult: - """Trigger a subscription with event parameters""" - # RBAC-Check: Nur Admin/Mandate-Admin kann triggern - # TODO: Add proper RBAC check here + """ + Trigger a subscription with event parameters. + + Requires Mandate-Admin role or SysAdmin. + """ + # RBAC-Check: Admin or Mandate-Admin can trigger + if not _hasTriggerPermission(context): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Admin or Mandate-Admin role required to trigger subscriptions" + ) # Get messaging service from request app state - # We need to access services through the request from modules.services import getInterface as getServicesInterface - services = getServicesInterface(currentUser, None) + services = getServicesInterface(context.user, None, mandateId=str(context.mandateId)) # Konvertiere Dict zu Pydantic Model eventParams = MessagingEventParameters(triggerData=eventParameters) @@ -397,6 +405,37 @@ async def triggerSubscription( return executionResult +def _hasTriggerPermission(context: RequestContext) -> bool: + """ + Check if user has permission to trigger subscriptions. + Requires admin or mandate-admin role. + """ + if context.isSysAdmin: + return True + + if not context.roleIds: + return False + + try: + from modules.interfaces.interfaceDbAppObjects import getRootInterface + rootInterface = getRootInterface() + + for roleId in context.roleIds: + roleRecords = rootInterface.db.getRecordset(Role, recordFilter={"id": roleId}) + if roleRecords: + role = roleRecords[0] + roleLabel = role.get("roleLabel", "") + # Admin role at mandate level or system admin + if roleLabel in ("admin", "sysadmin"): + return True + + return False + + except Exception as e: + logger.error(f"Error checking trigger permission: {e}") + return False + + # Delivery Endpoints @router.get("/deliveries", response_model=PaginatedResponse[MessagingDelivery]) diff --git a/modules/routes/routeRbac.py b/modules/routes/routeRbac.py index 3c940b72..d2e92c82 100644 --- a/modules/routes/routeRbac.py +++ b/modules/routes/routeRbac.py @@ -3,6 +3,11 @@ """ RBAC routes for the backend API. Implements endpoints for role-based access control permissions. + +MULTI-TENANT: +- Permission queries use RequestContext (mandateId from header) +- AccessRule management is SysAdmin-only (system resources) +- Role management is SysAdmin-only (system resources) """ from fastapi import APIRouter, HTTPException, Depends, Query, Body, Path, Request @@ -11,11 +16,11 @@ import logging import json import math -from modules.auth import getCurrentUser, limiter +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.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict -from modules.interfaces.interfaceDbAppObjects import getInterface +from modules.interfaces.interfaceDbAppObjects import getInterface, getRootInterface # Configure logger logger = logging.getLogger(__name__) @@ -33,10 +38,11 @@ async def getPermissions( request: Request, context: str = Query(..., description="Context type: DATA, UI, or RESOURCE"), item: Optional[str] = Query(None, description="Item identifier (table name, UI path, or resource path)"), - currentUser: User = Depends(getCurrentUser) + reqContext: RequestContext = Depends(getRequestContext) ) -> UserPermissions: """ Get RBAC permissions for the current user for a specific context and item. + MULTI-TENANT: Uses RequestContext (mandateId/featureInstanceId from headers). Query Parameters: - context: Context type (DATA, UI, or RESOURCE) @@ -63,16 +69,17 @@ async def getPermissions( ) # Get interface and RBAC permissions - interface = getInterface(currentUser) + interface = getInterface(reqContext.user) if not interface.rbac: raise HTTPException( status_code=500, detail="RBAC interface not available" ) - # Get permissions + # MULTI-TENANT: Get permissions using context (mandateId/featureInstanceId) + # For now, pass user - RBAC will be extended to use context in later phases permissions = interface.rbac.getUserPermissions( - currentUser, + reqContext.user, accessContext, item or "" ) @@ -94,10 +101,11 @@ async def getPermissions( async def getAllPermissions( request: Request, context: Optional[str] = Query(None, description="Context type: UI or RESOURCE (if not provided, returns both)"), - currentUser: User = Depends(getCurrentUser) + reqContext: RequestContext = Depends(getRequestContext) ) -> Dict[str, Any]: """ Get all RBAC permissions for the current user for UI and/or RESOURCE contexts. + MULTI-TENANT: Uses RequestContext (mandateId/featureInstanceId from headers). This endpoint is optimized for UI initialization to avoid multiple API calls. Query Parameters: @@ -128,7 +136,7 @@ async def getAllPermissions( """ try: # Get interface and RBAC permissions - interface = getInterface(currentUser) + interface = getInterface(reqContext.user) if not interface.rbac: raise HTTPException( status_code=500, @@ -158,9 +166,9 @@ async def getAllPermissions( result: Dict[str, Any] = {} - # Get all access rules for user's roles - roleLabels = currentUser.roleLabels or [] - if not roleLabels: + # MULTI-TENANT: Get role IDs from context (computed from mandateId/featureInstanceId) + roleIds = reqContext.roleIds or [] + if not roleIds and not reqContext.isSysAdmin: # User has no roles, return empty permissions for ctx in contextsToFetch: result[ctx.value.lower()] = {} @@ -171,9 +179,9 @@ async def getAllPermissions( for ctx in contextsToFetch: allRules[ctx] = [] # Get all rules for user's roles in this context - for roleLabel in roleLabels: + for roleId in roleIds: rules = interface.getAccessRules( - roleLabel=roleLabel, + roleId=str(roleId), context=ctx, pagination=None ) @@ -191,7 +199,7 @@ async def getAllPermissions( # For each item, calculate user permissions for item in sorted(items): - permissions = interface.rbac.getUserPermissions(currentUser, ctx, item) + permissions = interface.rbac.getUserPermissions(reqContext.user, ctx, item) # Only include if user has view permission if permissions.view: result[ctx.value.lower()][item] = { @@ -222,11 +230,11 @@ async def getAccessRules( context: Optional[str] = Query(None, description="Filter by context (DATA, UI, RESOURCE)"), item: Optional[str] = Query(None, description="Filter by item identifier"), pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"), - currentUser: User = Depends(getCurrentUser) + reqContext: RequestContext = Depends(requireSysAdmin) ) -> PaginatedResponse: """ Get access rules with optional filters. - Only returns rules that the current user has permission to view. + MULTI-TENANT: SysAdmin-only (AccessRules are system resources). Query Parameters: - roleLabel: Optional role label filter @@ -237,29 +245,8 @@ async def getAccessRules( - List of AccessRule objects """ try: - # Get interface - interface = getInterface(currentUser) - - # Check if user has permission to view access rules - # For now, only sysadmin can view rules - if not interface.rbac: - raise HTTPException( - status_code=500, - detail="RBAC interface not available" - ) - - # Check permission - only sysadmin can view rules - permissions = interface.rbac.getUserPermissions( - currentUser, - AccessRuleContext.DATA, - "AccessRule" - ) - - if not permissions.view or permissions.read == AccessLevel.NONE: - raise HTTPException( - status_code=403, - detail="No permission to view access rules" - ) + # Get interface - SysAdmin uses root interface + interface = getRootInterface() # Parse context if provided accessContext = None @@ -329,11 +316,11 @@ async def getAccessRules( async def getAccessRule( request: Request, ruleId: str = Path(..., description="Access rule ID"), - currentUser: User = Depends(getCurrentUser) + reqContext: RequestContext = Depends(requireSysAdmin) ) -> dict: """ Get a specific access rule by ID. - Only returns rule if the current user has permission to view it. + MULTI-TENANT: SysAdmin-only. Path Parameters: - ruleId: Access rule ID @@ -342,28 +329,8 @@ async def getAccessRule( - AccessRule object """ try: - # Get interface - interface = getInterface(currentUser) - - # Check if user has permission to view access rules - if not interface.rbac: - raise HTTPException( - status_code=500, - detail="RBAC interface not available" - ) - - # Check permission - only sysadmin can view rules - permissions = interface.rbac.getUserPermissions( - currentUser, - AccessRuleContext.DATA, - "AccessRule" - ) - - if not permissions.view or permissions.read == AccessLevel.NONE: - raise HTTPException( - status_code=403, - detail="No permission to view access rules" - ) + # Get interface - SysAdmin uses root interface + interface = getRootInterface() # Get rule rule = interface.getAccessRule(ruleId) @@ -391,11 +358,11 @@ async def getAccessRule( async def createAccessRule( request: Request, accessRuleData: dict = Body(..., description="Access rule data"), - currentUser: User = Depends(getCurrentUser) + reqContext: RequestContext = Depends(requireSysAdmin) ) -> dict: """ Create a new access rule. - Only sysadmin can create access rules. + MULTI-TENANT: SysAdmin-only. Request Body: - AccessRule object data (roleLabel, context, item, view, read, create, update, delete) @@ -404,28 +371,8 @@ async def createAccessRule( - Created AccessRule object """ try: - # Get interface - interface = getInterface(currentUser) - - # Check if user has permission to create access rules - if not interface.rbac: - raise HTTPException( - status_code=500, - detail="RBAC interface not available" - ) - - # Check permission - only sysadmin can create rules - permissions = interface.rbac.getUserPermissions( - currentUser, - AccessRuleContext.DATA, - "AccessRule" - ) - - if not permissions.create or permissions.create == AccessLevel.NONE: - raise HTTPException( - status_code=403, - detail="No permission to create access rules" - ) + # Get interface - SysAdmin uses root interface + interface = getRootInterface() # Validate and parse access rule data try: @@ -457,7 +404,7 @@ async def createAccessRule( # Create rule createdRule = interface.createAccessRule(accessRule) - logger.info(f"Created access rule {createdRule.id} by user {currentUser.id}") + logger.info(f"Created access rule {createdRule.id} by SysAdmin {reqContext.user.id}") # Convert to dict for JSON serialization return createdRule.model_dump() @@ -478,11 +425,11 @@ async def updateAccessRule( request: Request, ruleId: str = Path(..., description="Access rule ID"), accessRuleData: dict = Body(..., description="Updated access rule data"), - currentUser: User = Depends(getCurrentUser) + reqContext: RequestContext = Depends(requireSysAdmin) ) -> dict: """ Update an existing access rule. - Only sysadmin can update access rules. + MULTI-TENANT: SysAdmin-only. Path Parameters: - ruleId: Access rule ID @@ -494,28 +441,8 @@ async def updateAccessRule( - Updated AccessRule object """ try: - # Get interface - interface = getInterface(currentUser) - - # Check if user has permission to update access rules - if not interface.rbac: - raise HTTPException( - status_code=500, - detail="RBAC interface not available" - ) - - # Check permission - only sysadmin can update rules - permissions = interface.rbac.getUserPermissions( - currentUser, - AccessRuleContext.DATA, - "AccessRule" - ) - - if not permissions.update or permissions.update == AccessLevel.NONE: - raise HTTPException( - status_code=403, - detail="No permission to update access rules" - ) + # Get interface - SysAdmin uses root interface + interface = getRootInterface() # Get existing rule to ensure it exists existingRule = interface.getAccessRule(ruleId) @@ -560,7 +487,7 @@ async def updateAccessRule( # Update rule updatedRule = interface.updateAccessRule(ruleId, accessRule) - logger.info(f"Updated access rule {ruleId} by user {currentUser.id}") + logger.info(f"Updated access rule {ruleId} by SysAdmin {reqContext.user.id}") # Convert to dict for JSON serialization return updatedRule.model_dump() @@ -580,11 +507,11 @@ async def updateAccessRule( async def deleteAccessRule( request: Request, ruleId: str = Path(..., description="Access rule ID"), - currentUser: User = Depends(getCurrentUser) + reqContext: RequestContext = Depends(requireSysAdmin) ) -> dict: """ Delete an access rule. - Only sysadmin can delete access rules. + MULTI-TENANT: SysAdmin-only. Path Parameters: - ruleId: Access rule ID @@ -593,28 +520,8 @@ async def deleteAccessRule( - Success message """ try: - # Get interface - interface = getInterface(currentUser) - - # Check if user has permission to delete access rules - if not interface.rbac: - raise HTTPException( - status_code=500, - detail="RBAC interface not available" - ) - - # Check permission - only sysadmin can delete rules - permissions = interface.rbac.getUserPermissions( - currentUser, - AccessRuleContext.DATA, - "AccessRule" - ) - - if not permissions.delete or permissions.delete == AccessLevel.NONE: - raise HTTPException( - status_code=403, - detail="No permission to delete access rules" - ) + # Get interface - SysAdmin uses root interface + interface = getRootInterface() # Get existing rule to ensure it exists existingRule = interface.getAccessRule(ruleId) @@ -633,7 +540,7 @@ async def deleteAccessRule( detail=f"Failed to delete access rule {ruleId}" ) - logger.info(f"Deleted access rule {ruleId} by user {currentUser.id}") + logger.info(f"Deleted access rule {ruleId} by SysAdmin {reqContext.user.id}") return {"success": True, "message": f"Access rule {ruleId} deleted successfully"} @@ -649,38 +556,26 @@ async def deleteAccessRule( # ============================================================================ # Role Management Endpoints +# MULTI-TENANT: All role management is SysAdmin-only (roles are system resources) # ============================================================================ -def _ensureAdminAccess(currentUser: User) -> None: - """Ensure current user has admin access to RBAC roles management.""" - interface = getInterface(currentUser) - - # Check if user has admin or sysadmin role - roleLabels = currentUser.roleLabels or [] - if "sysadmin" not in roleLabels and "admin" not in roleLabels: - raise HTTPException( - status_code=403, - detail="Admin or sysadmin role required to manage RBAC roles" - ) - @router.get("/roles", response_model=PaginatedResponse) @limiter.limit("60/minute") async def listRoles( request: Request, pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"), - currentUser: User = Depends(getCurrentUser) + reqContext: RequestContext = Depends(requireSysAdmin) ) -> PaginatedResponse: """ Get list of all available roles with metadata. + MULTI-TENANT: SysAdmin-only (roles are system resources). Returns: - List of role dictionaries with role label, description, and user count """ try: - _ensureAdminAccess(currentUser) - - interface = getInterface(currentUser) + interface = getRootInterface() # Parse pagination parameter paginationParams = None @@ -696,21 +591,11 @@ async def listRoles( detail=f"Invalid pagination parameter: {str(e)}" ) - # Get all roles from database (without pagination) to enrich with user counts and add custom roles - # Note: We get all roles first because we need to add custom roles before pagination + # Get all roles from database dbRoles = interface.getAllRoles(pagination=None) - # Get all users to count role assignments - # Since _ensureAdminAccess ensures user is sysadmin or admin, - # and getUsersByMandate returns all users for sysadmin regardless of mandateId, - # we can pass the current user's mandateId (for sysadmin it will be ignored by RBAC) - allUsers = interface.getUsersByMandate(currentUser.mandateId or "", pagination=None) - - # Count users per role - roleCounts: Dict[str, int] = {} - for user in allUsers: - for roleLabel in (user.roleLabels or []): - roleCounts[roleLabel] = roleCounts.get(roleLabel, 0) + 1 + # Count role assignments from UserMandateRole table + roleCounts = interface.countRoleAssignments() # Convert Role objects to dictionaries and add user counts result = [] @@ -719,22 +604,10 @@ async def listRoles( "id": role.id, "roleLabel": role.roleLabel, "description": role.description, - "userCount": roleCounts.get(role.roleLabel, 0), + "userCount": roleCounts.get(str(role.id), 0), "isSystemRole": role.isSystemRole }) - # Add any roles found in user assignments that don't exist in database - dbRoleLabels = {role.roleLabel for role in dbRoles} - for roleLabel, count in roleCounts.items(): - if roleLabel not in dbRoleLabels: - result.append({ - "id": None, - "roleLabel": roleLabel, - "description": {"en": f"Custom role: {roleLabel}", "fr": f"Rôle personnalisé : {roleLabel}"}, - "userCount": count, - "isSystemRole": False - }) - # Apply filtering and sorting if pagination requested if paginationParams: # Apply filtering (if filters provided) @@ -789,19 +662,17 @@ async def listRoles( @limiter.limit("60/minute") async def getRoleOptions( request: Request, - currentUser: User = Depends(getCurrentUser) + reqContext: RequestContext = Depends(requireSysAdmin) ) -> List[Dict[str, Any]]: """ Get role options for select dropdowns. - Returns roles in format suitable for frontend select components. + MULTI-TENANT: SysAdmin-only. Returns: - List of role option dictionaries with value and label """ try: - _ensureAdminAccess(currentUser) - - interface = getInterface(currentUser) + interface = getRootInterface() # Get all roles from database dbRoles = interface.getAllRoles() @@ -833,10 +704,11 @@ async def getRoleOptions( async def createRole( request: Request, role: Role = Body(...), - currentUser: User = Depends(getCurrentUser) + reqContext: RequestContext = Depends(requireSysAdmin) ) -> Dict[str, Any]: """ Create a new role. + MULTI-TENANT: SysAdmin-only. Request Body: - role: Role object to create @@ -845,12 +717,12 @@ async def createRole( - Created role dictionary """ try: - _ensureAdminAccess(currentUser) - - interface = getInterface(currentUser) + interface = getRootInterface() createdRole = interface.createRole(role) + logger.info(f"Created role {createdRole.roleLabel} by SysAdmin {reqContext.user.id}") + return { "id": createdRole.id, "roleLabel": createdRole.roleLabel, @@ -878,10 +750,11 @@ async def createRole( async def getRole( request: Request, roleId: str = Path(..., description="Role ID"), - currentUser: User = Depends(getCurrentUser) + reqContext: RequestContext = Depends(requireSysAdmin) ) -> Dict[str, Any]: """ Get a role by ID. + MULTI-TENANT: SysAdmin-only. Path Parameters: - roleId: Role ID @@ -890,9 +763,7 @@ async def getRole( - Role dictionary """ try: - _ensureAdminAccess(currentUser) - - interface = getInterface(currentUser) + interface = getRootInterface() role = interface.getRole(roleId) if not role: @@ -924,10 +795,11 @@ async def updateRole( request: Request, roleId: str = Path(..., description="Role ID"), role: Role = Body(...), - currentUser: User = Depends(getCurrentUser) + reqContext: RequestContext = Depends(requireSysAdmin) ) -> Dict[str, Any]: """ Update an existing role. + MULTI-TENANT: SysAdmin-only. Path Parameters: - roleId: Role ID @@ -939,12 +811,12 @@ async def updateRole( - Updated role dictionary """ try: - _ensureAdminAccess(currentUser) - - interface = getInterface(currentUser) + interface = getRootInterface() updatedRole = interface.updateRole(roleId, role) + logger.info(f"Updated role {roleId} by SysAdmin {reqContext.user.id}") + return { "id": updatedRole.id, "roleLabel": updatedRole.roleLabel, @@ -972,10 +844,11 @@ async def updateRole( async def deleteRole( request: Request, roleId: str = Path(..., description="Role ID"), - currentUser: User = Depends(getCurrentUser) + reqContext: RequestContext = Depends(requireSysAdmin) ) -> Dict[str, str]: """ Delete a role. + MULTI-TENANT: SysAdmin-only. Path Parameters: - roleId: Role ID @@ -984,9 +857,7 @@ async def deleteRole( - Success message """ try: - _ensureAdminAccess(currentUser) - - interface = getInterface(currentUser) + interface = getRootInterface() success = interface.deleteRole(roleId) if not success: @@ -995,6 +866,8 @@ async def deleteRole( detail=f"Role {roleId} not found" ) + logger.info(f"Deleted role {roleId} by SysAdmin {reqContext.user.id}") + return {"message": f"Role {roleId} deleted successfully"} except HTTPException: diff --git a/modules/routes/routeRbacExport.py b/modules/routes/routeRbacExport.py new file mode 100644 index 00000000..e7cc7204 --- /dev/null +++ b/modules/routes/routeRbacExport.py @@ -0,0 +1,608 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +""" +RBAC export/import routes for the backend API. +Implements endpoints for exporting and importing RBAC configurations. + +Multi-Tenant Design: +- Global templates: SysAdmin can export/import +- Mandate-scoped RBAC: Mandate Admin can export/import +- Feature instance roles: Included in mandate export +""" + +from fastapi import APIRouter, HTTPException, Depends, Request, UploadFile, File +from fastapi.responses import JSONResponse +from typing import List, Dict, Any, Optional +from fastapi import status +import logging +import json +from pydantic import BaseModel, Field + +from modules.auth import limiter, getRequestContext, RequestContext, requireSysAdmin +from modules.datamodels.datamodelUam import User +from modules.datamodels.datamodelRbac import Role, AccessRule +from modules.interfaces.interfaceDbAppObjects import getRootInterface +from modules.shared.timeUtils import getUtcTimestamp + +logger = logging.getLogger(__name__) + +router = APIRouter( + prefix="/api/rbac", + tags=["RBAC Export/Import"], + responses={404: {"description": "Not found"}} +) + + +# ============================================================================= +# Request/Response Models +# ============================================================================= + +class RoleExport(BaseModel): + """Export model for a role with its access rules""" + roleLabel: str + description: Dict[str, str] + featureCode: Optional[str] + isSystemRole: bool + accessRules: List[Dict[str, Any]] + + +class RbacExportData(BaseModel): + """Complete RBAC export data""" + exportVersion: str = "1.0" + exportedAt: float + exportedBy: str + scope: str # "global" or "mandate" + mandateId: Optional[str] + roles: List[RoleExport] + + +class RbacImportResult(BaseModel): + """Result of RBAC import operation""" + rolesCreated: int + rolesUpdated: int + rolesSkipped: int + rulesCreated: int + rulesUpdated: int + errors: List[str] + + +# ============================================================================= +# Global RBAC Export/Import (SysAdmin only) +# ============================================================================= + +@router.get("/export/global", response_model=RbacExportData) +@limiter.limit("10/minute") +async def exportGlobalRbac( + request: Request, + sysAdmin: User = Depends(requireSysAdmin) +) -> RbacExportData: + """ + Export global (template) RBAC rules. + + SysAdmin only - exports template roles that are copied to new feature instances. + These are roles with mandateId=NULL. + """ + try: + rootInterface = getRootInterface() + + # Get all global template roles (mandateId is NULL) + allRoles = rootInterface.db.getRecordset(Role) + globalRoles = [r for r in allRoles if r.get("mandateId") is None] + + exportRoles = [] + for role in globalRoles: + roleId = role.get("id") + + # Get access rules for this role + accessRules = rootInterface.db.getRecordset( + AccessRule, + recordFilter={"roleId": roleId} + ) + + exportRoles.append(RoleExport( + roleLabel=role.get("roleLabel"), + description=role.get("description", {}), + featureCode=role.get("featureCode"), + isSystemRole=role.get("isSystemRole", False), + accessRules=[ + { + "context": r.get("context"), + "item": r.get("item"), + "view": r.get("view", False), + "read": r.get("read"), + "create": r.get("create"), + "update": r.get("update"), + "delete": r.get("delete") + } + for r in accessRules + ] + )) + + logger.info(f"SysAdmin {sysAdmin.id} exported global RBAC ({len(exportRoles)} roles)") + + return RbacExportData( + exportedAt=getUtcTimestamp(), + exportedBy=str(sysAdmin.id), + scope="global", + mandateId=None, + roles=exportRoles + ) + + except Exception as e: + logger.error(f"Error exporting global RBAC: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to export RBAC: {str(e)}" + ) + + +@router.post("/import/global", response_model=RbacImportResult) +@limiter.limit("5/minute") +async def importGlobalRbac( + request: Request, + file: UploadFile = File(..., description="JSON file with RBAC export data"), + updateExisting: bool = False, + sysAdmin: User = Depends(requireSysAdmin) +) -> RbacImportResult: + """ + Import global (template) RBAC rules. + + SysAdmin only - imports template roles and their access rules. + + Args: + file: JSON file containing RbacExportData + updateExisting: If True, update existing roles. If False, skip them. + """ + try: + # Read and parse file + content = await file.read() + try: + data = json.loads(content.decode("utf-8")) + except json.JSONDecodeError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid JSON: {str(e)}" + ) + + # Validate structure + if "roles" not in data: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Missing 'roles' field in import data" + ) + + rootInterface = getRootInterface() + result = RbacImportResult( + rolesCreated=0, + rolesUpdated=0, + rolesSkipped=0, + rulesCreated=0, + rulesUpdated=0, + errors=[] + ) + + for roleData in data.get("roles", []): + try: + roleLabel = roleData.get("roleLabel") + featureCode = roleData.get("featureCode") + + if not roleLabel: + result.errors.append(f"Role without label skipped") + result.rolesSkipped += 1 + continue + + # Check if role exists (global role with same label and featureCode) + existingRoles = rootInterface.db.getRecordset( + Role, + recordFilter={ + "roleLabel": roleLabel, + "mandateId": None, + "featureCode": featureCode + } + ) + + if existingRoles: + if updateExisting: + # Update existing role + existingRole = existingRoles[0] + roleId = existingRole.get("id") + + rootInterface.db.recordUpdate( + Role, + roleId, + { + "description": roleData.get("description", {}), + "isSystemRole": roleData.get("isSystemRole", False) + } + ) + + # Update access rules + result.rulesUpdated += _updateAccessRules( + rootInterface, + roleId, + roleData.get("accessRules", []) + ) + + result.rolesUpdated += 1 + else: + result.rolesSkipped += 1 + continue + else: + # Create new role + newRole = Role( + roleLabel=roleLabel, + description=roleData.get("description", {}), + featureCode=featureCode, + mandateId=None, + featureInstanceId=None, + isSystemRole=roleData.get("isSystemRole", False) + ) + + createdRole = rootInterface.db.recordCreate(Role, newRole.model_dump()) + roleId = createdRole.get("id") + + # Create access rules + for ruleData in roleData.get("accessRules", []): + newRule = AccessRule( + roleId=roleId, + context=ruleData.get("context"), + item=ruleData.get("item"), + view=ruleData.get("view", False), + read=ruleData.get("read"), + create=ruleData.get("create"), + update=ruleData.get("update"), + delete=ruleData.get("delete") + ) + rootInterface.db.recordCreate(AccessRule, newRule.model_dump()) + result.rulesCreated += 1 + + result.rolesCreated += 1 + + except Exception as e: + result.errors.append(f"Error processing role '{roleData.get('roleLabel', 'unknown')}': {str(e)}") + + logger.info( + f"SysAdmin {sysAdmin.id} imported global RBAC: " + f"{result.rolesCreated} created, {result.rolesUpdated} updated, " + f"{result.rolesSkipped} skipped" + ) + + return result + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error importing global RBAC: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to import RBAC: {str(e)}" + ) + + +# ============================================================================= +# Mandate RBAC Export/Import (Mandate Admin) +# ============================================================================= + +@router.get("/export/mandate", response_model=RbacExportData) +@limiter.limit("10/minute") +async def exportMandateRbac( + request: Request, + includeFeatureInstances: bool = True, + context: RequestContext = Depends(getRequestContext) +) -> RbacExportData: + """ + Export RBAC rules for the current mandate. + + Requires Mandate-Admin role. Exports mandate-level roles and optionally + feature instance roles. + + Args: + includeFeatureInstances: Include feature instance roles in export + """ + if not context.mandateId: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="X-Mandate-Id header is required" + ) + + # Check mandate admin permission + if not _hasMandateAdminRole(context): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Mandate-Admin role required to export RBAC" + ) + + try: + rootInterface = getRootInterface() + + # Get mandate-level roles + allRoles = rootInterface.db.getRecordset(Role) + mandateRoles = [ + r for r in allRoles + if str(r.get("mandateId")) == str(context.mandateId) + ] + + # Filter by feature instance if not including them + if not includeFeatureInstances: + mandateRoles = [r for r in mandateRoles if not r.get("featureInstanceId")] + + exportRoles = [] + for role in mandateRoles: + roleId = role.get("id") + + # Get access rules for this role + accessRules = rootInterface.db.getRecordset( + AccessRule, + recordFilter={"roleId": roleId} + ) + + exportRoles.append(RoleExport( + roleLabel=role.get("roleLabel"), + description=role.get("description", {}), + featureCode=role.get("featureCode"), + isSystemRole=role.get("isSystemRole", False), + accessRules=[ + { + "context": r.get("context"), + "item": r.get("item"), + "view": r.get("view", False), + "read": r.get("read"), + "create": r.get("create"), + "update": r.get("update"), + "delete": r.get("delete") + } + for r in accessRules + ] + )) + + logger.info( + f"User {context.user.id} exported mandate {context.mandateId} RBAC " + f"({len(exportRoles)} roles)" + ) + + return RbacExportData( + exportedAt=getUtcTimestamp(), + exportedBy=str(context.user.id), + scope="mandate", + mandateId=str(context.mandateId), + roles=exportRoles + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error exporting mandate RBAC: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to export RBAC: {str(e)}" + ) + + +@router.post("/import/mandate", response_model=RbacImportResult) +@limiter.limit("5/minute") +async def importMandateRbac( + request: Request, + file: UploadFile = File(..., description="JSON file with RBAC export data"), + updateExisting: bool = False, + context: RequestContext = Depends(getRequestContext) +) -> RbacImportResult: + """ + Import RBAC rules for the current mandate. + + Requires Mandate-Admin role. Imports roles as mandate-level roles + (not feature instance roles - those are created via template copying). + + Args: + file: JSON file containing RbacExportData + updateExisting: If True, update existing roles. If False, skip them. + """ + if not context.mandateId: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="X-Mandate-Id header is required" + ) + + # Check mandate admin permission + if not _hasMandateAdminRole(context): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Mandate-Admin role required to import RBAC" + ) + + try: + # Read and parse file + content = await file.read() + try: + data = json.loads(content.decode("utf-8")) + except json.JSONDecodeError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid JSON: {str(e)}" + ) + + # Validate structure + if "roles" not in data: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Missing 'roles' field in import data" + ) + + rootInterface = getRootInterface() + result = RbacImportResult( + rolesCreated=0, + rolesUpdated=0, + rolesSkipped=0, + rulesCreated=0, + rulesUpdated=0, + errors=[] + ) + + for roleData in data.get("roles", []): + try: + roleLabel = roleData.get("roleLabel") + featureCode = roleData.get("featureCode") + + if not roleLabel: + result.errors.append(f"Role without label skipped") + result.rolesSkipped += 1 + continue + + # System roles cannot be imported at mandate level + if roleData.get("isSystemRole", False): + result.errors.append(f"System role '{roleLabel}' skipped (SysAdmin only)") + result.rolesSkipped += 1 + continue + + # Check if role exists (mandate role with same label) + existingRoles = rootInterface.db.getRecordset( + Role, + recordFilter={ + "roleLabel": roleLabel, + "mandateId": str(context.mandateId), + "featureInstanceId": None # Only mandate-level roles + } + ) + + if existingRoles: + if updateExisting: + # Update existing role + existingRole = existingRoles[0] + roleId = existingRole.get("id") + + rootInterface.db.recordUpdate( + Role, + roleId, + {"description": roleData.get("description", {})} + ) + + # Update access rules + result.rulesUpdated += _updateAccessRules( + rootInterface, + roleId, + roleData.get("accessRules", []) + ) + + result.rolesUpdated += 1 + else: + result.rolesSkipped += 1 + continue + else: + # Create new role at mandate level + newRole = Role( + roleLabel=roleLabel, + description=roleData.get("description", {}), + featureCode=featureCode, + mandateId=str(context.mandateId), + featureInstanceId=None, + isSystemRole=False # Never create system roles via import + ) + + createdRole = rootInterface.db.recordCreate(Role, newRole.model_dump()) + roleId = createdRole.get("id") + + # Create access rules + for ruleData in roleData.get("accessRules", []): + newRule = AccessRule( + roleId=roleId, + context=ruleData.get("context"), + item=ruleData.get("item"), + view=ruleData.get("view", False), + read=ruleData.get("read"), + create=ruleData.get("create"), + update=ruleData.get("update"), + delete=ruleData.get("delete") + ) + rootInterface.db.recordCreate(AccessRule, newRule.model_dump()) + result.rulesCreated += 1 + + result.rolesCreated += 1 + + except Exception as e: + result.errors.append(f"Error processing role '{roleData.get('roleLabel', 'unknown')}': {str(e)}") + + logger.info( + f"User {context.user.id} imported mandate {context.mandateId} RBAC: " + f"{result.rolesCreated} created, {result.rolesUpdated} updated, " + f"{result.rolesSkipped} skipped" + ) + + return result + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error importing mandate RBAC: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to import RBAC: {str(e)}" + ) + + +# ============================================================================= +# Helper Functions +# ============================================================================= + +def _hasMandateAdminRole(context: RequestContext) -> bool: + """ + Check if the user has mandate admin role in the current context. + """ + if context.isSysAdmin: + return True + + if not context.roleIds: + return False + + try: + rootInterface = getRootInterface() + + for roleId in context.roleIds: + roleRecords = rootInterface.db.getRecordset(Role, recordFilter={"id": roleId}) + if roleRecords: + role = roleRecords[0] + roleLabel = role.get("roleLabel", "") + # Admin role at mandate level + if roleLabel == "admin" and role.get("mandateId") and not role.get("featureInstanceId"): + return True + + return False + + except Exception as e: + logger.error(f"Error checking mandate admin role: {e}") + return False + + +def _updateAccessRules(interface, roleId: str, newRules: List[Dict[str, Any]]) -> int: + """ + Update access rules for a role. + Replaces existing rules with new ones. + + Returns: + Number of rules created/updated + """ + try: + # Delete existing rules for this role + existingRules = interface.db.getRecordset(AccessRule, recordFilter={"roleId": roleId}) + for rule in existingRules: + interface.db.recordDelete(AccessRule, rule.get("id")) + + # Create new rules + count = 0 + for ruleData in newRules: + newRule = AccessRule( + roleId=roleId, + context=ruleData.get("context"), + item=ruleData.get("item"), + view=ruleData.get("view", False), + read=ruleData.get("read"), + create=ruleData.get("create"), + update=ruleData.get("update"), + delete=ruleData.get("delete") + ) + interface.db.recordCreate(AccessRule, newRule.model_dump()) + count += 1 + + return count + + except Exception as e: + logger.error(f"Error updating access rules: {e}") + return 0 diff --git a/modules/routes/routeSecurityAdmin.py b/modules/routes/routeSecurityAdmin.py index 19aef5fc..3ba35e65 100644 --- a/modules/routes/routeSecurityAdmin.py +++ b/modules/routes/routeSecurityAdmin.py @@ -1,13 +1,19 @@ # Copyright (c) 2025 Patrick Motsch # All rights reserved. +""" +Security Administration routes. +MULTI-TENANT: These are SYSTEM-LEVEL operations requiring isSysAdmin=true. +No mandate context - SysAdmin manages infrastructure, not data. +""" from fastapi import APIRouter, HTTPException, Depends, status, Request, Body from fastapi.responses import FileResponse, JSONResponse from typing import Optional, Dict, Any, List import os import logging -from modules.auth import getCurrentUser, limiter -from modules.interfaces.interfaceDbAppObjects import getInterface, getRootInterface +from modules.auth import getCurrentUser, limiter, requireSysAdmin +from modules.connectors.connectorDbPostgre import DatabaseConnector +from modules.interfaces.interfaceDbAppObjects import getRootInterface from modules.datamodels.datamodelUam import User, UserInDB, AuthAuthority from modules.datamodels.datamodelSecurity import Token from modules.shared.configuration import APP_CONFIG @@ -26,13 +32,63 @@ router = APIRouter( } ) -def _ensure_admin_scope(current_user: User, target_mandate_id: Optional[str] = None) -> None: - roleLabels = current_user.roleLabels or [] - if "admin" not in roleLabels and "sysadmin" not in roleLabels: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin privileges required") - if "admin" in roleLabels and "sysadmin" not in roleLabels: - if target_mandate_id and str(target_mandate_id) != str(current_user.mandateId): - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden for target mandate") + +def _getPoweronDatabases() -> List[str]: + """Load databases from PostgreSQL host matching poweron_%.""" + dbHost = APP_CONFIG.get("DB_HOST") + dbUser = APP_CONFIG.get("DB_USER") + dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET") + dbPort = int(APP_CONFIG.get("DB_PORT", 5432)) + + # Connect to 'postgres' system database to query all databases + connector = DatabaseConnector( + dbHost=dbHost, + dbDatabase="postgres", + dbUser=dbUser, + dbPassword=dbPassword, + dbPort=dbPort, + userId=None + ) + + try: + with connector.connection.cursor() as cursor: + cursor.execute( + """ + SELECT datname + FROM pg_database + WHERE datname LIKE 'poweron_%' + AND datistemplate = false + ORDER BY datname + """ + ) + rows = cursor.fetchall() + return [row["datname"] for row in rows if row.get("datname")] + finally: + connector.close() + + +def _getDatabaseConnector(databaseName: str, userId: str = None) -> DatabaseConnector: + """ + Create a generic DatabaseConnector for any poweron_* database. + Fully dynamic - no interface mapping needed. + """ + if not databaseName.startswith("poweron_"): + raise ValueError(f"Invalid database name: {databaseName}") + + dbHost = APP_CONFIG.get("DB_HOST") + dbUser = APP_CONFIG.get("DB_USER") + dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET") + dbPort = int(APP_CONFIG.get("DB_PORT", 5432)) + + connector = DatabaseConnector( + dbHost=dbHost, + dbDatabase=databaseName, + dbUser=dbUser, + dbPassword=dbPassword, + dbPort=dbPort, + userId=userId + ) + return connector # ---------------------- @@ -43,17 +99,19 @@ def _ensure_admin_scope(current_user: User, target_mandate_id: Optional[str] = N @limiter.limit("30/minute") async def list_tokens( request: Request, - currentUser: User = Depends(getCurrentUser), + currentUser: User = Depends(requireSysAdmin), userId: Optional[str] = None, authority: Optional[str] = None, sessionId: Optional[str] = None, statusFilter: Optional[str] = None, connectionId: Optional[str] = None, ) -> List[Dict[str, Any]]: + """ + List all tokens in the system. + MULTI-TENANT: SysAdmin-only, no mandate filter (system-level view). + """ try: appInterface = getRootInterface() - target_mandate = currentUser.mandateId - _ensure_admin_scope(currentUser, target_mandate) recordFilter: Dict[str, Any] = {} if userId: @@ -66,9 +124,7 @@ async def list_tokens( recordFilter["connectionId"] = connectionId if statusFilter: recordFilter["status"] = statusFilter - roleLabels = currentUser.roleLabels or [] - if "admin" in roleLabels and "sysadmin" not in roleLabels: - recordFilter["mandateId"] = str(currentUser.mandateId) + # MULTI-TENANT: SysAdmin sees ALL tokens (no mandate filter) tokens = appInterface.db.getRecordset(Token, recordFilter=recordFilter) return tokens @@ -83,27 +139,26 @@ async def list_tokens( @limiter.limit("30/minute") async def revoke_tokens_by_user( request: Request, - currentUser: User = Depends(getCurrentUser), + currentUser: User = Depends(requireSysAdmin), payload: Dict[str, Any] = Body(...) ) -> Dict[str, Any]: + """ + Revoke all tokens for a user. + MULTI-TENANT: SysAdmin-only, can revoke across all mandates. + """ try: userId = payload.get("userId") authority = payload.get("authority") - reason = payload.get("reason", "admin revoke") + reason = payload.get("reason", "sysadmin revoke") if not userId: raise HTTPException(status_code=400, detail="userId is required") appInterface = getRootInterface() - # Tenant scope check - target_user = appInterface.db.getRecordset(User, recordFilter={"id": userId}) - target_mandate = target_user[0].get("mandateId") if target_user else None - _ensure_admin_scope(currentUser, target_mandate) - - roleLabels = currentUser.roleLabels or [] + # MULTI-TENANT: SysAdmin can revoke any user's tokens (no mandate restriction) count = appInterface.revokeTokensByUser( userId=userId, authority=AuthAuthority(authority) if authority else None, - mandateId=None if "sysadmin" in roleLabels else str(currentUser.mandateId), + mandateId=None, # SysAdmin: no mandate filter revokedBy=currentUser.id, reason=reason ) @@ -119,22 +174,23 @@ async def revoke_tokens_by_user( @limiter.limit("30/minute") async def revoke_tokens_by_session( request: Request, - currentUser: User = Depends(getCurrentUser), + currentUser: User = Depends(requireSysAdmin), payload: Dict[str, Any] = Body(...) ) -> Dict[str, Any]: + """ + Revoke all tokens for a specific session. + MULTI-TENANT: SysAdmin-only. + """ try: userId = payload.get("userId") sessionId = payload.get("sessionId") authority = payload.get("authority", "local") - reason = payload.get("reason", "admin session revoke") + reason = payload.get("reason", "sysadmin session revoke") if not userId or not sessionId: raise HTTPException(status_code=400, detail="userId and sessionId are required") appInterface = getRootInterface() - target_user = appInterface.db.getRecordset(User, recordFilter={"id": userId}) - target_mandate = target_user[0].get("mandateId") if target_user else None - _ensure_admin_scope(currentUser, target_mandate) - + # MULTI-TENANT: SysAdmin can revoke any session (no mandate check) count = appInterface.revokeTokensBySessionId( sessionId=sessionId, userId=userId, @@ -154,22 +210,20 @@ async def revoke_tokens_by_session( @limiter.limit("30/minute") async def revoke_token_by_id( request: Request, - currentUser: User = Depends(getCurrentUser), + currentUser: User = Depends(requireSysAdmin), payload: Dict[str, Any] = Body(...) ) -> Dict[str, Any]: + """ + Revoke a specific token by ID. + MULTI-TENANT: SysAdmin-only. + """ try: tokenId = payload.get("tokenId") - reason = payload.get("reason", "admin revoke") + reason = payload.get("reason", "sysadmin revoke") if not tokenId: raise HTTPException(status_code=400, detail="tokenId is required") appInterface = getRootInterface() - # Load token to check tenant scope for admins - tokens = appInterface.db.getRecordset(Token, recordFilter={"id": tokenId}) - if not tokens: - return {"revoked": 0} - target_mandate = tokens[0].get("mandateId") - _ensure_admin_scope(currentUser, target_mandate) - + # MULTI-TENANT: SysAdmin can revoke any token (no mandate check) ok = appInterface.revokeTokenById(tokenId, revokedBy=currentUser.id, reason=reason) return {"revoked": 1 if ok else 0} except HTTPException: @@ -183,29 +237,34 @@ async def revoke_token_by_id( @limiter.limit("10/minute") async def revoke_tokens_by_mandate( request: Request, - currentUser: User = Depends(getCurrentUser), + currentUser: User = Depends(requireSysAdmin), payload: Dict[str, Any] = Body(...) ) -> Dict[str, Any]: + """ + Revoke all tokens for users in a mandate. + MULTI-TENANT: SysAdmin-only, can revoke tokens for any mandate. + """ try: mandateId = payload.get("mandateId") authority = payload.get("authority", "local") - reason = payload.get("reason", "admin mandate revoke") + reason = payload.get("reason", "sysadmin mandate revoke") if not mandateId: raise HTTPException(status_code=400, detail="mandateId is required") - _ensure_admin_scope(currentUser, mandateId) - - # Revoke for all users in mandate + # MULTI-TENANT: SysAdmin can revoke tokens for any mandate appInterface = getRootInterface() - # IMPORTANT: user rows are stored as UserInDB in the database - users = appInterface.db.getRecordset(UserInDB, recordFilter={"mandateId": mandateId}) + + # Get all UserMandate entries for this mandate to find users + # Note: In new model, users are linked via UserMandate, not User.mandateId + from modules.datamodels.datamodelMembership import UserMandate + userMandates = appInterface.db.getRecordset(UserMandate, recordFilter={"mandateId": mandateId}) + total = 0 - for u in users: - # Revoke regardless of token.mandateId to also catch legacy tokens without mandateId + for um in userMandates: total += appInterface.revokeTokensByUser( - userId=u["id"], - authority=AuthAuthority(authority), - mandateId=None, + userId=um["userId"], + authority=AuthAuthority(authority) if authority else None, + mandateId=None, # Revoke all tokens for user revokedBy=currentUser.id, reason=reason ) @@ -225,10 +284,13 @@ async def revoke_tokens_by_mandate( @limiter.limit("60/minute") async def download_log( request: Request, - currentUser: User = Depends(getCurrentUser), + currentUser: User = Depends(requireSysAdmin), log_name: str = "poweron" ): - _ensure_admin_scope(currentUser) + """ + Download server logs. + MULTI-TENANT: SysAdmin-only (infrastructure management). + """ base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # base_dir -> gateway if log_name == "poweron": @@ -251,33 +313,18 @@ async def download_log( @limiter.limit("10/minute") async def list_databases( request: Request, - currentUser: User = Depends(getCurrentUser) + currentUser: User = Depends(requireSysAdmin) ) -> Dict[str, Any]: - _ensure_admin_scope(currentUser) - - # Get database names from configuration for each interface - databases = [] - - # App database (interfaceDbAppObjects.py) - app_db = APP_CONFIG.get("DB_APP_DATABASE") - if app_db: - databases.append(app_db) - - # Chat database (interfaceDbChatObjects.py) - chat_db = APP_CONFIG.get("DB_CHAT_DATABASE") - if chat_db: - databases.append(chat_db) - - # Management database (interfaceDbComponentObjects.py) - management_db = APP_CONFIG.get("DB_MANAGEMENT_DATABASE") - if management_db: - databases.append(management_db) - - # Fallback to default if no databases configured - if not databases: - databases = ["poweron"] - - return {"databases": databases} + """ + List all poweron_* databases. + MULTI-TENANT: SysAdmin-only (infrastructure management). + """ + try: + databases = _getPoweronDatabases() + return {"databases": databases} + except Exception as e: + logger.error(f"Failed to load databases from host: {e}") + raise HTTPException(status_code=500, detail="Failed to load databases from host") @router.get("/databases/{database_name}/tables") @@ -285,48 +332,28 @@ async def list_databases( async def get_database_tables( request: Request, database_name: str, - currentUser: User = Depends(getCurrentUser) + currentUser: User = Depends(requireSysAdmin) ) -> Dict[str, Any]: - _ensure_admin_scope(currentUser) - - # Get all configured database names - configured_dbs = [] - app_db = APP_CONFIG.get("DB_APP_DATABASE") - if app_db: - configured_dbs.append(app_db) - chat_db = APP_CONFIG.get("DB_CHAT_DATABASE") - if chat_db: - configured_dbs.append(chat_db) - management_db = APP_CONFIG.get("DB_MANAGEMENT_DATABASE") - if management_db: - configured_dbs.append(management_db) - - if not configured_dbs: - configured_dbs = ["poweron"] - - if database_name not in configured_dbs: - raise HTTPException(status_code=400, detail=f"Invalid database name. Available databases: {configured_dbs}") + """ + List tables in a database. + MULTI-TENANT: SysAdmin-only (infrastructure management). + """ + if not database_name.startswith("poweron_"): + raise HTTPException(status_code=400, detail="Invalid database name format") + connector = None try: - # Use the appropriate interface based on database name - if database_name == app_db: - appInterface = getRootInterface() - tables = appInterface.db.getTables() - elif database_name == chat_db: - from modules.interfaces.interfaceDbChatObjects import getInterface as getChatInterface - chatInterface = getChatInterface(currentUser) - tables = chatInterface.db.getTables() - elif database_name == management_db: - from modules.interfaces.interfaceDbComponentObjects import getInterface as getComponentInterface - componentInterface = getComponentInterface(currentUser) - tables = componentInterface.db.getTables() - else: - raise HTTPException(status_code=400, detail="Database not found") - + connector = _getDatabaseConnector(database_name, currentUser.id) + tables = connector.getTables() return {"tables": tables} + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) except Exception as e: logger.error(f"Error getting database tables: {str(e)}") - raise HTTPException(status_code=500, detail="Failed to get database tables") + raise HTTPException(status_code=500, detail=f"Failed to get database tables: {str(e)}") + finally: + if connector: + connector.close() @router.post("/databases/{database_name}/tables/{table_name}/drop") @@ -335,43 +362,20 @@ async def drop_table( request: Request, database_name: str, table_name: str, - currentUser: User = Depends(getCurrentUser), + currentUser: User = Depends(requireSysAdmin), payload: Dict[str, Any] = Body(...) ) -> Dict[str, Any]: - _ensure_admin_scope(currentUser) - - # Get all configured database names - configured_dbs = [] - app_db = APP_CONFIG.get("DB_APP_DATABASE") - if app_db: - configured_dbs.append(app_db) - chat_db = APP_CONFIG.get("DB_CHAT_DATABASE") - if chat_db: - configured_dbs.append(chat_db) - management_db = APP_CONFIG.get("DB_MANAGEMENT_DATABASE") - if management_db: - configured_dbs.append(management_db) - - if not configured_dbs: - configured_dbs = ["poweron"] - - if database_name not in configured_dbs: - raise HTTPException(status_code=400, detail=f"Invalid database name. Available databases: {configured_dbs}") + """ + Drop a table from a database. + MULTI-TENANT: SysAdmin-only (infrastructure management). + """ + if not database_name.startswith("poweron_"): + raise HTTPException(status_code=400, detail="Invalid database name format") + connector = None try: - # Use the appropriate interface based on database name - if database_name == app_db: - interface = getRootInterface() - elif database_name == chat_db: - from modules.interfaces.interfaceDbChatObjects import getInterface as getChatInterface - interface = getChatInterface(currentUser) - elif database_name == management_db: - from modules.interfaces.interfaceDbComponentObjects import getInterface as getComponentInterface - interface = getComponentInterface(currentUser) - else: - raise HTTPException(status_code=400, detail="Database not found") - - conn = interface.db.connection + connector = _getDatabaseConnector(database_name, currentUser.id) + conn = connector.connection with conn.cursor() as cursor: # Check if table exists cursor.execute(""" @@ -388,57 +392,50 @@ async def drop_table( return {"message": f"Table '{table_name}' dropped successfully from database '{database_name}'"} except HTTPException: raise + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) except Exception as e: logger.error(f"Error dropping table: {str(e)}") - if 'interface' in locals() and interface and interface.db and interface.db.connection: - interface.db.connection.rollback() + if connector and connector.connection: + connector.connection.rollback() raise HTTPException(status_code=500, detail="Failed to drop table") + finally: + if connector: + connector.close() @router.post("/databases/drop") @limiter.limit("5/minute") async def drop_database( request: Request, - currentUser: User = Depends(getCurrentUser), + currentUser: User = Depends(requireSysAdmin), payload: Dict[str, Any] = Body(...) ) -> Dict[str, Any]: - _ensure_admin_scope(currentUser) - db_name = payload.get("database") + """ + Drop all tables in a database. + MULTI-TENANT: SysAdmin-only (infrastructure management). + """ + dbName = payload.get("database") - # Get all configured database names - configured_dbs = [] - app_db = APP_CONFIG.get("DB_APP_DATABASE") - if app_db: - configured_dbs.append(app_db) - chat_db = APP_CONFIG.get("DB_CHAT_DATABASE") - if chat_db: - configured_dbs.append(chat_db) - management_db = APP_CONFIG.get("DB_MANAGEMENT_DATABASE") - if management_db: - configured_dbs.append(management_db) + if not dbName or not dbName.startswith("poweron_"): + raise HTTPException(status_code=400, detail="Invalid database name") - if not configured_dbs: - configured_dbs = ["poweron"] - - if not db_name or db_name not in configured_dbs: - raise HTTPException(status_code=400, detail=f"Invalid database name. Available databases: {configured_dbs}") - + # Validate database exists try: - # Use the appropriate interface based on database name - if db_name == app_db: - interface = getRootInterface() - elif db_name == chat_db: - from modules.interfaces.interfaceDbChatObjects import getInterface as getChatInterface - interface = getChatInterface(currentUser) - elif db_name == management_db: - from modules.interfaces.interfaceDbComponentObjects import getInterface as getComponentInterface - interface = getComponentInterface(currentUser) - else: - raise HTTPException(status_code=400, detail="Database not found") - - conn = interface.db.connection + configuredDbs = _getPoweronDatabases() + except Exception as e: + logger.warning(f"Failed to load databases from host: {e}") + configuredDbs = [] + + if configuredDbs and dbName not in configuredDbs: + raise HTTPException(status_code=400, detail=f"Database not found. Available: {configuredDbs}") + + connector = None + try: + connector = _getDatabaseConnector(dbName, currentUser.id) + conn = connector.connection with conn.cursor() as cursor: - # Drop all user tables (public schema) except system table + # Drop all user tables (public schema) cursor.execute(""" SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_type = 'BASE TABLE' @@ -449,12 +446,17 @@ async def drop_database( cursor.execute(f'DROP TABLE IF EXISTS "{tbl}" CASCADE') dropped.append(tbl) conn.commit() - logger.warning(f"Admin drop_database executed by {currentUser.id}: dropped tables from '{db_name}': {dropped}") + logger.warning(f"Admin drop_database executed by {currentUser.id}: dropped tables from '{dbName}': {dropped}") return {"droppedTables": dropped} + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) except Exception as e: logger.error(f"Error dropping database tables: {str(e)}") - if 'interface' in locals() and interface and interface.db and interface.db.connection: - interface.db.connection.rollback() + if connector and connector.connection: + connector.connection.rollback() raise HTTPException(status_code=500, detail="Failed to drop database tables") + finally: + if connector: + connector.close() diff --git a/modules/routes/routeSecurityGoogle.py b/modules/routes/routeSecurityGoogle.py index fb94555c..4e61a045 100644 --- a/modules/routes/routeSecurityGoogle.py +++ b/modules/routes/routeSecurityGoogle.py @@ -340,11 +340,12 @@ async def auth_callback(code: str, state: str, request: Request, response: Respo ) # Create JWT token data (like Microsoft does) + # MULTI-TENANT: Token does NOT contain mandateId anymore jwt_token_data = { "sub": user.username, - "mandateId": str(user.mandateId), "userId": str(user.id), "authenticationAuthority": AuthAuthority.GOOGLE.value + # NO mandateId in token - stateless multi-tenant design } # Create JWT access token @@ -360,6 +361,7 @@ async def auth_callback(code: str, state: str, request: Request, response: Respo jti = payload.get("jti") # Create JWT token with matching id + # MULTI-TENANT: Token model no longer has mandateId field token = Token( id=jti, userId=user.id, # Use local user's ID @@ -368,8 +370,8 @@ async def auth_callback(code: str, state: str, request: Request, response: Respo tokenRefresh=token_response.get("refresh_token", ""), tokenType="bearer", expiresAt=jwt_expires_at.timestamp(), - createdAt=getUtcTimestamp(), - mandateId=str(user.mandateId) + createdAt=getUtcTimestamp() + # NO mandateId - Token is not mandate-bound ) # Save access token (no connectionId) @@ -615,11 +617,12 @@ async def logout( appInterface.logout() # Log successful logout + # MULTI-TENANT: Logout is a system-level function, no mandate context try: from modules.shared.auditLogger import audit_logger audit_logger.logUserAccess( userId=str(currentUser.id), - mandateId=str(currentUser.mandateId), + mandateId="system", action="logout", successInfo="google_auth_logout" ) diff --git a/modules/routes/routeSecurityLocal.py b/modules/routes/routeSecurityLocal.py index 89351825..4b64b671 100644 --- a/modules/routes/routeSecurityLocal.py +++ b/modules/routes/routeSecurityLocal.py @@ -83,11 +83,13 @@ async def login( ) # Create token data + # MULTI-TENANT: Token does NOT contain mandateId anymore + # Mandate context is determined per request via X-Mandate-Id header token_data = { "sub": user.username, - "mandateId": str(user.mandateId), "userId": str(user.id), "authenticationAuthority": AuthAuthority.LOCAL + # NO mandateId in token - stateless multi-tenant design } # Create session id and include in token claims for session-scoped logout @@ -116,7 +118,8 @@ async def login( # Get jti from already decoded payload jti = payload.get("jti") - # Create token + # Create token record in database + # MULTI-TENANT: Token model no longer has mandateId field token = Token( id=jti, userId=user.id, @@ -124,19 +127,20 @@ async def login( tokenAccess=access_token, tokenType="bearer", expiresAt=expires_at.timestamp(), - sessionId=session_id, - mandateId=str(user.mandateId) + sessionId=session_id + # NO mandateId - Token is not mandate-bound ) # Save access token userInterface.saveAccessToken(token) # Log successful login + # MULTI-TENANT: Login is a system-level function, no mandate context try: from modules.shared.auditLogger import audit_logger audit_logger.logUserAccess( userId=str(user.id), - mandateId=str(user.mandateId), + mandateId="system", action="login", successInfo="local_auth_success" ) @@ -236,7 +240,6 @@ async def register_user( fullName=userData.fullName, language=userData.language, enabled=True, # Users are enabled by default (can login after setting password) - roleLabels=["user"], # Default role for new registrations authenticationAuthority=AuthAuthority.LOCAL ) @@ -358,11 +361,12 @@ async def refresh_token( raise HTTPException(status_code=500, detail="Failed to validate user") # Create new token data + # MULTI-TENANT: Token does NOT contain mandateId anymore token_data = { "sub": current_user.username, - "mandateId": str(current_user.mandateId), "userId": str(current_user.id), "authenticationAuthority": current_user.authenticationAuthority + # NO mandateId in token } # Create new access token + set cookie @@ -427,11 +431,12 @@ async def logout(request: Request, response: Response, currentUser: User = Depen revoked = 1 # Log successful logout + # MULTI-TENANT: Logout is a system-level function, no mandate context try: from modules.shared.auditLogger import audit_logger audit_logger.logUserAccess( userId=str(currentUser.id), - mandateId=str(currentUser.mandateId), + mandateId="system", action="logout", successInfo=f"revoked_tokens: {revoked}" ) diff --git a/modules/routes/routeSecurityMsft.py b/modules/routes/routeSecurityMsft.py index 4b2964db..c145d1d3 100644 --- a/modules/routes/routeSecurityMsft.py +++ b/modules/routes/routeSecurityMsft.py @@ -348,11 +348,12 @@ async def auth_callback(code: str, state: str, request: Request, response: Respo appInterface.saveAccessToken(token) # Create JWT token data + # MULTI-TENANT: Token does NOT contain mandateId anymore jwt_token_data = { "sub": user.username, - "mandateId": str(user.mandateId), "userId": str(user.id), "authenticationAuthority": AuthAuthority.MSFT.value + # NO mandateId in token - stateless multi-tenant design } # Create JWT access token @@ -368,6 +369,7 @@ async def auth_callback(code: str, state: str, request: Request, response: Respo jti = payload.get("jti") # Create JWT token with matching id + # MULTI-TENANT: Token model no longer has mandateId field jwt_token_obj = Token( id=jti, userId=user.id, @@ -375,8 +377,8 @@ async def auth_callback(code: str, state: str, request: Request, response: Respo tokenAccess=jwt_token, tokenType="bearer", expiresAt=jwt_expires_at.timestamp(), - createdAt=getUtcTimestamp(), - mandateId=str(user.mandateId) + createdAt=getUtcTimestamp() + # NO mandateId - Token is not mandate-bound ) # Save JWT access token @@ -625,11 +627,12 @@ async def logout( appInterface.logout() # Log successful logout + # MULTI-TENANT: Logout is a system-level function, no mandate context try: from modules.shared.auditLogger import audit_logger audit_logger.logUserAccess( userId=str(currentUser.id), - mandateId=str(currentUser.mandateId), + mandateId="system", action="logout", successInfo="microsoft_auth_logout" ) diff --git a/modules/security/rbac.py b/modules/security/rbac.py index c8a58e92..f236852a 100644 --- a/modules/security/rbac.py +++ b/modules/security/rbac.py @@ -2,14 +2,24 @@ # All rights reserved. """ RBAC interface: Core RBAC logic and permission resolution. -Moved from interfaces to security module to maintain proper architectural layering. -Connectors can import from security, but not from interfaces. + +Multi-Tenant Design: +- AccessRules referenzieren roleId (FK), nicht roleLabel +- Rollen werden über UserMandate + UserMandateRole geladen +- Priorisierung: Instance > Mandate > Global +- Stateless Design: Kein Cache, direkt aus DB """ import logging -from typing import List, Optional, Dict, Any, TYPE_CHECKING -from modules.datamodels.datamodelRbac import AccessRule, AccessRuleContext +from typing import List, Optional, TYPE_CHECKING +from modules.datamodels.datamodelRbac import AccessRule, AccessRuleContext, Role from modules.datamodels.datamodelUam import User, UserPermissions, AccessLevel +from modules.datamodels.datamodelMembership import ( + UserMandate, + UserMandateRole, + FeatureAccess, + FeatureAccessRole +) if TYPE_CHECKING: from modules.connectors.connectorDbPostgre import DatabaseConnector @@ -20,6 +30,11 @@ logger = logging.getLogger(__name__) class RbacClass: """ RBAC interface for permission resolution and rule validation. + + Multi-Tenant Design: + - Lädt Rollen über UserMandate + UserMandateRole + - AccessRules werden über roleId gefunden + - isSysAdmin für System-Level Operationen (ohne Mandant) """ def __init__(self, db: "DatabaseConnector", dbApp: "DatabaseConnector"): @@ -34,14 +49,27 @@ class RbacClass: self.db = db self.dbApp = dbApp - def getUserPermissions(self, user: User, context: AccessRuleContext, item: str) -> UserPermissions: + def getUserPermissions( + self, + user: User, + context: AccessRuleContext, + item: str, + mandateId: Optional[str] = None, + featureInstanceId: Optional[str] = None + ) -> UserPermissions: """ Get combined permissions for a user across all their roles. + Multi-Tenant Design: + - Lädt Rollen aus UserMandate + UserMandateRole wenn mandateId gegeben + - isSysAdmin gibt vollen Zugriff auf System-Level (kein mandateId) + Args: - user: User object with roleLabels + user: User object context: Access rule context (DATA, UI, RESOURCE) item: Item identifier (table name, UI path, resource path) + mandateId: Optional mandate context for role lookup + featureInstanceId: Optional feature instance context Returns: UserPermissions object with combined permissions @@ -54,23 +82,37 @@ class RbacClass: delete=AccessLevel.NONE ) - if not hasattr(user, 'roleLabels') or not user.roleLabels: + # SysAdmin auf System-Level (kein Mandant) hat vollen Zugriff + if hasattr(user, 'isSysAdmin') and user.isSysAdmin and not mandateId: + return UserPermissions( + view=True, + read=AccessLevel.ALL, + create=AccessLevel.ALL, + update=AccessLevel.ALL, + delete=AccessLevel.ALL + ) + + # Lade Role-IDs für den User via UserMandate + UserMandateRole + roleIds = self._getRoleIdsForUser(user, mandateId, featureInstanceId) + + if not roleIds: return permissions - # Step 1: For each role, find the most specific matching rule (most specific wins within role) - rolePermissions = {} - for roleLabel in user.roleLabels: - # Get all rules for this role and context - allRules = self._getRulesForRole(roleLabel, context) - - # Find most specific rule for this item (longest matching prefix) - mostSpecificRule = self.findMostSpecificRule(allRules, item) - - if mostSpecificRule: - rolePermissions[roleLabel] = mostSpecificRule + # Lade alle relevanten Regeln für alle Rollen + allRulesWithPriority = self._getRulesForRoleIds(roleIds, context, mandateId, featureInstanceId) - # Step 2: Combine permissions across roles using opening (union) logic - for roleLabel, rule in rolePermissions.items(): + # Für jede Rolle die spezifischste Regel finden + rolePermissions = {} + for priority, rule in allRulesWithPriority: + # Find most specific rule for this item + if self._ruleMatchesItem(rule, item): + roleId = rule.roleId + # Speichere mit Priorität (höhere Priorität überschreibt) + if roleId not in rolePermissions or priority > rolePermissions[roleId][0]: + rolePermissions[roleId] = (priority, rule) + + # Combine permissions across roles using opening (union) logic + for roleId, (priority, rule) in rolePermissions.items(): # View: union logic - if ANY role has view=true, then view=true if rule.view: permissions.view = True @@ -88,6 +130,274 @@ class RbacClass: return permissions + def _getRoleIdsForUser( + self, + user: User, + mandateId: Optional[str], + featureInstanceId: Optional[str] + ) -> List[str]: + """ + Get all role IDs for a user in the given context. + Uses UserMandate + UserMandateRole for the new multi-tenant model. + + Args: + user: User object + mandateId: Mandate context + featureInstanceId: Feature instance context + + Returns: + List of role IDs + """ + roleIds = [] + + if not mandateId: + return roleIds + + try: + # Lade UserMandate + userMandates = self.dbApp.getRecordset( + UserMandate, + recordFilter={"userId": user.id, "mandateId": mandateId, "enabled": True} + ) + + if not userMandates: + return roleIds + + userMandateId = userMandates[0].get("id") + + # Lade UserMandateRoles (Mandate-level roles) + userMandateRoles = self.dbApp.getRecordset( + UserMandateRole, + recordFilter={"userMandateId": userMandateId} + ) + + roleIds.extend([r.get("roleId") for r in userMandateRoles if r.get("roleId")]) + + # Load FeatureAccess + FeatureAccessRole (Instance-level roles) + if featureInstanceId: + featureAccessRecords = self.dbApp.getRecordset( + FeatureAccess, + recordFilter={ + "userId": user.id, + "featureInstanceId": featureInstanceId, + "enabled": True + } + ) + + if featureAccessRecords: + featureAccessId = featureAccessRecords[0].get("id") + + featureAccessRoles = self.dbApp.getRecordset( + FeatureAccessRole, + recordFilter={"featureAccessId": featureAccessId} + ) + + roleIds.extend([r.get("roleId") for r in featureAccessRoles if r.get("roleId")]) + + except Exception as e: + logger.error(f"Error loading role IDs for user {user.id}: {e}") + + return roleIds + + def getRulesForUserBulk( + self, + userId: str, + mandateId: str, + featureInstanceId: Optional[str] = None + ) -> List[tuple]: + """ + Lädt alle relevanten Regeln für einen User in EINEM Query. + Stateless: Kein Cache, direkt aus DB. + + Optimiert für Multi-Tenant mit Junction Tables: + - Mandant-Rollen via UserMandate → UserMandateRole + - Instanz-Rollen via FeatureAccess → FeatureAccessRole + + Args: + userId: User ID + mandateId: Mandate context + featureInstanceId: Optional feature instance context + + Returns: + Liste von (priority, AccessRule) Tupeln + """ + if not mandateId: + return [] + + try: + conn = self.dbApp.connection + roleIds = set() + + # 1. Mandant-Rollen via UserMandate → UserMandateRole (SINGLE Query) + with conn.cursor() as cursor: + cursor.execute( + """ + SELECT umr."roleId" + FROM "UserMandate" um + JOIN "UserMandateRole" umr ON umr."userMandateId" = um.id + WHERE um."userId" = %s AND um."mandateId" = %s AND um."enabled" = true + """, + (userId, mandateId) + ) + mandateRoles = cursor.fetchall() + roleIds.update(r["roleId"] for r in mandateRoles if r.get("roleId")) + + # 2. Instanz-Rollen via FeatureAccess → FeatureAccessRole (SINGLE Query) + if featureInstanceId: + with conn.cursor() as cursor: + cursor.execute( + """ + SELECT far."roleId" + FROM "FeatureAccess" fa + JOIN "FeatureAccessRole" far ON far."featureAccessId" = fa.id + WHERE fa."userId" = %s AND fa."featureInstanceId" = %s AND fa."enabled" = true + """, + (userId, featureInstanceId) + ) + instanceRoles = cursor.fetchall() + roleIds.update(r["roleId"] for r in instanceRoles if r.get("roleId")) + + if not roleIds: + return [] + + # 3. BULK Query: Alle Regeln für alle Rollen + zugehörige Role-Daten + # SINGLE Query mit JOIN statt N+1 + roleIdsList = list(roleIds) + with conn.cursor() as cursor: + cursor.execute( + """ + SELECT ar.*, r."mandateId" as "roleMandateId", + r."featureInstanceId" as "roleInstanceId" + FROM "AccessRule" ar + JOIN "Role" r ON ar."roleId" = r.id + WHERE ar."roleId" = ANY(%s) + """, + (roleIdsList,) + ) + allRulesWithContext = cursor.fetchall() + + # 4. Priorität zuweisen basierend auf Role-Scope + rulesWithPriority = [] + for ruleRecord in allRulesWithContext: + ruleDict = dict(ruleRecord) + + # Bestimme Priorität + if ruleDict.get("roleInstanceId"): + priority = 3 # Instance-Rolle = höchste Priorität + elif ruleDict.get("roleMandateId"): + priority = 2 # Mandate-Rolle + else: + priority = 1 # Global-Rolle = niedrigste Priorität + + # Entferne Hilfsspalten vor AccessRule-Erstellung + ruleDict.pop("roleMandateId", None) + ruleDict.pop("roleInstanceId", None) + + try: + rule = AccessRule(**ruleDict) + rulesWithPriority.append((priority, rule)) + except Exception as e: + logger.error(f"Error converting rule record: {e}") + + return rulesWithPriority + + except Exception as e: + logger.error(f"Error in getRulesForUserBulk: {e}") + return [] + + def _getRulesForRoleIds( + self, + roleIds: List[str], + context: AccessRuleContext, + mandateId: Optional[str], + featureInstanceId: Optional[str] + ) -> List[tuple]: + """ + Get all access rules for the given role IDs with priority. + + Priority: + - 3: Instance-specific role (featureInstanceId set) + - 2: Mandate-specific role (mandateId set, no featureInstanceId) + - 1: Global role (no mandateId) + + Args: + roleIds: List of role IDs + context: Access rule context + mandateId: Current mandate context + featureInstanceId: Current feature instance context + + Returns: + List of (priority, AccessRule) tuples + """ + rulesWithPriority = [] + + if not roleIds: + return rulesWithPriority + + try: + # Lade alle Regeln für alle Rollen + for roleId in roleIds: + rules = self.dbApp.getRecordset( + AccessRule, + recordFilter={"roleId": roleId, "context": context.value} + ) + + # Lade Role um Priorität zu bestimmen + roleRecords = self.dbApp.getRecordset(Role, recordFilter={"id": roleId}) + if not roleRecords: + continue + + role = roleRecords[0] + + # Bestimme Priorität basierend auf Role-Scope + if role.get("featureInstanceId"): + priority = 3 # Instance-specific + elif role.get("mandateId"): + priority = 2 # Mandate-specific + else: + priority = 1 # Global + + for ruleRecord in rules: + try: + rule = AccessRule(**ruleRecord) + rulesWithPriority.append((priority, rule)) + except Exception as e: + logger.error(f"Error converting rule record: {e}") + + except Exception as e: + logger.error(f"Error loading rules for role IDs: {e}") + + return rulesWithPriority + + def _ruleMatchesItem(self, rule: AccessRule, item: str) -> bool: + """ + Check if a rule matches the given item. + + Args: + rule: Access rule to check + item: Item to match against + + Returns: + True if rule matches item + """ + if rule.item is None: + # Generic rule matches everything + return True + + if not item: + # No item specified, only generic rules match + return rule.item is None + + # Exact match + if rule.item == item: + return True + + # Prefix match (e.g., "trustee" matches "trustee.contract") + if item.startswith(rule.item + "."): + return True + + return False + def findMostSpecificRule(self, rules: List[AccessRule], item: str) -> Optional[AccessRule]: """ Find the most specific rule for an item (longest matching prefix wins). @@ -105,7 +415,6 @@ class RbacClass: return genericRules[0] if genericRules else None # Find longest matching prefix - itemParts = item.split(".") bestMatch = None bestMatchLength = -1 @@ -176,39 +485,3 @@ class RbacClass: AccessLevel.ALL: 3 } return hierarchy.get(level1, 0) > hierarchy.get(level2, 0) - - def _getRulesForRole(self, roleLabel: str, context: AccessRuleContext) -> List[AccessRule]: - """ - Get all access rules for a specific role and context. - Always queries from DbApp database, not the current database. - - Args: - roleLabel: Role label to get rules for - context: Context type - - Returns: - List of AccessRule objects - """ - try: - # Always use DbApp database for AccessRule queries - rules = self.dbApp.getRecordset( - AccessRule, - recordFilter={ - "roleLabel": roleLabel, - "context": context.value - } - ) - - # Convert dict records to AccessRule objects - accessRules = [] - for record in rules: - try: - accessRule = AccessRule(**record) - accessRules.append(accessRule) - except Exception as e: - logger.error(f"Error converting rule record to AccessRule: {e}, record={record}") - - return accessRules - except Exception as e: - logger.error(f"Error getting rules for role {roleLabel} and context {context.value}: {e}", exc_info=True) - return [] diff --git a/modules/security/rootAccess.py b/modules/security/rootAccess.py index 2d4a2ae2..d87c22e8 100644 --- a/modules/security/rootAccess.py +++ b/modules/security/rootAccess.py @@ -3,6 +3,8 @@ """ Root access management for system-level operations. Provides secure access to root user and DbApp database connector. + +Bei leerer Datenbank wird automatisch Bootstrap ausgeführt. """ import logging @@ -14,6 +16,7 @@ logger = logging.getLogger(__name__) _rootDbAppConnector = None _rootUser = None +_bootstrapExecuted = False def getRootDbAppConnector() -> DatabaseConnector: """ @@ -24,29 +27,62 @@ def getRootDbAppConnector() -> DatabaseConnector: if _rootDbAppConnector is None: _rootDbAppConnector = DatabaseConnector( - dbHost=APP_CONFIG.get("DB_APP_HOST"), - dbDatabase=APP_CONFIG.get("DB_APP_DATABASE", "app"), - dbUser=APP_CONFIG.get("DB_APP_USER"), - dbPassword=APP_CONFIG.get("DB_APP_PASSWORD_SECRET"), - dbPort=int(APP_CONFIG.get("DB_APP_PORT", 5432)), + dbHost=APP_CONFIG.get("DB_HOST"), + dbDatabase="poweron_app", + dbUser=APP_CONFIG.get("DB_USER"), + dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET"), + dbPort=int(APP_CONFIG.get("DB_PORT", 5432)), userId=None # No user context for root connector ) _rootDbAppConnector.initDbSystem() return _rootDbAppConnector + +def _ensureBootstrap(): + """ + Führt Bootstrap aus, falls noch nicht geschehen. + Wird automatisch aufgerufen, wenn getRootUser() keinen User findet. + """ + global _bootstrapExecuted + + if _bootstrapExecuted: + return + + logger.info("Running bootstrap to initialize database") + + # Import here to avoid circular imports + from modules.interfaces.interfaceBootstrap import initBootstrap + + dbApp = getRootDbAppConnector() + initBootstrap(dbApp) + + _bootstrapExecuted = True + logger.info("Bootstrap completed") + + def getRootUser() -> User: """ Returns the root user (initial user from database). Used for system-level operations that require root privileges. + + Falls kein User existiert, wird Bootstrap automatisch ausgeführt. """ global _rootUser if _rootUser is None: dbApp = getRootDbAppConnector() initialUserId = dbApp.getInitialId(UserInDB) + + # Wenn kein User existiert, Bootstrap ausführen if not initialUserId: - raise ValueError("No initial user ID found in database") + logger.info("No initial user found, running bootstrap") + _ensureBootstrap() + + # Nochmal versuchen nach Bootstrap + initialUserId = dbApp.getInitialId(UserInDB) + if not initialUserId: + raise ValueError("No initial user ID found in database after bootstrap") users = dbApp.getRecordset(UserInDB, recordFilter={"id": initialUserId}) if not users: @@ -56,4 +92,3 @@ def getRootUser() -> User: _rootUser = User(**user_data) return _rootUser - diff --git a/modules/services/__init__.py b/modules/services/__init__.py index 16d2ed6d..7033bfbb 100644 --- a/modules/services/__init__.py +++ b/modules/services/__init__.py @@ -1,6 +1,6 @@ # Copyright (c) 2025 Patrick Motsch # All rights reserved. -from typing import Any +from typing import Any, Optional from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelChat import ChatWorkflow @@ -40,25 +40,26 @@ class PublicService: class Services: - def __init__(self, user: User, workflow: ChatWorkflow = None): + def __init__(self, user: User, workflow: ChatWorkflow = None, mandateId: Optional[str] = None): self.user: User = user self.workflow: ChatWorkflow = workflow + self.mandateId: Optional[str] = mandateId self.currentUserPrompt: str = "" # Cleaned/normalized user intent for the current round self.rawUserPrompt: str = "" # Original raw user message for the current round - # Initialize interfaces + # Initialize interfaces with explicit mandateId from modules.interfaces.interfaceDbChatObjects import getInterface as getChatInterface - self.interfaceDbChat = getChatInterface(user) + self.interfaceDbChat = getChatInterface(user, mandateId=mandateId) from modules.interfaces.interfaceDbAppObjects import getInterface as getAppInterface - self.interfaceDbApp = getAppInterface(user) + self.interfaceDbApp = getAppInterface(user, mandateId=mandateId) from modules.interfaces.interfaceDbComponentObjects import getInterface as getComponentInterface - self.interfaceDbComponent = getComponentInterface(user) + self.interfaceDbComponent = getComponentInterface(user, mandateId=mandateId) from modules.interfaces.interfaceDbTrusteeObjects import getInterface as getTrusteeInterface - self.interfaceDbTrustee = getTrusteeInterface(user) + self.interfaceDbTrustee = getTrusteeInterface(user, mandateId=mandateId) # Expose RBAC directly on services for convenience self.rbac = self.interfaceDbApp.rbac if self.interfaceDbApp else None @@ -99,7 +100,15 @@ class Services: self.messaging = PublicService(MessagingService(self)) -def getInterface(user: User, workflow: ChatWorkflow) -> Services: - return Services(user, workflow) +def getInterface(user: User, workflow: ChatWorkflow = None, mandateId: Optional[str] = None) -> Services: + """ + Get Services instance for the given user and mandate context. + + Args: + user: The authenticated user + workflow: Optional ChatWorkflow context + mandateId: Explicit mandate context (from RequestContext / X-Mandate-Id header). Required. + """ + return Services(user, workflow, mandateId=mandateId) diff --git a/modules/shared/dbMultiTenantOptimizations.py b/modules/shared/dbMultiTenantOptimizations.py new file mode 100644 index 00000000..38f154a5 --- /dev/null +++ b/modules/shared/dbMultiTenantOptimizations.py @@ -0,0 +1,438 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +""" +Database optimizations for Multi-Tenant model. + +Applies indexes, immutable triggers, and foreign key constraints +for the junction tables used in the multi-tenant mandate model. + +Usage: + from modules.shared.dbMultiTenantOptimizations import applyMultiTenantOptimizations + + # Call after database tables are created + applyMultiTenantOptimizations(dbConnector) + +All operations are idempotent (safe to call multiple times). +""" + +import logging +from typing import Optional, List + +logger = logging.getLogger(__name__) + + +# ============================================================================= +# Index Definitions +# ============================================================================= + +_INDEXES = [ + # UserMandate indexes + ("UserMandate", "idx_usermandate_user", ["userId"]), + ("UserMandate", "idx_usermandate_user_mandate", ["userId", "mandateId"]), + ("UserMandate", "idx_usermandate_mandate", ["mandateId"]), + + # UserMandateRole indexes + ("UserMandateRole", "idx_usermandaterole_usermandate", ["userMandateId"]), + ("UserMandateRole", "idx_usermandaterole_role", ["roleId"]), + + # FeatureAccess indexes + ("FeatureAccess", "idx_featureaccess_user_instance", ["userId", "featureInstanceId"]), + ("FeatureAccess", "idx_featureaccess_user", ["userId"]), + ("FeatureAccess", "idx_featureaccess_instance", ["featureInstanceId"]), + + # FeatureAccessRole indexes + ("FeatureAccessRole", "idx_featureaccessrole_featureaccess", ["featureAccessId"]), + ("FeatureAccessRole", "idx_featureaccessrole_role", ["roleId"]), + + # AccessRule indexes + ("AccessRule", "idx_accessrule_roleid", ["roleId"]), + ("AccessRule", "idx_accessrule_context_roleid", ["context", "roleId"]), + + # Role indexes + ("Role", "idx_role_mandate_instance", ["mandateId", "featureInstanceId"]), + ("Role", "idx_role_label", ["roleLabel"]), + + # FeatureInstance indexes + ("FeatureInstance", "idx_featureinstance_mandate", ["mandateId"]), + ("FeatureInstance", "idx_featureinstance_mandate_code", ["mandateId", "featureCode"]), + + # Invitation indexes + ("Invitation", "idx_invitation_mandate", ["mandateId"]), + ("Invitation", "idx_invitation_createdby", ["createdBy"]), +] + +# Unique indexes (separate list) +_UNIQUE_INDEXES = [ + ("Invitation", "idx_invitation_token", ["token"]), +] + +# Partial indexes (with WHERE clause) +_PARTIAL_INDEXES = [ + ("UserMandate", "idx_usermandate_user_enabled", ["userId"], '"enabled" = true'), + ("Role", "idx_role_featurecode", ["featureCode"], '"mandateId" IS NULL'), +] + + +# ============================================================================= +# Foreign Key Definitions +# ============================================================================= + +_FOREIGN_KEYS = [ + # UserMandate FKs + ("UserMandate", "fk_usermandate_mandate", "mandateId", "Mandate", "id"), + ("UserMandate", "fk_usermandate_user", "userId", "User", "id"), + + # FeatureInstance FKs + ("FeatureInstance", "fk_featureinstance_mandate", "mandateId", "Mandate", "id"), + + # Role FKs (nullable - only cascade when not null) + ("Role", "fk_role_mandate", "mandateId", "Mandate", "id"), + ("Role", "fk_role_instance", "featureInstanceId", "FeatureInstance", "id"), + + # FeatureAccess FKs + ("FeatureAccess", "fk_featureaccess_instance", "featureInstanceId", "FeatureInstance", "id"), + ("FeatureAccess", "fk_featureaccess_user", "userId", "User", "id"), + + # AccessRule FKs + ("AccessRule", "fk_accessrule_role", "roleId", "Role", "id"), + + # Junction table FKs + ("UserMandateRole", "fk_usermandaterole_usermandate", "userMandateId", "UserMandate", "id"), + ("UserMandateRole", "fk_usermandaterole_role", "roleId", "Role", "id"), + ("FeatureAccessRole", "fk_featureaccessrole_featureaccess", "featureAccessId", "FeatureAccess", "id"), + ("FeatureAccessRole", "fk_featureaccessrole_role", "roleId", "Role", "id"), + + # Invitation FKs + ("Invitation", "fk_invitation_mandate", "mandateId", "Mandate", "id"), +] + + +# ============================================================================= +# Immutable Trigger Definitions +# ============================================================================= + +_IMMUTABLE_TRIGGERS = [ + # Role: mandateId, featureInstanceId, featureCode are immutable + ("Role", "tr_role_immutable", ["mandateId", "featureInstanceId", "featureCode"]), + + # AccessRule: context, roleId are immutable + ("AccessRule", "tr_accessrule_immutable", ["context", "roleId"]), +] + + +# ============================================================================= +# Main Functions +# ============================================================================= + +def applyMultiTenantOptimizations(dbConnector, tables: Optional[List[str]] = None) -> dict: + """ + Apply all multi-tenant database optimizations. + + Args: + dbConnector: Database connector with execute capability + tables: Optional list of table names to optimize. If None, optimizes all. + + Returns: + dict with counts of created indexes, triggers, and foreign keys + """ + results = { + "indexesCreated": 0, + "triggersCreated": 0, + "foreignKeysCreated": 0, + "errors": [] + } + + try: + # Get a connection from the connector + conn = dbConnector._get_connection() + conn.autocommit = True + + with conn.cursor() as cursor: + # Apply indexes + results["indexesCreated"] = _applyIndexes(cursor, tables) + + # Apply foreign keys + results["foreignKeysCreated"] = _applyForeignKeys(cursor, tables) + + # Apply immutable triggers + results["triggersCreated"] = _applyImmutableTriggers(cursor, tables) + + logger.info( + f"Multi-tenant optimizations applied: " + f"{results['indexesCreated']} indexes, " + f"{results['triggersCreated']} triggers, " + f"{results['foreignKeysCreated']} foreign keys" + ) + + except Exception as e: + logger.error(f"Error applying multi-tenant optimizations: {e}") + results["errors"].append(str(e)) + + return results + + +def applyIndexesOnly(dbConnector, tables: Optional[List[str]] = None) -> int: + """Apply only indexes (lighter operation, safe for frequent calls).""" + try: + conn = dbConnector._get_connection() + conn.autocommit = True + + with conn.cursor() as cursor: + return _applyIndexes(cursor, tables) + except Exception as e: + logger.error(f"Error applying indexes: {e}") + return 0 + + +# ============================================================================= +# Internal Implementation +# ============================================================================= + +def _tableExists(cursor, tableName: str) -> bool: + """Check if a table exists in the database.""" + cursor.execute(""" + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_name = %s + ) + """, (tableName,)) + return cursor.fetchone()[0] + + +def _indexExists(cursor, indexName: str) -> bool: + """Check if an index exists.""" + cursor.execute(""" + SELECT EXISTS ( + SELECT FROM pg_indexes + WHERE indexname = %s + ) + """, (indexName,)) + return cursor.fetchone()[0] + + +def _constraintExists(cursor, constraintName: str) -> bool: + """Check if a constraint exists.""" + cursor.execute(""" + SELECT EXISTS ( + SELECT FROM pg_constraint + WHERE conname = %s + ) + """, (constraintName,)) + return cursor.fetchone()[0] + + +def _triggerExists(cursor, triggerName: str) -> bool: + """Check if a trigger exists.""" + cursor.execute(""" + SELECT EXISTS ( + SELECT FROM pg_trigger + WHERE tgname = %s + ) + """, (triggerName,)) + return cursor.fetchone()[0] + + +def _applyIndexes(cursor, tables: Optional[List[str]]) -> int: + """Apply all indexes. Returns count of newly created indexes.""" + created = 0 + + # Regular indexes + for tableName, indexName, columns in _INDEXES: + if tables and tableName not in tables: + continue + if not _tableExists(cursor, tableName): + continue + if _indexExists(cursor, indexName): + continue + + try: + columnList = ", ".join(f'"{c}"' for c in columns) + cursor.execute(f'CREATE INDEX "{indexName}" ON "{tableName}" ({columnList})') + created += 1 + logger.debug(f"Created index {indexName} on {tableName}") + except Exception as e: + logger.warning(f"Failed to create index {indexName}: {e}") + + # Unique indexes + for tableName, indexName, columns in _UNIQUE_INDEXES: + if tables and tableName not in tables: + continue + if not _tableExists(cursor, tableName): + continue + if _indexExists(cursor, indexName): + continue + + try: + columnList = ", ".join(f'"{c}"' for c in columns) + cursor.execute(f'CREATE UNIQUE INDEX "{indexName}" ON "{tableName}" ({columnList})') + created += 1 + logger.debug(f"Created unique index {indexName} on {tableName}") + except Exception as e: + logger.warning(f"Failed to create unique index {indexName}: {e}") + + # Partial indexes + for tableName, indexName, columns, whereClause in _PARTIAL_INDEXES: + if tables and tableName not in tables: + continue + if not _tableExists(cursor, tableName): + continue + if _indexExists(cursor, indexName): + continue + + try: + columnList = ", ".join(f'"{c}"' for c in columns) + cursor.execute(f'CREATE INDEX "{indexName}" ON "{tableName}" ({columnList}) WHERE {whereClause}') + created += 1 + logger.debug(f"Created partial index {indexName} on {tableName}") + except Exception as e: + logger.warning(f"Failed to create partial index {indexName}: {e}") + + return created + + +def _applyForeignKeys(cursor, tables: Optional[List[str]]) -> int: + """Apply foreign key constraints with CASCADE DELETE. Returns count created.""" + created = 0 + + for tableName, constraintName, column, refTable, refColumn in _FOREIGN_KEYS: + if tables and tableName not in tables: + continue + if not _tableExists(cursor, tableName): + continue + if not _tableExists(cursor, refTable): + continue + if _constraintExists(cursor, constraintName): + continue + + try: + cursor.execute(f""" + ALTER TABLE "{tableName}" + ADD CONSTRAINT "{constraintName}" + FOREIGN KEY ("{column}") + REFERENCES "{refTable}"("{refColumn}") + ON DELETE CASCADE + """) + created += 1 + logger.debug(f"Created FK {constraintName} on {tableName}") + except Exception as e: + logger.warning(f"Failed to create FK {constraintName}: {e}") + + return created + + +def _applyImmutableTriggers(cursor, tables: Optional[List[str]]) -> int: + """Apply immutable field triggers. Returns count created.""" + created = 0 + + for tableName, triggerName, immutableFields in _IMMUTABLE_TRIGGERS: + if tables and tableName not in tables: + continue + if not _tableExists(cursor, tableName): + continue + if _triggerExists(cursor, triggerName): + continue + + try: + # Create the function + functionName = f"fn_{triggerName}" + checks = [] + for field in immutableFields: + checks.append(f""" + IF OLD."{field}" IS DISTINCT FROM NEW."{field}" THEN + RAISE EXCEPTION '{field} is immutable on {tableName}. Delete and recreate instead.'; + END IF; + """) + + functionBody = "\n".join(checks) + + cursor.execute(f""" + CREATE OR REPLACE FUNCTION "{functionName}"() + RETURNS TRIGGER AS $$ + BEGIN + {functionBody} + RETURN NEW; + END; + $$ LANGUAGE plpgsql + """) + + # Create the trigger + cursor.execute(f""" + CREATE TRIGGER "{triggerName}" + BEFORE UPDATE ON "{tableName}" + FOR EACH ROW + EXECUTE FUNCTION "{functionName}"() + """) + + created += 1 + logger.debug(f"Created immutable trigger {triggerName} on {tableName}") + except Exception as e: + logger.warning(f"Failed to create trigger {triggerName}: {e}") + + return created + + +# ============================================================================= +# Utility: Check optimization status +# ============================================================================= + +def getOptimizationStatus(dbConnector) -> dict: + """ + Check which optimizations are already applied. + + Returns dict with lists of applied and missing optimizations. + """ + status = { + "indexes": {"applied": [], "missing": []}, + "uniqueIndexes": {"applied": [], "missing": []}, + "partialIndexes": {"applied": [], "missing": []}, + "foreignKeys": {"applied": [], "missing": []}, + "triggers": {"applied": [], "missing": []} + } + + try: + conn = dbConnector._get_connection() + with conn.cursor() as cursor: + # Check regular indexes + for tableName, indexName, _ in _INDEXES: + if _tableExists(cursor, tableName): + if _indexExists(cursor, indexName): + status["indexes"]["applied"].append(indexName) + else: + status["indexes"]["missing"].append(indexName) + + # Check unique indexes + for tableName, indexName, _ in _UNIQUE_INDEXES: + if _tableExists(cursor, tableName): + if _indexExists(cursor, indexName): + status["uniqueIndexes"]["applied"].append(indexName) + else: + status["uniqueIndexes"]["missing"].append(indexName) + + # Check partial indexes + for tableName, indexName, _, _ in _PARTIAL_INDEXES: + if _tableExists(cursor, tableName): + if _indexExists(cursor, indexName): + status["partialIndexes"]["applied"].append(indexName) + else: + status["partialIndexes"]["missing"].append(indexName) + + # Check foreign keys + for tableName, constraintName, _, _, _ in _FOREIGN_KEYS: + if _tableExists(cursor, tableName): + if _constraintExists(cursor, constraintName): + status["foreignKeys"]["applied"].append(constraintName) + else: + status["foreignKeys"]["missing"].append(constraintName) + + # Check triggers + for tableName, triggerName, _ in _IMMUTABLE_TRIGGERS: + if _tableExists(cursor, tableName): + if _triggerExists(cursor, triggerName): + status["triggers"]["applied"].append(triggerName) + else: + status["triggers"]["missing"].append(triggerName) + + except Exception as e: + logger.error(f"Error checking optimization status: {e}") + + return status diff --git a/modules/workflows/workflowManager.py b/modules/workflows/workflowManager.py index 980ce120..a9b656eb 100644 --- a/modules/workflows/workflowManager.py +++ b/modules/workflows/workflowManager.py @@ -96,7 +96,7 @@ class WorkflowManager: "currentAction": 0, "totalTasks": 0, "totalActions": 0, - "mandateId": self.services.user.mandateId, + "mandateId": self.services.mandateId, "messageIds": [], "workflowMode": workflowMode, "maxSteps": 10 , # Set maxSteps