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