Merge pull request #88 from valueonag/feat/saas-multi-tenant-mandates

Feat/saas multi tenant mandates
This commit is contained in:
Patrick Motsch 2026-01-27 00:41:21 +01:00 committed by GitHub
commit 26a2af1af8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
172 changed files with 32122 additions and 5947 deletions

138
app.py
View file

@ -19,8 +19,9 @@ from datetime import datetime
from modules.shared.configuration import APP_CONFIG from modules.shared.configuration import APP_CONFIG
from modules.shared.eventManagement import eventManager from modules.shared.eventManagement import eventManager
from modules.features import featuresLifecycle as featuresLifecycle from modules.workflows.automation import subAutomationSchedule
from modules.interfaces.interfaceDbAppObjects import getRootInterface from modules.interfaces.interfaceDbApp import getRootInterface
from modules.system.registry import loadFeatureMainModules
class DailyRotatingFileHandler(RotatingFileHandler): class DailyRotatingFileHandler(RotatingFileHandler):
""" """
@ -46,6 +47,9 @@ class DailyRotatingFileHandler(RotatingFileHandler):
def _updateFileIfNeeded(self): def _updateFileIfNeeded(self):
"""Update the log file if the date has changed""" """Update the log file if the date has changed"""
# Guard against interpreter shutdown when datetime may be None
if datetime is None:
return False
today = datetime.now().strftime("%Y%m%d") today = datetime.now().strftime("%Y%m%d")
if self.currentDate != today: if self.currentDate != today:
@ -145,21 +149,24 @@ def initLogging():
def filter(self, record): def filter(self, record):
if isinstance(record.msg, str): if isinstance(record.msg, str):
# Remove only emojis, preserve other Unicode characters like quotes # Remove only emojis, preserve other Unicode characters like quotes
# Guard against None characters during shutdown
# Remove emoji characters specifically try:
record.msg = "".join( record.msg = "".join(
char char
for char in record.msg for char in record.msg
if unicodedata.category(char) != "So" if char is not None and unicodedata.category(char) != "So"
or not ( or (char is not None and not (
0x1F600 <= ord(char) <= 0x1F64F 0x1F600 <= ord(char) <= 0x1F64F
or 0x1F300 <= ord(char) <= 0x1F5FF or 0x1F300 <= ord(char) <= 0x1F5FF
or 0x1F680 <= ord(char) <= 0x1F6FF or 0x1F680 <= ord(char) <= 0x1F6FF
or 0x1F1E0 <= ord(char) <= 0x1F1FF or 0x1F1E0 <= ord(char) <= 0x1F1FF
or 0x2600 <= ord(char) <= 0x26FF or 0x2600 <= ord(char) <= 0x26FF
or 0x2700 <= ord(char) <= 0x27BF or 0x2700 <= ord(char) <= 0x27BF
))
) )
) except (TypeError, AttributeError):
# Handle edge cases during shutdown
pass
return True return True
# Add filter to normalize problematic unicode (e.g., arrows) to ASCII for terminals like cp1252 # Add filter to normalize problematic unicode (e.g., arrows) to ASCII for terminals like cp1252
@ -279,36 +286,73 @@ instanceLabel = APP_CONFIG.get("APP_ENV_LABEL")
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
logger.info("Application is starting up") logger.info("Application is starting up")
# Initialize AI connectors once at startup to avoid per-request discovery
from modules.aicore.aicoreModelRegistry import modelRegistry
modelRegistry.ensureConnectorsRegistered()
# Get event user for feature lifecycle (system-level user for background operations) # Get event user for feature lifecycle (system-level user for background operations)
rootInterface = getRootInterface() rootInterface = getRootInterface()
eventUser = rootInterface.getUserByUsername("event") eventUser = rootInterface.getUserByUsername("event")
if not eventUser: if not eventUser:
logger.error("Could not get event user - some features may not start properly") logger.error("Could not get event user - some features may not start properly")
# --- Init Feature Containers (Plug&Play) ---
try:
mainModules = loadFeatureMainModules()
for featureName, module in mainModules.items():
if hasattr(module, "onStart"):
try:
await module.onStart(eventUser)
logger.info(f"Feature '{featureName}' started")
except Exception as e:
logger.error(f"Feature '{featureName}' failed to start: {e}")
except Exception as e:
logger.warning(f"Could not initialize feature containers: {e}")
# --- Init Managers --- # --- Init Managers ---
await featuresLifecycle.start(eventUser) await subAutomationSchedule.start(eventUser) # Automation scheduler
eventManager.start() eventManager.start()
# Register audit log cleanup scheduler
from modules.shared.auditLogger import registerAuditLogCleanupScheduler
registerAuditLogCleanupScheduler()
yield yield
# --- Stop Managers --- # --- Stop Managers ---
eventManager.stop() eventManager.stop()
await featuresLifecycle.stop(eventUser) await subAutomationSchedule.stop(eventUser) # Automation scheduler
# --- Stop Feature Containers (Plug&Play) ---
try:
mainModules = loadFeatureMainModules()
for featureName, module in mainModules.items():
if hasattr(module, "onStop"):
try:
await module.onStop(eventUser)
logger.info(f"Feature '{featureName}' stopped")
except Exception as e:
logger.error(f"Feature '{featureName}' failed to stop: {e}")
except Exception as e:
logger.warning(f"Could not shutdown feature containers: {e}")
logger.info("Application has been shut down") logger.info("Application has been shut down")
# Custom function to generate readable operation IDs for Swagger UI
# Uses snake_case function names directly instead of auto-generated IDs
def _generateOperationId(route) -> str:
"""Generate operation ID from route function name (snake_case)."""
if hasattr(route, "endpoint") and hasattr(route.endpoint, "__name__"):
return route.endpoint.__name__
return route.name if route.name else "unknown"
# START APP # START APP
app = FastAPI( app = FastAPI(
title="PowerOn | Data Platform API", title="PowerOn AG | Workflow Engine",
description=f"Backend API for the Multi-Agent Platform by ValueOn AG ({instanceLabel})", description=f"API for dynamic SaaS platforms ({instanceLabel})",
lifespan=lifespan, lifespan=lifespan,
swagger_ui_init_oauth={ swagger_ui_init_oauth={
"usePkceWithAuthorizationCodeGrant": True, "usePkceWithAuthorizationCodeGrant": True,
}, },
generate_unique_id_function=_generateOperationId,
) )
# Configure OpenAPI security scheme for Swagger UI # Configure OpenAPI security scheme for Swagger UI
@ -365,7 +409,7 @@ app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=getAllowedOrigins(), allow_origins=getAllowedOrigins(),
allow_credentials=True, allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["*"], allow_headers=["*"],
expose_headers=["*"], expose_headers=["*"],
max_age=86400, # Increased caching for preflight requests max_age=86400, # Increased caching for preflight requests
@ -405,23 +449,14 @@ app.include_router(userRouter)
from modules.routes.routeDataFiles import router as fileRouter from modules.routes.routeDataFiles import router as fileRouter
app.include_router(fileRouter) app.include_router(fileRouter)
from modules.routes.routeDataNeutralization import router as neutralizationRouter
app.include_router(neutralizationRouter)
from modules.routes.routeDataPrompts import router as promptRouter from modules.routes.routeDataPrompts import router as promptRouter
app.include_router(promptRouter) app.include_router(promptRouter)
from modules.routes.routeDataConnections import router as connectionsRouter from modules.routes.routeDataConnections import router as connectionsRouter
app.include_router(connectionsRouter) app.include_router(connectionsRouter)
from modules.routes.routeWorkflows import router as workflowRouter from modules.routes.routeDataWorkflows import router as dataWorkflowsRouter
app.include_router(workflowRouter) app.include_router(dataWorkflowsRouter)
from modules.routes.routeChatPlayground import router as chatPlaygroundRouter
app.include_router(chatPlaygroundRouter)
from modules.routes.routeRealEstate import router as realEstateRouter
app.include_router(realEstateRouter)
from modules.routes.routeSecurityLocal import router as localRouter from modules.routes.routeSecurityLocal import router as localRouter
app.include_router(localRouter) app.include_router(localRouter)
@ -441,24 +476,49 @@ app.include_router(adminSecurityRouter)
from modules.routes.routeSharepoint import router as sharepointRouter from modules.routes.routeSharepoint import router as sharepointRouter
app.include_router(sharepointRouter) 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.routeAdminAutomationEvents import router as adminAutomationEventsRouter
app.include_router(adminAutomationEventsRouter) app.include_router(adminAutomationEventsRouter)
from modules.routes.routeRbac import router as rbacRouter from modules.routes.routeAdminRbacRules import router as rbacAdminRulesRouter
app.include_router(rbacRouter) app.include_router(rbacAdminRulesRouter)
from modules.routes.routeOptions import router as optionsRouter
app.include_router(optionsRouter)
from modules.routes.routeMessaging import router as messagingRouter from modules.routes.routeMessaging import router as messagingRouter
app.include_router(messagingRouter) app.include_router(messagingRouter)
from modules.routes.routeChatbot import router as chatbotRouter # Phase 8: New Feature Routes
app.include_router(chatbotRouter) from modules.routes.routeAdminFeatures import router as featuresAdminRouter
app.include_router(featuresAdminRouter)
from modules.routes.routeDataTrustee import router as trusteeRouter from modules.routes.routeInvitations import router as invitationsRouter
app.include_router(trusteeRouter) app.include_router(invitationsRouter)
from modules.routes.routeNotifications import router as notificationsRouter
app.include_router(notificationsRouter)
from modules.routes.routeAdminRbacExport import router as rbacAdminExportRouter
app.include_router(rbacAdminExportRouter)
from modules.routes.routeAdminUserAccessOverview import router as userAccessOverviewRouter
app.include_router(userAccessOverviewRouter)
from modules.routes.routeGdpr import router as gdprRouter
app.include_router(gdprRouter)
from modules.routes.routeChat import router as chatRouter
app.include_router(chatRouter)
# ============================================================================
# SYSTEM ROUTES (Navigation, etc.)
# ============================================================================
from modules.routes.routeSystem import router as systemRouter, navigationRouter
app.include_router(systemRouter)
app.include_router(navigationRouter)
# ============================================================================
# PLUG&PLAY FEATURE ROUTERS
# Dynamically load routers from feature containers in modules/features/
# ============================================================================
from modules.system.registry import loadFeatureRouters
featureLoadResults = loadFeatureRouters(app)
logger.info(f"Feature router load results: {featureLoadResults}")

View file

@ -8,40 +8,18 @@ APP_KEY_SYSVAR = D:/Athi/Local/Web/poweron/local/key.txt
APP_INIT_PASS_ADMIN_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEeFFtRGtQeVUtcjlrU3dab1ZxUm9WSks0MlJVYUtERFlqUElHemZrOGNENk1tcmJNX3Vxc01UMDhlNU40VzZZRVBpUGNmT3podzZrOGhOeEJIUEt4eVlSWG5UYXA3d09DVXlLT21Kb1JYSUU9 APP_INIT_PASS_ADMIN_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEeFFtRGtQeVUtcjlrU3dab1ZxUm9WSks0MlJVYUtERFlqUElHemZrOGNENk1tcmJNX3Vxc01UMDhlNU40VzZZRVBpUGNmT3podzZrOGhOeEJIUEt4eVlSWG5UYXA3d09DVXlLT21Kb1JYSUU9
APP_INIT_PASS_EVENT_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERzZjNm56WGVBdjJTeG5Udjd6OGQwUVotYXUzQjJ1YVNyVXVBa3NZVml3ODU0MVNkZjhWWmJwNUFkc19BcHlHMTU1Q3BRcHU0cDBoZkFlR2l6UEZQU3d2U3MtMDh5UDZteGFoQ0EyMUE1ckE9 APP_INIT_PASS_EVENT_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERzZjNm56WGVBdjJTeG5Udjd6OGQwUVotYXUzQjJ1YVNyVXVBa3NZVml3ODU0MVNkZjhWWmJwNUFkc19BcHlHMTU1Q3BRcHU0cDBoZkFlR2l6UEZQU3d2U3MtMDh5UDZteGFoQ0EyMUE1ckE9
# PostgreSQL Storage (new) # PostgreSQL DB Host
DB_APP_HOST=localhost DB_HOST=localhost
DB_APP_DATABASE=poweron_app DB_USER=poweron_dev
DB_APP_USER=poweron_dev DB_PASSWORD_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEcUIxNEFfQ2xnS0RrSC1KNnUxTlVvTGZoMHgzaEI4Z3NlVzVROTVLak5Ubi1vaEZubFZaMTFKMGd6MXAxekN2d2NvMy1hRjg2UVhybktlcFA5anZ1WjFlQmZhcXdwaGhWdzRDc3ExeUhzWTg9
DB_APP_PASSWORD_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEcUIxNEFfQ2xnS0RrSC1KNnUxTlVvTGZoMHgzaEI4Z3NlVzVROTVLak5Ubi1vaEZubFZaMTFKMGd6MXAxekN2d2NvMy1hRjg2UVhybktlcFA5anZ1WjFlQmZhcXdwaGhWdzRDc3ExeUhzWTg9 DB_PORT=5432
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
# Security Configuration # Security Configuration
APP_JWT_KEY_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERjlrSktmZHVuQnJ1VVJDdndLaUcxZGJsT2ZlUFRlcFdOZ001RnlzM2FhLWhRV2tjWWFhaWQwQ3hkcUFvbThMcndxSjFpYTdfRV9OZGhTcksxbXFTZWg5MDZvOHpCVXBHcDJYaHlJM0tyNWRZckZsVHpQcmxTZHJoZUs1M3lfU2ljRnJaTmNSQ0w0X085OXI0QW80M2xfQnJqZmZ6VEh3TUltX0xzeE42SGtZPQ== APP_JWT_KEY_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERjlrSktmZHVuQnJ1VVJDdndLaUcxZGJsT2ZlUFRlcFdOZ001RnlzM2FhLWhRV2tjWWFhaWQwQ3hkcUFvbThMcndxSjFpYTdfRV9OZGhTcksxbXFTZWg5MDZvOHpCVXBHcDJYaHlJM0tyNWRZckZsVHpQcmxTZHJoZUs1M3lfU2ljRnJaTmNSQ0w0X085OXI0QW80M2xfQnJqZmZ6VEh3TUltX0xzeE42SGtZPQ==
APP_TOKEN_EXPIRY=300 APP_TOKEN_EXPIRY=300
# CORS Configuration # CORS Configuration
APP_ALLOWED_ORIGINS=http://localhost:8080,https://playground.poweron-center.net APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://playground.poweron-center.net
# Logging configuration # Logging configuration
APP_LOGGING_LOG_LEVEL = DEBUG APP_LOGGING_LOG_LEVEL = DEBUG

View file

@ -8,33 +8,11 @@ APP_KEY_SYSVAR = CONFIG_KEY
APP_INIT_PASS_ADMIN_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjWm41MWZ4TUZGaVlrX3pWZWNwakJsY3Facm0wLVZDd1VKeTFoZEVZQnItcEdUUnVJS1NXeDBpM2xKbGRsYmxOSmRhc29PZjJSU2txQjdLbUVrTTE1NEJjUXBHbV9NOVJWZUR3QlJkQnJvTEU9 APP_INIT_PASS_ADMIN_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjWm41MWZ4TUZGaVlrX3pWZWNwakJsY3Facm0wLVZDd1VKeTFoZEVZQnItcEdUUnVJS1NXeDBpM2xKbGRsYmxOSmRhc29PZjJSU2txQjdLbUVrTTE1NEJjUXBHbV9NOVJWZUR3QlJkQnJvTEU9
APP_INIT_PASS_EVENT_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjdmtrakgxa0djekZVNGtTZV8wM2I5UUpCZllveVBMWXROYk5yS3BiV3JEelJSM09VYTRONHpnY3VtMGxDRk5JTEZSRFhtcDZ0RVRmZ1RicTFhb3c5dVZRQ1o4SmlkLVpPTW5MMTU2eTQ0Vkk9 APP_INIT_PASS_EVENT_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjdmtrakgxa0djekZVNGtTZV8wM2I5UUpCZllveVBMWXROYk5yS3BiV3JEelJSM09VYTRONHpnY3VtMGxDRk5JTEZSRFhtcDZ0RVRmZ1RicTFhb3c5dVZRQ1o4SmlkLVpPTW5MMTU2eTQ0Vkk9
# PostgreSQL Storage (new) # PostgreSQL DB Host
DB_APP_HOST=gateway-int-server.postgres.database.azure.com DB_HOST=gateway-int-server.postgres.database.azure.com
DB_APP_DATABASE=poweron_app DB_USER=heeshkdlby
DB_APP_USER=heeshkdlby DB_PASSWORD_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjczYzOUtTa21MMGJVTUQ5UmFfdWc3YlhCbWZOeXFaNEE1QzdJV3BLVjhnalBkLVVCMm5BZzdxdlFXQXc2RHYzLWtPSFZkZE1iWG9rQ1NkVWlpRnF5TURVbnl1cm9iYXlSMGYxd1BGYVc0VDA9
DB_APP_PASSWORD_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjb2dka2pnN0tUbW1EU0w1Rk1jNERKQ0Z1U3JkVDhuZWZDM0g5M0kwVDE5VHdubkZna3gtZVAxTnl4MDdrR1c1ZXJ3ejJHYkZvcGUwbHJaajBGOWJob0EzRXVHc0JnZkJyNGhHZTZHOXBxd2c9 DB_PORT=5432
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
# Security Configuration # Security Configuration
APP_JWT_KEY_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNUctb2RwU25iR3ZnanBOdHZhWUtIajZ1RnZzTEp4aDR0MktWRjNoeVBrY1Npd1R0VE9YVHp3M2w1cXRzbUxNaU82QUJvaDNFeVQyN05KblRWblBvbWtoT0VXbkNBbDQ5OHhwSUFnaDZGRG10Vmgtdm1YUkRsYUhFMzRVZURmSFlDTFIzVWg4MXNueDZyMGc5aVpFdWRxY3dkTExGM093ZTVUZVl5LUhGWnlRPQ== APP_JWT_KEY_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNUctb2RwU25iR3ZnanBOdHZhWUtIajZ1RnZzTEp4aDR0MktWRjNoeVBrY1Npd1R0VE9YVHp3M2w1cXRzbUxNaU82QUJvaDNFeVQyN05KblRWblBvbWtoT0VXbkNBbDQ5OHhwSUFnaDZGRG10Vmgtdm1YUkRsYUhFMzRVZURmSFlDTFIzVWg4MXNueDZyMGc5aVpFdWRxY3dkTExGM093ZTVUZVl5LUhGWnlRPQ==

View file

@ -8,33 +8,11 @@ APP_INIT_PASS_ADMIN_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3UnJRV0sySFlDblpXUlREclREaW
APP_INIT_PASS_EVENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3QVpIY19DQVZSSzJmc2F0VEZvQlU1cHBhTEgxdHdnR3g4eW01aTEzYTUxc1gxTDR1RVVpSHRXYjV6N1BLZUdCUGlfOW1qdy0xSHFVRkNBcGZvaGlSSkZycXRuUllaWnpyVGRoeFg1dGEyNUk9 APP_INIT_PASS_EVENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3QVpIY19DQVZSSzJmc2F0VEZvQlU1cHBhTEgxdHdnR3g4eW01aTEzYTUxc1gxTDR1RVVpSHRXYjV6N1BLZUdCUGlfOW1qdy0xSHFVRkNBcGZvaGlSSkZycXRuUllaWnpyVGRoeFg1dGEyNUk9
APP_API_URL = https://gateway-prod.poweron-center.net APP_API_URL = https://gateway-prod.poweron-center.net
# PostgreSQL Storage (new) # PostgreSQL DB Host
DB_APP_HOST=gateway-prod-server.postgres.database.azure.com DB_HOST=gateway-prod-server.postgres.database.azure.com
DB_APP_DATABASE=poweron_app DB_USER=gzxxmcrdhn
DB_APP_USER=gzxxmcrdhn DB_PASSWORD_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3Y1JScGxjZG9TdUkwaHRzSHZhRHpNcDV3N1U2TnIwZ21PRG5TWFFfR1k0N3BiRk5WelVadjlnXzVSTDZ6NXFQNFpqbnJ1R3dNVkJocm1zVEgtSk0xaDRiR19zNDBEbVIzSk51ekNlQ0Z3b0U9
DB_APP_PASSWORD_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3cm5LQWV1OURQanVyTklVaVhJbDI2Y1Itb29pTWFmR2RYM0pyYUhhRUpWZ29tWWwzSmdQeVhScHlHQWVyY0xUTElIdVBJUjh5Zm9ZMzg1ZERNQXZ6TXlGb2tYOGpDX1gzXzB3UUlCM1ZaYWM9 DB_PORT=5432
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
# Security Configuration # Security Configuration
APP_JWT_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3elhfV0Rnd2pQRjlMdkVwX1FnSmRhSzNZUlV5SVpaWXBNX1hpa2xPZGdMSWpnN2ZINHQxeGZnNHJweU5pZjlyYlY5Qm9zOUZEbl9wUEgtZHZXd1NhR19JSG9kbFU4MnFGQnllbFhRQVphRGQyNHlFVWR5VHQyUUpqN0stUmRuY2QyTi1oalczRHpLTEJqWURjZWs4YjZvT2U5YnFqcXEwdEpxV05fX05QMmtrPQ== APP_JWT_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3elhfV0Rnd2pQRjlMdkVwX1FnSmRhSzNZUlV5SVpaWXBNX1hpa2xPZGdMSWpnN2ZINHQxeGZnNHJweU5pZjlyYlY5Qm9zOUZEbl9wUEgtZHZXd1NhR19JSG9kbFU4MnFGQnllbFhRQVphRGQyNHlFVWR5VHQyUUpqN0stUmRuY2QyTi1oalczRHpLTEJqWURjZWs4YjZvT2U5YnFqcXEwdEpxV05fX05QMmtrPQ==

View file

@ -10,7 +10,7 @@ import importlib
import os import os
from typing import Dict, List, Optional, Any from typing import Dict, List, Optional, Any
from modules.datamodels.datamodelAi import AiModel from modules.datamodels.datamodelAi import AiModel
from modules.aicore.aicoreBase import BaseConnectorAi from .aicoreBase import BaseConnectorAi
from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelUam import User
from modules.security.rbacHelpers import checkResourceAccess from modules.security.rbacHelpers import checkResourceAccess
from modules.security.rbac import RbacClass from modules.security.rbac import RbacClass

View file

@ -72,10 +72,16 @@ class ModelSelector:
promptSize = len(prompt.encode("utf-8")) promptSize = len(prompt.encode("utf-8"))
contextSize = len(context.encode("utf-8")) contextSize = len(context.encode("utf-8"))
totalSize = promptSize + contextSize totalSize = promptSize + contextSize
# Convert bytes to approximate tokens (1 token ≈ 4 bytes) # Convert bytes to approximate tokens
promptTokens = promptSize / 4 # Conservative estimate: 1 token ≈ 2 bytes (for safety margin)
contextTokens = contextSize / 4 # Note: Actual tokenization varies by content type and model
totalTokens = totalSize / 4 # - English text: ~4 bytes/token
# - Structured data/JSON: ~2-3 bytes/token
# - Base64/encoded data: ~1.5-2 bytes/token
bytesPerToken = 2 # Conservative estimate for mixed content
promptTokens = promptSize / bytesPerToken
contextTokens = contextSize / bytesPerToken
totalTokens = totalSize / bytesPerToken
logger.debug(f"Request sizes - Prompt: {promptTokens:.0f} tokens ({promptSize} bytes), Context: {contextTokens:.0f} tokens ({contextSize} bytes), Total: {totalTokens:.0f} tokens ({totalSize} bytes)") logger.debug(f"Request sizes - Prompt: {promptTokens:.0f} tokens ({promptSize} bytes), Context: {contextTokens:.0f} tokens ({contextSize} bytes), Total: {totalTokens:.0f} tokens ({totalSize} bytes)")

View file

@ -6,7 +6,7 @@ import os
from typing import Dict, Any, List from typing import Dict, Any, List
from fastapi import HTTPException from fastapi import HTTPException
from modules.shared.configuration import APP_CONFIG from modules.shared.configuration import APP_CONFIG
from modules.aicore.aicoreBase import BaseConnectorAi from .aicoreBase import BaseConnectorAi
from modules.datamodels.datamodelAi import AiModel, PriorityEnum, ProcessingModeEnum, OperationTypeEnum, AiModelCall, AiModelResponse, createOperationTypeRatings from modules.datamodels.datamodelAi import AiModel, PriorityEnum, ProcessingModeEnum, OperationTypeEnum, AiModelCall, AiModelResponse, createOperationTypeRatings
# Configure logger # Configure logger

View file

@ -2,7 +2,7 @@
# All rights reserved. # All rights reserved.
import logging import logging
from typing import List from typing import List
from modules.aicore.aicoreBase import BaseConnectorAi from .aicoreBase import BaseConnectorAi
from modules.datamodels.datamodelAi import AiModel, PriorityEnum, ProcessingModeEnum, OperationTypeEnum, AiModelCall, AiModelResponse, createOperationTypeRatings from modules.datamodels.datamodelAi import AiModel, PriorityEnum, ProcessingModeEnum, OperationTypeEnum, AiModelCall, AiModelResponse, createOperationTypeRatings
# Configure logger # Configure logger

View file

@ -5,7 +5,7 @@ import httpx
from typing import List from typing import List
from fastapi import HTTPException from fastapi import HTTPException
from modules.shared.configuration import APP_CONFIG from modules.shared.configuration import APP_CONFIG
from modules.aicore.aicoreBase import BaseConnectorAi from .aicoreBase import BaseConnectorAi
from modules.datamodels.datamodelAi import AiModel, PriorityEnum, ProcessingModeEnum, OperationTypeEnum, AiModelCall, AiModelResponse, createOperationTypeRatings from modules.datamodels.datamodelAi import AiModel, PriorityEnum, ProcessingModeEnum, OperationTypeEnum, AiModelCall, AiModelResponse, createOperationTypeRatings
# Configure logger # Configure logger
@ -298,7 +298,6 @@ class AiOpenai(BaseConnectorAi):
promptContent = messages[0]["content"] if messages else "" promptContent = messages[0]["content"] if messages else ""
# Parse prompt using AiCallPromptImage model # Parse prompt using AiCallPromptImage model
from modules.datamodels.datamodelAi import AiCallPromptImage
import json import json
try: try:

View file

@ -5,8 +5,8 @@ import httpx
from typing import List from typing import List
from fastapi import HTTPException from fastapi import HTTPException
from modules.shared.configuration import APP_CONFIG from modules.shared.configuration import APP_CONFIG
from modules.aicore.aicoreBase import BaseConnectorAi from .aicoreBase import BaseConnectorAi
from modules.datamodels.datamodelAi import AiModel, PriorityEnum, ProcessingModeEnum, OperationTypeEnum, AiModelCall, AiModelResponse, createOperationTypeRatings, AiCallPromptWebSearch, AiCallPromptWebCrawl from modules.datamodels.datamodelAi import AiModel, PriorityEnum, ProcessingModeEnum, OperationTypeEnum, AiModelCall, AiModelResponse, createOperationTypeRatings, AiCallPromptWebSearch, AiCallPromptWebCrawl, AiCallOptions
from modules.datamodels.datamodelTools import CountryCodes from modules.datamodels.datamodelTools import CountryCodes
# Configure logger # Configure logger
@ -184,7 +184,6 @@ class AiPerplexity(BaseConnectorAi):
] ]
# Create a model call for testing # Create a model call for testing
from modules.datamodels.datamodelAi import AiCallOptions
model = self.getModels()[0] # Get first model for testing model = self.getModels()[0] # Get first model for testing
testCall = AiModelCall( testCall = AiModelCall(
messages=testMessages, messages=testMessages,

View file

@ -10,7 +10,7 @@ from dataclasses import dataclass
from typing import Optional, List, Dict from typing import Optional, List, Dict
from tavily import AsyncTavilyClient from tavily import AsyncTavilyClient
from modules.shared.configuration import APP_CONFIG from modules.shared.configuration import APP_CONFIG
from modules.aicore.aicoreBase import BaseConnectorAi from .aicoreBase import BaseConnectorAi
from modules.datamodels.datamodelAi import AiModel, PriorityEnum, ProcessingModeEnum, OperationTypeEnum, AiModelCall, AiModelResponse, createOperationTypeRatings, AiCallPromptWebSearch, AiCallPromptWebCrawl from modules.datamodels.datamodelAi import AiModel, PriorityEnum, ProcessingModeEnum, OperationTypeEnum, AiModelCall, AiModelResponse, createOperationTypeRatings, AiCallPromptWebSearch, AiCallPromptWebCrawl
from modules.datamodels.datamodelTools import CountryCodes from modules.datamodels.datamodelTools import CountryCodes
@ -728,8 +728,7 @@ class AiTavily(BaseConnectorAi):
maxBreadth=webCrawlPrompt.maxWidth or 40 # Use same as limit for breadth 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 # Format multiple pages from the crawl into a single response
# Return the first result for backwards compatibility, but include total page count
if crawlResults and len(crawlResults) > 0: if crawlResults and len(crawlResults) > 0:
# Get all pages content with error handling # Get all pages content with error handling
allContent = "" allContent = ""

View file

@ -3,9 +3,23 @@
""" """
Authentication and authorization modules for routes and services. Authentication and authorization modules for routes and services.
High-level security functionality that depends on FastAPI and interfaces. 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 ( from .jwtService import (
createAccessToken, createAccessToken,
createRefreshToken, createRefreshToken,
@ -20,22 +34,30 @@ from .tokenRefreshMiddleware import TokenRefreshMiddleware, ProactiveTokenRefres
from .csrf import CSRFMiddleware from .csrf import CSRFMiddleware
__all__ = [ __all__ = [
# Authentication
"getCurrentUser", "getCurrentUser",
"limiter", "limiter",
"SECRET_KEY", "SECRET_KEY",
"ALGORITHM", "ALGORITHM",
"cookieAuth", "cookieAuth",
# Multi-Tenant Context
"RequestContext",
"getRequestContext",
"requireSysAdmin",
# JWT Service
"createAccessToken", "createAccessToken",
"createRefreshToken", "createRefreshToken",
"setAccessTokenCookie", "setAccessTokenCookie",
"setRefreshTokenCookie", "setRefreshTokenCookie",
"clearAccessTokenCookie", "clearAccessTokenCookie",
"clearRefreshTokenCookie", "clearRefreshTokenCookie",
# Token Management
"TokenManager", "TokenManager",
"token_refresh_service", "token_refresh_service",
"TokenRefreshService", "TokenRefreshService",
"TokenRefreshMiddleware", "TokenRefreshMiddleware",
"ProactiveTokenRefreshMiddleware", "ProactiveTokenRefreshMiddleware",
# CSRF
"CSRFMiddleware", "CSRFMiddleware",
] ]

View file

@ -3,10 +3,16 @@
""" """
Authentication module for backend API. Authentication module for backend API.
Handles JWT-based authentication, token generation, and user context. 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 typing import Optional, Dict, Any, Tuple, List
from fastapi import Depends, HTTPException, status, Request, Response from fastapi import Depends, HTTPException, status, Request, Response, Header
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import JWTError, jwt from jose import JWTError, jwt
import logging import logging
@ -15,9 +21,10 @@ from slowapi.util import get_remote_address
from modules.shared.configuration import APP_CONFIG from modules.shared.configuration import APP_CONFIG
from modules.security.rootAccess import getRootDbAppConnector, getRootUser from modules.security.rootAccess import getRootDbAppConnector, getRootUser
from modules.interfaces.interfaceDbAppObjects import getInterface from modules.interfaces.interfaceDbApp import getInterface, getRootInterface
from modules.datamodels.datamodelUam import User, AuthAuthority from modules.datamodels.datamodelUam import User, AuthAuthority, AccessLevel
from modules.datamodels.datamodelSecurity import Token from modules.datamodels.datamodelSecurity import Token
from modules.datamodels.datamodelRbac import AccessRule
# Get Config Data # Get Config Data
SECRET_KEY = APP_CONFIG.get("APP_JWT_KEY_SECRET") SECRET_KEY = APP_CONFIG.get("APP_JWT_KEY_SECRET")
@ -98,15 +105,16 @@ def _getUserBase(token: str = Depends(cookieAuth)) -> User:
if username is None: if username is None:
raise credentialsException raise credentialsException
# Extract mandate ID and user ID from token # Extract user ID from token
mandateId: str = payload.get("mandateId") # MULTI-TENANT: mandateId is NO LONGER in the token - it comes from X-Mandate-Id header
userId: str = payload.get("userId") userId: str = payload.get("userId")
authority: str = payload.get("authenticationAuthority") authority: str = payload.get("authenticationAuthority")
tokenId: Optional[str] = payload.get("jti") tokenId: Optional[str] = payload.get("jti")
sessionId: Optional[str] = payload.get("sid") or payload.get("sessionId") sessionId: Optional[str] = payload.get("sid") or payload.get("sessionId")
if not mandateId or not userId: # Only userId is required in token now (no mandateId)
logger.error(f"Missing context in token: mandateId={mandateId}, userId={userId}") if not userId:
logger.error(f"Missing userId in token")
raise credentialsException raise credentialsException
except JWTError: except JWTError:
@ -129,9 +137,10 @@ def _getUserBase(token: str = Depends(cookieAuth)) -> User:
logger.warning(f"User {username} is disabled") logger.warning(f"User {username} is disabled")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is disabled") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is disabled")
# Ensure the user has the correct context # Ensure the user ID in token matches the user in database
if str(user.mandateId) != str(mandateId) or str(user.id) != str(userId): # MULTI-TENANT: mandateId is NO LONGER checked here - it comes from headers
logger.error(f"User context mismatch: token(mandateId={mandateId}, userId={userId}) vs user(mandateId={user.mandateId}, id={user.id})") if str(user.id) != str(userId):
logger.error(f"User ID mismatch: token(userId={userId}) vs user(id={user.id})")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="User context has changed. Please log in again.", 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] db_token = db_tokens[0]
token_authority = str(db_token.get("authority", "")).lower() token_authority = str(db_token.get("authority", "")).lower()
if token_authority == str(AuthAuthority.LOCAL.value): 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( active_token = appInterface.findActiveTokenById(
tokenId=tokenId, tokenId=tokenId,
userId=user.id, userId=user.id,
authority=AuthAuthority.LOCAL, authority=AuthAuthority.LOCAL,
sessionId=sessionId, sessionId=sessionId,
mandateId=str(mandateId) if mandateId else None, mandateId=None, # Token is no longer mandate-bound
) )
if not active_token: if not active_token:
logger.info( 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 raise credentialsException
else: else:
@ -203,3 +213,183 @@ def getCurrentUser(currentUser: User = Depends(_getUserBase)) -> User:
return currentUser 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.
Security Model:
- Regular users: Must be explicit members of mandates/feature instances
- SysAdmin users: Can access ANY mandate for administrative operations,
but don't get implicit roleIds (no automatic data access rights).
Routes can check ctx.isSysAdmin to allow admin operations.
Args:
request: FastAPI Request object
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 non-SysAdmin user is not member of mandate or has no feature access
"""
ctx = RequestContext(user=currentUser)
isSysAdmin = getattr(currentUser, 'isSysAdmin', False)
# Get root interface for membership checks
rootInterface = getRootInterface()
if mandateId:
# Check mandate membership
membership = rootInterface.getUserMandate(currentUser.id, mandateId)
if membership:
# User is a member - load their roles
if not membership.enabled:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Mandate membership is disabled"
)
ctx.mandateId = mandateId
ctx.roleIds = rootInterface.getRoleIdsForUserMandate(membership.id)
elif isSysAdmin:
# SysAdmin can access any mandate for admin operations
# But they don't get roleIds - no implicit data access
ctx.mandateId = mandateId
# roleIds stays empty - SysAdmin must rely on isSysAdmin flag for authorization
logger.debug(f"SysAdmin {currentUser.id} accessing mandate {mandateId} without membership")
else:
# Regular user without membership - denied
logger.warning(f"User {currentUser.id} is not member of mandate {mandateId}")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not member of mandate"
)
if featureInstanceId:
# Check feature access
access = rootInterface.getFeatureAccess(currentUser.id, featureInstanceId)
if access:
# User has access - load their instance roles
if not access.enabled:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Feature access is disabled"
)
ctx.featureInstanceId = featureInstanceId
instanceRoleIds = rootInterface.getRoleIdsForFeatureAccess(access.id)
ctx.roleIds.extend(instanceRoleIds)
elif isSysAdmin:
# SysAdmin can access any feature instance for admin operations
ctx.featureInstanceId = featureInstanceId
logger.debug(f"SysAdmin {currentUser.id} accessing feature instance {featureInstanceId} without explicit access")
else:
# Regular user without access - denied
logger.warning(f"User {currentUser.id} has no access to feature instance {featureInstanceId}")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="No access to feature instance"
)
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

View file

@ -259,7 +259,7 @@ class TokenManager:
try: try:
if interface is None: if interface is None:
from modules.security.rootAccess import getRootUser from modules.security.rootAccess import getRootUser
from modules.interfaces.interfaceDbAppObjects import getInterface from modules.interfaces.interfaceDbApp import getInterface
rootUser = getRootUser() rootUser = getRootUser()
interface = getInterface(rootUser) interface = getInterface(rootUser)

View file

@ -159,7 +159,7 @@ class TokenRefreshService:
# Get user interface # Get user interface
from modules.security.rootAccess import getRootUser from modules.security.rootAccess import getRootUser
from modules.interfaces.interfaceDbAppObjects import getInterface from modules.interfaces.interfaceDbApp import getInterface
rootUser = getRootUser() rootUser = getRootUser()
root_interface = getInterface(rootUser) root_interface = getInterface(rootUser)
@ -228,7 +228,7 @@ class TokenRefreshService:
# Get user interface # Get user interface
from modules.security.rootAccess import getRootUser from modules.security.rootAccess import getRootUser
from modules.interfaces.interfaceDbAppObjects import getInterface from modules.interfaces.interfaceDbApp import getInterface
rootUser = getRootUser() rootUser = getRootUser()
root_interface = getInterface(rootUser) root_interface = getInterface(rootUser)

View file

@ -40,6 +40,34 @@ class SystemTable(BaseModel):
) )
def _isJsonbType(fieldType) -> bool:
"""Check if a type should be stored as JSONB in PostgreSQL."""
# Direct dict or list
if fieldType == dict or fieldType == list:
return True
# Generic List[X] or Dict[X, Y]
origin = get_origin(fieldType)
if origin in (dict, list):
return True
# Direct Pydantic BaseModel subclass
if isinstance(fieldType, type) and issubclass(fieldType, BaseModel):
return True
# Optional[X] - check the inner type
if origin is Union:
args = get_args(fieldType)
for arg in args:
if arg is type(None):
continue
# Recursively check the inner type
if _isJsonbType(arg):
return True
return False
def _get_model_fields(model_class) -> Dict[str, str]: def _get_model_fields(model_class) -> Dict[str, str]:
"""Get all fields from Pydantic model and map to SQL types.""" """Get all fields from Pydantic model and map to SQL types."""
# Pydantic v2 # Pydantic v2
@ -52,20 +80,7 @@ def _get_model_fields(model_class) -> Dict[str, str]:
# Check for JSONB fields (Dict, List, or complex types) # Check for JSONB fields (Dict, List, or complex types)
# Purely type-based detection - no hardcoded field names # Purely type-based detection - no hardcoded field names
if ( if _isJsonbType(field_type):
field_type == dict
or field_type == list
or (
hasattr(field_type, "__origin__")
and field_type.__origin__ in (dict, list)
)
# Check if field type is directly a Pydantic BaseModel subclass (for nested models like TextMultilingual)
or (isinstance(field_type, type) and issubclass(field_type, BaseModel))
# Check if field type is Optional[BaseModel] (Union with None)
or (hasattr(field_type, "__origin__") and get_origin(field_type) is Union
and any(isinstance(arg, type) and issubclass(arg, BaseModel)
for arg in get_args(field_type) if arg is not type(None)))
):
fields[field_name] = "JSONB" fields[field_name] = "JSONB"
# Simple type mapping # Simple type mapping
elif field_type in (str, type(None)) or ( elif field_type in (str, type(None)) or (
@ -638,14 +653,12 @@ class DatabaseConnector:
# Only set _createdBy if userId is valid (not None or empty string) # Only set _createdBy if userId is valid (not None or empty string)
if self.userId: if self.userId:
record["_createdBy"] = self.userId record["_createdBy"] = self.userId
else: # No warning - empty userId is normal during bootstrap
logger.warning(f"Attempting to create record with empty userId - _createdBy will not be set")
# Also ensure _createdBy is set even if _createdAt exists but _createdBy is missing/empty # Also ensure _createdBy is set even if _createdAt exists but _createdBy is missing/empty
elif "_createdBy" not in record or not record.get("_createdBy"): elif "_createdBy" not in record or not record.get("_createdBy"):
if self.userId: if self.userId:
record["_createdBy"] = self.userId record["_createdBy"] = self.userId
else: # No warning - empty userId is normal during bootstrap
logger.warning(f"Attempting to set _createdBy with empty userId for record {recordId}")
# Always update modification metadata # Always update modification metadata
record["_modifiedAt"] = currentTime record["_modifiedAt"] = currentTime
if self.userId: if self.userId:
@ -855,6 +868,10 @@ class DatabaseConnector:
if recordFilter: if recordFilter:
for field, value in recordFilter.items(): for field, value in recordFilter.items():
if value is None:
# Use IS NULL for None values (= NULL is always false in SQL)
where_conditions.append(f'"{field}" IS NULL')
else:
where_conditions.append(f'"{field}" = %s') where_conditions.append(f'"{field}" = %s')
where_values.append(value) where_values.append(value)
@ -968,7 +985,10 @@ class DatabaseConnector:
record["id"] = str(uuid.uuid4()) record["id"] = str(uuid.uuid4())
# Save record # Save record
self._saveRecord(model_class, record["id"], record) success = self._saveRecord(model_class, record["id"], record)
if not success:
table = model_class.__name__
raise ValueError(f"Failed to save record {record['id']} to table {table}")
# Check if this is the first record in the table and register as initial ID # Check if this is the first record in the table and register as initial ID
table = model_class.__name__ table = model_class.__name__

View file

@ -1,314 +0,0 @@
| Field Name | Type Pattern | Models Using It |
|------------|--------------|-----------------|
| `accumulatedJsonString` | str | JsonAccumulationState |
| `action` | str | ActionDefinition |
| `actionId` | str | ChatDocument, ChatMessage |
| `actionList` | List | TaskItem |
| `actionMethod` | str | ChatMessage |
| `actionName` | str | ChatMessage |
| `actionNumber` | int | ChatLog, ChatDocument, ChatMessage |
| `actionObjective` | str | ActionDefinition, TaskContext |
| `actionProgress` | str | ChatMessage |
| `actionResult` | Any | TaskResult |
| `active` | bool | AutomationDefinition |
| `additionalData` | Dict | AiResponseMetadata |
| `aiPrompt` | str | AiProcessParameters |
| `allSections` | List | JsonAccumulationState |
| `apiUrl` | str | AiModel |
| `authenticationAuthority` | AuthAuthority | User |
| `authority` | AuthAuthority | UserConnection, Token |
| `availableConnections` | list | TaskContext |
| `availableDocuments` | str | TaskContext |
| `base64Encoded` | bool | ContentMetadata, FileData |
| `bytesSent` | int | ChatStat, AiCallResponse |
| `bytesReceived` | int | ChatStat, AiCallResponse |
| `classes` | List | CodeContentPromptArgs |
| `colorMode` | str | ContentMetadata |
| `compressContext` | bool | AiCallOptions |
| `compressPrompt` | bool | AiCallOptions |
| `condition` | str | SelectionRule |
| `confidence` | float | ReviewResult |
| `connectedAt` | float | UserConnection |
| `connectionId` | str | Token |
| `connectionReference` | str | ActionDefinition |
| `connectorType` | str | AiModel |
| `content` | str | Prompt, ContentItem, AiResponse, AiCallResponse, FilePreview |
| `contentAnalysis` | Dict | Observation |
| `contentParts` | List | AiCallRequest, AiProcessParameters, SectionPromptArgs, ChapterStructurePromptArgs, CodeContentPromptArgs, CodeStructurePromptArgs |
| `contentSize` | str | ObservationPreview |
| `contentValidation` | Dict | Observation |
| `contents` | List | ChatContentExtracted |
| `context` | Dict, AccessRuleContext | TaskHandover, UnderstandingResult, AccessRule |
| `contextInfo` | str | CodeContentPromptArgs |
| `costPer1kTokensInput` | float | AiModel |
| `costPer1kTokensOutput` | float | AiModel |
| `country` | str | AiCallPromptWebSearch |
| `create` | AccessLevel | AccessRule, UserPermissions |
| `created` | str | ObservationPreview |
| `createdAt` | float | Token |
| `creationDate` | float | FileItem |
| `criteriaProgress` | dict | TaskContext |
| `currentAction` | int | ChatWorkflow |
| `currentRound` | int | ChatWorkflow |
| `currentTask` | int | ChatWorkflow |
| `data` | str | ContentItem, FileData |
| `dataType` | str | TaskStep |
| `delete` | AccessLevel | AccessRule, UserPermissions |
| `deliverable` | Dict | TaskDefinition |
| `delivered_summary` | str | ContinuationContext |
| `dependencies` | List | TaskItem, TaskStep, CodeContentPromptArgs |
| `description` | TextMultilingual | Role |
| `details` | str | AuthEvent |
| `detectedComplexity` | str | RequestContext |
| `displayName` | str | AiModel |
| `documentData` | Any | ActionDocument, DocumentData |
| `documentId` | str | ObservationPreview |
| `documentList` | DocumentReferenceList | ActionDefinition, ExtractContentParameters |
| `documentName` | str | ActionDocument, DocumentData |
| `documentReferences` | List | UnderstandingResult |
| `documents` | List | ChatMessage, ActionResult, AiResponse |
| `documentsCount` | int | Observation |
| `documentsLabel` | str | ChatMessage, DocumentExchange |
| `durationSec` | float | ContentMetadata |
| `email` | EmailStr | User |
| `enabled` | bool | Mandate, User |
| `encoding` | str | FilePreview |
| `engine` | str | ChatStat |
| `error` | str | ContentMetadata, ActionResult, ActionItem, TaskItem, TaskResult |
| `errorCount` | int | ChatStat, AiCallResponse |
| `estimatedComplexity` | str | TaskStep |
| `eventId` | str | AutomationDefinition |
| `eventType` | str | AuthEvent |
| `execAction` | str | ActionItem |
| `execMethod` | str | ActionItem |
| `execParameters` | Dict | ActionItem |
| `execResultLabel` | str | ActionItem |
| `executedActions` | list | TaskContext |
| `executionLogs` | List | AutomationDefinition |
| `expectedDocumentFormats` | List | ActionItem |
| `expectedFormats` | List | ChatWorkflow, TaskStep |
| `expectedOutputFormat` | str | RequestContext |
| `expectedOutputType` | str | RequestContext |
| `expiresAt` | float | UserConnection, Token |
| `externalEmail` | EmailStr | UserConnection |
| `externalId` | str | UserConnection |
| `externalUsername` | str | UserConnection |
| `extractionMethod` | str | AiResponseMetadata |
| `extractionOptions` | Any | TaskDefinition, ExtractContentParameters |
| `failedActions` | list | TaskContext |
| `failurePatterns` | list | TaskContext |
| `feedback` | str | TaskResult, TaskItem |
| `fileHash` | str | FileItem |
| `fileId` | str | ChatDocument |
| `fileName` | str | FileItem, FilePreview, ChatDocument |
| `fileSize` | int | FileItem, ChatDocument |
| `fileType` | str | CodeContentPromptArgs |
| `filename` | str | AiResponseMetadata, CodeContentPromptArgs |
| `finishedAt` | float | TaskItem |
| `fps` | float | ContentMetadata |
| `fullName` | str | User |
| `functionCall` | Callable | AiModel |
| `functions` | List | CodeContentPromptArgs |
| `generationHint` | str | SectionPromptArgs |
| `handoverType` | str | TaskHandover |
| `hashedPassword` | str | UserInDB |
| `height` | int | ContentMetadata |
| `hierarchyContext` | str | JsonContinuationContexts |
| `hierarchyContextForPrompt` | str | JsonContinuationContexts |
| `id` | str | *Most models* |
| `improvements` | List | TaskHandover, TaskContext, ReviewResult |
| `incomplete_part` | str | ContinuationContext |
| `inputDocuments` | List | TaskHandover |
| `instruction` | str | AiCallPromptWebSearch, AiCallPromptWebCrawl |
| `intention` | Dict | UnderstandingResult |
| `ipAddress` | str | AuthEvent |
| `isAccumulationMode` | bool | JsonAccumulationState |
| `isAggregation` | bool | SectionPromptArgs |
| `isAvailable` | bool | AiModel |
| `isRegeneration` | bool | TaskContext |
| `isSystemRole` | bool | Role |
| `isText` | bool | FilePreview |
| `item` | str | AccessRule |
| `jsonParsingSuccess` | bool | JsonContinuationContexts |
| `kpis` | List | JsonAccumulationState |
| `label` | str | ContentItem, AutomationDefinition |
| `language` | str | Mandate, User, SectionPromptArgs, AiCallPromptWebSearch |
| `last_complete_part` | str | ContinuationContext |
| `last_raw_json` | str | ContinuationContext |
| `lastActivity` | float | ChatWorkflow |
| `lastChecked` | float | UserConnection |
| `lastParsedResult` | Dict | JsonAccumulationState |
| `lastUpdated` | str | AiModel |
| `learnings` | List | ActionDefinition, TaskContext |
| `listFileId` | List | UserInputRequest |
| `logs` | List | ChatWorkflow |
| `mandateId` | str | ChatWorkflow, FileItem, Prompt, User, AutomationDefinition, Token |
| `maxCost` | float | SelectionRule, AiCallOptions |
| `maxDepth` | int | AiCallPromptWebCrawl |
| `maxNumberPages` | int | AiCallPromptWebSearch |
| `maxParts` | int | AiCallOptions |
| `maxProcessingTime` | int | AiCallOptions |
| `maxSteps` | int | ChatWorkflow |
| `maxTokens` | int | AiModel |
| `maxWidth` | int | AiCallPromptWebCrawl |
| `message` | str | ChatLog, ChatMessage |
| `messageHistory` | List | TaskHandover |
| `messageId` | str | ChatDocument |
| `messages` | List | ChatWorkflow, AiModelCall |
| `metadata` | ContentMetadata, AiResponseMetadata, Dict | ContentItem, AiResponse, AiModelResponse, CodeContentPromptArgs |
| `metCriteria` | List | ReviewResult |
| `method` | str | ActionSelection |
| `mime` | str | ObservationPreview |
| `mimeType` | str | ContentMetadata, FileItem, FilePreview, ChatDocument, ActionDocument, DocumentData |
| `minContextLength` | int | AiModel, SelectionRule |
| `minQualityRating` | int | SelectionRule |
| `missingOutputs` | List | ReviewResult |
| `model` | AiModel | AiModelCall |
| `modelId` | str | AiModelResponse |
| `modelName` | str | AiCallResponse |
| `modified` | str | ObservationPreview |
| `name` | str | Mandate, Prompt, ActionSelection, AiModel, SelectionRule, ObservationPreview |
| `nextAction` | str | ReviewResult |
| `nextActionGuidance` | Dict | TaskContext |
| `nextActionObjective` | str | ReviewResult |
| `nextActionParameters` | Dict | ReviewResult |
| `notes` | List | Observation |
| `objective` | str | TaskStep, TaskDefinition |
| `operationId` | str | ChatLog |
| `operationType` | OperationTypeEnum, str | OperationTypeRating, AiCallOptions, AiResponseMetadata |
| `operationTypes` | List | AiModel, SelectionRule |
| `options` | AiCallOptions | AiCallRequest, AiModelCall |
| `originalPrompt` | str | RequestContext |
| `outputDocuments` | List | TaskHandover |
| `outputFormat` | str | ChapterStructurePromptArgs |
| `overlapContext` | str | JsonContinuationContexts |
| `overlap_context` | str | ContinuationContext |
| `overview` | str | TaskPlan |
| `pages` | int | ContentMetadata |
| `parameters` | Dict | ActionParameters, UnderstandingResult, ActionDefinition |
| `parametersContext` | str | ActionDefinition, TaskContext |
| `parentId` | str | ChatLog |
| `parentMessageId` | str | ChatMessage |
| `performance` | Dict | ChatLog |
| `placeholders` | List, Dict | PromptBundle, AutomationDefinition |
| `priceUsd` | float | ChatStat, AiCallResponse |
| `previews` | List | Observation |
| `previousActionResults` | list | TaskContext |
| `previousHandover` | TaskHandover | TaskContext |
| `previousResults` | List | TaskHandover, TaskContext, ReviewContext |
| `previousReviewResult` | dict | TaskContext |
| `priority` | PriorityEnum | AiModel, SelectionRule, AiCallOptions |
| `process` | str | ChatStat |
| `processDocumentsIndividually` | bool | AiCallOptions |
| `processingMode` | ProcessingModeEnum | AiModel, AiCallOptions |
| `processingTime` | float | ChatStat, ActionItem, TaskItem, AiCallResponse, AiModelResponse |
| `progress` | float | ChatLog |
| `prompt` | str | UserInputRequest, PromptBundle, AiCallPromptImage |
| `publishedAt` | float | ChatMessage |
| `qualityRating` | int | AiModel |
| `qualityRequirements` | Dict | TaskStep |
| `qualityScore` | float | ReviewResult |
| `quality` | str | AiCallPromptImage |
| `rating` | int | OperationTypeRating |
| `read` | AccessLevel | AccessRule, UserPermissions |
| `reason` | str | Token, ReviewResult |
| `reference` | str | ObservationPreview |
| `requiredDocuments` | List | TaskDefinition |
| `requiresAnalysis` | bool | RequestContext |
| `requiresContentGeneration` | bool | TaskDefinition |
| `requiresDocumentAnalysis` | bool | TaskDefinition |
| `requiresDocuments` | bool | RequestContext |
| `requiresWebResearch` | bool | RequestContext, TaskDefinition |
| `researchDepth` | str | AiCallPromptWebSearch |
| `resetToken` | str | UserInDB |
| `resetTokenExpires` | float | UserInDB |
| `result` | str | ActionItem |
| `resultFormat` | str | AiCallOptions |
| `resultLabel` | str | ActionResult, Observation |
| `resultLabels` | Dict | TaskItem |
| `resultType` | str | AiProcessParameters |
| `retryCount` | int | ActionItem, TaskItem, TaskContext |
| `retryMax` | int | ActionItem, TaskItem |
| `revokedAt` | float | Token |
| `revokedBy` | str | Token |
| `role` | str | ChatMessage |
| `roleLabel` | str | Role, AccessRule |
| `roleLabels` | List | User |
| `rollbackOnFailure` | bool | TaskItem |
| `roundNumber` | int | ChatLog, ChatDocument, ChatMessage |
| `safetyMargin` | float | AiCallOptions |
| `schedule` | str | AutomationDefinition |
| `schemaVersion` | str | AiResponseMetadata |
| `section` | Dict | SectionPromptArgs |
| `section_count` | int | ContinuationContext |
| `sectionIndex` | int | SectionPromptArgs |
| `sequenceNr` | int | ChatMessage |
| `sessionId` | str | Token |
| `size` | int, str | ContentMetadata, FilePreview, AiCallPromptImage, ObservationPreview |
| `snippet` | str | ObservationPreview |
| `sourceDocuments` | List | AiResponseMetadata |
| `sourceJson` | Dict | ActionDocument, DocumentData |
| `sourceTask` | str | TaskHandover |
| `speedRating` | int | AiModel |
| `stage1Selection` | dict | TaskContext |
| `startedAt` | float | ChatWorkflow, TaskItem |
| `stats` | List | ChatWorkflow |
| `status` | str, TokenStatus, TaskStatus, ConnectionStatus | ChatLog, ChatWorkflow, ChatMessage, UserConnection, AutomationDefinition, ActionItem, TaskItem, TaskResult, ReviewResult, Token |
| `style` | str | AiCallPromptImage |
| `success` | bool | ChatMessage, ActionResult, Observation, TaskResult, AuthEvent, AiModelResponse |
| `successCriteria` | list | TaskStep |
| `successfulActions` | list | TaskContext |
| `summary` | str | ChatMessage |
| `summaryAllowed` | bool | PromptPlaceholder |
| `taskActions` | list | ReviewContext |
| `taskId` | str | TaskResult, TaskHandover |
| `taskNumber` | int | ChatLog, ChatDocument, ChatMessage |
| `taskProgress` | str | ChatMessage |
| `tasks` | List | ChatWorkflow, TaskPlan, UnderstandingResult |
| `taskStep` | TaskStep | TaskContext, ReviewContext |
| `temperature` | float | AiModel, AiCallOptions |
| `template` | str | AutomationDefinition |
| `template_structure` | str | ContinuationContext |
| `timestamp` | float | ChatLog, ActionItem, TaskHandover, AuthEvent |
| `title` | str | AiResponseMetadata |
| `tokenAccess` | str | Token |
| `tokenExpiresAt` | float | UserConnection |
| `tokenRefresh` | str | Token |
| `tokensUsed` | Dict | AiModelResponse |
| `tokenStatus` | str | UserConnection |
| `tokenType` | str | Token |
| `totalActions` | int | ChatWorkflow |
| `totalTasks` | int | ChatWorkflow |
| `type` | str | ChatLog |
| `typeGroup` | str | ObservationPreview |
| `unmetCriteria` | List | ReviewResult |
| `update` | AccessLevel | AccessRule, UserPermissions |
| `url` | str | AiCallPromptWebCrawl |
| `userAgent` | str | AuthEvent |
| `userId` | str | UserConnection, Token, AuthEvent |
| `userInput` | str | TaskItem |
| `userLanguage` | str | UserInputRequest, RequestContext |
| `userMessage` | str | ActionItem, TaskStep, ReviewResult, TaskPlan, ActionDefinition |
| `userPrompt` | str | SectionPromptArgs, ChapterStructurePromptArgs, CodeContentPromptArgs, CodeStructurePromptArgs |
| `username` | str | User |
| `validationMetadata` | Dict | ActionDocument |
| `version` | str | AiModel |
| `view` | bool | AccessRule, UserPermissions |
| `weight` | float | SelectionRule |
| `width` | int | ContentMetadata |
| `workflow` | ChatWorkflow | TaskContext |
| `workflowId` | str | ChatStat, ChatLog, ChatMessage, ChatWorkflow, TaskItem, TaskContext, ReviewContext |
| `workflowMode` | WorkflowModeEnum | ChatWorkflow |
| `workflowSummary` | str | TaskHandover |
---
Can you adapt following fields to Multilingual Fields (`TextMultilingual`):
| Field Name | Models |
|------------|--------|
| `description` | Role | (is already)

View file

@ -10,7 +10,6 @@ Usage examples:
from . import datamodelAi as ai from . import datamodelAi as ai
from . import datamodelUam as uam from . import datamodelUam as uam
from . import datamodelSecurity as security from . import datamodelSecurity as security
from . import datamodelNeutralizer as neutralizer
from . import datamodelChat as chat from . import datamodelChat as chat
from . import datamodelFiles as files from . import datamodelFiles as files
from . import datamodelVoice as voice from . import datamodelVoice as voice

View file

@ -0,0 +1,208 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Audit Log Data Model for database-based audit logging.
This model stores security-relevant audit events for GDPR compliance and security monitoring.
GDPR-Relevant Events:
- User access: login, logout, failed login attempts
- Data access: create, read, update, delete operations on personal data
- Security events: password changes, token refresh, session management
- Key access: encryption/decryption of sensitive data
- GDPR actions: data export, data portability, account deletion
- Mandate/permission changes: user added/removed from mandates, role changes
"""
from typing import Optional
from pydantic import BaseModel, Field
from enum import Enum
import uuid
from modules.shared.timeUtils import getUtcTimestamp
from modules.shared.attributeUtils import registerModelLabels
class AuditCategory(str, Enum):
"""Categories for audit log entries"""
ACCESS = "access" # Login/logout events
KEY = "key" # Encryption key access
DATA = "data" # Data CRUD operations
SECURITY = "security" # Security-related events
GDPR = "gdpr" # GDPR-specific actions
PERMISSION = "permission" # Permission/role changes
SYSTEM = "system" # System-level events
class AuditAction(str, Enum):
"""Actions for audit log entries"""
# Access actions
LOGIN = "login"
LOGIN_FAILED = "login_failed"
LOGOUT = "logout"
TOKEN_REFRESH = "token_refresh"
TOKEN_REVOKE = "token_revoke"
SESSION_EXPIRED = "session_expired"
# Key actions
KEY_ENCODE = "encode"
KEY_DECODE = "decode"
KEY_ACCESS = "key_access"
# Data actions
DATA_CREATE = "create"
DATA_READ = "read"
DATA_UPDATE = "update"
DATA_DELETE = "delete"
DATA_EXPORT = "export"
# Security actions
PASSWORD_CHANGE = "password_change"
PASSWORD_RESET = "password_reset"
MFA_ENABLED = "mfa_enabled"
MFA_DISABLED = "mfa_disabled"
# GDPR actions
GDPR_DATA_EXPORT = "gdpr_data_export"
GDPR_DATA_PORTABILITY = "gdpr_data_portability"
GDPR_ACCOUNT_DELETION = "gdpr_account_deletion"
GDPR_CONSENT_UPDATE = "gdpr_consent_update"
# Permission actions
USER_ADDED_TO_MANDATE = "user_added_to_mandate"
USER_REMOVED_FROM_MANDATE = "user_removed_from_mandate"
ROLE_ASSIGNED = "role_assigned"
ROLE_REVOKED = "role_revoked"
FEATURE_ACCESS_GRANTED = "feature_access_granted"
FEATURE_ACCESS_REVOKED = "feature_access_revoked"
# System actions
SYSTEM_STARTUP = "system_startup"
SYSTEM_SHUTDOWN = "system_shutdown"
CONFIG_CHANGE = "config_change"
class AuditLogEntry(BaseModel):
"""
Audit log entry for database storage.
Stores all security-relevant events for compliance and monitoring.
Entries are immutable once created (append-only audit trail).
"""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique identifier for the audit entry",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
# Timestamp
timestamp: float = Field(
default_factory=getUtcTimestamp,
description="UTC timestamp when the event occurred",
json_schema_extra={"frontend_type": "datetime", "frontend_readonly": True, "frontend_required": True}
)
# Actor identification
userId: str = Field(
description="ID of the user who performed the action (or 'system' for system events)",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
)
username: Optional[str] = Field(
default=None,
description="Username at the time of the event (for historical reference)",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
# Context
mandateId: Optional[str] = Field(
default=None,
description="Mandate context (if applicable)",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
featureInstanceId: Optional[str] = Field(
default=None,
description="Feature instance context (if applicable)",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
# Event classification
category: str = Field(
description="Event category (access, key, data, security, gdpr, permission, system)",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
)
action: str = Field(
description="Specific action performed",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
)
# Event details
resourceType: Optional[str] = Field(
default=None,
description="Type of resource affected (e.g., 'User', 'ChatWorkflow', 'TrusteeContract')",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
resourceId: Optional[str] = Field(
default=None,
description="ID of the affected resource",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
details: Optional[str] = Field(
default=None,
description="Additional details about the event",
json_schema_extra={"frontend_type": "textarea", "frontend_readonly": True, "frontend_required": False}
)
# Request metadata
ipAddress: Optional[str] = Field(
default=None,
description="IP address of the client",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
userAgent: Optional[str] = Field(
default=None,
description="User agent string from the request",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
# Outcome
success: bool = Field(
default=True,
description="Whether the action was successful",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": True}
)
errorMessage: Optional[str] = Field(
default=None,
description="Error message if the action failed",
json_schema_extra={"frontend_type": "textarea", "frontend_readonly": True, "frontend_required": False}
)
# Register labels for internationalization
registerModelLabels(
"AuditLogEntry",
{"en": "Audit Log Entry", "de": "Audit-Log-Eintrag", "fr": "Entrée du journal d'audit"},
{
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"timestamp": {"en": "Timestamp", "de": "Zeitstempel", "fr": "Horodatage"},
"userId": {"en": "User ID", "de": "Benutzer-ID", "fr": "ID utilisateur"},
"username": {"en": "Username", "de": "Benutzername", "fr": "Nom d'utilisateur"},
"mandateId": {"en": "Mandate ID", "de": "Mandanten-ID", "fr": "ID du mandat"},
"featureInstanceId": {"en": "Feature Instance ID", "de": "Feature-Instanz-ID", "fr": "ID de l'instance"},
"category": {"en": "Category", "de": "Kategorie", "fr": "Catégorie"},
"action": {"en": "Action", "de": "Aktion", "fr": "Action"},
"resourceType": {"en": "Resource Type", "de": "Ressourcentyp", "fr": "Type de ressource"},
"resourceId": {"en": "Resource ID", "de": "Ressourcen-ID", "fr": "ID de ressource"},
"details": {"en": "Details", "de": "Details", "fr": "Détails"},
"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"},
"errorMessage": {"en": "Error Message", "de": "Fehlermeldung", "fr": "Message d'erreur"},
},
)

View file

@ -11,6 +11,7 @@ import uuid
class ChatStat(BaseModel): class ChatStat(BaseModel):
"""Statistics for chat operations. User-owned, no mandate context."""
id: str = Field( id: str = Field(
default_factory=lambda: str(uuid.uuid4()), description="Primary key" default_factory=lambda: str(uuid.uuid4()), description="Primary key"
) )
@ -46,6 +47,7 @@ registerModelLabels(
class ChatLog(BaseModel): class ChatLog(BaseModel):
"""Log entries for chat workflows. User-owned, no mandate context."""
id: str = Field( id: str = Field(
default_factory=lambda: str(uuid.uuid4()), description="Primary key" default_factory=lambda: str(uuid.uuid4()), description="Primary key"
) )
@ -91,6 +93,7 @@ registerModelLabels(
class ChatDocument(BaseModel): class ChatDocument(BaseModel):
"""Documents attached to chat messages. User-owned, no mandate context."""
id: str = Field( id: str = Field(
default_factory=lambda: str(uuid.uuid4()), description="Primary key" default_factory=lambda: str(uuid.uuid4()), description="Primary key"
) )
@ -197,6 +200,7 @@ registerModelLabels(
class ChatMessage(BaseModel): class ChatMessage(BaseModel):
"""Messages in chat workflows. User-owned, no mandate context."""
id: str = Field( id: str = Field(
default_factory=lambda: str(uuid.uuid4()), description="Primary key" default_factory=lambda: str(uuid.uuid4()), description="Primary key"
) )
@ -216,11 +220,12 @@ class ChatMessage(BaseModel):
) )
role: str = Field(description="Role of the message sender") role: str = Field(description="Role of the message sender")
status: str = Field(description="Status of the message (first, step, last)") status: str = Field(description="Status of the message (first, step, last)")
sequenceNr: int = Field( sequenceNr: Optional[int] = Field(
default=0,
description="Sequence number of the message (set automatically)" description="Sequence number of the message (set automatically)"
) )
publishedAt: float = Field( publishedAt: Optional[float] = Field(
default_factory=getUtcTimestamp, default=None,
description="When the message was published (UTC timestamp in seconds)", description="When the message was published (UTC timestamp in seconds)",
) )
success: Optional[bool] = Field( success: Optional[bool] = Field(
@ -294,8 +299,8 @@ registerModelLabels(
class ChatWorkflow(BaseModel): class ChatWorkflow(BaseModel):
"""Chat workflow container. User-owned, no mandate context."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
mandateId: str = Field(description="ID of the mandate this workflow belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
status: str = Field(default="running", description="Current status of the workflow", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [ status: str = Field(default="running", description="Current status of the workflow", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
{"value": "running", "label": {"en": "Running", "fr": "En cours"}}, {"value": "running", "label": {"en": "Running", "fr": "En cours"}},
{"value": "completed", "label": {"en": "Completed", "fr": "Terminé"}}, {"value": "completed", "label": {"en": "Completed", "fr": "Terminé"}},
@ -369,7 +374,6 @@ registerModelLabels(
{"en": "Chat Workflow", "fr": "Flux de travail de chat"}, {"en": "Chat Workflow", "fr": "Flux de travail de chat"},
{ {
"id": {"en": "ID", "fr": "ID"}, "id": {"en": "ID", "fr": "ID"},
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat"},
"status": {"en": "Status", "fr": "Statut"}, "status": {"en": "Status", "fr": "Statut"},
"name": {"en": "Name", "fr": "Nom"}, "name": {"en": "Name", "fr": "Nom"},
"currentRound": {"en": "Current Round", "fr": "Tour actuel"}, "currentRound": {"en": "Current Round", "fr": "Tour actuel"},
@ -988,38 +992,3 @@ registerModelLabels(
"placeholders": {"en": "Placeholders", "fr": "Espaces réservés"}, "placeholders": {"en": "Placeholders", "fr": "Espaces réservés"},
}, },
) )
class AutomationDefinition(BaseModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
mandateId: str = Field(description="Mandate ID", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
label: str = Field(description="User-friendly name", json_schema_extra={"frontend_type": "text", "frontend_required": True})
schedule: str = Field(description="Cron schedule pattern", json_schema_extra={"frontend_type": "select", "frontend_required": True, "frontend_options": [
{"value": "0 */4 * * *", "label": {"en": "Every 4 hours", "fr": "Toutes les 4 heures"}},
{"value": "0 22 * * *", "label": {"en": "Daily at 22:00", "fr": "Quotidien à 22:00"}},
{"value": "0 10 * * 1", "label": {"en": "Weekly Monday 10:00", "fr": "Hebdomadaire lundi 10:00"}}
]})
template: str = Field(description="JSON template with placeholders (format: {{KEY:PLACEHOLDER_NAME}})", json_schema_extra={"frontend_type": "textarea", "frontend_required": True})
placeholders: Dict[str, str] = Field(default_factory=dict, description="Dictionary of placeholder key/value pairs (e.g., {'connectionName': 'MyConnection', 'sharepointFolderNameSource': '/folder/path', 'webResearchUrl': 'https://...', 'webResearchPrompt': '...', 'documentPrompt': '...'})", json_schema_extra={"frontend_type": "text"})
active: bool = Field(default=False, description="Whether automation should be launched in event handler", json_schema_extra={"frontend_type": "checkbox", "frontend_required": False})
eventId: Optional[str] = Field(None, description="Event ID from event management (None if not registered)", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
status: Optional[str] = Field(None, description="Status: 'active' if event is registered, 'inactive' if not (computed, readonly)", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
executionLogs: List[Dict[str, Any]] = Field(default_factory=list, description="List of execution logs, each containing timestamp, workflowId, status, and messages", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
registerModelLabels(
"AutomationDefinition",
{"en": "Automation Definition", "fr": "Définition d'automatisation"},
{
"id": {"en": "ID", "fr": "ID"},
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat"},
"label": {"en": "Label", "fr": "Libellé"},
"schedule": {"en": "Schedule", "fr": "Planification"},
"template": {"en": "Template", "fr": "Modèle"},
"placeholders": {"en": "Placeholders", "fr": "Espaces réservés"},
"active": {"en": "Active", "fr": "Actif"},
"eventId": {"en": "Event ID", "fr": "ID de l'événement"},
"status": {"en": "Status", "fr": "Statut"},
"executionLogs": {"en": "Execution Logs", "fr": "Journaux d'exécution"},
},
)

View file

@ -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
from modules.datamodels.datamodelUtils import TextMultilingual
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: TextMultilingual = Field(
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é"},
},
)

View file

@ -12,7 +12,8 @@ import base64
class FileItem(BaseModel): class FileItem(BaseModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
mandateId: str = Field(description="ID of the mandate this file belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) mandateId: Optional[str] = Field(default="", description="ID of the mandate this file belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
featureInstanceId: Optional[str] = Field(default="", description="ID of the feature instance this file belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
fileName: str = Field(description="Name of the file", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True}) fileName: str = Field(description="Name of the file", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True})
mimeType: str = Field(description="MIME type of the file", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) mimeType: str = Field(description="MIME type of the file", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
fileHash: str = Field(description="Hash of the file", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) fileHash: str = Field(description="Hash of the file", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
@ -25,6 +26,7 @@ registerModelLabels(
{ {
"id": {"en": "ID", "fr": "ID"}, "id": {"en": "ID", "fr": "ID"},
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat"}, "mandateId": {"en": "Mandate ID", "fr": "ID du mandat"},
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"},
"fileName": {"en": "fileName", "fr": "Nom de fichier"}, "fileName": {"en": "fileName", "fr": "Nom de fichier"},
"mimeType": {"en": "MIME Type", "fr": "Type MIME"}, "mimeType": {"en": "MIME Type", "fr": "Type MIME"},
"fileHash": {"en": "File Hash", "fr": "Hash du fichier"}, "fileHash": {"en": "File Hash", "fr": "Hash du fichier"},

View file

@ -0,0 +1,133 @@
# 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
targetUsername: str = Field(
description="Username of the invited user (must match on acceptance)",
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True}
)
email: Optional[str] = Field(
default=None,
description="Email address to send invitation link (optional)",
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}
)
# Email-Status
emailSent: bool = Field(
default=False,
description="Whether the invitation email was successfully sent",
json_schema_extra={"frontend_type": "checkbox", "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"},
"targetUsername": {"en": "Target Username", "de": "Ziel-Benutzername", "fr": "Nom d'utilisateur cible"},
"email": {"en": "Email (optional)", "de": "E-Mail (optional)", "fr": "Email (optionnel)"},
"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"},
"emailSent": {"en": "Email Sent", "de": "E-Mail gesendet", "fr": "Email envoyé"},
"maxUses": {"en": "Max Uses", "de": "Max. Verwendungen", "fr": "Utilisations max"},
"currentUses": {"en": "Current Uses", "de": "Aktuelle Verwendungen", "fr": "Utilisations actuelles"},
},
)

View file

@ -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_visible": False, "frontend_required": False}
)
userId: str = Field(
description="FK → User.id (CASCADE DELETE)",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/users/", "frontend_fk_display_field": "username"}
)
mandateId: str = Field(
description="FK → Mandate.id (CASCADE DELETE)",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/mandates/", "frontend_fk_display_field": "name"}
)
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_visible": False, "frontend_required": False}
)
userId: str = Field(
description="FK → User.id (CASCADE DELETE)",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/users/", "frontend_fk_display_field": "username"}
)
featureInstanceId: str = Field(
description="FK → FeatureInstance.id (CASCADE DELETE)",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/feature-instances/", "frontend_fk_display_field": "name"}
)
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_visible": False, "frontend_required": False}
)
userMandateId: str = Field(
description="FK → UserMandate.id (CASCADE DELETE)",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/user-mandates/", "frontend_fk_display_field": "userId"}
)
roleId: str = Field(
description="FK → Role.id (CASCADE DELETE)",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/rbac/roles", "frontend_fk_display_field": "roleLabel"}
)
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_visible": False, "frontend_required": False}
)
featureAccessId: str = Field(
description="FK → FeatureAccess.id (CASCADE DELETE)",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/feature-access/", "frontend_fk_display_field": "userId"}
)
roleId: str = Field(
description="FK → Role.id (CASCADE DELETE)",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/rbac/roles", "frontend_fk_display_field": "roleLabel"}
)
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"},
},
)

View file

@ -45,6 +45,10 @@ class MessagingSubscription(BaseModel):
description="ID of the mandate this subscription belongs to", description="ID of the mandate this subscription belongs to",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
) )
featureInstanceId: str = Field(
description="ID of the feature instance this subscription belongs to",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
description: Optional[str] = Field( description: Optional[str] = Field(
default=None, default=None,
description="Description of the subscription", description="Description of the subscription",
@ -92,6 +96,7 @@ registerModelLabels(
"subscriptionId": {"en": "Subscription ID", "fr": "ID d'abonnement"}, "subscriptionId": {"en": "Subscription ID", "fr": "ID d'abonnement"},
"subscriptionLabel": {"en": "Subscription Label", "fr": "Label d'abonnement"}, "subscriptionLabel": {"en": "Subscription Label", "fr": "Label d'abonnement"},
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat"}, "mandateId": {"en": "Mandate ID", "fr": "ID du mandat"},
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"},
"description": {"en": "Description", "fr": "Description"}, "description": {"en": "Description", "fr": "Description"},
"isSystemSubscription": {"en": "System Subscription", "fr": "Abonnement système"}, "isSystemSubscription": {"en": "System Subscription", "fr": "Abonnement système"},
"enabled": {"en": "Enabled", "fr": "Activé"}, "enabled": {"en": "Enabled", "fr": "Activé"},
@ -110,6 +115,14 @@ class MessagingSubscriptionRegistration(BaseModel):
description="Unique ID of the registration", description="Unique ID of the registration",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
) )
mandateId: str = Field(
description="ID of the mandate this registration belongs to",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
featureInstanceId: str = Field(
description="ID of the feature instance this registration belongs to",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
subscriptionId: str = Field( subscriptionId: str = Field(
description="ID of the subscription this registration belongs to", description="ID of the subscription this registration belongs to",
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True} json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True}
@ -161,6 +174,8 @@ registerModelLabels(
{"en": "Messaging Registration", "fr": "Inscription à la messagerie"}, {"en": "Messaging Registration", "fr": "Inscription à la messagerie"},
{ {
"id": {"en": "ID", "fr": "ID"}, "id": {"en": "ID", "fr": "ID"},
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat"},
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"},
"subscriptionId": {"en": "Subscription ID", "fr": "ID d'abonnement"}, "subscriptionId": {"en": "Subscription ID", "fr": "ID d'abonnement"},
"userId": {"en": "User ID", "fr": "ID utilisateur"}, "userId": {"en": "User ID", "fr": "ID utilisateur"},
"channel": {"en": "Channel", "fr": "Canal"}, "channel": {"en": "Channel", "fr": "Canal"},
@ -179,6 +194,14 @@ class MessagingDelivery(BaseModel):
description="Unique ID of the delivery", description="Unique ID of the delivery",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
) )
mandateId: str = Field(
description="ID of the mandate this delivery belongs to",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
featureInstanceId: str = Field(
description="ID of the feature instance this delivery belongs to",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
subscriptionId: str = Field( subscriptionId: str = Field(
description="ID of the subscription this delivery belongs to", description="ID of the subscription this delivery belongs to",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
@ -239,6 +262,8 @@ registerModelLabels(
{"en": "Messaging Delivery", "fr": "Livraison de messagerie"}, {"en": "Messaging Delivery", "fr": "Livraison de messagerie"},
{ {
"id": {"en": "ID", "fr": "ID"}, "id": {"en": "ID", "fr": "ID"},
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat"},
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"},
"subscriptionId": {"en": "Subscription ID", "fr": "ID d'abonnement"}, "subscriptionId": {"en": "Subscription ID", "fr": "ID d'abonnement"},
"userId": {"en": "User ID", "fr": "ID utilisateur"}, "userId": {"en": "User ID", "fr": "ID utilisateur"},
"channel": {"en": "Channel", "fr": "Canal"}, "channel": {"en": "Channel", "fr": "Canal"},

View file

@ -0,0 +1,209 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Notification model for in-app notifications.
Supports actionable notifications (e.g., invitation accept/decline).
"""
import uuid
from typing import Optional, List
from enum import Enum
from pydantic import BaseModel, Field, ConfigDict
from modules.shared.attributeUtils import registerModelLabels
from modules.shared.timeUtils import getUtcTimestamp
class NotificationType(str, Enum):
"""Types of notifications"""
INVITATION = "invitation" # Einladung zu Mandat/Feature
SYSTEM = "system" # System-Nachrichten
WORKFLOW = "workflow" # Workflow-Status Updates
MENTION = "mention" # Erwähnung in Chat/Kommentar
class NotificationStatus(str, Enum):
"""Status of a notification"""
UNREAD = "unread" # Noch nicht gelesen
READ = "read" # Gelesen
ACTIONED = "actioned" # Aktion wurde durchgeführt
DISMISSED = "dismissed" # Verworfen/Geschlossen
class NotificationAction(BaseModel):
"""Possible action for a notification"""
actionId: str = Field(
description="Unique identifier for the action (e.g., 'accept', 'decline')"
)
label: str = Field(
description="Display label for the action button"
)
style: str = Field(
default="default",
description="Button style: 'primary', 'danger', 'default'"
)
class UserNotification(BaseModel):
"""
In-app notification for a user.
Supports actionable notifications with accept/decline buttons.
"""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the notification",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
userId: str = Field(
description="Target user ID for this notification",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
)
# Notification type and status
type: NotificationType = Field(
default=NotificationType.SYSTEM,
description="Type of notification",
json_schema_extra={
"frontend_type": "select",
"frontend_readonly": True,
"frontend_required": True,
"frontend_options": [
{"value": "invitation", "label": {"en": "Invitation", "de": "Einladung"}},
{"value": "system", "label": {"en": "System", "de": "System"}},
{"value": "workflow", "label": {"en": "Workflow", "de": "Workflow"}},
{"value": "mention", "label": {"en": "Mention", "de": "Erwähnung"}}
]
}
)
status: NotificationStatus = Field(
default=NotificationStatus.UNREAD,
description="Current status of the notification",
json_schema_extra={
"frontend_type": "select",
"frontend_readonly": True,
"frontend_required": False,
"frontend_options": [
{"value": "unread", "label": {"en": "Unread", "de": "Ungelesen"}},
{"value": "read", "label": {"en": "Read", "de": "Gelesen"}},
{"value": "actioned", "label": {"en": "Actioned", "de": "Bearbeitet"}},
{"value": "dismissed", "label": {"en": "Dismissed", "de": "Verworfen"}}
]
}
)
# Content
title: str = Field(
description="Notification title",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
)
message: str = Field(
description="Notification message/body",
json_schema_extra={"frontend_type": "textarea", "frontend_readonly": True, "frontend_required": True}
)
icon: Optional[str] = Field(
default=None,
description="Optional icon identifier (e.g., 'mail', 'warning', 'info')",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
# Reference to triggering object (for actionable notifications)
referenceType: Optional[str] = Field(
default=None,
description="Type of referenced object (e.g., 'Invitation', 'Workflow')",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
referenceId: Optional[str] = Field(
default=None,
description="ID of referenced object",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
# Actions (for actionable notifications like invitations)
actions: Optional[List[NotificationAction]] = Field(
default=None,
description="List of possible actions for this notification",
json_schema_extra={"frontend_type": "json", "frontend_readonly": True, "frontend_required": False}
)
# Action result (when user takes action)
actionTaken: Optional[str] = Field(
default=None,
description="Which action was taken (actionId)",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
actionResult: Optional[str] = Field(
default=None,
description="Result message from the action",
json_schema_extra={"frontend_type": "textarea", "frontend_readonly": True, "frontend_required": False}
)
# Timestamps
createdAt: float = Field(
default_factory=getUtcTimestamp,
description="When the notification was created (UTC timestamp)",
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}
)
readAt: Optional[float] = Field(
default=None,
description="When the notification was read (UTC timestamp)",
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}
)
actionedAt: Optional[float] = Field(
default=None,
description="When action was taken (UTC timestamp)",
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}
)
expiresAt: Optional[float] = Field(
default=None,
description="When the notification expires (optional, UTC timestamp)",
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}
)
model_config = ConfigDict(use_enum_values=True)
registerModelLabels(
"UserNotification",
{"en": "Notification", "de": "Benachrichtigung", "fr": "Notification"},
{
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"userId": {"en": "User", "de": "Benutzer", "fr": "Utilisateur"},
"type": {"en": "Type", "de": "Typ", "fr": "Type"},
"status": {"en": "Status", "de": "Status", "fr": "Statut"},
"title": {"en": "Title", "de": "Titel", "fr": "Titre"},
"message": {"en": "Message", "de": "Nachricht", "fr": "Message"},
"icon": {"en": "Icon", "de": "Symbol", "fr": "Icône"},
"referenceType": {"en": "Reference Type", "de": "Referenz-Typ", "fr": "Type de référence"},
"referenceId": {"en": "Reference ID", "de": "Referenz-ID", "fr": "ID de référence"},
"actions": {"en": "Actions", "de": "Aktionen", "fr": "Actions"},
"actionTaken": {"en": "Action Taken", "de": "Durchgeführte Aktion", "fr": "Action effectuée"},
"actionResult": {"en": "Action Result", "de": "Aktions-Ergebnis", "fr": "Résultat de l'action"},
"createdAt": {"en": "Created At", "de": "Erstellt am", "fr": "Créé le"},
"readAt": {"en": "Read At", "de": "Gelesen am", "fr": "Lu le"},
"actionedAt": {"en": "Actioned At", "de": "Bearbeitet am", "fr": "Traité le"},
"expiresAt": {"en": "Expires At", "de": "Gültig bis", "fr": "Expire le"},
},
)
registerModelLabels(
"NotificationType",
{"en": "Notification Type", "de": "Benachrichtigungs-Typ", "fr": "Type de notification"},
{
"invitation": {"en": "Invitation", "de": "Einladung", "fr": "Invitation"},
"system": {"en": "System", "de": "System", "fr": "Système"},
"workflow": {"en": "Workflow", "de": "Workflow", "fr": "Workflow"},
"mention": {"en": "Mention", "de": "Erwähnung", "fr": "Mention"},
},
)
registerModelLabels(
"NotificationStatus",
{"en": "Notification Status", "de": "Benachrichtigungs-Status", "fr": "Statut de notification"},
{
"unread": {"en": "Unread", "de": "Ungelesen", "fr": "Non lu"},
"read": {"en": "Read", "de": "Gelesen", "fr": "Lu"},
"actioned": {"en": "Actioned", "de": "Bearbeitet", "fr": "Traité"},
"dismissed": {"en": "Dismissed", "de": "Verworfen", "fr": "Rejeté"},
},
)

View file

@ -1,9 +1,16 @@
# Copyright (c) 2025 Patrick Motsch # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # 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 import uuid
from typing import Optional, Dict from typing import Optional
from enum import Enum from enum import Enum
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from modules.shared.attributeUtils import registerModelLabels from modules.shared.attributeUtils import registerModelLabels
@ -19,11 +26,21 @@ class AccessRuleContext(str, Enum):
class Role(BaseModel): 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( id: str = Field(
default_factory=lambda: str(uuid.uuid4()), default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the role", description="Unique ID of the role",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False}
) )
roleLabel: str = Field( roleLabel: str = Field(
description="Unique role label identifier (e.g., 'admin', 'user', 'viewer')", description="Unique role label identifier (e.g., 'admin', 'user', 'viewer')",
@ -33,106 +50,163 @@ class Role(BaseModel):
description="Role description in multiple languages", description="Role description in multiple languages",
json_schema_extra={"frontend_type": "multilingual", "frontend_readonly": False, "frontend_required": True} 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": "select", "frontend_readonly": True, "frontend_visible": True, "frontend_required": False, "frontend_fk_source": "/api/mandates/", "frontend_fk_display_field": "name"}
)
featureInstanceId: Optional[str] = Field(
default=None,
description="FK → FeatureInstance.id (CASCADE DELETE). Null = Mandate-level or Global role.",
json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_visible": True, "frontend_required": False, "frontend_fk_source": "/api/feature-instances/", "frontend_fk_display_field": "name"}
)
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_visible": False, "frontend_required": False}
)
isSystemRole: bool = Field( isSystemRole: bool = Field(
False, default=False,
description="Whether this is a system role that cannot be deleted", description="Whether this is a system role that cannot be deleted",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": False} json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": False}
) )
registerModelLabels( registerModelLabels(
"Role", "Role",
{"en": "Role", "fr": "Rôle"}, {"en": "Role", "de": "Rolle", "fr": "Rôle"},
{ {
"id": {"en": "ID", "fr": "ID"}, "id": {"en": "ID", "de": "ID", "fr": "ID"},
"roleLabel": {"en": "Role Label", "fr": "Label du rôle"}, "roleLabel": {"en": "Role Label", "de": "Rollen-Label", "fr": "Label du rôle"},
"description": {"en": "Description", "fr": "Description"}, "description": {"en": "Description", "de": "Beschreibung", "fr": "Description"},
"isSystemRole": {"en": "System Role", "fr": "Rôle système"}, "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): 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( id: str = Field(
default_factory=lambda: str(uuid.uuid4()), default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the access rule", description="Unique ID of the access rule",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False}
) )
roleLabel: str = Field( roleId: str = Field(
description="Role label this rule applies to", description="FK → Role.id (CASCADE DELETE!)",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_options": "user.role"} json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": True, "frontend_fk_source": "/api/rbac/roles", "frontend_fk_display_field": "roleLabel"}
) )
context: AccessRuleContext = Field( context: AccessRuleContext = Field(
description="Context type: DATA (database), UI (interface), RESOURCE (system resources)", description="Context type: DATA (database), UI (interface), RESOURCE (system resources). IMMUTABLE!",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_options": [ json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": True, "frontend_options": [
{"value": "DATA", "label": {"en": "Data", "fr": "Données"}}, {"value": "DATA", "label": {"en": "Data", "de": "Daten", "fr": "Données"}},
{"value": "UI", "label": {"en": "UI", "fr": "Interface"}}, {"value": "UI", "label": {"en": "UI", "de": "Oberfläche", "fr": "Interface"}},
{"value": "RESOURCE", "label": {"en": "Resource", "fr": "Ressource"}} {"value": "RESOURCE", "label": {"en": "Resource", "de": "Ressource", "fr": "Ressource"}}
]} ]}
) )
item: Optional[str] = Field( item: Optional[str] = Field(
None, default=None,
description="Item identifier (null = all items in context). Format: DATA: '<table>' or '<table>.<field>', UI: cascading string (e.g., 'playground.voice.settings'), RESOURCE: cascading string (e.g., 'ai.model.anthropic')", description="Item identifier (null = all items in context). Format: DATA: '<table>' or '<table>.<field>', 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} json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False}
) )
view: bool = Field( view: bool = Field(
False, default=False,
description="View permission: if true, item is visible/enabled. Only objects with view=true are shown.", 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} json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": True}
) )
read: Optional[AccessLevel] = Field( read: Optional[AccessLevel] = Field(
None, default=None,
description="Read permission level (only for DATA context)", description="Read permission level (only for DATA context)",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [ 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": "a", "label": {"en": "All Records", "de": "Alle Datensätze", "fr": "Tous les enregistrements"}},
{"value": "m", "label": {"en": "My Records", "fr": "Mes enregistrements"}}, {"value": "m", "label": {"en": "My Records", "de": "Meine Datensätze", "fr": "Mes enregistrements"}},
{"value": "g", "label": {"en": "Group Records", "fr": "Enregistrements du groupe"}}, {"value": "g", "label": {"en": "Group Records", "de": "Gruppen-Datensätze", "fr": "Enregistrements du groupe"}},
{"value": "n", "label": {"en": "No Access", "fr": "Aucun accès"}} {"value": "n", "label": {"en": "No Access", "de": "Kein Zugriff", "fr": "Aucun accès"}}
]} ]}
) )
create: Optional[AccessLevel] = Field( create: Optional[AccessLevel] = Field(
None, default=None,
description="Create permission level (only for DATA context)", description="Create permission level (only for DATA context)",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [ 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": "a", "label": {"en": "All Records", "de": "Alle Datensätze", "fr": "Tous les enregistrements"}},
{"value": "m", "label": {"en": "My Records", "fr": "Mes enregistrements"}}, {"value": "m", "label": {"en": "My Records", "de": "Meine Datensätze", "fr": "Mes enregistrements"}},
{"value": "g", "label": {"en": "Group Records", "fr": "Enregistrements du groupe"}}, {"value": "g", "label": {"en": "Group Records", "de": "Gruppen-Datensätze", "fr": "Enregistrements du groupe"}},
{"value": "n", "label": {"en": "No Access", "fr": "Aucun accès"}} {"value": "n", "label": {"en": "No Access", "de": "Kein Zugriff", "fr": "Aucun accès"}}
]} ]}
) )
update: Optional[AccessLevel] = Field( update: Optional[AccessLevel] = Field(
None, default=None,
description="Update permission level (only for DATA context)", description="Update permission level (only for DATA context)",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [ 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": "a", "label": {"en": "All Records", "de": "Alle Datensätze", "fr": "Tous les enregistrements"}},
{"value": "m", "label": {"en": "My Records", "fr": "Mes enregistrements"}}, {"value": "m", "label": {"en": "My Records", "de": "Meine Datensätze", "fr": "Mes enregistrements"}},
{"value": "g", "label": {"en": "Group Records", "fr": "Enregistrements du groupe"}}, {"value": "g", "label": {"en": "Group Records", "de": "Gruppen-Datensätze", "fr": "Enregistrements du groupe"}},
{"value": "n", "label": {"en": "No Access", "fr": "Aucun accès"}} {"value": "n", "label": {"en": "No Access", "de": "Kein Zugriff", "fr": "Aucun accès"}}
]} ]}
) )
delete: Optional[AccessLevel] = Field( delete: Optional[AccessLevel] = Field(
None, default=None,
description="Delete permission level (only for DATA context)", description="Delete permission level (only for DATA context)",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [ 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": "a", "label": {"en": "All Records", "de": "Alle Datensätze", "fr": "Tous les enregistrements"}},
{"value": "m", "label": {"en": "My Records", "fr": "Mes enregistrements"}}, {"value": "m", "label": {"en": "My Records", "de": "Meine Datensätze", "fr": "Mes enregistrements"}},
{"value": "g", "label": {"en": "Group Records", "fr": "Enregistrements du groupe"}}, {"value": "g", "label": {"en": "Group Records", "de": "Gruppen-Datensätze", "fr": "Enregistrements du groupe"}},
{"value": "n", "label": {"en": "No Access", "fr": "Aucun accès"}} {"value": "n", "label": {"en": "No Access", "de": "Kein Zugriff", "fr": "Aucun accès"}}
]} ]}
) )
registerModelLabels( registerModelLabels(
"AccessRule", "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"}, "id": {"en": "ID", "de": "ID", "fr": "ID"},
"roleLabel": {"en": "Role Label", "fr": "Label du rôle"}, "roleId": {"en": "Role", "de": "Rolle", "fr": "Rôle"},
"context": {"en": "Context", "fr": "Contexte"}, "context": {"en": "Context", "de": "Kontext", "fr": "Contexte"},
"item": {"en": "Item", "fr": "Élément"}, "item": {"en": "Item", "de": "Element", "fr": "Élément"},
"view": {"en": "View", "fr": "Vue"}, "view": {"en": "View", "de": "Anzeigen", "fr": "Vue"},
"read": {"en": "Read", "fr": "Lecture"}, "read": {"en": "Read", "de": "Lesen", "fr": "Lecture"},
"create": {"en": "Create", "fr": "Créer"}, "create": {"en": "Create", "de": "Erstellen", "fr": "Créer"},
"update": {"en": "Update", "fr": "Mettre à jour"}, "update": {"en": "Update", "de": "Aktualisieren", "fr": "Mettre à jour"},
"delete": {"en": "Delete", "fr": "Supprimer"}, "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."
)

View file

@ -1,6 +1,13 @@
# Copyright (c) 2025 Patrick Motsch # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # 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 typing import Optional
from pydantic import BaseModel, Field, ConfigDict from pydantic import BaseModel, Field, ConfigDict
@ -17,6 +24,14 @@ class TokenStatus(str, Enum):
class Token(BaseModel): 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 id: Optional[str] = None
userId: str userId: str
authority: AuthAuthority authority: AuthAuthority
@ -45,37 +60,36 @@ class Token(BaseModel):
sessionId: Optional[str] = Field( sessionId: Optional[str] = Field(
None, description="Logical session grouping for logout revocation" None, description="Logical session grouping for logout revocation"
) )
mandateId: Optional[str] = Field( # ENTFERNT: mandateId - Token ist nicht mehr Mandant-spezifisch
None, description="Mandate ID for tenant scoping of the token" # Mandant-Kontext wird per Request-Header (X-Mandate-Id) bestimmt
)
model_config = ConfigDict(use_enum_values=True) model_config = ConfigDict(use_enum_values=True)
registerModelLabels( registerModelLabels(
"Token", "Token",
{"en": "Token", "fr": "Jeton"}, {"en": "Token", "de": "Token", "fr": "Jeton"},
{ {
"id": {"en": "ID", "fr": "ID"}, "id": {"en": "ID", "de": "ID", "fr": "ID"},
"userId": {"en": "User ID", "fr": "ID utilisateur"}, "userId": {"en": "User ID", "de": "Benutzer-ID", "fr": "ID utilisateur"},
"authority": {"en": "Authority", "fr": "Autorité"}, "authority": {"en": "Authority", "de": "Autorität", "fr": "Autorité"},
"connectionId": {"en": "Connection ID", "fr": "ID de connexion"}, "connectionId": {"en": "Connection ID", "de": "Verbindungs-ID", "fr": "ID de connexion"},
"tokenAccess": {"en": "Access Token", "fr": "Jeton d'accès"}, "tokenAccess": {"en": "Access Token", "de": "Zugriffstoken", "fr": "Jeton d'accès"},
"tokenType": {"en": "Token Type", "fr": "Type de jeton"}, "tokenType": {"en": "Token Type", "de": "Token-Typ", "fr": "Type de jeton"},
"expiresAt": {"en": "Expires At", "fr": "Expire le"}, "expiresAt": {"en": "Expires At", "de": "Läuft ab am", "fr": "Expire le"},
"tokenRefresh": {"en": "Refresh Token", "fr": "Jeton de rafraîchissement"}, "tokenRefresh": {"en": "Refresh Token", "de": "Refresh-Token", "fr": "Jeton de rafraîchissement"},
"createdAt": {"en": "Created At", "fr": "Créé le"}, "createdAt": {"en": "Created At", "de": "Erstellt am", "fr": "Créé le"},
"status": {"en": "Status", "fr": "Statut"}, "status": {"en": "Status", "de": "Status", "fr": "Statut"},
"revokedAt": {"en": "Revoked At", "fr": "Révoqué le"}, "revokedAt": {"en": "Revoked At", "de": "Widerrufen am", "fr": "Révoqué le"},
"revokedBy": {"en": "Revoked By", "fr": "Révoqué par"}, "revokedBy": {"en": "Revoked By", "de": "Widerrufen von", "fr": "Révoqué par"},
"reason": {"en": "Reason", "fr": "Raison"}, "reason": {"en": "Reason", "de": "Grund", "fr": "Raison"},
"sessionId": {"en": "Session ID", "fr": "ID de session"}, "sessionId": {"en": "Session ID", "de": "Sitzungs-ID", "fr": "ID de session"},
"mandateId": {"en": "Mandate ID", "fr": "ID de mandat"},
}, },
) )
class AuthEvent(BaseModel): 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}) 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}) 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}) 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( registerModelLabels(
"AuthEvent", "AuthEvent",
{"en": "Authentication Event", "fr": "Événement d'authentification"}, {"en": "Authentication Event", "de": "Authentifizierungsereignis", "fr": "Événement d'authentification"},
{ {
"id": {"en": "ID", "fr": "ID"}, "id": {"en": "ID", "de": "ID", "fr": "ID"},
"userId": {"en": "User ID", "fr": "ID utilisateur"}, "userId": {"en": "User ID", "de": "Benutzer-ID", "fr": "ID utilisateur"},
"eventType": {"en": "Event Type", "fr": "Type d'événement"}, "eventType": {"en": "Event Type", "de": "Ereignistyp", "fr": "Type d'événement"},
"timestamp": {"en": "Timestamp", "fr": "Horodatage"}, "timestamp": {"en": "Timestamp", "de": "Zeitstempel", "fr": "Horodatage"},
"ipAddress": {"en": "IP Address", "fr": "Adresse IP"}, "ipAddress": {"en": "IP Address", "de": "IP-Adresse", "fr": "Adresse IP"},
"userAgent": {"en": "User Agent", "fr": "Agent utilisateur"}, "userAgent": {"en": "User Agent", "de": "User-Agent", "fr": "Agent utilisateur"},
"success": {"en": "Success", "fr": "Succès"}, "success": {"en": "Success", "de": "Erfolgreich", "fr": "Succès"},
"details": {"en": "Details", "fr": "Détails"}, "details": {"en": "Details", "de": "Details", "fr": "Détails"},
}, },
) )

View file

@ -1,11 +1,18 @@
# Copyright (c) 2025 Patrick Motsch # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # 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 import uuid
from typing import Optional, List from typing import Optional, List
from enum import Enum 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.attributeUtils import registerModelLabels
from modules.shared.timeUtils import getUtcTimestamp from modules.shared.timeUtils import getUtcTimestamp
@ -51,55 +58,53 @@ class UserPermissions(BaseModel):
description="Delete permission level" description="Delete permission level"
) )
class Mandate(BaseModel): class Mandate(BaseModel):
"""
Mandate (Mandant/Tenant) model.
Ein Mandant ist ein isolierter Bereich für Daten und Berechtigungen.
"""
id: str = Field( id: str = Field(
default_factory=lambda: str(uuid.uuid4()), default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the mandate", description="Unique ID of the mandate",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False}
) )
name: str = Field( name: str = Field(
description="Name of the mandate", description="Name of the mandate",
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True} json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True}
) )
language: str = Field( description: Optional[str] = Field(
default="en", default=None,
description="Default language of the mandate", description="Description of the mandate",
json_schema_extra={ json_schema_extra={"frontend_type": "textarea", "frontend_readonly": False, "frontend_required": False}
"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( enabled: bool = Field(
default=True, default=True,
description="Indicates whether the mandate is enabled", description="Indicates whether the mandate is enabled",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False} json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
) )
registerModelLabels( registerModelLabels(
"Mandate", "Mandate",
{"en": "Mandate", "fr": "Mandat"}, {"en": "Mandate", "de": "Mandant", "fr": "Mandat"},
{ {
"id": {"en": "ID", "fr": "ID"}, "id": {"en": "ID", "de": "ID", "fr": "ID"},
"name": {"en": "Name", "fr": "Nom"}, "name": {"en": "Name", "de": "Name", "fr": "Nom"},
"language": {"en": "Language", "fr": "Langue"}, "description": {"en": "Description", "de": "Beschreibung", "fr": "Description"},
"enabled": {"en": "Enabled", "fr": "Activé"}, "enabled": {"en": "Enabled", "de": "Aktiviert", "fr": "Activé"},
}, },
) )
class UserConnection(BaseModel): 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}) 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}) userId: str = Field(description="ID of the user this connection belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
authority: AuthAuthority = Field(description="Authentication authority", json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": False, "frontend_options": "auth.authority"}) authority: AuthAuthority = Field(description="Authentication authority", json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": False, "frontend_options": "/api/connections/authorities/options"})
externalId: str = Field(description="User ID in the external system", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) externalId: str = Field(description="User ID in the external system", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
externalUsername: str = Field(description="Username in the external system", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False}) externalUsername: str = Field(description="Username in the external system", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False})
externalEmail: Optional[EmailStr] = Field(None, description="Email in the external system", json_schema_extra={"frontend_type": "email", "frontend_readonly": False, "frontend_required": False}) externalEmail: Optional[EmailStr] = Field(None, description="Email in the external system", json_schema_extra={"frontend_type": "email", "frontend_readonly": False, "frontend_required": False})
status: ConnectionStatus = Field(default=ConnectionStatus.ACTIVE, description="Connection status", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": "connection.status"}) status: ConnectionStatus = Field(default=ConnectionStatus.ACTIVE, description="Connection status", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": "/api/connections/statuses/options"})
connectedAt: float = Field(default_factory=getUtcTimestamp, description="When the connection was established (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}) connectedAt: float = Field(default_factory=getUtcTimestamp, description="When the connection was established (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False})
lastChecked: float = Field(default_factory=getUtcTimestamp, description="When the connection was last verified (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}) lastChecked: float = Field(default_factory=getUtcTimestamp, description="When the connection was last verified (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False})
expiresAt: Optional[float] = Field(None, description="When the connection expires (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}) expiresAt: Optional[float] = Field(None, description="When the connection expires (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False})
@ -109,70 +114,146 @@ class UserConnection(BaseModel):
{"value": "none", "label": {"en": "None", "fr": "Aucun"}}, {"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}) 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( registerModelLabels(
"UserConnection", "UserConnection",
{"en": "User Connection", "fr": "Connexion utilisateur"}, {"en": "User Connection", "de": "Benutzerverbindung", "fr": "Connexion utilisateur"},
{ {
"id": {"en": "ID", "fr": "ID"}, "id": {"en": "ID", "de": "ID", "fr": "ID"},
"userId": {"en": "User ID", "fr": "ID utilisateur"}, "userId": {"en": "User ID", "de": "Benutzer-ID", "fr": "ID utilisateur"},
"authority": {"en": "Authority", "fr": "Autorité"}, "authority": {"en": "Authority", "de": "Autorität", "fr": "Autorité"},
"externalId": {"en": "External ID", "fr": "ID externe"}, "externalId": {"en": "External ID", "de": "Externe ID", "fr": "ID externe"},
"externalUsername": {"en": "External Username", "fr": "Nom d'utilisateur externe"}, "externalUsername": {"en": "External Username", "de": "Externer Benutzername", "fr": "Nom d'utilisateur externe"},
"externalEmail": {"en": "External Email", "fr": "Email externe"}, "externalEmail": {"en": "External Email", "de": "Externe E-Mail", "fr": "Email externe"},
"status": {"en": "Status", "fr": "Statut"}, "status": {"en": "Status", "de": "Status", "fr": "Statut"},
"connectedAt": {"en": "Connected At", "fr": "Connecté le"}, "connectedAt": {"en": "Connected At", "de": "Verbunden am", "fr": "Connecté le"},
"lastChecked": {"en": "Last Checked", "fr": "Dernière vérification"}, "lastChecked": {"en": "Last Checked", "de": "Zuletzt geprüft", "fr": "Dernière vérification"},
"expiresAt": {"en": "Expires At", "fr": "Expire le"}, "expiresAt": {"en": "Expires At", "de": "Läuft ab am", "fr": "Expire le"},
"tokenStatus": {"en": "Connection Status", "fr": "Statut de connexion"}, "tokenStatus": {"en": "Connection Status", "de": "Verbindungsstatus", "fr": "Statut de connexion"},
"tokenExpiresAt": {"en": "Expires At", "fr": "Expire le"}, "tokenExpiresAt": {"en": "Expires At", "de": "Läuft ab am", "fr": "Expire le"},
}, },
) )
class User(BaseModel): 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}) User model.
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}) Multi-Tenant Design:
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": [ - User gehört NICHT direkt zu einem Mandanten
{"value": "de", "label": {"en": "Deutsch", "fr": "Allemand"}}, - Zugehörigkeit wird über UserMandate gesteuert (siehe datamodelMembership.py)
{"value": "en", "label": {"en": "English", "fr": "Anglais"}}, - Rollen werden über UserMandateRole gesteuert
{"value": "fr", "label": {"en": "Français", "fr": "Français"}}, - isSysAdmin = System-Zugriff, KEIN Daten-Zugriff
{"value": "it", "label": {"en": "Italiano", "fr": "Italien"}}, """
]}) id: str = Field(
enabled: bool = Field(default=True, description="Indicates whether the user is enabled", json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}) default_factory=lambda: str(uuid.uuid4()),
roleLabels: List[str] = Field( description="Unique ID of the user",
default_factory=list, json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False}
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"}
) )
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"}) username: str = Field(
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}) description="Username for login (immutable after creation)",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "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="de",
description="Preferred language of the user (ISO 639-1 code: de, en, fr, it)",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_options": [
{"value": "de", "label": {"en": "Deutsch", "de": "Deutsch", "fr": "Allemand"}},
{"value": "en", "label": {"en": "English", "de": "Englisch", "fr": "Anglais"}},
{"value": "fr", "label": {"en": "Français", "de": "Französisch", "fr": "Français"}},
{"value": "it", "label": {"en": "Italiano", "de": "Italienisch", "fr": "Italien"}},
]}
)
@field_validator('language', mode='before')
@classmethod
def _normalizeLanguage(cls, v):
"""Normalize language to valid ISO 639-1 code."""
if v is None:
return "de"
# Map common variations to standard codes
langMap = {
'english': 'en', 'englisch': 'en',
'german': 'de', 'deutsch': 'de',
'french': 'fr', 'französisch': 'fr', 'francais': 'fr',
'italian': 'it', 'italienisch': 'it', 'italiano': 'it',
}
normalized = str(v).lower().strip()
if normalized in langMap:
return langMap[normalized]
# If already a valid code, return as-is
if normalized in ['de', 'en', 'fr', 'it']:
return normalized
# Default fallback
return "de"
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": "/api/connections/authorities/options"}
)
registerModelLabels( registerModelLabels(
"User", "User",
{"en": "User", "fr": "Utilisateur"}, {"en": "User", "de": "Benutzer", "fr": "Utilisateur"},
{ {
"id": {"en": "ID", "fr": "ID"}, "id": {"en": "ID", "de": "ID", "fr": "ID"},
"username": {"en": "Username", "fr": "Nom d'utilisateur"}, "username": {"en": "Username", "de": "Benutzername", "fr": "Nom d'utilisateur"},
"email": {"en": "Email", "fr": "Email"}, "email": {"en": "Email", "de": "E-Mail", "fr": "Email"},
"fullName": {"en": "Full Name", "fr": "Nom complet"}, "fullName": {"en": "Full Name", "de": "Vollständiger Name", "fr": "Nom complet"},
"language": {"en": "Language", "fr": "Langue"}, "language": {"en": "Language", "de": "Sprache", "fr": "Langue"},
"enabled": {"en": "Enabled", "fr": "Activé"}, "enabled": {"en": "Enabled", "de": "Aktiviert", "fr": "Activé"},
"roleLabels": {"en": "Role Labels", "fr": "Labels de rôle"}, "isSysAdmin": {"en": "System Admin", "de": "System-Admin", "fr": "Admin système"},
"authenticationAuthority": {"en": "Auth Authority", "fr": "Autorité d'authentification"}, "authenticationAuthority": {"en": "Auth Authority", "de": "Authentifizierung", "fr": "Autorité d'authentification"},
"mandateId": {"en": "Mandate ID", "fr": "ID de mandat"},
}, },
) )
class UserInDB(User): class UserInDB(User):
"""User model with password hash for database storage."""
hashedPassword: Optional[str] = Field(None, description="Hash of the user password") hashedPassword: Optional[str] = Field(None, description="Hash of the user password")
resetToken: Optional[str] = Field(None, description="Password reset token (UUID)") resetToken: Optional[str] = Field(None, description="Password reset token (UUID)")
resetTokenExpires: Optional[float] = Field(None, description="Reset token expiration (UTC timestamp in seconds)") resetTokenExpires: Optional[float] = Field(None, description="Reset token expiration (UTC timestamp in seconds)")
registerModelLabels( registerModelLabels(
"UserInDB", "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"}, "hashedPassword": {"en": "Password hash", "de": "Passwort-Hash", "fr": "Hachage de mot de passe"},
"resetToken": {"en": "Reset Token", "fr": "Jeton de réinitialisation"}, "resetToken": {"en": "Reset Token", "de": "Reset-Token", "fr": "Jeton de réinitialisation"},
"resetTokenExpires": {"en": "Reset Token Expires", "fr": "Expiration du jeton"}, "resetTokenExpires": {"en": "Reset Token Expires", "de": "Token läuft ab", "fr": "Expiration du jeton"},
}, },
) )

View file

@ -12,6 +12,7 @@ class VoiceSettings(BaseModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
userId: str = Field(description="ID of the user these settings belong to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}) userId: str = Field(description="ID of the user these settings belong to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
mandateId: str = Field(description="ID of the mandate these settings belong to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}) mandateId: str = Field(description="ID of the mandate these settings belong to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
featureInstanceId: str = Field(description="ID of the feature instance these settings belong to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
sttLanguage: str = Field(default="de-DE", description="Speech-to-Text language", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True}) sttLanguage: str = Field(default="de-DE", description="Speech-to-Text language", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True})
ttsLanguage: str = Field(default="de-DE", description="Text-to-Speech language", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True}) ttsLanguage: str = Field(default="de-DE", description="Text-to-Speech language", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True})
ttsVoice: str = Field(default="de-DE-KatjaNeural", description="Text-to-Speech voice", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True}) ttsVoice: str = Field(default="de-DE-KatjaNeural", description="Text-to-Speech voice", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True})
@ -28,6 +29,7 @@ registerModelLabels(
"id": {"en": "ID", "fr": "ID"}, "id": {"en": "ID", "fr": "ID"},
"userId": {"en": "User ID", "fr": "ID utilisateur"}, "userId": {"en": "User ID", "fr": "ID utilisateur"},
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat"}, "mandateId": {"en": "Mandate ID", "fr": "ID du mandat"},
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"},
"sttLanguage": {"en": "STT Language", "fr": "Langue STT"}, "sttLanguage": {"en": "STT Language", "fr": "Langue STT"},
"ttsLanguage": {"en": "TTS Language", "fr": "Langue TTS"}, "ttsLanguage": {"en": "TTS Language", "fr": "Langue TTS"},
"ttsVoice": {"en": "TTS Voice", "fr": "Voix TTS"}, "ttsVoice": {"en": "TTS Voice", "fr": "Voix TTS"},

View file

@ -12,12 +12,6 @@ from modules.shared.jsonUtils import extractJsonString, tryParseJson, repairBrok
# Import DocumentReferenceList at runtime (needed for ActionDefinition) # Import DocumentReferenceList at runtime (needed for ActionDefinition)
from modules.datamodels.datamodelDocref import DocumentReferenceList from modules.datamodels.datamodelDocref import DocumentReferenceList
# Forward references for circular imports (use string annotations)
if TYPE_CHECKING:
from modules.datamodels.datamodelChat import ChatDocument, ActionResult
from modules.datamodels.datamodelExtraction import ExtractionOptions
class ActionDefinition(BaseModel): class ActionDefinition(BaseModel):
"""Action definition with selection and parameters from planning phase""" """Action definition with selection and parameters from planning phase"""

View file

@ -0,0 +1,45 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""Automation models: AutomationDefinition."""
from typing import List, Dict, Any, Optional
from pydantic import BaseModel, Field
from modules.shared.attributeUtils import registerModelLabels
import uuid
class AutomationDefinition(BaseModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
mandateId: str = Field(description="Mandate ID", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
featureInstanceId: str = Field(description="ID of the feature instance this automation belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
label: str = Field(description="User-friendly name", json_schema_extra={"frontend_type": "text", "frontend_required": True})
schedule: str = Field(description="Cron schedule pattern", json_schema_extra={"frontend_type": "select", "frontend_required": True, "frontend_options": [
{"value": "0 */4 * * *", "label": {"en": "Every 4 hours", "fr": "Toutes les 4 heures"}},
{"value": "0 22 * * *", "label": {"en": "Daily at 22:00", "fr": "Quotidien à 22:00"}},
{"value": "0 10 * * 1", "label": {"en": "Weekly Monday 10:00", "fr": "Hebdomadaire lundi 10:00"}}
]})
template: str = Field(description="JSON template with placeholders (format: {{KEY:PLACEHOLDER_NAME}})", json_schema_extra={"frontend_type": "textarea", "frontend_required": True})
placeholders: Dict[str, str] = Field(default_factory=dict, description="Dictionary of placeholder key/value pairs (e.g., {'connectionName': 'MyConnection', 'sharepointFolderNameSource': '/folder/path', 'webResearchUrl': 'https://...', 'webResearchPrompt': '...', 'documentPrompt': '...'})", json_schema_extra={"frontend_type": "textarea"})
active: bool = Field(default=False, description="Whether automation should be launched in event handler", json_schema_extra={"frontend_type": "checkbox", "frontend_required": False})
eventId: Optional[str] = Field(None, description="Event ID from event management (None if not registered)", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
status: Optional[str] = Field(None, description="Status: 'active' if event is registered, 'inactive' if not (computed, readonly)", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
executionLogs: List[Dict[str, Any]] = Field(default_factory=list, description="List of execution logs, each containing timestamp, workflowId, status, and messages", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
registerModelLabels(
"AutomationDefinition",
{"en": "Automation Definition", "fr": "Définition d'automatisation"},
{
"id": {"en": "ID", "fr": "ID"},
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat"},
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"},
"label": {"en": "Label", "fr": "Libellé"},
"schedule": {"en": "Schedule", "fr": "Planification"},
"template": {"en": "Template", "fr": "Modèle"},
"placeholders": {"en": "Placeholders", "fr": "Espaces réservés"},
"active": {"en": "Active", "fr": "Actif"},
"eventId": {"en": "Event ID", "fr": "ID de l'événement"},
"status": {"en": "Status", "fr": "Statut"},
"executionLogs": {"en": "Execution Logs", "fr": "Journaux d'exécution"},
},
)

View file

@ -0,0 +1,169 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Automation Feature Container - Main Module.
Handles feature initialization and RBAC catalog registration.
"""
import logging
from typing import Dict, List, Any
logger = logging.getLogger(__name__)
# Feature metadata
FEATURE_CODE = "automation"
FEATURE_LABEL = {"en": "Automation", "de": "Automatisierung", "fr": "Automatisation"}
FEATURE_ICON = "mdi-cog-clockwise"
# UI Objects for RBAC catalog
UI_OBJECTS = [
{
"objectKey": "ui.feature.automation.definitions",
"label": {"en": "Automation Definitions", "de": "Automatisierungs-Definitionen", "fr": "Définitions d'automatisation"},
"meta": {"area": "definitions"}
},
{
"objectKey": "ui.feature.automation.templates",
"label": {"en": "Templates", "de": "Vorlagen", "fr": "Modèles"},
"meta": {"area": "templates"}
},
{
"objectKey": "ui.feature.automation.logs",
"label": {"en": "Execution Logs", "de": "Ausführungsprotokolle", "fr": "Journaux d'exécution"},
"meta": {"area": "logs"}
},
]
# Resource Objects for RBAC catalog
RESOURCE_OBJECTS = [
{
"objectKey": "resource.feature.automation.create",
"label": {"en": "Create Automation", "de": "Automatisierung erstellen", "fr": "Créer automatisation"},
"meta": {"endpoint": "/api/automations", "method": "POST"}
},
{
"objectKey": "resource.feature.automation.update",
"label": {"en": "Update Automation", "de": "Automatisierung aktualisieren", "fr": "Modifier automatisation"},
"meta": {"endpoint": "/api/automations/{automationId}", "method": "PUT"}
},
{
"objectKey": "resource.feature.automation.delete",
"label": {"en": "Delete Automation", "de": "Automatisierung löschen", "fr": "Supprimer automatisation"},
"meta": {"endpoint": "/api/automations/{automationId}", "method": "DELETE"}
},
{
"objectKey": "resource.feature.automation.execute",
"label": {"en": "Execute Automation", "de": "Automatisierung ausführen", "fr": "Exécuter automatisation"},
"meta": {"endpoint": "/api/automations/{automationId}/execute", "method": "POST"}
},
]
# Template roles for this feature
TEMPLATE_ROLES = [
{
"roleLabel": "automation-admin",
"description": {
"en": "Automation Administrator - Full access to automation configuration and execution",
"de": "Automatisierungs-Administrator - Vollzugriff auf Automatisierungs-Konfiguration und Ausführung",
"fr": "Administrateur automatisation - Accès complet à la configuration et exécution"
},
"accessRules": [
# Full UI access
{"context": "UI", "item": None, "view": True},
# Full DATA access
{"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"},
]
},
{
"roleLabel": "automation-editor",
"description": {
"en": "Automation Editor - Create and modify automations",
"de": "Automatisierungs-Editor - Automatisierungen erstellen und bearbeiten",
"fr": "Éditeur automatisation - Créer et modifier les automatisations"
},
"accessRules": [
# UI access to definitions and templates - vollqualifizierte ObjectKeys
{"context": "UI", "item": "ui.feature.automation.definitions", "view": True},
{"context": "UI", "item": "ui.feature.automation.templates", "view": True},
{"context": "UI", "item": "ui.feature.automation.logs", "view": True},
# Group-level DATA access
{"context": "DATA", "item": None, "view": True, "read": "g", "create": "g", "update": "g", "delete": "n"},
]
},
{
"roleLabel": "automation-viewer",
"description": {
"en": "Automation Viewer - View automations and execution results",
"de": "Automatisierungs-Betrachter - Automatisierungen und Ausführungsergebnisse einsehen",
"fr": "Visualiseur automatisation - Consulter les automatisations et résultats"
},
"accessRules": [
# UI access to view only - vollqualifizierte ObjectKeys
{"context": "UI", "item": "ui.feature.automation.definitions", "view": True},
{"context": "UI", "item": "ui.feature.automation.logs", "view": True},
# Read-only DATA access (my level)
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
]
},
]
def getFeatureDefinition() -> Dict[str, Any]:
"""Return the feature definition for registration."""
return {
"code": FEATURE_CODE,
"label": FEATURE_LABEL,
"icon": FEATURE_ICON
}
def getUiObjects() -> List[Dict[str, Any]]:
"""Return UI objects for RBAC catalog registration."""
return UI_OBJECTS
def getResourceObjects() -> List[Dict[str, Any]]:
"""Return resource objects for RBAC catalog registration."""
return RESOURCE_OBJECTS
def getTemplateRoles() -> List[Dict[str, Any]]:
"""Return template roles for this feature."""
return TEMPLATE_ROLES
def registerFeature(catalogService) -> bool:
"""
Register this feature's RBAC objects in the catalog.
Args:
catalogService: The RBAC catalog service instance
Returns:
True if registration was successful
"""
try:
# Register UI objects
for uiObj in UI_OBJECTS:
catalogService.registerUiObject(
featureCode=FEATURE_CODE,
objectKey=uiObj["objectKey"],
label=uiObj["label"],
meta=uiObj.get("meta")
)
# Register Resource objects
for resObj in RESOURCE_OBJECTS:
catalogService.registerResourceObject(
featureCode=FEATURE_CODE,
objectKey=resObj["objectKey"],
label=resObj["label"],
meta=resObj.get("meta")
)
logger.info(f"Feature '{FEATURE_CODE}' registered {len(UI_OBJECTS)} UI objects and {len(RESOURCE_OBJECTS)} resource objects")
return True
except Exception as e:
logger.error(f"Failed to register feature '{FEATURE_CODE}': {e}")
return False

View file

@ -13,13 +13,14 @@ import logging
import json import json
# Import interfaces and models # Import interfaces and models
from modules.interfaces.interfaceDbChatObjects import getInterface as getChatInterface from modules.interfaces.interfaceDbChat import getInterface as getChatInterface
from modules.auth import getCurrentUser, limiter from modules.auth import limiter, getRequestContext, RequestContext
from modules.datamodels.datamodelChat import AutomationDefinition, ChatWorkflow from modules.features.automation.datamodelFeatureAutomation import AutomationDefinition
from modules.datamodels.datamodelChat import ChatWorkflow
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
from modules.shared.attributeUtils import getModelAttributeDefinitions from modules.shared.attributeUtils import getModelAttributeDefinitions
from modules.features.workflow import executeAutomation from modules.workflows.automation import executeAutomation
from modules.features.workflow.subAutomationTemplates import getAutomationTemplates from .subAutomationTemplates import getAutomationTemplates
# Configure logger # Configure logger
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -45,7 +46,7 @@ router = APIRouter(
async def get_automations( async def get_automations(
request: Request, request: Request,
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"), pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
currentUser = Depends(getCurrentUser) context: RequestContext = Depends(getRequestContext)
) -> PaginatedResponse[AutomationDefinition]: ) -> PaginatedResponse[AutomationDefinition]:
""" """
Get automation definitions with optional pagination, sorting, and filtering. Get automation definitions with optional pagination, sorting, and filtering.
@ -68,7 +69,7 @@ async def get_automations(
detail=f"Invalid pagination parameter: {str(e)}" detail=f"Invalid pagination parameter: {str(e)}"
) )
chatInterface = getChatInterface(currentUser) chatInterface = getChatInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None)
result = chatInterface.getAllAutomationDefinitions(pagination=paginationParams) result = chatInterface.getAllAutomationDefinitions(pagination=paginationParams)
# If pagination was requested, result is PaginatedResult # If pagination was requested, result is PaginatedResult
@ -110,14 +111,14 @@ async def get_automations(
async def create_automation( async def create_automation(
request: Request, request: Request,
automation: AutomationDefinition, automation: AutomationDefinition,
currentUser = Depends(getCurrentUser) context: RequestContext = Depends(getRequestContext)
) -> AutomationDefinition: ) -> AutomationDefinition:
"""Create a new automation definition""" """Create a new automation definition"""
try: try:
chatInterface = getChatInterface(currentUser) chatInterface = getChatInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None)
automationData = automation.model_dump() automationData = automation.model_dump()
created = chatInterface.createAutomationDefinition(automationData) created = chatInterface.createAutomationDefinition(automationData)
return AutomationDefinition(**created) return created
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
@ -131,7 +132,7 @@ async def create_automation(
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def get_automation_templates( async def get_automation_templates(
request: Request, request: Request,
currentUser = Depends(getCurrentUser) context: RequestContext = Depends(getRequestContext)
) -> JSONResponse: ) -> JSONResponse:
""" """
Get automation templates from backend module. Get automation templates from backend module.
@ -159,11 +160,11 @@ async def get_automation_attributes(
async def get_automation( async def get_automation(
request: Request, request: Request,
automationId: str = Path(..., description="Automation ID"), automationId: str = Path(..., description="Automation ID"),
currentUser = Depends(getCurrentUser) context: RequestContext = Depends(getRequestContext)
) -> AutomationDefinition: ) -> AutomationDefinition:
"""Get a single automation definition by ID""" """Get a single automation definition by ID"""
try: try:
chatInterface = getChatInterface(currentUser) chatInterface = getChatInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None)
automation = chatInterface.getAutomationDefinition(automationId) automation = chatInterface.getAutomationDefinition(automationId)
if not automation: if not automation:
raise HTTPException( raise HTTPException(
@ -171,7 +172,7 @@ async def get_automation(
detail=f"Automation {automationId} not found" detail=f"Automation {automationId} not found"
) )
return AutomationDefinition(**automation) return automation
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
@ -187,14 +188,14 @@ async def update_automation(
request: Request, request: Request,
automationId: str = Path(..., description="Automation ID"), automationId: str = Path(..., description="Automation ID"),
automation: AutomationDefinition = Body(...), automation: AutomationDefinition = Body(...),
currentUser = Depends(getCurrentUser) context: RequestContext = Depends(getRequestContext)
) -> AutomationDefinition: ) -> AutomationDefinition:
"""Update an automation definition""" """Update an automation definition"""
try: try:
chatInterface = getChatInterface(currentUser) chatInterface = getChatInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None)
automationData = automation.model_dump() automationData = automation.model_dump()
updated = chatInterface.updateAutomationDefinition(automationId, automationData) updated = chatInterface.updateAutomationDefinition(automationId, automationData)
return AutomationDefinition(**updated) return updated
except HTTPException: except HTTPException:
raise raise
except PermissionError as e: except PermissionError as e:
@ -209,16 +210,57 @@ async def update_automation(
detail=f"Error updating automation: {str(e)}" detail=f"Error updating automation: {str(e)}"
) )
@router.patch("/{automationId}/status")
@limiter.limit("30/minute")
async def update_automation_status(
request: Request,
automationId: str = Path(..., description="Automation ID"),
active: bool = Body(..., embed=True),
context: RequestContext = Depends(getRequestContext)
) -> AutomationDefinition:
"""Update only the active status of an automation definition"""
try:
chatInterface = getChatInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None)
# Get existing automation
automation = chatInterface.getAutomationDefinition(automationId)
if not automation:
raise HTTPException(
status_code=404,
detail=f"Automation {automationId} not found"
)
# Update only the active field
automationData = automation if isinstance(automation, dict) else automation.model_dump()
automationData['active'] = active
updated = chatInterface.updateAutomationDefinition(automationId, automationData)
return updated
except HTTPException:
raise
except PermissionError as e:
raise HTTPException(
status_code=403,
detail=str(e)
)
except Exception as e:
logger.error(f"Error updating automation status: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Error updating automation status: {str(e)}"
)
@router.delete("/{automationId}") @router.delete("/{automationId}")
@limiter.limit("10/minute") @limiter.limit("10/minute")
async def delete_automation( async def delete_automation(
request: Request, request: Request,
automationId: str = Path(..., description="Automation ID"), automationId: str = Path(..., description="Automation ID"),
currentUser = Depends(getCurrentUser) context: RequestContext = Depends(getRequestContext)
) -> Response: ) -> Response:
"""Delete an automation definition""" """Delete an automation definition"""
try: try:
chatInterface = getChatInterface(currentUser) chatInterface = getChatInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None, featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None)
success = chatInterface.deleteAutomationDefinition(automationId) success = chatInterface.deleteAutomationDefinition(automationId)
if success: if success:
return Response(status_code=204) return Response(status_code=204)
@ -243,15 +285,15 @@ async def delete_automation(
@router.post("/{automationId}/execute", response_model=ChatWorkflow) @router.post("/{automationId}/execute", response_model=ChatWorkflow)
@limiter.limit("5/minute") @limiter.limit("5/minute")
async def execute_automation( async def execute_automation_route(
request: Request, request: Request,
automationId: str = Path(..., description="Automation ID"), automationId: str = Path(..., description="Automation ID"),
currentUser = Depends(getCurrentUser) context: RequestContext = Depends(getRequestContext)
) -> ChatWorkflow: ) -> ChatWorkflow:
"""Execute an automation immediately (test mode)""" """Execute an automation immediately (test mode)"""
try: try:
from modules.services import getInterface as getServices from modules.services import getInterface as getServices
services = getServices(currentUser, None) services = getServices(context.user, context.mandateId)
workflow = await executeAutomation(automationId, services) workflow = await executeAutomation(automationId, services)
return workflow return workflow
except HTTPException: except HTTPException:

View file

@ -0,0 +1,417 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Automation templates for workflow definitions.
Contains predefined workflow templates that can be used to create automation definitions.
"""
from typing import Dict, Any, List
# Automation templates structure
AUTOMATION_TEMPLATES: Dict[str, Any] = {
"sets": [
{
"template": {
"overview": "SharePoint Themen Zusammenfassung",
"tasks": [
{
"id": "Task01",
"title": "SharePoint Themen Zusammenfassung",
"description": "Erstellt eine Zusammenfassung aller SharePoint Sites und deren Inhalte",
"objective": "Erstelle eine Zusammenfassung aller SharePoint Themen (Sites) und deren Inhalte als Word-Dokument",
"actionList": [
{
"execMethod": "sharepoint",
"execAction": "findDocumentPath",
"execParameters": {
"connectionReference": "{{KEY:connectionName}}",
"searchQuery": "*",
"maxResults": 100
},
"execResultLabel": "sharepoint_sites_found"
},
{
"execMethod": "sharepoint",
"execAction": "listDocuments",
"execParameters": {
"connectionReference": "{{KEY:connectionName}}",
"pathQuery": "{{KEY:sharepointBasePath}}",
"includeSubfolders": True
},
"execResultLabel": "sharepoint_structure"
},
{
"execMethod": "ai",
"execAction": "process",
"execParameters": {
"aiPrompt": "{{KEY:summaryPrompt}}",
"documentList": ["sharepoint_sites_found", "sharepoint_structure"],
"resultType": "docx"
},
"execResultLabel": "sharepoint_summary"
},
{
"execMethod": "sharepoint",
"execAction": "uploadDocument",
"execParameters": {
"connectionReference": "{{KEY:connectionName}}",
"documentList": ["sharepoint_summary"],
"pathQuery": "{{KEY:sharepointFolderNameDestination}}"
},
"execResultLabel": "sharepoint_upload_result"
}
]
}
]
},
"parameters": {
"connectionName": "connection:msft:p.motsch@valueon.ch",
"sharepointBasePath": "/sites/company-share",
"sharepointFolderNameDestination": "/sites/company-share/Freigegebene Dokumente/15. Persoenliche Ordner/Patrick Motsch/output",
"summaryPrompt": "Erstelle eine umfassende Zusammenfassung aller SharePoint Sites und deren Inhalte. Strukturiere das Dokument nach Sites und fasse für jede Site die wichtigsten Themen, Ordnerstrukturen und Dokumente zusammen. Erstelle ein professionelles Word-Dokument mit Überschriften, Abschnitten und einer klaren Gliederung. Berücksichtige alle gefundenen Sites, deren Ordnerstrukturen und dokumentiere die wichtigsten Inhalte pro Site."
}
},
{
"template": {
"overview": "Immobilienrecherche Zürich",
"tasks": [
{
"id": "Task02",
"title": "Immobilienrecherche Zürich",
"description": "Webrecherche nach Immobilien im Kanton Zürich und Speicherung in Excel",
"objective": "Immobilienrecherche im Kanton Zürich zum Verkauf (5-20 Mio. CHF) und speichere Ergebnisse in Excel-Liste auf SharePoint",
"actionList": [
{
"execMethod": "ai",
"execAction": "webResearch",
"execParameters": {
"prompt": "{{KEY:immobilienResearchPrompt}}",
"urlList": ["{{KEY:immobilienResearchUrl}}"]
},
"execResultLabel": "immobilien_research_results"
},
{
"execMethod": "ai",
"execAction": "process",
"execParameters": {
"aiPrompt": "{{KEY:excelFormatPrompt}}",
"documentList": ["immobilien_research_results"],
"resultType": "xlsx"
},
"execResultLabel": "immobilien_excel_list"
},
{
"execMethod": "sharepoint",
"execAction": "uploadDocument",
"execParameters": {
"connectionReference": "{{KEY:connectionName}}",
"documentList": ["immobilien_excel_list"],
"pathQuery": "{{KEY:sharepointFolderNameDestination}}"
},
"execResultLabel": "immobilien_upload_result"
}
]
}
]
},
"parameters": {
"connectionName": "connection:msft:p.motsch@valueon.ch",
"sharepointFolderNameDestination": "/sites/company-share/Freigegebene Dokumente/15. Persoenliche Ordner/Patrick Motsch/output",
"immobilienResearchUrl": ["https://www.homegate.ch", "https://www.immoscout24.ch", "https://www.immowelt.ch"],
"immobilienResearchPrompt": "Suche nach Immobilien zum Verkauf im Kanton Zürich, Schweiz, im Preisbereich von 5-20 Millionen CHF. Sammle Informationen zu: Ort, Preis, Beschreibung, URL zu Bildern, Verkäufer/Kontaktinformationen.",
"excelFormatPrompt": "Erstelle eine Excel-Datei mit den recherchierten Immobilien. Jede Immobilie soll eine Zeile sein mit den folgenden Spalten: Ort, Preis (in CHF), Beschreibung, URL zu Bild, Verkäufer. Verwende die Daten aus der Webrecherche."
}
},
{
"template": {
"overview": "Spesenbelege Zusammenfassung",
"tasks": [
{
"id": "Task03",
"title": "Spesenbelege CSV Zusammenfassung",
"description": "Liest PDF-Spesenbelege aus SharePoint-Ordner und erstellt CSV-Zusammenfassung",
"objective": "Extrahiere alle PDF-Spesenbelege aus einem SharePoint-Ordner und erstelle eine CSV-Datei mit allen Spesendaten im selben Ordner",
"actionList": [
{
"execMethod": "sharepoint",
"execAction": "findDocumentPath",
"execParameters": {
"connectionReference": "{{KEY:connectionName}}",
"searchQuery": "{{KEY:sharepointFolderNameSource}}:files:.pdf",
"maxResults": 100
},
"execResultLabel": "sharepoint_pdf_files"
},
{
"execMethod": "sharepoint",
"execAction": "readDocuments",
"execParameters": {
"connectionReference": "{{KEY:connectionName}}",
"pathObject": "sharepoint_pdf_files"
},
"execResultLabel": "spesenbelege_documents"
},
{
"execMethod": "ai",
"execAction": "process",
"execParameters": {
"aiPrompt": "{{KEY:expenseExtractionPrompt}}",
"documentList": ["spesenbelege_documents"],
"resultType": "csv"
},
"execResultLabel": "spesenbelege_csv"
},
{
"execMethod": "sharepoint",
"execAction": "uploadDocument",
"execParameters": {
"connectionReference": "{{KEY:connectionName}}",
"documentList": ["spesenbelege_csv"],
"pathQuery": "{{KEY:sharepointFolderNameDestination}}"
},
"execResultLabel": "spesenbelege_upload_result"
}
]
}
]
},
"parameters": {
"connectionName": "connection:msft:p.motsch@valueon.ch",
"sharepointFolderNameSource": "/sites/company-share/Freigegebene Dokumente/15. Persoenliche Ordner/Patrick Motsch/expenses",
"sharepointFolderNameDestination": "/sites/company-share/Freigegebene Dokumente/15. Persoenliche Ordner/Patrick Motsch/output",
"expenseExtractionPrompt": "Verarbeite alle bereitgestellten Dokumente, aber extrahiere nur Daten aus PDF-Spesenbelegen (ignoriere andere Dateitypen). Für jeden gefundenen PDF-Spesenbeleg extrahiere als separaten Datensatz: Datum, Betrag, MWST %, Währung, Kategorie, Beschreibung, Rechnungsnummer, Händler/Verkäufer, Steuerbetrag. Erstelle eine CSV-Datei mit einer Zeile pro Spesenbeleg. Verwende die folgenden Spaltenüberschriften: Datum, Betrag, Währung, Kategorie, Beschreibung, Rechnungsnummer, Händler, Steuerbetrag. Stelle sicher, dass alle Beträge numerisch sind und Datumswerte im Format YYYY-MM-DD vorliegen. Wenn ein Dokument kein Spesenbeleg ist, ignoriere es."
}
},
{
"template": {
"overview": "Preprocessing Server Data Update",
"tasks": [
{
"id": "Task04",
"title": "Trigger Preprocessing Server",
"description": "Triggers the preprocessing server at customer tenant to update database with configuration",
"objective": "Call preprocessing server endpoint to update database with provided configuration JSON",
"actionList": [
{
"execMethod": "context",
"execAction": "triggerPreprocessingServer",
"execParameters": {
"endpoint": "{{KEY:endpoint}}",
"configJson": "{{KEY:configJson}}",
"authSecretConfigKey": "{{KEY:authSecretConfigKey}}"
},
"execResultLabel": "preprocessing_server_result"
}
]
}
]
},
"parameters": {
"endpoint": "https://poweron-althaus-preprocess-prod-e3fegaatc7faency.switzerlandnorth-01.azurewebsites.net/api/v1/dataprocessor/update-db-with-config",
"authSecretConfigKey": "PREPROCESS_ALTHAUS_CHAT_SECRET",
"configJson": "{\"tables\":[{\"name\":\"Artikel\",\"powerbi_table_name\":\"Artikel\",\"steps\":[{\"keep\":{\"columns\":[\"I_ID\",\"Artikelbeschrieb\",\"Artikelbezeichnung\",\"Artikelgruppe\",\"Artikelkategorie\",\"Artikelkürzel\",\"Artikelnummer\",\"Einheit\",\"Gesperrt\",\"Keywords\",\"Lieferant\",\"Warengruppe\"]}},{\"fillna\":{\"column\":\"Lieferant\",\"value\":\"Unbekannt\"}}]},{\"name\":\"Einkaufspreis\",\"powerbi_table_name\":\"Einkaufspreis\",\"steps\":[{\"to_numeric\":{\"column\":\"EP_CHF\",\"errors\":\"coerce\"}},{\"dropna\":{\"subset\":[\"EP_CHF\"]}}]}]}"
}
},
{
"template": {
"overview": "JIRA to SharePoint Ticket Synchronization",
"tasks": [
{
"id": "Task01",
"title": "Sync JIRA Tickets to SharePoint",
"description": "Export JIRA tickets, merge with SharePoint file, upload back, and import changes to JIRA",
"objective": "Synchronize JIRA tickets with SharePoint file (bidirectional sync)",
"actionList": [
{
"execMethod": "sharepoint",
"execAction": "findSiteByUrl",
"execParameters": {
"connectionReference": "{{KEY:sharepointConnection}}",
"hostname": "{{KEY:sharepointHostname}}",
"sitePath": "{{KEY:sharepointSitePath}}"
},
"execResultLabel": "sharepoint_site"
},
{
"execMethod": "jira",
"execAction": "connectJira",
"execParameters": {
"apiUsername": "{{KEY:jiraUsername}}",
"apiTokenConfigKey": "{{KEY:jiraTokenConfigKey}}",
"apiUrl": "{{KEY:jiraUrl}}",
"projectCode": "{{KEY:jiraProjectCode}}",
"issueType": "{{KEY:jiraIssueType}}",
"taskSyncDefinition": "{{KEY:taskSyncDefinition}}"
},
"execResultLabel": "jira_connection"
},
{
"execMethod": "jira",
"execAction": "exportTicketsAsJson",
"execParameters": {
"connectionId": "jira_connection",
"taskSyncDefinition": "{{KEY:taskSyncDefinition}}"
},
"execResultLabel": "jira_exported_tickets"
},
{
"execMethod": "sharepoint",
"execAction": "downloadFileByPath",
"execParameters": {
"connectionReference": "{{KEY:sharepointConnection}}",
"siteId": "sharepoint_site",
"filePath": "{{KEY:sharepointMainFolder}}/{{KEY:syncFileName}}"
},
"execResultLabel": "existing_file_content"
},
{
"execMethod": "jira",
"execAction": "parseExcelContent",
"execParameters": {
"excelContent": "existing_file_content",
"skipRows": 3,
"hasCustomHeaders": True
},
"execResultLabel": "existing_parsed_data"
},
{
"execMethod": "jira",
"execAction": "mergeTicketData",
"execParameters": {
"jiraData": "jira_exported_tickets",
"existingData": "existing_parsed_data",
"taskSyncDefinition": "{{KEY:taskSyncDefinition}}",
"idField": "ID"
},
"execResultLabel": "merged_ticket_data"
},
{
"execMethod": "sharepoint",
"execAction": "copyFile",
"execParameters": {
"connectionReference": "{{KEY:sharepointConnection}}",
"siteId": "sharepoint_site",
"sourceFolder": "{{KEY:sharepointMainFolder}}",
"sourceFile": "{{KEY:syncFileName}}",
"destFolder": "{{KEY:sharepointBackupFolder}}",
"destFile": "backup_{{TIMESTAMP}}_{{KEY:syncFileName}}"
},
"execResultLabel": "file_backup"
},
{
"execMethod": "jira",
"execAction": "createExcelContent",
"execParameters": {
"data": "merged_ticket_data",
"headers": "existing_parsed_data",
"taskSyncDefinition": "{{KEY:taskSyncDefinition}}"
},
"execResultLabel": "new_file_content"
},
{
"execMethod": "sharepoint",
"execAction": "uploadFile",
"execParameters": {
"connectionReference": "{{KEY:sharepointConnection}}",
"siteId": "sharepoint_site",
"folderPath": "{{KEY:sharepointMainFolder}}",
"fileName": "{{KEY:syncFileName}}",
"content": "new_file_content"
},
"execResultLabel": "uploaded_file"
},
{
"execMethod": "sharepoint",
"execAction": "downloadFileByPath",
"execParameters": {
"connectionReference": "{{KEY:sharepointConnection}}",
"siteId": "sharepoint_site",
"filePath": "{{KEY:sharepointMainFolder}}/{{KEY:syncFileName}}"
},
"execResultLabel": "uploaded_file_content"
},
{
"execMethod": "jira",
"execAction": "parseExcelContent",
"execParameters": {
"excelContent": "uploaded_file_content",
"skipRows": 3,
"hasCustomHeaders": True
},
"execResultLabel": "import_data"
},
{
"execMethod": "jira",
"execAction": "importTicketsFromJson",
"execParameters": {
"connectionId": "jira_connection",
"ticketData": "import_data",
"taskSyncDefinition": "{{KEY:taskSyncDefinition}}"
},
"execResultLabel": "import_result"
}
]
}
]
},
"parameters": {
"sharepointConnection": "connection:msft:patrick.motsch@delta.ch",
"sharepointHostname": "deltasecurityag.sharepoint.com",
"sharepointSitePath": "SteeringBPM",
"sharepointMainFolder": "/General/50 Docs hosted by SELISE",
"sharepointBackupFolder": "/General/50 Docs hosted by SELISE/SyncHistory",
"syncFileName": "DELTAgroup x SELISE Ticket Exchange List.xlsx",
"jiraUsername": "p.motsch@valueon.ch",
"jiraTokenConfigKey": "Feature_SyncDelta_JIRA_DELTA_TOKEN_SECRET",
"jiraUrl": "https://deltasecurity.atlassian.net",
"jiraProjectCode": "DCS",
"jiraIssueType": "Task",
"taskSyncDefinition": "{\"ID\":[\"get\",[\"key\"]],\"Module Category\":[\"get\",[\"fields\",\"customfield_10058\",\"value\"]],\"Summary\":[\"get\",[\"fields\",\"summary\"]],\"Description\":[\"get\",[\"fields\",\"description\"]],\"References\":[\"get\",[\"fields\",\"customfield_10066\"]],\"Priority\":[\"get\",[\"fields\",\"priority\",\"name\"]],\"Issue Status\":[\"get\",[\"fields\",\"status\",\"name\"]],\"Assignee\":[\"get\",[\"fields\",\"assignee\",\"displayName\"]],\"Issue Created\":[\"get\",[\"fields\",\"created\"]],\"Due Date\":[\"get\",[\"fields\",\"duedate\"]],\"DELTA Comments\":[\"get\",[\"fields\",\"customfield_10167\"]],\"SELISE Ticket References\":[\"put\",[\"fields\",\"customfield_10067\"]],\"SELISE Status Values\":[\"put\",[\"fields\",\"customfield_10065\"]],\"SELISE Comments\":[\"put\",[\"fields\",\"customfield_10168\"]]}"
}
},
{
"template": {
"overview": "Expenses PDF to Trustee Position",
"tasks": [
{
"id": "Task01",
"title": "Extract Expenses from SharePoint PDFs",
"description": "Reads PDF expense documents from SharePoint folder, extracts data via AI, and saves to TrusteePosition",
"objective": "Extract expense data from PDF documents and store in Trustee database with automatic file organization",
"actionList": [
{
"execMethod": "sharepoint",
"execAction": "getExpensesFromPdf",
"execParameters": {
"connectionReference": "{{KEY:connectionName}}",
"sharepointFolder": "{{KEY:sharepointFolder}}",
"featureInstanceId": "{{KEY:featureInstanceId}}",
"prompt": "{{KEY:extractionPrompt}}"
},
"execResultLabel": "expense_extraction_result"
}
]
}
]
},
"parameters": {
"connectionName": "",
"sharepointFolder": "",
"featureInstanceId": "",
"extractionPrompt": "Du bist ein Spezialist für die Extraktion von Belegdaten aus PDF-Dokumenten.\n\nAUFGABE:\nExtrahiere die Daten aus dem bereitgestellten Zahlungsbeleg und erstelle EINE EINZIGE CSV-Tabelle mit allen Datensätzen.\n\nOUTPUT-STRUKTUR:\nErstelle genau EINE Tabelle mit den folgenden Spalten. Alle extrahierten Datensätze kommen in diese eine Tabelle als Zeilen.\n\nWICHTIGE REGELN:\n1. Pro MwSt-Prozentsatz einen separaten Datensatz (= Zeile) erstellen\n2. Alle Datensätze zusammen müssen den Gesamtbetrag des Dokuments ergeben\n3. Der gesamte extrahierte Text des Dokuments muss im Feld \"desc\" erfasst werden\n4. Feld \"company\" enthält den Lieferanten/Verkäufer der Buchung\n5. Tags müssen aus dieser Liste gewählt werden: customer, meeting, license, subscription, fuel, food, material\n - Mehrere zutreffende Tags mit Komma trennen\n\nCSV-SPALTEN (in dieser Reihenfolge):\nvaluta,transactionDateTime,company,desc,tags,bookingCurrency,bookingAmount,originalCurrency,originalAmount,vatPercentage,vatAmount\n\nDATENFORMAT:\n- valuta: YYYY-MM-DD (Valutadatum)\n- transactionDateTime: Unix-Timestamp in Sekunden (Transaktionszeitpunkt)\n- company: Lieferant/Verkäufer Name\n- desc: Vollständiger extrahierter Text des Dokuments\n- tags: Komma-getrennte Tags aus der erlaubten Liste\n- bookingCurrency: Währungscode (CHF, EUR, USD, GBP)\n- bookingAmount: Buchungsbetrag als Dezimalzahl\n- originalCurrency: Original-Währungscode\n- originalAmount: Original-Betrag als Dezimalzahl\n- vatPercentage: MwSt-Prozentsatz (z.B. 8.1 für 8.1%)\n- vatAmount: MwSt-Betrag als Dezimalzahl\n\nHINWEISE:\n- Wenn nur ein MwSt-Satz vorhanden ist, einen Datensatz erstellen\n- Wenn mehrere MwSt-Sätze vorhanden sind (z.B. Lebensmittel 2.6% und Non-Food 8.1%), separate Datensätze erstellen\n- Bei fehlenden Informationen: leeres Feld oder Standardwert"
}
}
]
}
def getAutomationTemplates() -> Dict[str, Any]:
"""
Get automation templates.
Returns:
Dict containing the automation templates structure with 'sets' key.
"""
return AUTOMATION_TEMPLATES

View file

@ -3,7 +3,7 @@
""" """
Utility functions for automation feature. Utility functions for automation feature.
Moved from interfaces/interfaceDbChatObjects.py. Moved from interfaces/interfaceDbChat.py.
""" """
import json import json

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,8 @@
# Copyright (c) 2025 Patrick Motsch # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
""" """
Interface to LucyDOM database and AI Connectors. Interface to Chatbot database and AI Connectors.
Uses the JSON connector for data access with added language support. Uses the PostgreSQL connector for data access with user/mandate filtering.
""" """
import logging import logging
@ -16,16 +16,16 @@ from modules.security.rbac import RbacClass
from modules.datamodels.datamodelRbac import AccessRuleContext from modules.datamodels.datamodelRbac import AccessRuleContext
from modules.datamodels.datamodelUam import AccessLevel from modules.datamodels.datamodelUam import AccessLevel
from modules.datamodels.datamodelChat import ( from .datamodelFeatureChatbot import (
ChatDocument, ChatDocument,
ChatStat, ChatStat,
ChatLog, ChatLog,
ChatMessage, ChatMessage,
ChatWorkflow, ChatWorkflow,
WorkflowModeEnum, WorkflowModeEnum,
AutomationDefinition,
UserInputRequest UserInputRequest
) )
from modules.features.automation.datamodelFeatureAutomation import AutomationDefinition
import json import json
from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelUam import User
@ -59,7 +59,7 @@ def storeDebugMessageAndDocuments(message, currentUser) -> None:
import os import os
from datetime import datetime, UTC from datetime import datetime, UTC
from modules.shared.debugLogger import _getBaseDebugDir, _ensureDir from modules.shared.debugLogger import _getBaseDebugDir, _ensureDir
from modules.interfaces.interfaceDbComponentObjects import getInterface from modules.interfaces.interfaceDbManagement import getInterface
# Create base debug directory (use base debug dir, not prompts subdirectory) # Create base debug directory (use base debug dir, not prompts subdirectory)
baseDebugDir = _getBaseDebugDir() baseDebugDir = _getBaseDebugDir()
@ -178,12 +178,20 @@ class ChatObjects:
Uses the JSON connector for data access with added language support. Uses the JSON connector for data access with added language support.
""" """
def __init__(self, currentUser: Optional[User] = None): def __init__(self, currentUser: Optional[User] = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None):
"""Initializes the Chat Interface.""" """Initializes the Chat Interface.
Args:
currentUser: The authenticated user
mandateId: The mandate ID from RequestContext (X-Mandate-Id header)
featureInstanceId: The feature instance ID from RequestContext (X-Feature-Instance-Id header)
"""
# Initialize variables # Initialize variables
self.currentUser = currentUser # Store User object directly self.currentUser = currentUser # Store User object directly
self.userId = currentUser.id if currentUser else None 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.featureInstanceId = featureInstanceId
self.rbac = None # RBAC interface self.rbac = None # RBAC interface
# Initialize services # Initialize services
@ -194,7 +202,7 @@ class ChatObjects:
# Set user context if provided # Set user context if provided
if currentUser: if currentUser:
self.setUserContext(currentUser) self.setUserContext(currentUser, mandateId=mandateId, featureInstanceId=featureInstanceId)
# ===== Generic Utility Methods ===== # ===== Generic Utility Methods =====
@ -257,14 +265,27 @@ class ChatObjects:
def _initializeServices(self): def _initializeServices(self):
pass pass
def setUserContext(self, currentUser: User): def setUserContext(self, currentUser: User, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None):
"""Sets the user context for the interface.""" """Sets the user context for the interface.
Args:
currentUser: The authenticated user
mandateId: The mandate ID from RequestContext (X-Mandate-Id header)
featureInstanceId: The feature instance ID from RequestContext (X-Feature-Instance-Id header)
"""
self.currentUser = currentUser # Store User object directly self.currentUser = currentUser # Store User object directly
self.userId = currentUser.id self.userId = currentUser.id
self.mandateId = currentUser.mandateId # Use mandateId from parameter (Request-Context), not from user object
self.mandateId = mandateId
self.featureInstanceId = featureInstanceId
if not self.userId or not self.mandateId: if not self.userId:
raise ValueError("Invalid user context: id and mandateId are required") raise ValueError("Invalid user context: id is required")
# Note: mandateId is ALWAYS optional here - it comes from Request-Context, not from User.
# Users are NOT assigned to mandates by design - they get mandate context from the request.
# sysAdmin users can additionally perform cross-mandate operations.
# Without mandateId, operations will be filtered to accessible mandates via RBAC.
# Add language settings # Add language settings
self.userLanguage = currentUser.language # Default user language self.userLanguage = currentUser.language # Default user language
@ -293,11 +314,11 @@ class ChatObjects:
"""Initializes the database connection directly.""" """Initializes the database connection directly."""
try: try:
# Get configuration values with defaults # Get configuration values with defaults
dbHost = APP_CONFIG.get("DB_CHAT_HOST", "_no_config_default_data") dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data")
dbDatabase = APP_CONFIG.get("DB_CHAT_DATABASE", "chat") dbDatabase = "poweron_chatbot"
dbUser = APP_CONFIG.get("DB_CHAT_USER") dbUser = APP_CONFIG.get("DB_USER")
dbPassword = APP_CONFIG.get("DB_CHAT_PASSWORD_SECRET") dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
dbPort = int(APP_CONFIG.get("DB_CHAT_PORT", 5432)) dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
# Create database connector directly # Create database connector directly
self.db = DatabaseConnector( self.db = DatabaseConnector(
@ -346,7 +367,9 @@ class ChatObjects:
permissions = self.rbac.getUserPermissions( permissions = self.rbac.getUserPermissions(
self.currentUser, self.currentUser,
AccessRuleContext.DATA, AccessRuleContext.DATA,
tableName tableName,
mandateId=self.mandateId,
featureInstanceId=self.featureInstanceId
) )
if operation == "create": if operation == "create":
@ -587,10 +610,12 @@ class ChatObjects:
If pagination is None: List[Dict[str, Any]] If pagination is None: List[Dict[str, Any]]
If pagination is provided: PaginatedResult with items and metadata If pagination is provided: PaginatedResult with items and metadata
""" """
# Use RBAC filtering # Use RBAC filtering with featureInstanceId for instance-level isolation
filteredWorkflows = getRecordsetWithRBAC(self.db, filteredWorkflows = getRecordsetWithRBAC(self.db,
ChatWorkflow, ChatWorkflow,
self.currentUser self.currentUser,
mandateId=self.mandateId,
featureInstanceId=self.featureInstanceId
) )
# If no pagination requested, return all items (no sorting - frontend handles it) # If no pagination requested, return all items (no sorting - frontend handles it)
@ -622,11 +647,13 @@ class ChatObjects:
def getWorkflow(self, workflowId: str) -> Optional[ChatWorkflow]: def getWorkflow(self, workflowId: str) -> Optional[ChatWorkflow]:
"""Returns a workflow by ID if user has access.""" """Returns a workflow by ID if user has access."""
# Use RBAC filtering # Use RBAC filtering with featureInstanceId for instance-level isolation
workflows = getRecordsetWithRBAC(self.db, workflows = getRecordsetWithRBAC(self.db,
ChatWorkflow, ChatWorkflow,
self.currentUser, self.currentUser,
recordFilter={"id": workflowId} recordFilter={"id": workflowId},
mandateId=self.mandateId,
featureInstanceId=self.featureInstanceId
) )
if not workflows: if not workflows:
@ -654,7 +681,7 @@ class ChatObjects:
logs=logs, logs=logs,
messages=messages, messages=messages,
stats=stats, stats=stats,
mandateId=workflow.get("mandateId", self.currentUser.mandateId) mandateId=workflow.get("mandateId", self.mandateId)
) )
except Exception as e: except Exception as e:
logger.error(f"Error validating workflow data: {str(e)}") logger.error(f"Error validating workflow data: {str(e)}")
@ -673,6 +700,12 @@ class ChatObjects:
if "lastActivity" not in workflowData: if "lastActivity" not in workflowData:
workflowData["lastActivity"] = currentTime workflowData["lastActivity"] = currentTime
# Set mandateId and featureInstanceId from context for proper data isolation
if "mandateId" not in workflowData or not workflowData["mandateId"]:
workflowData["mandateId"] = self.mandateId
if "featureInstanceId" not in workflowData or not workflowData["featureInstanceId"]:
workflowData["featureInstanceId"] = self.featureInstanceId
# Use generic field separation based on ChatWorkflow model # Use generic field separation based on ChatWorkflow model
simpleFields, objectFields = self._separateObjectFields(ChatWorkflow, workflowData) simpleFields, objectFields = self._separateObjectFields(ChatWorkflow, workflowData)
@ -695,7 +728,7 @@ class ChatObjects:
logs=[], logs=[],
messages=[], messages=[],
stats=[], stats=[],
mandateId=created.get("mandateId", self.currentUser.mandateId), mandateId=created.get("mandateId", self.mandateId),
workflowMode=created["workflowMode"], workflowMode=created["workflowMode"],
maxSteps=created.get("maxSteps", 1) maxSteps=created.get("maxSteps", 1)
) )
@ -993,6 +1026,12 @@ class ChatObjects:
if "actionNumber" not in messageData: if "actionNumber" not in messageData:
messageData["actionNumber"] = workflow.currentAction messageData["actionNumber"] = workflow.currentAction
# Set mandateId and featureInstanceId from context for proper data isolation
if "mandateId" not in messageData or not messageData["mandateId"]:
messageData["mandateId"] = self.mandateId
if "featureInstanceId" not in messageData or not messageData["featureInstanceId"]:
messageData["featureInstanceId"] = self.featureInstanceId
# Use generic field separation based on ChatMessage model # Use generic field separation based on ChatMessage model
simpleFields, objectFields = self._separateObjectFields(ChatMessage, messageData) simpleFields, objectFields = self._separateObjectFields(ChatMessage, messageData)
@ -1088,7 +1127,7 @@ class ChatObjects:
logger.error(f"Error creating workflow message: {str(e)}") logger.error(f"Error creating workflow message: {str(e)}")
return None 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.""" """Updates a workflow message if user has access to the workflow."""
try: try:
@ -1174,8 +1213,10 @@ class ChatObjects:
logger.error(f"Error updating message documents: {str(e)}") logger.error(f"Error updating message documents: {str(e)}")
if not updatedMessage: if not updatedMessage:
logger.warning(f"Failed to update message {messageId}") logger.warning(f"Failed to update message {messageId}")
return None
return updatedMessage # Convert to ChatMessage model
return ChatMessage(**updatedMessage)
except Exception as e: except Exception as e:
logger.error(f"Error updating message {messageId}: {str(e)}", exc_info=True) logger.error(f"Error updating message {messageId}: {str(e)}", exc_info=True)
raise ValueError(f"Error updating message {messageId}: {str(e)}") raise ValueError(f"Error updating message {messageId}: {str(e)}")
@ -1285,6 +1326,12 @@ class ChatObjects:
def createDocument(self, documentData: Dict[str, Any]) -> ChatDocument: def createDocument(self, documentData: Dict[str, Any]) -> ChatDocument:
"""Creates a document for a message in normalized table.""" """Creates a document for a message in normalized table."""
try: try:
# Set mandateId and featureInstanceId from context for proper data isolation
if "mandateId" not in documentData or not documentData["mandateId"]:
documentData["mandateId"] = self.mandateId
if "featureInstanceId" not in documentData or not documentData["featureInstanceId"]:
documentData["featureInstanceId"] = self.featureInstanceId
# Validate and normalize document data to dict # Validate and normalize document data to dict
document = ChatDocument(**documentData) document = ChatDocument(**documentData)
logger.debug(f"Creating document in database: fileName={document.fileName}, fileId={document.fileId}, messageId={document.messageId}") logger.debug(f"Creating document in database: fileName={document.fileName}, fileId={document.fileId}, messageId={document.messageId}")
@ -1404,6 +1451,12 @@ class ChatObjects:
if "timestamp" not in logData: if "timestamp" not in logData:
logData["timestamp"] = getUtcTimestamp() logData["timestamp"] = getUtcTimestamp()
# Set mandateId and featureInstanceId from context for proper data isolation
if "mandateId" not in logData or not logData["mandateId"]:
logData["mandateId"] = self.mandateId
if "featureInstanceId" not in logData or not logData["featureInstanceId"]:
logData["featureInstanceId"] = self.featureInstanceId
# Add status information if not present # Add status information if not present
if "status" not in logData and "type" in logData: if "status" not in logData and "type" in logData:
if logData["type"] == "error": if logData["type"] == "error":
@ -1490,6 +1543,12 @@ class ChatObjects:
if "workflowId" not in statData: if "workflowId" not in statData:
raise ValueError("workflowId is required in statData") raise ValueError("workflowId is required in statData")
# Set mandateId and featureInstanceId from context for proper data isolation
if "mandateId" not in statData or not statData["mandateId"]:
statData["mandateId"] = self.mandateId
if "featureInstanceId" not in statData or not statData["featureInstanceId"]:
statData["featureInstanceId"] = self.featureInstanceId
# Validate the stat data against ChatStat model # Validate the stat data against ChatStat model
stat = ChatStat(**statData) stat = ChatStat(**statData)
@ -1612,7 +1671,7 @@ class ChatObjects:
if not automations: if not automations:
return automations return automations
from modules.interfaces.interfaceDbAppObjects import getInterface as getAppInterface from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
# Collect all unique user IDs and mandate IDs # Collect all unique user IDs and mandate IDs
userIds = set() userIds = set()
@ -1716,7 +1775,7 @@ class ChatObjects:
totalPages=totalPages 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.""" """Returns an automation definition by ID if user has access, with computed status."""
try: try:
# Use RBAC filtering # Use RBAC filtering
@ -1736,21 +1795,25 @@ class ChatObjects:
automation["executionLogs"] = [] automation["executionLogs"] = []
# Enrich with user and mandate names # Enrich with user and mandate names
self._enrichAutomationWithUserAndMandate(automation) 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: except Exception as e:
logger.error(f"Error getting automation definition: {str(e)}") logger.error(f"Error getting automation definition: {str(e)}")
return None 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.""" """Creates a new automation definition, then triggers sync."""
try: try:
# Ensure ID is present # Ensure ID is present
if "id" not in automationData or not automationData["id"]: if "id" not in automationData or not automationData["id"]:
automationData["id"] = str(uuid.uuid4()) automationData["id"] = str(uuid.uuid4())
# Ensure mandateId is set # Ensure mandateId and featureInstanceId are set for proper data isolation
if "mandateId" not in automationData: if "mandateId" not in automationData:
automationData["mandateId"] = self.mandateId automationData["mandateId"] = self.mandateId
if "featureInstanceId" not in automationData:
automationData["featureInstanceId"] = self.featureInstanceId
# Ensure database connector has correct userId context # Ensure database connector has correct userId context
# The connector should have been initialized with userId, but ensure it's updated # The connector should have been initialized with userId, but ensure it's updated
@ -1777,12 +1840,14 @@ class ChatObjects:
# Trigger automation change callback (async, don't wait) # Trigger automation change callback (async, don't wait)
asyncio.create_task(self._notifyAutomationChanged()) 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: except Exception as e:
logger.error(f"Error creating automation definition: {str(e)}") logger.error(f"Error creating automation definition: {str(e)}")
raise 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.""" """Updates an automation definition, then triggers sync."""
try: try:
# Check access # Check access
@ -1808,7 +1873,9 @@ class ChatObjects:
# Trigger automation change callback (async, don't wait) # Trigger automation change callback (async, don't wait)
asyncio.create_task(self._notifyAutomationChanged()) 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: except Exception as e:
logger.error(f"Error updating automation definition: {str(e)}") logger.error(f"Error updating automation definition: {str(e)}")
raise raise
@ -1870,19 +1937,30 @@ class ChatObjects:
logger.error(f"Error notifying automation change: {str(e)}") 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, featureInstanceId: Optional[str] = None) -> 'ChatObjects':
""" """
Returns a ChatObjects instance for the current user. Returns a ChatObjects instance for the current user.
Handles initialization of database and records. Handles initialization of database and records.
Args:
currentUser: The authenticated user
mandateId: The mandate ID from RequestContext (X-Mandate-Id header). Required.
featureInstanceId: The feature instance ID from RequestContext (X-Feature-Instance-Id header).
""" """
if not currentUser: if not currentUser:
raise ValueError("Invalid user context: user is required") raise ValueError("Invalid user context: user is required")
# Create context key effectiveMandateId = str(mandateId) if mandateId else None
contextKey = f"{currentUser.mandateId}_{currentUser.id}" effectiveFeatureInstanceId = str(featureInstanceId) if featureInstanceId else None
# Create context key including featureInstanceId for proper isolation
contextKey = f"{effectiveMandateId}_{effectiveFeatureInstanceId}_{currentUser.id}"
# Create new instance if not exists # Create new instance if not exists
if contextKey not in _chatInterfaces: if contextKey not in _chatInterfaces:
_chatInterfaces[contextKey] = ChatObjects(currentUser) _chatInterfaces[contextKey] = ChatObjects(currentUser, mandateId=effectiveMandateId, featureInstanceId=effectiveFeatureInstanceId)
else:
# Update user context if needed
_chatInterfaces[contextKey].setUserContext(currentUser, mandateId=effectiveMandateId, featureInstanceId=effectiveFeatureInstanceId)
return _chatInterfaces[contextKey] return _chatInterfaces[contextKey]

View file

@ -4,16 +4,136 @@
Simple chatbot feature - basic implementation. Simple chatbot feature - basic implementation.
User input is processed by AI to create list of needed queries. User input is processed by AI to create list of needed queries.
Those queries get streamed back. Those queries get streamed back.
This module also handles feature initialization and RBAC catalog registration.
""" """
import logging import logging
# Feature metadata for RBAC catalog
FEATURE_CODE = "chatbot"
FEATURE_LABEL = {"en": "Chatbot", "de": "Chatbot", "fr": "Chatbot"}
FEATURE_ICON = "mdi-robot"
# UI Objects for RBAC catalog
UI_OBJECTS = [
{
"objectKey": "ui.feature.chatbot.conversations",
"label": {"en": "Conversations", "de": "Konversationen", "fr": "Conversations"},
"meta": {"area": "conversations"}
},
{
"objectKey": "ui.feature.chatbot.settings",
"label": {"en": "Settings", "de": "Einstellungen", "fr": "Paramètres"},
"meta": {"area": "settings"}
},
]
# Resource Objects for RBAC catalog
RESOURCE_OBJECTS = [
{
"objectKey": "resource.feature.chatbot.start",
"label": {"en": "Start Chatbot", "de": "Chatbot starten", "fr": "Démarrer chatbot"},
"meta": {"endpoint": "/api/chatbot/start/stream", "method": "POST"}
},
{
"objectKey": "resource.feature.chatbot.stop",
"label": {"en": "Stop Chatbot", "de": "Chatbot stoppen", "fr": "Arrêter chatbot"},
"meta": {"endpoint": "/api/chatbot/stop/{workflowId}", "method": "POST"}
},
]
# Template roles for this feature
TEMPLATE_ROLES = [
{
"roleLabel": "chatbot-admin",
"description": {
"en": "Chatbot Administrator - Full access to chatbot settings and all conversations",
"de": "Chatbot-Administrator - Vollzugriff auf Chatbot-Einstellungen und alle Konversationen",
"fr": "Administrateur chatbot - Accès complet aux paramètres et conversations"
},
"accessRules": [
# Full UI access
{"context": "UI", "item": None, "view": True},
# Full DATA access
{"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"},
# Resource access
{"context": "RESOURCE", "item": "resource.feature.chatbot.start", "view": True},
]
},
{
"roleLabel": "chatbot-user",
"description": {
"en": "Chatbot User - Use chatbot and view own conversations",
"de": "Chatbot-Benutzer - Chatbot nutzen und eigene Konversationen einsehen",
"fr": "Utilisateur chatbot - Utiliser le chatbot et consulter ses conversations"
},
"accessRules": [
# UI access to conversations - vollqualifizierte ObjectKeys
{"context": "UI", "item": "ui.feature.chatbot.conversations", "view": True},
# Own DATA access (my level)
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "m"},
# Resource access
{"context": "RESOURCE", "item": "resource.feature.chatbot.start", "view": True},
]
},
]
def getFeatureDefinition():
"""Return the feature definition for registration."""
return {
"code": FEATURE_CODE,
"label": FEATURE_LABEL,
"icon": FEATURE_ICON
}
def getUiObjects():
"""Return UI objects for RBAC catalog registration."""
return UI_OBJECTS
def getResourceObjects():
"""Return resource objects for RBAC catalog registration."""
return RESOURCE_OBJECTS
def getTemplateRoles():
"""Return template roles for this feature."""
return TEMPLATE_ROLES
def registerFeature(catalogService) -> bool:
"""Register this feature's RBAC objects in the catalog."""
try:
for uiObj in UI_OBJECTS:
catalogService.registerUiObject(
featureCode=FEATURE_CODE,
objectKey=uiObj["objectKey"],
label=uiObj["label"],
meta=uiObj.get("meta")
)
for resObj in RESOURCE_OBJECTS:
catalogService.registerResourceObject(
featureCode=FEATURE_CODE,
objectKey=resObj["objectKey"],
label=resObj["label"],
meta=resObj.get("meta")
)
return True
except Exception as e:
logging.getLogger(__name__).error(f"Failed to register feature '{FEATURE_CODE}': {e}")
return False
import json import json
import uuid import uuid
import asyncio import asyncio
import re import re
from typing import Optional, Dict, Any, List from typing import Optional, Dict, Any, List
from modules.datamodels.datamodelChat import ChatWorkflow, UserInputRequest, WorkflowModeEnum, ChatLog, ChatDocument from modules.features.chatbot.datamodelFeatureChatbot import ChatWorkflow, UserInputRequest, WorkflowModeEnum, ChatLog, ChatDocument
from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelUam import User
from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum, ProcessingModeEnum from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum, ProcessingModeEnum
from modules.datamodels.datamodelDocref import DocumentReferenceList, DocumentItemReference from modules.datamodels.datamodelDocref import DocumentReferenceList, DocumentItemReference
@ -62,6 +182,7 @@ def _extractJsonFromResponse(content: str) -> Optional[dict]:
async def chatProcess( async def chatProcess(
currentUser: User, currentUser: User,
mandateId: str,
userInput: UserInputRequest, userInput: UserInputRequest,
workflowId: Optional[str] = None workflowId: Optional[str] = None
) -> ChatWorkflow: ) -> ChatWorkflow:
@ -76,6 +197,7 @@ async def chatProcess(
Args: Args:
currentUser: Current user currentUser: Current user
mandateId: Mandate context (from RequestContext / X-Mandate-Id header)
userInput: User input request userInput: User input request
workflowId: Optional workflow ID to continue existing conversation workflowId: Optional workflow ID to continue existing conversation
@ -83,8 +205,8 @@ async def chatProcess(
ChatWorkflow instance ChatWorkflow instance
""" """
try: try:
# Get services # Get services with mandate context
services = getServices(currentUser, None) services = getServices(currentUser, None, mandateId=mandateId)
interfaceDbChat = services.interfaceDbChat interfaceDbChat = services.interfaceDbChat
# Get event manager and create queue if needed # Get event manager and create queue if needed
@ -120,7 +242,7 @@ async def chatProcess(
# Create new workflow # Create new workflow
workflowData = { workflowData = {
"id": str(uuid.uuid4()), "id": str(uuid.uuid4()),
"mandateId": currentUser.mandateId, "mandateId": mandateId,
"status": "running", "status": "running",
"name": conversation_name, "name": conversation_name,
"currentRound": 1, "currentRound": 1,
@ -333,7 +455,6 @@ async def _emit_log_and_event(
# Emit event directly for streaming (using correct signature) # Emit event directly for streaming (using correct signature)
if created_log and event_manager: if created_log and event_manager:
try: try:
from modules.datamodels.datamodelChat import ChatLog
# Convert to dict if it's a Pydantic model # Convert to dict if it's a Pydantic model
if hasattr(created_log, "model_dump"): if hasattr(created_log, "model_dump"):
log_dict = created_log.model_dump() log_dict = created_log.model_dump()
@ -687,12 +808,13 @@ async def _convert_file_ids_to_document_references(
# Search database if not found in messages # Search database if not found in messages
if not document_id: if not document_id:
try: try:
from modules.shared.databaseUtils import getRecordsetWithRBAC from modules.interfaces.interfaceRbac import getRecordsetWithRBAC
documents = getRecordsetWithRBAC( documents = getRecordsetWithRBAC(
services.interfaceDbChat.db, services.interfaceDbChat.db,
ChatDocument, ChatDocument,
services.currentUser, services.user,
recordFilter={"fileId": file_id} recordFilter={"fileId": file_id},
mandateId=services.mandateId
) )
if documents: if documents:
workflow_message_ids = {msg.id for msg in workflow.messages} if workflow.messages else set() workflow_message_ids = {msg.id for msg in workflow.messages} if workflow.messages else set()
@ -1141,7 +1263,6 @@ async def _processChatbotMessage(
) )
# Retry analysis with empty results context - create NEW analysis with alternative strategies # Retry analysis with empty results context - create NEW analysis with alternative strategies
from modules.features.chatbot.chatbotConstants import get_empty_results_retry_instructions
# Build retry prompt with progressively different strategies # Build retry prompt with progressively different strategies
empty_count = len(sql_queries) empty_count = len(sql_queries)

View file

@ -15,23 +15,22 @@ from fastapi.responses import StreamingResponse
from modules.shared.timeUtils import parseTimestamp from modules.shared.timeUtils import parseTimestamp
# Import auth modules # Import auth modules
from modules.auth import limiter, getCurrentUser from modules.auth import limiter, getRequestContext, RequestContext
# Import interfaces # Import interfaces
import modules.interfaces.interfaceDbChatObjects as interfaceDbChatObjects from . import interfaceFeatureChatbot as interfaceDbChat
from modules.interfaces.interfaceRbac import getRecordsetWithRBAC from modules.interfaces.interfaceRbac import getRecordsetWithRBAC
# Import models # Import models
from modules.datamodels.datamodelChat import ChatWorkflow, UserInputRequest, WorkflowModeEnum from .datamodelFeatureChatbot import ChatWorkflow, UserInputRequest, WorkflowModeEnum
from modules.datamodels.datamodelUam import User
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse
# Import chatbot feature # Import chatbot feature
from modules.features.chatbot import chatProcess from . import chatProcess
from modules.features.chatbot.eventManager import get_event_manager from .eventManager import get_event_manager
# Import workflow control functions # Import workflow control functions
from modules.features.workflow import chatStop from modules.workflows.automation import chatStop
# Configure logger # Configure logger
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -43,8 +42,8 @@ router = APIRouter(
responses={404: {"description": "Not found"}} responses={404: {"description": "Not found"}}
) )
def getServiceChat(currentUser: User): def _getServiceChat(context: RequestContext):
return interfaceDbChatObjects.getInterface(currentUser) return interfaceDbChat.getInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None)
# Chatbot streaming endpoint (SSE) # Chatbot streaming endpoint (SSE)
@router.post("/start/stream") @router.post("/start/stream")
@ -53,7 +52,7 @@ async def stream_chatbot_start(
request: Request, request: Request,
workflowId: Optional[str] = Query(None, description="Optional ID of the workflow to continue (can also be in request body)"), workflowId: Optional[str] = Query(None, description="Optional ID of the workflow to continue (can also be in request body)"),
userInput: UserInputRequest = Body(...), userInput: UserInputRequest = Body(...),
currentUser: User = Depends(getCurrentUser) context: RequestContext = Depends(getRequestContext)
) -> StreamingResponse: ) -> StreamingResponse:
""" """
Starts a new chatbot workflow or continues an existing one with SSE streaming. 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 final_workflow_id = workflowId or userInput.workflowId
# Start background processing (this will create the workflow and event queue) # 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 # Get event queue for the workflow
queue = event_manager.get_queue(workflow.id) 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).""" """Async generator for SSE events - pure event-driven streaming (no polling)."""
try: try:
# Get interface for initial data and status checks # 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 # Get current workflow to check if resuming and get current round
current_workflow = interfaceDbChat.getWorkflow(workflow.id) current_workflow = interfaceDbChat.getWorkflow(workflow.id)
@ -239,11 +238,11 @@ async def stream_chatbot_start(
async def stop_chatbot( async def stop_chatbot(
request: Request, request: Request,
workflowId: str = Path(..., description="ID of the workflow to stop"), workflowId: str = Path(..., description="ID of the workflow to stop"),
currentUser: User = Depends(getCurrentUser) context: RequestContext = Depends(getRequestContext)
) -> ChatWorkflow: ) -> ChatWorkflow:
"""Stops a running chatbot workflow.""" """Stops a running chatbot workflow."""
try: try:
workflow = await chatStop(currentUser, workflowId) workflow = await chatStop(context.user, workflowId)
# Emit stopped event to active streams # Emit stopped event to active streams
event_manager = get_event_manager() event_manager = get_event_manager()
@ -272,18 +271,18 @@ async def stop_chatbot(
async def delete_chatbot( async def delete_chatbot(
request: Request, request: Request,
workflowId: str = Path(..., description="ID of the workflow to delete"), workflowId: str = Path(..., description="ID of the workflow to delete"),
currentUser: User = Depends(getCurrentUser) context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Deletes a chatbot workflow and its associated data.""" """Deletes a chatbot workflow and its associated data."""
try: try:
# Get service center # Get service center
interfaceDbChat = getServiceChat(currentUser) interfaceDbChat = _getServiceChat(context)
# Check workflow access and permission using RBAC # Check workflow access and permission using RBAC
workflows = getRecordsetWithRBAC( workflows = getRecordsetWithRBAC(
interfaceDbChat.db, interfaceDbChat.db,
ChatWorkflow, ChatWorkflow,
currentUser, context.user,
recordFilter={"id": workflowId} recordFilter={"id": workflowId}
) )
if not workflows: if not workflows:
@ -337,7 +336,7 @@ async def get_chatbot_threads(
request: Request, request: Request,
workflowId: Optional[str] = Query(None, description="Optional workflow ID to get details and chat data for a specific thread"), 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)"), 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]]: ) -> 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. 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 - If workflowId is not provided: Returns a paginated list of all workflows
""" """
try: try:
interfaceDbChat = getServiceChat(currentUser) interfaceDbChat = _getServiceChat(context)
# If workflowId is provided, return single workflow with chat data # If workflowId is provided, return single workflow with chat data
if workflowId: if workflowId:
@ -433,7 +432,6 @@ async def get_chatbot_threads(
normalized_workflows.append(normalized_wf) normalized_workflows.append(normalized_wf)
# Create paginated response # Create paginated response
from modules.datamodels.datamodelPagination import PaginationMetadata
metadata = PaginationMetadata( metadata = PaginationMetadata(
currentPage=paginationParams.page if paginationParams else 1, currentPage=paginationParams.page if paginationParams else 1,
pageSize=paginationParams.pageSize if paginationParams else len(workflows), pageSize=paginationParams.pageSize if paginationParams else len(workflows),
@ -456,4 +454,3 @@ async def get_chatbot_threads(
status_code=500, status_code=500,
detail=f"Error getting chatbot threads: {str(e)}" detail=f"Error getting chatbot threads: {str(e)}"
) )

View file

@ -1,237 +0,0 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Dynamic Options API feature module.
Provides dynamic options for frontend select/multiselect fields.
"""
import logging
from typing import List, Dict, Any, Optional
from modules.datamodels.datamodelUam import User
logger = logging.getLogger(__name__)
# Standard role definitions (fallback if database is not available)
STANDARD_ROLES = [
{"value": "sysadmin", "label": {"en": "System Administrator", "fr": "Administrateur système"}},
{"value": "admin", "label": {"en": "Administrator", "fr": "Administrateur"}},
{"value": "user", "label": {"en": "User", "fr": "Utilisateur"}},
{"value": "viewer", "label": {"en": "Viewer", "fr": "Visualiseur"}},
]
# Authentication authority options
AUTH_AUTHORITY_OPTIONS = [
{"value": "local", "label": {"en": "Local", "fr": "Local"}},
{"value": "google", "label": {"en": "Google", "fr": "Google"}},
{"value": "msft", "label": {"en": "Microsoft", "fr": "Microsoft"}},
]
# Connection status options
# Note: Matches ConnectionStatus enum values (active, expired, revoked, pending)
# Plus "error" for error states (not in enum but used in UI)
CONNECTION_STATUS_OPTIONS = [
{"value": "active", "label": {"en": "Active", "fr": "Actif"}},
{"value": "expired", "label": {"en": "Expired", "fr": "Expiré"}},
{"value": "revoked", "label": {"en": "Revoked", "fr": "Révoqué"}},
{"value": "pending", "label": {"en": "Pending", "fr": "En attente"}},
{"value": "error", "label": {"en": "Error", "fr": "Erreur"}},
]
def getOptions(optionsName: str, services, currentUser: Optional[User] = None) -> List[Dict[str, Any]]:
"""
Get options for a given options name.
Args:
optionsName: Name of the options set to retrieve (e.g., "user.role", "user.connection")
services: Services instance for data access
currentUser: Optional current user for context-aware options
Returns:
List of option dictionaries with "value" and "label" keys
Raises:
ValueError: If optionsName is not recognized
"""
logger.debug(f"getOptions called with optionsName='{optionsName}' (repr: {repr(optionsName)})")
optionsNameLower = optionsName.lower()
logger.debug(f"optionsNameLower='{optionsNameLower}'")
if optionsNameLower == "user.role":
# Fetch roles from database
if currentUser:
try:
roles = services.interfaceDbApp.getAllRoles()
# Convert Role objects to options format
options = []
for role in roles:
# Use English description as label, fallback to roleLabel
# Handle TextMultilingual object
if hasattr(role.description, 'get_text'):
# TextMultilingual object
label = role.description.get_text('en')
elif isinstance(role.description, dict):
# Dict format (backward compatibility)
label = role.description.get("en", role.roleLabel)
else:
# Fallback to roleLabel
label = role.roleLabel
options.append({
"value": role.roleLabel,
"label": label
})
# If no roles in database, return standard roles as fallback
if options:
return options
except Exception as e:
logger.warning(f"Error fetching roles from database, using fallback: {e}")
# Fallback to standard roles if database fetch fails or no user context
return STANDARD_ROLES
elif optionsNameLower == "auth.authority":
return AUTH_AUTHORITY_OPTIONS
elif optionsNameLower == "connection.status":
return CONNECTION_STATUS_OPTIONS
elif optionsNameLower == "user.connection":
# Dynamic options: Get user connections from database
if not currentUser:
return []
try:
connections = services.interfaceDbApp.getUserConnections(currentUser.id)
return [
{
"value": conn.id,
"label": {
"en": f"{conn.authority.value} - {conn.externalUsername or conn.externalId}",
"fr": f"{conn.authority.value} - {conn.externalUsername or conn.externalId}"
}
}
for conn in connections
]
except Exception as e:
logger.error(f"Error fetching user connections for options: {e}")
return []
elif optionsNameLower in ("user", "users"):
# Dynamic options: Get all users for the current mandate
if not currentUser:
return []
try:
users = services.interfaceDbApp.getUsersByMandate(currentUser.mandateId)
# Handle both list and PaginatedResult
if hasattr(users, 'items'):
userList = users.items
else:
userList = users
return [
{
"value": user.id,
"label": user.fullName or user.username or user.email or user.id
}
for user in userList
]
except Exception as e:
logger.error(f"Error fetching users for options: {e}")
return []
elif optionsNameLower in ("trusteeorganisation", "trustee.organisation"):
# Dynamic options: Get all trustee organisations
if not currentUser:
return []
try:
result = services.interfaceDbTrustee.getAllOrganisations()
# Handle PaginatedResult
items = result.items if hasattr(result, 'items') else result
return [
{
"value": org.get("id") if isinstance(org, dict) else org.id,
"label": org.get("label") if isinstance(org, dict) else org.label
}
for org in items
]
except Exception as e:
logger.error(f"Error fetching trustee organisations for options: {e}")
return []
elif optionsNameLower in ("trusteerole", "trustee.role"):
# Dynamic options: Get all trustee roles
if not currentUser:
return []
try:
result = services.interfaceDbTrustee.getAllRoles()
# Handle PaginatedResult
items = result.items if hasattr(result, 'items') else result
return [
{
"value": role.get("id") if isinstance(role, dict) else role.id,
# TrusteeRole uses 'desc' field, not 'label'
"label": role.get("desc", role.get("id")) if isinstance(role, dict) else getattr(role, "desc", role.id)
}
for role in items
]
except Exception as e:
logger.error(f"Error fetching trustee roles for options: {e}")
return []
elif optionsNameLower in ("trusteecontract", "trustee.contract"):
# Dynamic options: Get all trustee contracts
if not currentUser:
return []
try:
result = services.interfaceDbTrustee.getAllContracts()
# Handle PaginatedResult
items = result.items if hasattr(result, 'items') else result
return [
{
"value": contract.get("id") if isinstance(contract, dict) else contract.id,
"label": contract.get("label") if isinstance(contract, dict) else (contract.get("name") if isinstance(contract, dict) else getattr(contract, "label", getattr(contract, "name", contract.id)))
}
for contract in items
]
except Exception as e:
logger.error(f"Error fetching trustee contracts for options: {e}")
return []
else:
logger.error(f"Unknown options name: '{optionsName}' (lower: '{optionsNameLower}')")
raise ValueError(f"Unknown options name: {optionsName}")
def getAvailableOptionsNames() -> List[str]:
"""
Get list of all available options names.
Returns:
List of available options names
"""
return [
"user.role",
"auth.authority",
"connection.status",
"user.connection",
"User",
"TrusteeOrganisation",
"TrusteeRole",
"TrusteeContract",
]

View file

@ -1,62 +0,0 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
import logging
from modules.services import getInterface as getServices
logger = logging.getLogger(__name__)
async def start(eventUser) -> None:
""" Start feature triggers and background managers
Args:
eventUser: System-level event user for background operations (provided by app.py)
"""
# Feature Workflow (Automation)
if eventUser:
try:
from modules.features.workflow import syncAutomationEvents
from modules.shared.callbackRegistry import callbackRegistry
# Get services for event user (provides access to interfaces)
services = getServices(eventUser, None)
# Register callback for automation changes
async def onAutomationChanged(chatInterface):
"""Callback triggered when automations are created/updated/deleted."""
# Get services for event user to pass to syncAutomationEvents
eventServices = getServices(eventUser, None)
await syncAutomationEvents(eventServices, eventUser)
callbackRegistry.register('automation.changed', onAutomationChanged)
logger.info("Workflow: Registered change callback")
# Initial sync on startup - use services
await syncAutomationEvents(services, eventUser)
logger.info("Workflow: Events synced on startup")
except Exception as e:
logger.error(f"Workflow: Error setting up events on startup: {str(e)}")
# Don't fail startup if automation sync fails
# Feature ...
return True
async def stop(eventUser) -> None:
""" Stop feature triggers and background managers
Args:
eventUser: System-level event user (provided by app.py)
"""
# Feature Workflow (Automation)
# Callbacks will remain registered (acceptable for shutdown)
logger.info("Workflow: Callbacks remain registered (will be cleaned up on shutdown)")
# Feature ...
return True

View file

@ -11,6 +11,7 @@ from modules.shared.attributeUtils import registerModelLabels
class DataNeutraliserConfig(BaseModel): class DataNeutraliserConfig(BaseModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the configuration", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the configuration", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
mandateId: str = Field(description="ID of the mandate this configuration belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}) mandateId: str = Field(description="ID of the mandate this configuration belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
featureInstanceId: str = Field(description="ID of the feature instance this configuration belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
userId: str = Field(description="ID of the user who created this configuration", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}) userId: str = Field(description="ID of the user who created this configuration", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
enabled: bool = Field(default=True, description="Whether data neutralization is enabled", json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}) enabled: bool = Field(default=True, description="Whether data neutralization is enabled", json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False})
namesToParse: str = Field(default="", description="Multiline list of names to parse for neutralization", json_schema_extra={"frontend_type": "textarea", "frontend_readonly": False, "frontend_required": False}) namesToParse: str = Field(default="", description="Multiline list of names to parse for neutralization", json_schema_extra={"frontend_type": "textarea", "frontend_readonly": False, "frontend_required": False})
@ -22,6 +23,7 @@ registerModelLabels(
{ {
"id": {"en": "ID", "fr": "ID"}, "id": {"en": "ID", "fr": "ID"},
"mandateId": {"en": "Mandate ID", "fr": "ID de mandat"}, "mandateId": {"en": "Mandate ID", "fr": "ID de mandat"},
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"},
"userId": {"en": "User ID", "fr": "ID utilisateur"}, "userId": {"en": "User ID", "fr": "ID utilisateur"},
"enabled": {"en": "Enabled", "fr": "Activé"}, "enabled": {"en": "Enabled", "fr": "Activé"},
"namesToParse": {"en": "Names to Parse", "fr": "Noms à analyser"}, "namesToParse": {"en": "Names to Parse", "fr": "Noms à analyser"},
@ -33,6 +35,7 @@ registerModelLabels(
class DataNeutralizerAttributes(BaseModel): class DataNeutralizerAttributes(BaseModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the attribute mapping (used as UID in neutralized files)", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the attribute mapping (used as UID in neutralized files)", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
mandateId: str = Field(description="ID of the mandate this attribute belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}) mandateId: str = Field(description="ID of the mandate this attribute belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
featureInstanceId: str = Field(description="ID of the feature instance this attribute belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
userId: str = Field(description="ID of the user who created this attribute", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}) userId: str = Field(description="ID of the user who created this attribute", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
originalText: str = Field(description="Original text that was neutralized", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}) originalText: str = Field(description="Original text that was neutralized", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
fileId: Optional[str] = Field(default=None, description="ID of the file this attribute belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) fileId: Optional[str] = Field(default=None, description="ID of the file this attribute belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
@ -43,6 +46,7 @@ registerModelLabels(
{ {
"id": {"en": "ID", "fr": "ID"}, "id": {"en": "ID", "fr": "ID"},
"mandateId": {"en": "Mandate ID", "fr": "ID de mandat"}, "mandateId": {"en": "Mandate ID", "fr": "ID de mandat"},
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"},
"userId": {"en": "User ID", "fr": "ID utilisateur"}, "userId": {"en": "User ID", "fr": "ID utilisateur"},
"originalText": {"en": "Original Text", "fr": "Texte original"}, "originalText": {"en": "Original Text", "fr": "Texte original"},
"fileId": {"en": "File ID", "fr": "ID de fichier"}, "fileId": {"en": "File ID", "fr": "ID de fichier"},

View file

@ -0,0 +1,240 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Database interface for the Neutralizer feature.
Handles CRUD operations for neutralization configuration and attributes.
"""
import logging
from typing import Dict, List, Any, Optional
from modules.features.neutralization.datamodelFeatureNeutralizer import (
DataNeutraliserConfig,
DataNeutralizerAttributes,
)
from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.interfaces.interfaceRbac import getRecordsetWithRBAC
from modules.shared.configuration import APP_CONFIG
from modules.shared.timeUtils import getUtcTimestamp
from modules.datamodels.datamodelUam import User
logger = logging.getLogger(__name__)
# Singleton cache for interface instances
_neutralizerInterfaces = {}
class InterfaceFeatureNeutralizer:
"""Database interface for Neutralizer feature operations"""
# Feature code for RBAC objectKey construction
FEATURE_CODE = "neutralization"
def __init__(self, currentUser: Optional[User] = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None):
"""
Initialize the interface with database connection and user context.
Args:
currentUser: Current user object for RBAC
mandateId: Current mandate ID
featureInstanceId: Current feature instance ID
"""
self.currentUser = currentUser
self.mandateId = mandateId
self.featureInstanceId = featureInstanceId
self.userId = currentUser.id if currentUser else None
self.db = None
# Initialize database
self._initializeDatabase()
def _initializeDatabase(self):
"""Initialize the database connection."""
try:
# Use same database config pattern as other feature interfaces
dbHost = APP_CONFIG.get("DB_HOST", "localhost")
dbDatabase = APP_CONFIG.get("DB_DATABASE_NEUTRALIZATION", APP_CONFIG.get("DB_DATABASE", "poweron"))
dbUser = APP_CONFIG.get("DB_USER", "postgres")
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
self.db = DatabaseConnector(
dbHost=dbHost,
dbDatabase=dbDatabase,
dbUser=dbUser,
dbPassword=dbPassword,
dbPort=dbPort,
userId=self.userId,
)
self.db.initDbSystem()
logger.debug("Neutralizer database initialized successfully")
except Exception as e:
logger.error(f"Error initializing Neutralizer database: {str(e)}")
raise
def setUserContext(self, currentUser: User, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None):
"""Sets the user context for the interface."""
if not currentUser:
logger.info("Initializing interface without user context")
return
self.currentUser = currentUser
self.userId = currentUser.id
self.mandateId = mandateId
self.featureInstanceId = featureInstanceId
def getNeutralizationConfig(self) -> Optional[DataNeutraliserConfig]:
"""Get the data neutralization configuration for the current user's mandate"""
try:
# Use RBAC filtering
filteredConfigs = getRecordsetWithRBAC(
self.db,
DataNeutraliserConfig,
self.currentUser,
recordFilter={"mandateId": self.mandateId}
)
if not filteredConfigs:
return None
# Filter out database-specific fields
configDict = filteredConfigs[0]
cleanedConfig = {k: v for k, v in configDict.items() if not k.startswith("_")}
return DataNeutraliserConfig(**cleanedConfig)
except Exception as e:
logger.error(f"Error getting neutralization config: {str(e)}")
return None
def createOrUpdateNeutralizationConfig(
self, configData: Dict[str, Any]
) -> DataNeutraliserConfig:
"""Create or update the data neutralization configuration"""
try:
# Check if config already exists
existingConfig = self.getNeutralizationConfig()
if existingConfig:
# Update existing config
updateData = existingConfig.model_dump()
updateData.update(configData)
updateData["updatedAt"] = getUtcTimestamp()
updatedConfig = DataNeutraliserConfig(**updateData)
self.db.recordModify(
DataNeutraliserConfig, existingConfig.id, updatedConfig
)
return updatedConfig
else:
# Create new config
configData["mandateId"] = self.mandateId
configData["userId"] = self.userId
newConfig = DataNeutraliserConfig(**configData)
createdRecord = self.db.recordCreate(DataNeutraliserConfig, newConfig)
return DataNeutraliserConfig(**createdRecord)
except Exception as e:
logger.error(f"Error creating/updating neutralization config: {str(e)}")
raise ValueError(f"Failed to create/update neutralization config: {str(e)}")
def getNeutralizationAttributes(
self, fileId: Optional[str] = None
) -> List[DataNeutralizerAttributes]:
"""Get neutralization attributes, optionally filtered by file ID"""
try:
filterDict = {"mandateId": self.mandateId}
if fileId:
filterDict["fileId"] = fileId
# Use RBAC filtering
filteredAttributes = getRecordsetWithRBAC(
self.db,
DataNeutralizerAttributes,
self.currentUser,
recordFilter=filterDict
)
# Filter out database-specific fields
cleanedAttributes = []
for attr in filteredAttributes:
cleanedAttr = {k: v for k, v in attr.items() if not k.startswith("_")}
cleanedAttributes.append(cleanedAttr)
return [
DataNeutralizerAttributes(**attr)
for attr in cleanedAttributes
]
except Exception as e:
logger.error(f"Error getting neutralization attributes: {str(e)}")
return []
def deleteNeutralizationAttributes(self, fileId: str) -> bool:
"""Delete all neutralization attributes for a specific file"""
try:
attributes = self.db.getRecordset(
DataNeutralizerAttributes,
recordFilter={"mandateId": self.mandateId, "fileId": fileId},
)
for attribute in attributes:
self.db.recordDelete(DataNeutralizerAttributes, attribute["id"])
logger.info(
f"Deleted {len(attributes)} neutralization attributes for file {fileId}"
)
return True
except Exception as e:
logger.error(f"Error deleting neutralization attributes: {str(e)}")
return False
def getAttributeById(self, attributeId: str) -> Optional[Dict[str, Any]]:
"""Get a single neutralization attribute by ID"""
try:
attributes = self.db.getRecordset(
DataNeutralizerAttributes,
recordFilter={"mandateId": self.mandateId, "id": attributeId}
)
if attributes:
return attributes[0]
return None
except Exception as e:
logger.error(f"Error getting attribute by ID: {str(e)}")
return None
def getInterface(currentUser: Optional[User] = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None) -> InterfaceFeatureNeutralizer:
"""
Factory function to get or create a Neutralizer interface instance.
Uses singleton pattern per user context.
Args:
currentUser: Current user for RBAC
mandateId: Current mandate ID
featureInstanceId: Current feature instance ID
Returns:
InterfaceFeatureNeutralizer instance
"""
global _neutralizerInterfaces
if not currentUser:
raise ValueError("Valid user context required")
effectiveMandateId = str(mandateId) if mandateId else None
effectiveFeatureInstanceId = str(featureInstanceId) if featureInstanceId else None
# Include featureInstanceId in cache key for proper isolation
cacheKey = f"{currentUser.id}_{effectiveMandateId}_{effectiveFeatureInstanceId}"
if cacheKey not in _neutralizerInterfaces:
_neutralizerInterfaces[cacheKey] = InterfaceFeatureNeutralizer(currentUser, mandateId=effectiveMandateId, featureInstanceId=effectiveFeatureInstanceId)
else:
# Update user context if needed
_neutralizerInterfaces[cacheKey].setUserContext(currentUser, mandateId=effectiveMandateId, featureInstanceId=effectiveFeatureInstanceId)
return _neutralizerInterfaces[cacheKey]

View file

@ -6,7 +6,7 @@ from typing import Any, Dict, List, Optional
from urllib.parse import urlparse, unquote from urllib.parse import urlparse, unquote
from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelUam import User
from modules.datamodels.datamodelNeutralizer import DataNeutralizerAttributes, DataNeutraliserConfig from .datamodelFeatureNeutralizer import DataNeutralizerAttributes, DataNeutraliserConfig
from modules.services import getInterface as getServices from modules.services import getInterface as getServices
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -15,9 +15,10 @@ logger = logging.getLogger(__name__)
class NeutralizationPlayground: class NeutralizationPlayground:
"""Feature/UI wrapper around NeutralizationService for playground & routes.""" """Feature/UI wrapper around NeutralizationService for playground & routes."""
def __init__(self, currentUser: User): def __init__(self, currentUser: User, mandateId: str):
self.currentUser = currentUser 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]: def processText(self, text: str) -> Dict[str, Any]:
return self.services.neutralization.processText(text) return self.services.neutralization.processText(text)
@ -81,7 +82,7 @@ class NeutralizationPlayground:
'total_attributes': len(allAttributes), 'total_attributes': len(allAttributes),
'unique_files': len(uniqueFiles), 'unique_files': len(uniqueFiles),
'pattern_counts': patternCounts, 'pattern_counts': patternCounts,
'mandate_id': self.currentUser.mandateId if self.currentUser else None, 'mandate_id': self.mandateId,
} }
except Exception as e: except Exception as e:
logger.error(f"Error getting stats: {str(e)}") logger.error(f"Error getting stats: {str(e)}")
@ -181,7 +182,6 @@ class SharepointProcessor:
async def _getSharepointConnection(self, sharepointPath: str = None): async def _getSharepointConnection(self, sharepointPath: str = None):
try: try:
from modules.datamodels.datamodelUam import UserConnection
connections = self.services.interfaceDbApp.db.getRecordset( connections = self.services.interfaceDbApp.db.getRecordset(
UserConnection, UserConnection,
recordFilter={"userId": self.services.interfaceDbApp.userId} recordFilter={"userId": self.services.interfaceDbApp.userId}

View file

@ -0,0 +1,138 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Neutralizer Feature Container - Main Module.
Handles feature initialization and RBAC catalog registration.
"""
import logging
from typing import Dict, List, Any
logger = logging.getLogger(__name__)
# Feature metadata
FEATURE_CODE = "neutralization"
FEATURE_LABEL = {"en": "Neutralization", "de": "Neutralisierung", "fr": "Neutralisation"}
FEATURE_ICON = "mdi-shield-check"
# UI Objects for RBAC catalog
UI_OBJECTS = [
{
"objectKey": "ui.feature.neutralization.playground",
"label": {"en": "Playground", "de": "Spielwiese", "fr": "Bac à sable"},
"meta": {"area": "playground"}
},
{
"objectKey": "ui.feature.neutralization.config",
"label": {"en": "Configuration", "de": "Konfiguration", "fr": "Configuration"},
"meta": {"area": "config"}
},
{
"objectKey": "ui.feature.neutralization.attributes",
"label": {"en": "Attributes", "de": "Attribute", "fr": "Attributs"},
"meta": {"area": "attributes"}
},
]
# Resource Objects for RBAC catalog
RESOURCE_OBJECTS = [
{
"objectKey": "resource.feature.neutralization.process.text",
"label": {"en": "Process Text", "de": "Text verarbeiten", "fr": "Traiter texte"},
"meta": {"endpoint": "/api/neutralization/process/text", "method": "POST"}
},
{
"objectKey": "resource.feature.neutralization.process.files",
"label": {"en": "Process Files", "de": "Dateien verarbeiten", "fr": "Traiter fichiers"},
"meta": {"endpoint": "/api/neutralization/process/files", "method": "POST"}
},
{
"objectKey": "resource.feature.neutralization.config.update",
"label": {"en": "Update Config", "de": "Konfiguration aktualisieren", "fr": "Mettre à jour config"},
"meta": {"endpoint": "/api/neutralization/config", "method": "PUT"}
},
]
# Template roles for this feature
TEMPLATE_ROLES = [
{
"roleLabel": "neutralization-admin",
"description": {
"en": "Neutralization Administrator - Full access to neutralization settings and data",
"de": "Neutralisierungs-Administrator - Vollzugriff auf Neutralisierungs-Einstellungen und Daten",
"fr": "Administrateur neutralisation - Accès complet aux paramètres et données"
},
"accessRules": [
# Full UI access (all views including admin views)
{"context": "UI", "item": None, "view": True},
# Full DATA access
{"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"},
]
},
{
"roleLabel": "neutralization-analyst",
"description": {
"en": "Neutralization Analyst - Analyze and process neutralization data",
"de": "Neutralisierungs-Analyst - Neutralisierungsdaten analysieren und verarbeiten",
"fr": "Analyste neutralisation - Analyser et traiter les données de neutralisation"
},
"accessRules": [
# UI access to specific views - vollqualifizierte ObjectKeys
{"context": "UI", "item": "ui.feature.neutralization.playground", "view": True},
{"context": "UI", "item": "ui.feature.neutralization.attributes", "view": True},
# Group-level DATA access (read-only for sensitive config)
{"context": "DATA", "item": None, "view": True, "read": "g", "create": "n", "update": "n", "delete": "n"},
]
},
]
def getFeatureDefinition() -> Dict[str, Any]:
"""Return the feature definition for registration."""
return {
"code": FEATURE_CODE,
"label": FEATURE_LABEL,
"icon": FEATURE_ICON
}
def getUiObjects() -> List[Dict[str, Any]]:
"""Return UI objects for RBAC catalog registration."""
return UI_OBJECTS
def getResourceObjects() -> List[Dict[str, Any]]:
"""Return resource objects for RBAC catalog registration."""
return RESOURCE_OBJECTS
def getTemplateRoles() -> List[Dict[str, Any]]:
"""Return template roles for this feature."""
return TEMPLATE_ROLES
def registerFeature(catalogService) -> bool:
"""Register this feature's RBAC objects in the catalog."""
try:
for uiObj in UI_OBJECTS:
catalogService.registerUiObject(
featureCode=FEATURE_CODE,
objectKey=uiObj["objectKey"],
label=uiObj["label"],
meta=uiObj.get("meta")
)
for resObj in RESOURCE_OBJECTS:
catalogService.registerResourceObject(
featureCode=FEATURE_CODE,
objectKey=resObj["objectKey"],
label=resObj["label"],
meta=resObj.get("meta")
)
logger.info(f"Feature '{FEATURE_CODE}' registered {len(UI_OBJECTS)} UI objects and {len(RESOURCE_OBJECTS)} resource objects")
return True
except Exception as e:
logger.error(f"Failed to register feature '{FEATURE_CODE}': {e}")
return False

View file

@ -5,12 +5,11 @@ from typing import List, Dict, Any, Optional
import logging import logging
# Import auth module # Import auth module
from modules.auth import limiter, getCurrentUser from modules.auth import limiter, getRequestContext, RequestContext
# Import interfaces # Import interfaces
from modules.datamodels.datamodelUam import User from .datamodelFeatureNeutralizer import DataNeutraliserConfig, DataNeutralizerAttributes
from modules.datamodels.datamodelNeutralizer import DataNeutraliserConfig, DataNeutralizerAttributes from .mainNeutralizePlayground import NeutralizationPlayground
from modules.features.neutralizePlayground.mainNeutralizePlayground import NeutralizationPlayground
# Configure logger # Configure logger
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -32,18 +31,18 @@ router = APIRouter(
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def get_neutralization_config( async def get_neutralization_config(
request: Request, request: Request,
currentUser: User = Depends(getCurrentUser) context: RequestContext = Depends(getRequestContext)
) -> DataNeutraliserConfig: ) -> DataNeutraliserConfig:
"""Get data neutralization configuration""" """Get data neutralization configuration"""
try: try:
service = NeutralizationPlayground(currentUser) service = NeutralizationPlayground(context.user, str(context.mandateId))
config = service.getConfig() config = service.getConfig()
if not config: if not config:
# Return default config instead of 404 # Return default config instead of 404
return DataNeutraliserConfig( return DataNeutraliserConfig(
mandateId=currentUser.mandateId, mandateId=context.mandateId,
userId=currentUser.id, userId=context.user.id,
enabled=True, enabled=True,
namesToParse="", namesToParse="",
sharepointSourcePath="", sharepointSourcePath="",
@ -66,11 +65,11 @@ async def get_neutralization_config(
async def save_neutralization_config( async def save_neutralization_config(
request: Request, request: Request,
config_data: Dict[str, Any] = Body(...), config_data: Dict[str, Any] = Body(...),
currentUser: User = Depends(getCurrentUser) context: RequestContext = Depends(getRequestContext)
) -> DataNeutraliserConfig: ) -> DataNeutraliserConfig:
"""Save or update data neutralization configuration""" """Save or update data neutralization configuration"""
try: try:
service = NeutralizationPlayground(currentUser) service = NeutralizationPlayground(context.user, str(context.mandateId))
config = service.saveConfig(config_data) config = service.saveConfig(config_data)
return config return config
@ -87,7 +86,7 @@ async def save_neutralization_config(
async def neutralize_text( async def neutralize_text(
request: Request, request: Request,
text_data: Dict[str, Any] = Body(...), text_data: Dict[str, Any] = Body(...),
currentUser: User = Depends(getCurrentUser) context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Neutralize text content""" """Neutralize text content"""
try: try:
@ -100,7 +99,7 @@ async def neutralize_text(
detail="Text content is required" detail="Text content is required"
) )
service = NeutralizationPlayground(currentUser) service = NeutralizationPlayground(context.user, str(context.mandateId))
result = service.neutralizeText(text, file_id) result = service.neutralizeText(text, file_id)
return result return result
@ -119,7 +118,7 @@ async def neutralize_text(
async def resolve_text( async def resolve_text(
request: Request, request: Request,
text_data: Dict[str, str] = Body(...), text_data: Dict[str, str] = Body(...),
currentUser: User = Depends(getCurrentUser) context: RequestContext = Depends(getRequestContext)
) -> Dict[str, str]: ) -> Dict[str, str]:
"""Resolve UIDs in neutralized text back to original text""" """Resolve UIDs in neutralized text back to original text"""
try: try:
@ -131,7 +130,7 @@ async def resolve_text(
detail="Text content is required" detail="Text content is required"
) )
service = NeutralizationPlayground(currentUser) service = NeutralizationPlayground(context.user, str(context.mandateId))
resolved_text = service.resolveText(text) resolved_text = service.resolveText(text)
return {"resolved_text": resolved_text} return {"resolved_text": resolved_text}
@ -150,11 +149,11 @@ async def resolve_text(
async def get_neutralization_attributes( async def get_neutralization_attributes(
request: Request, request: Request,
fileId: Optional[str] = Query(None, description="Filter by file ID"), fileId: Optional[str] = Query(None, description="Filter by file ID"),
currentUser: User = Depends(getCurrentUser) context: RequestContext = Depends(getRequestContext)
) -> List[DataNeutralizerAttributes]: ) -> List[DataNeutralizerAttributes]:
"""Get neutralization attributes, optionally filtered by file ID""" """Get neutralization attributes, optionally filtered by file ID"""
try: try:
service = NeutralizationPlayground(currentUser) service = NeutralizationPlayground(context.user, str(context.mandateId))
attributes = service.getAttributes(fileId) attributes = service.getAttributes(fileId)
return attributes return attributes
@ -171,7 +170,7 @@ async def get_neutralization_attributes(
async def process_sharepoint_files( async def process_sharepoint_files(
request: Request, request: Request,
paths_data: Dict[str, str] = Body(...), paths_data: Dict[str, str] = Body(...),
currentUser: User = Depends(getCurrentUser) context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Process files from SharePoint source path and store neutralized files in target path""" """Process files from SharePoint source path and store neutralized files in target path"""
try: try:
@ -184,7 +183,7 @@ async def process_sharepoint_files(
detail="Both source and target paths are required" 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) result = await service.processSharepointFiles(source_path, target_path)
return result return result
@ -203,7 +202,7 @@ async def process_sharepoint_files(
async def batch_process_files( async def batch_process_files(
request: Request, request: Request,
files_data: List[Dict[str, Any]] = Body(...), files_data: List[Dict[str, Any]] = Body(...),
currentUser: User = Depends(getCurrentUser) context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Process multiple files for neutralization""" """Process multiple files for neutralization"""
try: try:
@ -213,7 +212,7 @@ async def batch_process_files(
detail="Files data is required" detail="Files data is required"
) )
service = NeutralizationPlayground(currentUser) service = NeutralizationPlayground(context.user, str(context.mandateId))
result = service.batchNeutralizeFiles(files_data) result = service.batchNeutralizeFiles(files_data)
return result return result
@ -231,11 +230,11 @@ async def batch_process_files(
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def get_neutralization_stats( async def get_neutralization_stats(
request: Request, request: Request,
currentUser: User = Depends(getCurrentUser) context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Get neutralization processing statistics""" """Get neutralization processing statistics"""
try: try:
service = NeutralizationPlayground(currentUser) service = NeutralizationPlayground(context.user, str(context.mandateId))
stats = service.getProcessingStats() stats = service.getProcessingStats()
return stats return stats
@ -252,11 +251,11 @@ async def get_neutralization_stats(
async def cleanup_file_attributes( async def cleanup_file_attributes(
request: Request, request: Request,
fileId: str = Path(..., description="File ID to cleanup attributes for"), fileId: str = Path(..., description="File ID to cleanup attributes for"),
currentUser: User = Depends(getCurrentUser) context: RequestContext = Depends(getRequestContext)
) -> Dict[str, str]: ) -> Dict[str, str]:
"""Clean up neutralization attributes for a specific file""" """Clean up neutralization attributes for a specific file"""
try: try:
service = NeutralizationPlayground(currentUser) service = NeutralizationPlayground(context.user, str(context.mandateId))
success = service.cleanupFileAttributes(fileId) success = service.cleanupFileAttributes(fileId)
if success: if success:

View file

@ -13,14 +13,15 @@ import re
import json import json
from typing import Dict, List, Any, Optional from typing import Dict, List, Any, Optional
from modules.datamodels.datamodelNeutralizer import DataNeutraliserConfig, DataNeutralizerAttributes from modules.features.neutralization.datamodelFeatureNeutralizer import DataNeutraliserConfig, DataNeutralizerAttributes
from modules.features.neutralization.interfaceFeatureNeutralizer import InterfaceFeatureNeutralizer, getInterface as getNeutralizerInterface
# Import all necessary classes and functions for neutralization # Import all necessary classes and functions for neutralization
from modules.services.serviceNeutralization.subProcessCommon import CommonUtils, NeutralizationResult, NeutralizationAttribute from .subProcessCommon import CommonUtils, NeutralizationResult, NeutralizationAttribute
from modules.services.serviceNeutralization.subProcessText import TextProcessor, PlainText from .subProcessText import TextProcessor, PlainText
from modules.services.serviceNeutralization.subProcessList import ListProcessor, TableData from .subProcessList import ListProcessor, TableData
from modules.services.serviceNeutralization.subProcessBinary import BinaryProcessor from .subProcessBinary import BinaryProcessor
from modules.services.serviceNeutralization.subPatterns import HeaderPatterns, DataPatterns, TextTablePatterns from .subPatterns import HeaderPatterns, DataPatterns, TextTablePatterns
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -35,9 +36,18 @@ class NeutralizationService:
NamesToParse: List of names to parse and replace (case-insensitive) NamesToParse: List of names to parse and replace (case-insensitive)
""" """
self.services = serviceCenter self.services = serviceCenter
self.interfaceDbApp = serviceCenter.interfaceDbApp
self.interfaceDbComponent = serviceCenter.interfaceDbComponent self.interfaceDbComponent = serviceCenter.interfaceDbComponent
# Create feature-specific interface for neutralizer DB operations
self.interfaceNeutralizer: InterfaceFeatureNeutralizer = None
if serviceCenter and serviceCenter.interfaceDbApp:
dbApp = serviceCenter.interfaceDbApp
self.interfaceNeutralizer = getNeutralizerInterface(
currentUser=dbApp.currentUser,
mandateId=dbApp.mandateId,
featureInstanceId=getattr(dbApp, 'featureInstanceId', None)
)
# Initialize anonymization processors # Initialize anonymization processors
self.NamesToParse = NamesToParse or [] self.NamesToParse = NamesToParse or []
self.textProcessor = TextProcessor(NamesToParse) self.textProcessor = TextProcessor(NamesToParse)
@ -47,15 +57,15 @@ class NeutralizationService:
def getConfig(self) -> Optional[DataNeutraliserConfig]: def getConfig(self) -> Optional[DataNeutraliserConfig]:
"""Get the neutralization configuration for the current user's mandate""" """Get the neutralization configuration for the current user's mandate"""
if not self.interfaceDbApp: if not self.interfaceNeutralizer:
return None return None
return self.interfaceDbApp.getNeutralizationConfig() return self.interfaceNeutralizer.getNeutralizationConfig()
def saveConfig(self, config_data: Dict[str, Any]) -> DataNeutraliserConfig: def saveConfig(self, configData: Dict[str, Any]) -> DataNeutraliserConfig:
"""Save or update the neutralization configuration""" """Save or update the neutralization configuration"""
if not self.interfaceDbApp: if not self.interfaceNeutralizer:
raise ValueError("User context required for saving configuration") raise ValueError("User context required for saving configuration")
return self.interfaceDbApp.createOrUpdateNeutralizationConfig(config_data) return self.interfaceNeutralizer.createOrUpdateNeutralizationConfig(configData)
# Public API: process text or file # Public API: process text or file
@ -125,44 +135,37 @@ class NeutralizationService:
return result return result
def resolveText(self, text: str) -> str: def resolveText(self, text: str) -> str:
if not self.interfaceDbApp: if not self.interfaceNeutralizer:
return text return text
try: try:
placeholder_pattern = r'\[([a-z]+)\.([a-f0-9-]{36})\]' placeholderPattern = r'\[([a-z]+)\.([a-f0-9-]{36})\]'
matches = re.findall(placeholder_pattern, text) matches = re.findall(placeholderPattern, text)
resolved_text = text resolvedText = text
for placeholder_type, uid in matches: for placeholderType, uid in matches:
attributes = self.interfaceDbApp.db.getRecordset( attribute = self.interfaceNeutralizer.getAttributeById(uid)
DataNeutralizerAttributes, if attribute:
recordFilter={ placeholder = f"[{placeholderType}.{uid}]"
"mandateId": self.interfaceDbApp.mandateId, resolvedText = resolvedText.replace(placeholder, attribute["originalText"])
"id": uid return resolvedText
}
)
if attributes:
attribute = attributes[0]
placeholder = f"[{placeholder_type}.{uid}]"
resolved_text = resolved_text.replace(placeholder, attribute["originalText"])
return resolved_text
except Exception: except Exception:
return text return text
def getAttributes(self) -> List[DataNeutralizerAttributes]: def getAttributes(self) -> List[DataNeutralizerAttributes]:
"""Get all neutralization attributes for the current user's mandate""" """Get all neutralization attributes for the current user's mandate"""
if not self.interfaceDbApp: if not self.interfaceNeutralizer:
return [] return []
try: try:
# Use the interface method which properly converts dicts to objects # Use the interface method which properly converts dicts to objects
return self.interfaceDbApp.getNeutralizationAttributes() return self.interfaceNeutralizer.getNeutralizationAttributes()
except Exception as e: except Exception as e:
logger.error(f"Error getting neutralization attributes: {str(e)}") logger.error(f"Error getting neutralization attributes: {str(e)}")
return [] return []
def deleteNeutralizationAttributes(self, fileId: str) -> bool: def deleteNeutralizationAttributes(self, fileId: str) -> bool:
"""Delete neutralization attributes for a specific file""" """Delete neutralization attributes for a specific file"""
if not self.interfaceDbApp: if not self.interfaceNeutralizer:
return False return False
return self.interfaceDbApp.deleteNeutralizationAttributes(fileId) return self.interfaceNeutralizer.deleteNeutralizationAttributes(fileId)
def _reloadNamesFromConfig(self) -> None: def _reloadNamesFromConfig(self) -> None:
"""Reload names from config and update processors""" """Reload names from config and update processors"""

View file

@ -8,7 +8,7 @@ Handles pattern matching and replacement for emails, phones, addresses, IDs and
import re import re
import uuid import uuid
from typing import Dict, List, Tuple, Any from typing import Dict, List, Tuple, Any
from modules.services.serviceNeutralization.subPatterns import DataPatterns, findPatternsInText from .subPatterns import DataPatterns, findPatternsInText
class StringParser: class StringParser:
"""Handles string parsing and replacement operations""" """Handles string parsing and replacement operations"""

View file

@ -11,8 +11,8 @@ import xml.etree.ElementTree as ET
from typing import Dict, List, Any, Union from typing import Dict, List, Any, Union
from dataclasses import dataclass from dataclasses import dataclass
from io import StringIO from io import StringIO
from modules.services.serviceNeutralization.subParseString import StringParser from .subParseString import StringParser
from modules.services.serviceNeutralization.subPatterns import getPatternForHeader, HeaderPatterns from .subPatterns import getPatternForHeader, HeaderPatterns
@dataclass @dataclass
class TableData: class TableData:
@ -158,7 +158,7 @@ class ListProcessor:
processedAttrs[attrName] = self.string_parser.mapping[attrValue] processedAttrs[attrName] = self.string_parser.mapping[attrValue]
else: else:
# Check if attribute value matches any data patterns # Check if attribute value matches any data patterns
from modules.services.serviceNeutralization.subPatterns import findPatternsInText, DataPatterns from .subPatterns import findPatternsInText, DataPatterns
matches = findPatternsInText(attrValue, DataPatterns.patterns) matches = findPatternsInText(attrValue, DataPatterns.patterns)
if matches: if matches:
patternName = matches[0][0] patternName = matches[0][0]
@ -193,7 +193,7 @@ class ListProcessor:
# Skip if already a placeholder # Skip if already a placeholder
if not self.string_parser._isPlaceholder(text): if not self.string_parser._isPlaceholder(text):
# Check if text matches any patterns # Check if text matches any patterns
from modules.services.serviceNeutralization.subPatterns import findPatternsInText, DataPatterns from .subPatterns import findPatternsInText, DataPatterns
patternMatches = findPatternsInText(text, DataPatterns.patterns) patternMatches = findPatternsInText(text, DataPatterns.patterns)
if patternMatches: if patternMatches:

View file

@ -7,7 +7,7 @@ Handles plain text processing without header information
from typing import Dict, List, Any from typing import Dict, List, Any
from dataclasses import dataclass from dataclasses import dataclass
from modules.services.serviceNeutralization.subParseString import StringParser from .subParseString import StringParser
@dataclass @dataclass
class PlainText: class PlainText:

View file

@ -123,6 +123,12 @@ class Dokument(BaseModel):
frontend_readonly=True, frontend_readonly=True,
frontend_required=False, frontend_required=False,
) )
featureInstanceId: str = Field(
description="ID of the feature instance this document belongs to",
frontend_type="text",
frontend_readonly=True,
frontend_required=False,
)
label: str = Field( label: str = Field(
description="Document label", description="Document label",
frontend_type="text", frontend_type="text",
@ -207,6 +213,12 @@ class Land(BaseModel):
frontend_readonly=True, frontend_readonly=True,
frontend_required=False, frontend_required=False,
) )
featureInstanceId: str = Field(
description="ID of the feature instance",
frontend_type="text",
frontend_readonly=True,
frontend_required=False,
)
label: str = Field( label: str = Field(
description="Country name (e.g. 'Schweiz')", description="Country name (e.g. 'Schweiz')",
frontend_type="text", frontend_type="text",
@ -251,6 +263,12 @@ class Kanton(BaseModel):
frontend_readonly=True, frontend_readonly=True,
frontend_required=False, frontend_required=False,
) )
featureInstanceId: str = Field(
description="ID of the feature instance",
frontend_type="text",
frontend_readonly=True,
frontend_required=False,
)
label: str = Field( label: str = Field(
description="Canton name (e.g. 'Zürich')", description="Canton name (e.g. 'Zürich')",
frontend_type="text", frontend_type="text",
@ -302,6 +320,12 @@ class Gemeinde(BaseModel):
frontend_readonly=True, frontend_readonly=True,
frontend_required=False, frontend_required=False,
) )
featureInstanceId: str = Field(
description="ID of the feature instance",
frontend_type="text",
frontend_readonly=True,
frontend_required=False,
)
label: str = Field( label: str = Field(
description="Municipality name (e.g. 'Zürich')", description="Municipality name (e.g. 'Zürich')",
frontend_type="text", frontend_type="text",
@ -359,6 +383,12 @@ class Parzelle(BaseModel):
frontend_readonly=True, frontend_readonly=True,
frontend_required=False, frontend_required=False,
) )
featureInstanceId: str = Field(
description="ID of the feature instance",
frontend_type="text",
frontend_readonly=True,
frontend_required=False,
)
# Grunddaten # Grunddaten
label: str = Field( label: str = Field(
@ -579,6 +609,12 @@ class Projekt(BaseModel):
frontend_readonly=True, frontend_readonly=True,
frontend_required=False, frontend_required=False,
) )
featureInstanceId: str = Field(
description="ID of the feature instance",
frontend_type="text",
frontend_readonly=True,
frontend_required=False,
)
label: str = Field( label: str = Field(
description="Project designation", description="Project designation",
frontend_type="text", frontend_type="text",
@ -643,6 +679,7 @@ registerModelLabels(
"label": {"en": "Label", "fr": "Libellé", "de": "Bezeichnung"}, "label": {"en": "Label", "fr": "Libellé", "de": "Bezeichnung"},
"statusProzess": {"en": "Process Status", "fr": "Statut du processus", "de": "Prozessstatus"}, "statusProzess": {"en": "Process Status", "fr": "Statut du processus", "de": "Prozessstatus"},
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat", "de": "Mandats-ID"}, "mandateId": {"en": "Mandate ID", "fr": "ID du mandat", "de": "Mandats-ID"},
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance", "de": "Feature-Instanz-ID"},
}, },
) )
@ -653,6 +690,7 @@ registerModelLabels(
"id": {"en": "ID", "fr": "ID", "de": "ID"}, "id": {"en": "ID", "fr": "ID", "de": "ID"},
"label": {"en": "Label", "fr": "Libellé", "de": "Bezeichnung"}, "label": {"en": "Label", "fr": "Libellé", "de": "Bezeichnung"},
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat", "de": "Mandats-ID"}, "mandateId": {"en": "Mandate ID", "fr": "ID du mandat", "de": "Mandats-ID"},
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance", "de": "Feature-Instanz-ID"},
}, },
) )
@ -662,6 +700,8 @@ registerModelLabels(
{ {
"id": {"en": "ID", "fr": "ID", "de": "ID"}, "id": {"en": "ID", "fr": "ID", "de": "ID"},
"label": {"en": "Label", "fr": "Libellé", "de": "Bezeichnung"}, "label": {"en": "Label", "fr": "Libellé", "de": "Bezeichnung"},
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat", "de": "Mandats-ID"},
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance", "de": "Feature-Instanz-ID"},
}, },
) )

View file

@ -6,7 +6,7 @@ Handles CRUD operations on Real Estate entities (Projekt, Parzelle, etc.).
import logging import logging
from typing import Dict, Any, List, Optional, Union from typing import Dict, Any, List, Optional, Union
from modules.datamodels.datamodelRealEstate import ( from .datamodelFeatureRealEstate import (
Projekt, Projekt,
Parzelle, Parzelle,
Dokument, Dokument,
@ -39,11 +39,23 @@ class RealEstateObjects:
Handles CRUD operations on Real Estate entities. Handles CRUD operations on Real Estate entities.
""" """
def __init__(self, currentUser: Optional[User] = None): # Feature code for RBAC objectKey construction
"""Initializes the Real Estate Interface.""" # Used to build: data.feature.realestate.{TableName}
FEATURE_CODE = "realestate"
def __init__(self, currentUser: Optional[User] = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None):
"""Initializes the Real Estate Interface.
Args:
currentUser: The authenticated user
mandateId: The mandate ID from RequestContext (X-Mandate-Id header)
featureInstanceId: The feature instance ID from RequestContext (X-Feature-Instance-Id header)
"""
self.currentUser = currentUser self.currentUser = currentUser
self.userId = currentUser.id if currentUser else None 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.featureInstanceId = featureInstanceId
self.rbac = None # RBAC interface self.rbac = None # RBAC interface
# Initialize database # Initialize database
@ -51,17 +63,17 @@ class RealEstateObjects:
# Set user context if provided # Set user context if provided
if currentUser: if currentUser:
self.setUserContext(currentUser) self.setUserContext(currentUser, mandateId=mandateId, featureInstanceId=featureInstanceId)
def _initializeDatabase(self): def _initializeDatabase(self):
"""Initialize PostgreSQL database connection.""" """Initialize PostgreSQL database connection."""
try: try:
# Get database configuration from environment # Get database configuration from environment
dbHost = APP_CONFIG.get("DB_REALESTATE_HOST", "localhost") dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data")
dbDatabase = APP_CONFIG.get("DB_REALESTATE_DATABASE", "poweron_realestate") dbDatabase = "poweron_realestate"
dbUser = APP_CONFIG.get("DB_REALESTATE_USER") dbUser = APP_CONFIG.get("DB_USER")
dbPassword = APP_CONFIG.get("DB_REALESTATE_PASSWORD_SECRET") dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
dbPort = int(APP_CONFIG.get("DB_REALESTATE_PORT", 5432)) dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
# Initialize database connector # Initialize database connector
self.db = DatabaseConnector( self.db = DatabaseConnector(
@ -101,14 +113,27 @@ class RealEstateObjects:
logger.warning(f"Error ensuring supporting tables exist: {e}") logger.warning(f"Error ensuring supporting tables exist: {e}")
# Don't raise - tables will be created on-demand anyway # Don't raise - tables will be created on-demand anyway
def setUserContext(self, currentUser: User): def setUserContext(self, currentUser: User, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None):
"""Sets the user context for the interface.""" """Sets the user context for the interface.
Args:
currentUser: The authenticated user
mandateId: The mandate ID from RequestContext (X-Mandate-Id header)
featureInstanceId: The feature instance ID from RequestContext (X-Feature-Instance-Id header)
"""
self.currentUser = currentUser self.currentUser = currentUser
self.userId = currentUser.id self.userId = currentUser.id
self.mandateId = currentUser.mandateId # Use mandateId from parameter (Request-Context), not from user object
self.mandateId = mandateId
self.featureInstanceId = featureInstanceId
if not self.userId or not self.mandateId: if not self.userId:
raise ValueError("Invalid user context: id and mandateId are required") raise ValueError("Invalid user context: id is required")
# Note: mandateId is ALWAYS optional here - it comes from Request-Context, not from User.
# Users are NOT assigned to mandates by design - they get mandate context from the request.
# sysAdmin users can additionally perform cross-mandate operations.
# Without mandateId, operations will be filtered to accessible mandates via RBAC.
# Initialize RBAC interface # Initialize RBAC interface
if not self.currentUser: if not self.currentUser:
@ -129,9 +154,11 @@ class RealEstateObjects:
if not self.checkRbacPermission(Projekt, "create"): if not self.checkRbacPermission(Projekt, "create"):
raise PermissionError(f"User {self.userId} cannot create projects") raise PermissionError(f"User {self.userId} cannot create projects")
# Ensure mandateId is set # Ensure mandateId and featureInstanceId are set for proper data isolation
if not projekt.mandateId: if not projekt.mandateId:
projekt.mandateId = self.mandateId projekt.mandateId = self.mandateId
if not projekt.featureInstanceId:
projekt.featureInstanceId = self.featureInstanceId
# Save to database - use mode='json' to ensure nested Pydantic models are serialized # Save to database - use mode='json' to ensure nested Pydantic models are serialized
self.db.recordCreate(Projekt, projekt.model_dump(mode='json')) self.db.recordCreate(Projekt, projekt.model_dump(mode='json'))
@ -144,7 +171,8 @@ class RealEstateObjects:
self.db, self.db,
Projekt, Projekt,
self.currentUser, self.currentUser,
recordFilter={"id": projektId} recordFilter={"id": projektId},
featureCode=self.FEATURE_CODE
) )
if not records: if not records:
@ -158,7 +186,8 @@ class RealEstateObjects:
self.db, self.db,
Projekt, Projekt,
self.currentUser, self.currentUser,
recordFilter=recordFilter or {} recordFilter=recordFilter or {},
featureCode=self.FEATURE_CODE
) )
return [Projekt(**r) for r in records] return [Projekt(**r) for r in records]
@ -215,8 +244,11 @@ class RealEstateObjects:
if not self.checkRbacPermission(Parzelle, "create"): if not self.checkRbacPermission(Parzelle, "create"):
raise PermissionError(f"User {self.userId} cannot create plots") raise PermissionError(f"User {self.userId} cannot create plots")
# Ensure mandateId and featureInstanceId are set for proper data isolation
if not parzelle.mandateId: if not parzelle.mandateId:
parzelle.mandateId = self.mandateId parzelle.mandateId = self.mandateId
if not parzelle.featureInstanceId:
parzelle.featureInstanceId = self.featureInstanceId
# Use mode='json' to ensure nested Pydantic models (like GeoPolylinie) are serialized # Use mode='json' to ensure nested Pydantic models (like GeoPolylinie) are serialized
self.db.recordCreate(Parzelle, parzelle.model_dump(mode='json')) self.db.recordCreate(Parzelle, parzelle.model_dump(mode='json'))
@ -229,7 +261,8 @@ class RealEstateObjects:
self.db, self.db,
Parzelle, Parzelle,
self.currentUser, self.currentUser,
recordFilter={"id": parzelleId} recordFilter={"id": parzelleId},
featureCode=self.FEATURE_CODE
) )
if not records: if not records:
@ -239,14 +272,8 @@ class RealEstateObjects:
def getParzellen(self, recordFilter: Optional[Dict[str, Any]] = None) -> List[Parzelle]: def getParzellen(self, recordFilter: Optional[Dict[str, Any]] = None) -> List[Parzelle]:
"""Get all plots matching the filter.""" """Get all plots matching the filter."""
original_gemeinde_value = None
# Resolve location names to IDs if needed # Resolve location names to IDs if needed
if recordFilter: if recordFilter:
# Save original value before resolution for fallback search
if "kontextGemeinde" in recordFilter:
original_gemeinde_value = recordFilter["kontextGemeinde"]
recordFilter = self._resolveLocationFilters(recordFilter) recordFilter = self._resolveLocationFilters(recordFilter)
records = getRecordsetWithRBAC( records = getRecordsetWithRBAC(
@ -256,23 +283,6 @@ class RealEstateObjects:
recordFilter=recordFilter or {} 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] return [Parzelle(**r) for r in records]
def _resolveLocationFilters(self, recordFilter: Dict[str, Any]) -> Dict[str, Any]: def _resolveLocationFilters(self, recordFilter: Dict[str, Any]) -> Dict[str, Any]:
@ -445,8 +455,11 @@ class RealEstateObjects:
if not self.checkRbacPermission(Dokument, "create"): if not self.checkRbacPermission(Dokument, "create"):
raise PermissionError(f"User {self.userId} cannot create documents") raise PermissionError(f"User {self.userId} cannot create documents")
# Ensure mandateId and featureInstanceId are set for proper data isolation
if not dokument.mandateId: if not dokument.mandateId:
dokument.mandateId = self.mandateId dokument.mandateId = self.mandateId
if not dokument.featureInstanceId:
dokument.featureInstanceId = self.featureInstanceId
self.db.recordCreate(Dokument, dokument.model_dump()) self.db.recordCreate(Dokument, dokument.model_dump())
@ -458,7 +471,8 @@ class RealEstateObjects:
self.db, self.db,
Dokument, Dokument,
self.currentUser, self.currentUser,
recordFilter={"id": dokumentId} recordFilter={"id": dokumentId},
featureCode=self.FEATURE_CODE
) )
if not records: if not records:
@ -472,7 +486,8 @@ class RealEstateObjects:
self.db, self.db,
Dokument, Dokument,
self.currentUser, self.currentUser,
recordFilter=recordFilter or {} recordFilter=recordFilter or {},
featureCode=self.FEATURE_CODE
) )
return [Dokument(**r) for r in records] return [Dokument(**r) for r in records]
@ -511,8 +526,11 @@ class RealEstateObjects:
if not self.checkRbacPermission(Gemeinde, "create"): if not self.checkRbacPermission(Gemeinde, "create"):
raise PermissionError(f"User {self.userId} cannot create municipalities") raise PermissionError(f"User {self.userId} cannot create municipalities")
# Ensure mandateId and featureInstanceId are set for proper data isolation
if not gemeinde.mandateId: if not gemeinde.mandateId:
gemeinde.mandateId = self.mandateId gemeinde.mandateId = self.mandateId
if not gemeinde.featureInstanceId:
gemeinde.featureInstanceId = self.featureInstanceId
self.db.recordCreate(Gemeinde, gemeinde.model_dump()) self.db.recordCreate(Gemeinde, gemeinde.model_dump())
@ -524,7 +542,8 @@ class RealEstateObjects:
self.db, self.db,
Gemeinde, Gemeinde,
self.currentUser, self.currentUser,
recordFilter={"id": gemeindeId} recordFilter={"id": gemeindeId},
featureCode=self.FEATURE_CODE
) )
if not records: if not records:
@ -538,7 +557,8 @@ class RealEstateObjects:
self.db, self.db,
Gemeinde, Gemeinde,
self.currentUser, self.currentUser,
recordFilter=recordFilter or {} recordFilter=recordFilter or {},
featureCode=self.FEATURE_CODE
) )
return [Gemeinde(**r) for r in records] return [Gemeinde(**r) for r in records]
@ -577,8 +597,11 @@ class RealEstateObjects:
if not self.checkRbacPermission(Kanton, "create"): if not self.checkRbacPermission(Kanton, "create"):
raise PermissionError(f"User {self.userId} cannot create cantons") raise PermissionError(f"User {self.userId} cannot create cantons")
# Ensure mandateId and featureInstanceId are set for proper data isolation
if not kanton.mandateId: if not kanton.mandateId:
kanton.mandateId = self.mandateId kanton.mandateId = self.mandateId
if not kanton.featureInstanceId:
kanton.featureInstanceId = self.featureInstanceId
self.db.recordCreate(Kanton, kanton.model_dump()) self.db.recordCreate(Kanton, kanton.model_dump())
@ -590,7 +613,8 @@ class RealEstateObjects:
self.db, self.db,
Kanton, Kanton,
self.currentUser, self.currentUser,
recordFilter={"id": kantonId} recordFilter={"id": kantonId},
featureCode=self.FEATURE_CODE
) )
if not records: if not records:
@ -604,7 +628,8 @@ class RealEstateObjects:
self.db, self.db,
Kanton, Kanton,
self.currentUser, self.currentUser,
recordFilter=recordFilter or {} recordFilter=recordFilter or {},
featureCode=self.FEATURE_CODE
) )
return [Kanton(**r) for r in records] return [Kanton(**r) for r in records]
@ -643,8 +668,11 @@ class RealEstateObjects:
if not self.checkRbacPermission(Land, "create"): if not self.checkRbacPermission(Land, "create"):
raise PermissionError(f"User {self.userId} cannot create countries") raise PermissionError(f"User {self.userId} cannot create countries")
# Ensure mandateId and featureInstanceId are set for proper data isolation
if not land.mandateId: if not land.mandateId:
land.mandateId = self.mandateId land.mandateId = self.mandateId
if not land.featureInstanceId:
land.featureInstanceId = self.featureInstanceId
self.db.recordCreate(Land, land.model_dump()) self.db.recordCreate(Land, land.model_dump())
@ -656,7 +684,8 @@ class RealEstateObjects:
self.db, self.db,
Land, Land,
self.currentUser, self.currentUser,
recordFilter={"id": landId} recordFilter={"id": landId},
featureCode=self.FEATURE_CODE
) )
if not records: if not records:
@ -670,7 +699,8 @@ class RealEstateObjects:
self.db, self.db,
Land, Land,
self.currentUser, self.currentUser,
recordFilter=recordFilter or {} recordFilter=recordFilter or {},
featureCode=self.FEATURE_CODE
) )
return [Land(**r) for r in records] return [Land(**r) for r in records]
@ -727,7 +757,9 @@ class RealEstateObjects:
permissions = self.rbac.getUserPermissions( permissions = self.rbac.getUserPermissions(
self.currentUser, self.currentUser,
AccessRuleContext.DATA, AccessRuleContext.DATA,
tableName tableName,
mandateId=self.mandateId,
featureInstanceId=self.featureInstanceId
) )
if operation == "create": if operation == "create":
@ -799,15 +831,27 @@ class RealEstateObjects:
raise raise
def getInterface(currentUser: User) -> RealEstateObjects: def getInterface(currentUser: User, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None) -> RealEstateObjects:
""" """
Factory function to get or create a Real Estate interface instance for a user. Factory function to get or create a Real Estate interface instance for a user.
Uses singleton pattern per user. Uses singleton pattern per user.
Args:
currentUser: The authenticated user
mandateId: The mandate ID from RequestContext (X-Mandate-Id header). Required.
featureInstanceId: The feature instance ID from RequestContext (X-Feature-Instance-Id header).
""" """
userKey = f"{currentUser.id}_{currentUser.mandateId}" effectiveMandateId = str(mandateId) if mandateId else None
effectiveFeatureInstanceId = str(featureInstanceId) if featureInstanceId else None
# Include featureInstanceId in key for proper isolation
userKey = f"{currentUser.id}_{effectiveMandateId}_{effectiveFeatureInstanceId}"
if userKey not in _realEstateInterfaces: if userKey not in _realEstateInterfaces:
_realEstateInterfaces[userKey] = RealEstateObjects(currentUser) _realEstateInterfaces[userKey] = RealEstateObjects(currentUser, mandateId=effectiveMandateId, featureInstanceId=effectiveFeatureInstanceId)
else:
# Update user context if needed
_realEstateInterfaces[userKey].setUserContext(currentUser, mandateId=effectiveMandateId, featureInstanceId=effectiveFeatureInstanceId)
return _realEstateInterfaces[userKey] return _realEstateInterfaces[userKey]

View file

@ -2,16 +2,154 @@
Real Estate feature main logic. Real Estate feature main logic.
Handles database operations with AI-powered natural language processing. Handles database operations with AI-powered natural language processing.
Stateless implementation without session management. Stateless implementation without session management.
This module also handles feature initialization and RBAC catalog registration.
""" """
import logging import logging
# Feature metadata for RBAC catalog
FEATURE_CODE = "realestate"
FEATURE_LABEL = {"en": "Real Estate", "de": "Immobilien", "fr": "Immobilier"}
FEATURE_ICON = "mdi-home-city"
# UI Objects for RBAC catalog
UI_OBJECTS = [
{
"objectKey": "ui.feature.realestate.projects",
"label": {"en": "Projects", "de": "Projekte", "fr": "Projets"},
"meta": {"area": "projects"}
},
{
"objectKey": "ui.feature.realestate.parcels",
"label": {"en": "Parcels", "de": "Parzellen", "fr": "Parcelles"},
"meta": {"area": "parcels"}
},
]
# Resource Objects for RBAC catalog
RESOURCE_OBJECTS = [
{
"objectKey": "resource.feature.realestate.project.create",
"label": {"en": "Create Project", "de": "Projekt erstellen", "fr": "Créer projet"},
"meta": {"endpoint": "/api/realestate/project", "method": "POST"}
},
{
"objectKey": "resource.feature.realestate.project.delete",
"label": {"en": "Delete Project", "de": "Projekt löschen", "fr": "Supprimer projet"},
"meta": {"endpoint": "/api/realestate/project/{projectId}", "method": "DELETE"}
},
]
# Template roles for this feature with AccessRules
# IMPORTANT: item uses vollqualifizierte ObjectKeys (gemäss Navigation-API-Konzept)
TEMPLATE_ROLES = [
{
"roleLabel": "realestate-admin",
"description": {
"en": "Real Estate Administrator - Full access to all property data and settings",
"de": "Immobilien-Administrator - Vollzugriff auf alle Immobiliendaten und Einstellungen",
"fr": "Administrateur immobilier - Accès complet aux données et paramètres"
},
"accessRules": [
# Full UI access (all views including admin views)
{"context": "UI", "item": None, "view": True},
# Full DATA access
{"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"},
# Admin resources
{"context": "RESOURCE", "item": "resource.feature.realestate.project.create", "view": True},
{"context": "RESOURCE", "item": "resource.feature.realestate.project.delete", "view": True},
]
},
{
"roleLabel": "realestate-manager",
"description": {
"en": "Real Estate Manager - Manage properties and tenants",
"de": "Immobilien-Verwalter - Immobilien und Mieter verwalten",
"fr": "Gestionnaire immobilier - Gérer les propriétés et locataires"
},
"accessRules": [
# UI access to main views - vollqualifizierte ObjectKeys
{"context": "UI", "item": "ui.feature.realestate.projects", "view": True},
{"context": "UI", "item": "ui.feature.realestate.parcels", "view": True},
# Group-level DATA access
{"context": "DATA", "item": None, "view": True, "read": "g", "create": "g", "update": "g", "delete": "g"},
# Resource: create projects
{"context": "RESOURCE", "item": "resource.feature.realestate.project.create", "view": True},
]
},
{
"roleLabel": "realestate-viewer",
"description": {
"en": "Real Estate Viewer - View property information",
"de": "Immobilien-Betrachter - Immobilien-Informationen einsehen",
"fr": "Visualiseur immobilier - Consulter les informations immobilières"
},
"accessRules": [
# UI access to view-only views - vollqualifizierte ObjectKeys
{"context": "UI", "item": "ui.feature.realestate.projects", "view": True},
{"context": "UI", "item": "ui.feature.realestate.parcels", "view": True},
# Read-only DATA access (my records)
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
]
},
]
def getFeatureDefinition():
"""Return the feature definition for registration."""
return {
"code": FEATURE_CODE,
"label": FEATURE_LABEL,
"icon": FEATURE_ICON
}
def getUiObjects():
"""Return UI objects for RBAC catalog registration."""
return UI_OBJECTS
def getResourceObjects():
"""Return resource objects for RBAC catalog registration."""
return RESOURCE_OBJECTS
def getTemplateRoles():
"""Return template roles for this feature."""
return TEMPLATE_ROLES
def registerFeature(catalogService) -> bool:
"""Register this feature's RBAC objects in the catalog."""
try:
for uiObj in UI_OBJECTS:
catalogService.registerUiObject(
featureCode=FEATURE_CODE,
objectKey=uiObj["objectKey"],
label=uiObj["label"],
meta=uiObj.get("meta")
)
for resObj in RESOURCE_OBJECTS:
catalogService.registerResourceObject(
featureCode=FEATURE_CODE,
objectKey=resObj["objectKey"],
label=resObj["label"],
meta=resObj.get("meta")
)
return True
except Exception as e:
logging.getLogger(__name__).error(f"Failed to register feature '{FEATURE_CODE}': {e}")
return False
import json import json
from typing import Optional, Dict, Any, List from typing import Optional, Dict, Any, List
from fastapi import HTTPException, status from fastapi import HTTPException, status
from shapely.geometry import Polygon from shapely.geometry import Polygon
from shapely.ops import unary_union from shapely.ops import unary_union
from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelUam import User
from modules.datamodels.datamodelRealEstate import ( from .datamodelFeatureRealEstate import (
Projekt, Projekt,
Parzelle, Parzelle,
StatusProzess, StatusProzess,
@ -23,7 +161,7 @@ from modules.datamodels.datamodelRealEstate import (
Land, Land,
) )
from modules.services import getInterface as getServices from modules.services import getInterface as getServices
from modules.interfaces.interfaceDbRealEstateObjects import getInterface as getRealEstateInterface from .interfaceFeatureRealEstate import getInterface as getRealEstateInterface
from modules.connectors.connectorSwissTopoMapServer import SwissTopoMapServerConnector from modules.connectors.connectorSwissTopoMapServer import SwissTopoMapServerConnector
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -346,6 +484,7 @@ async def fetch_parcel_polygon_from_swisstopo(
async def executeDirectQuery( async def executeDirectQuery(
currentUser: User, currentUser: User,
mandateId: str,
queryText: str, queryText: str,
parameters: Optional[Dict[str, Any]] = None, parameters: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
@ -354,6 +493,7 @@ async def executeDirectQuery(
Args: Args:
currentUser: Current authenticated user currentUser: Current authenticated user
mandateId: Mandate context (from RequestContext / X-Mandate-Id header)
queryText: SQL query text queryText: SQL query text
parameters: Optional parameters for parameterized queries parameters: Optional parameters for parameterized queries
@ -364,16 +504,15 @@ async def executeDirectQuery(
- No session or query history is saved - No session or query history is saved
- Query is executed directly and result is returned - Query is executed directly and result is returned
- For production, validate and sanitize queries before execution - For production, validate and sanitize queries before execution
- TODO: Implement actual database query execution via interface
""" """
try: 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}") logger.debug(f"Query text: {queryText}")
if parameters: if parameters:
logger.debug(f"Query parameters: {parameters}") logger.debug(f"Query parameters: {parameters}")
# Execute query via Real Estate interface (stateless) # Execute query via Real Estate interface (stateless)
realEstateInterface = getRealEstateInterface(currentUser) realEstateInterface = getRealEstateInterface(currentUser, mandateId=mandateId)
result = realEstateInterface.executeQuery(queryText, parameters) result = realEstateInterface.executeQuery(queryText, parameters)
logger.info( logger.info(
@ -529,6 +668,7 @@ def _formatEntitySummary(entity_type: str, items: List[Dict[str, Any]], filters:
async def processNaturalLanguageCommand( async def processNaturalLanguageCommand(
currentUser: User, currentUser: User,
mandateId: str,
userInput: str, userInput: str,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
@ -539,6 +679,7 @@ async def processNaturalLanguageCommand(
Args: Args:
currentUser: Current authenticated user currentUser: Current authenticated user
mandateId: Mandate context (from RequestContext / X-Mandate-Id header)
userInput: Natural language command from user userInput: Natural language command from user
Returns: Returns:
@ -552,11 +693,11 @@ async def processNaturalLanguageCommand(
- "SELECT * FROM Projekt WHERE plz = '8000'" - "SELECT * FROM Projekt WHERE plz = '8000'"
""" """
try: 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}") logger.debug(f"User input: {userInput}")
# Initialize services for AI access # Initialize services for AI access
services = getServices(currentUser, workflow=None) services = getServices(currentUser, workflow=None, mandateId=mandateId)
aiService = services.ai aiService = services.ai
# Step 1: Analyze user intent with AI # Step 1: Analyze user intent with AI
@ -567,6 +708,7 @@ async def processNaturalLanguageCommand(
# Step 2: Execute CRUD operation based on intent # Step 2: Execute CRUD operation based on intent
result = await executeIntentBasedOperation( result = await executeIntentBasedOperation(
currentUser=currentUser, currentUser=currentUser,
mandateId=mandateId,
intent=intentAnalysis["intent"], intent=intentAnalysis["intent"],
entity=intentAnalysis.get("entity"), entity=intentAnalysis.get("entity"),
parameters=intentAnalysis.get("parameters", {}), parameters=intentAnalysis.get("parameters", {}),
@ -839,6 +981,7 @@ IMPORTANT EXTRACTION RULES:
async def executeIntentBasedOperation( async def executeIntentBasedOperation(
currentUser: User, currentUser: User,
mandateId: str,
intent: str, intent: str,
entity: Optional[str], entity: Optional[str],
parameters: Dict[str, Any], parameters: Dict[str, Any],
@ -848,6 +991,7 @@ async def executeIntentBasedOperation(
Args: Args:
currentUser: Current authenticated user currentUser: Current authenticated user
mandateId: Mandate context (from RequestContext / X-Mandate-Id header)
intent: Intent from AI analysis (CREATE, READ, UPDATE, DELETE, QUERY) intent: Intent from AI analysis (CREATE, READ, UPDATE, DELETE, QUERY)
entity: Entity type from AI analysis entity: Entity type from AI analysis
parameters: Extracted parameters from AI analysis parameters: Extracted parameters from AI analysis
@ -856,8 +1000,8 @@ async def executeIntentBasedOperation(
Operation result Operation result
Note: Note:
- TODO: Implement actual interface calls once datamodels are ready - Supports CREATE, READ, UPDATE, DELETE, QUERY intents
- Currently returns test responses showing what would be executed - Entity types: Projekt, Parzelle, Gemeinde, Kanton, Land, Dokument
""" """
try: try:
logger.info(f"Executing intent-based operation: intent={intent}, entity={entity}") logger.info(f"Executing intent-based operation: intent={intent}, entity={entity}")
@ -872,6 +1016,7 @@ async def executeIntentBasedOperation(
result = await executeDirectQuery( result = await executeDirectQuery(
currentUser=currentUser, currentUser=currentUser,
mandateId=mandateId,
queryText=queryText, queryText=queryText,
parameters=parameters.get("queryParameters"), parameters=parameters.get("queryParameters"),
) )
@ -879,12 +1024,12 @@ async def executeIntentBasedOperation(
elif intent == "CREATE": elif intent == "CREATE":
# Create new entity # Create new entity
realEstateInterface = getRealEstateInterface(currentUser) realEstateInterface = getRealEstateInterface(currentUser, mandateId=mandateId)
if entity == "Projekt": if entity == "Projekt":
# Create Projekt from parameters # Create Projekt from parameters
projekt = Projekt( projekt = Projekt(
mandateId=currentUser.mandateId, mandateId=mandateId,
label=parameters.get("label", ""), label=parameters.get("label", ""),
statusProzess=StatusProzess(parameters.get("statusProzess", "EINGANG")) if parameters.get("statusProzess") else None, statusProzess=StatusProzess(parameters.get("statusProzess", "EINGANG")) if parameters.get("statusProzess") else None,
) )
@ -898,11 +1043,11 @@ async def executeIntentBasedOperation(
elif entity == "Parzelle": elif entity == "Parzelle":
# Create Parzelle from parameters # Create Parzelle from parameters
# Import Kontext for kontextInformationen # Import Kontext for kontextInformationen
from modules.datamodels.datamodelRealEstate import Kontext, GeoPolylinie from modules.features.realestate.datamodelFeatureRealEstate import Kontext, GeoPolylinie
# Build parzelle data with all extracted parameters # Build parzelle data with all extracted parameters
parzelle_data = { parzelle_data = {
"mandateId": currentUser.mandateId, "mandateId": mandateId,
"label": parameters.get("label", ""), "label": parameters.get("label", ""),
} }
@ -983,9 +1128,9 @@ async def executeIntentBasedOperation(
} }
elif entity == "Gemeinde": elif entity == "Gemeinde":
# Create Gemeinde from parameters # Create Gemeinde from parameters
from modules.datamodels.datamodelRealEstate import Gemeinde from modules.features.realestate.datamodelFeatureRealEstate import Gemeinde
gemeinde = Gemeinde( gemeinde = Gemeinde(
mandateId=currentUser.mandateId, mandateId=mandateId,
label=parameters.get("label", ""), label=parameters.get("label", ""),
id_kanton=parameters.get("id_kanton"), id_kanton=parameters.get("id_kanton"),
plz=parameters.get("plz"), plz=parameters.get("plz"),
@ -998,9 +1143,9 @@ async def executeIntentBasedOperation(
} }
elif entity == "Kanton": elif entity == "Kanton":
# Create Kanton from parameters # Create Kanton from parameters
from modules.datamodels.datamodelRealEstate import Kanton from modules.features.realestate.datamodelFeatureRealEstate import Kanton
kanton = Kanton( kanton = Kanton(
mandateId=currentUser.mandateId, mandateId=mandateId,
label=parameters.get("label", ""), label=parameters.get("label", ""),
id_land=parameters.get("id_land"), id_land=parameters.get("id_land"),
abk=parameters.get("abk"), abk=parameters.get("abk"),
@ -1013,9 +1158,9 @@ async def executeIntentBasedOperation(
} }
elif entity == "Land": elif entity == "Land":
# Create Land from parameters # Create Land from parameters
from modules.datamodels.datamodelRealEstate import Land from modules.features.realestate.datamodelFeatureRealEstate import Land
land = Land( land = Land(
mandateId=currentUser.mandateId, mandateId=mandateId,
label=parameters.get("label", ""), label=parameters.get("label", ""),
abk=parameters.get("abk"), abk=parameters.get("abk"),
) )
@ -1027,9 +1172,9 @@ async def executeIntentBasedOperation(
} }
elif entity == "Dokument": elif entity == "Dokument":
# Create Dokument from parameters # Create Dokument from parameters
from modules.datamodels.datamodelRealEstate import Dokument from modules.features.realestate.datamodelFeatureRealEstate import Dokument
dokument = Dokument( dokument = Dokument(
mandateId=currentUser.mandateId, mandateId=mandateId,
label=parameters.get("label", ""), label=parameters.get("label", ""),
dokumentReferenz=parameters.get("dokumentReferenz", ""), dokumentReferenz=parameters.get("dokumentReferenz", ""),
versionsbezeichnung=parameters.get("versionsbezeichnung"), versionsbezeichnung=parameters.get("versionsbezeichnung"),
@ -1188,7 +1333,7 @@ async def executeIntentBasedOperation(
"count": len(parzellen) "count": len(parzellen)
} }
elif entity == "Gemeinde": elif entity == "Gemeinde":
from modules.datamodels.datamodelRealEstate import Gemeinde from modules.features.realestate.datamodelFeatureRealEstate import Gemeinde
gemeindeId = parameters.get("id") gemeindeId = parameters.get("id")
if gemeindeId: if gemeindeId:
gemeinde = realEstateInterface.getGemeinde(gemeindeId) gemeinde = realEstateInterface.getGemeinde(gemeindeId)
@ -1209,7 +1354,7 @@ async def executeIntentBasedOperation(
"count": len(gemeinden) "count": len(gemeinden)
} }
elif entity == "Kanton": elif entity == "Kanton":
from modules.datamodels.datamodelRealEstate import Kanton from modules.features.realestate.datamodelFeatureRealEstate import Kanton
kantonId = parameters.get("id") kantonId = parameters.get("id")
if kantonId: if kantonId:
kanton = realEstateInterface.getKanton(kantonId) kanton = realEstateInterface.getKanton(kantonId)
@ -1230,7 +1375,7 @@ async def executeIntentBasedOperation(
"count": len(kantone) "count": len(kantone)
} }
elif entity == "Land": elif entity == "Land":
from modules.datamodels.datamodelRealEstate import Land from modules.features.realestate.datamodelFeatureRealEstate import Land
landId = parameters.get("id") landId = parameters.get("id")
if landId: if landId:
land = realEstateInterface.getLand(landId) land = realEstateInterface.getLand(landId)
@ -1251,7 +1396,7 @@ async def executeIntentBasedOperation(
"count": len(laender) "count": len(laender)
} }
elif entity == "Dokument": elif entity == "Dokument":
from modules.datamodels.datamodelRealEstate import Dokument from modules.features.realestate.datamodelFeatureRealEstate import Dokument
dokumentId = parameters.get("id") dokumentId = parameters.get("id")
if dokumentId: if dokumentId:
dokument = realEstateInterface.getDokument(dokumentId) dokument = realEstateInterface.getDokument(dokumentId)
@ -1315,7 +1460,7 @@ async def executeIntentBasedOperation(
"result": updated.model_dump() "result": updated.model_dump()
} }
elif entity == "Gemeinde": elif entity == "Gemeinde":
from modules.datamodels.datamodelRealEstate import Gemeinde from modules.features.realestate.datamodelFeatureRealEstate import Gemeinde
gemeindeId = parameters.get("id") gemeindeId = parameters.get("id")
if not gemeindeId: if not gemeindeId:
raise ValueError("UPDATE operation requires entity ID") raise ValueError("UPDATE operation requires entity ID")
@ -1332,7 +1477,7 @@ async def executeIntentBasedOperation(
"result": updated.model_dump() "result": updated.model_dump()
} }
elif entity == "Kanton": elif entity == "Kanton":
from modules.datamodels.datamodelRealEstate import Kanton from modules.features.realestate.datamodelFeatureRealEstate import Kanton
kantonId = parameters.get("id") kantonId = parameters.get("id")
if not kantonId: if not kantonId:
raise ValueError("UPDATE operation requires entity ID") raise ValueError("UPDATE operation requires entity ID")
@ -1349,7 +1494,7 @@ async def executeIntentBasedOperation(
"result": updated.model_dump() "result": updated.model_dump()
} }
elif entity == "Land": elif entity == "Land":
from modules.datamodels.datamodelRealEstate import Land from modules.features.realestate.datamodelFeatureRealEstate import Land
landId = parameters.get("id") landId = parameters.get("id")
if not landId: if not landId:
raise ValueError("UPDATE operation requires entity ID") raise ValueError("UPDATE operation requires entity ID")
@ -1366,7 +1511,7 @@ async def executeIntentBasedOperation(
"result": updated.model_dump() "result": updated.model_dump()
} }
elif entity == "Dokument": elif entity == "Dokument":
from modules.datamodels.datamodelRealEstate import Dokument from modules.features.realestate.datamodelFeatureRealEstate import Dokument
dokumentId = parameters.get("id") dokumentId = parameters.get("id")
if not dokumentId: if not dokumentId:
raise ValueError("UPDATE operation requires entity ID") raise ValueError("UPDATE operation requires entity ID")
@ -1412,7 +1557,7 @@ async def executeIntentBasedOperation(
"success": success "success": success
} }
elif entity == "Gemeinde": elif entity == "Gemeinde":
from modules.datamodels.datamodelRealEstate import Gemeinde from modules.features.realestate.datamodelFeatureRealEstate import Gemeinde
gemeindeId = parameters.get("id") gemeindeId = parameters.get("id")
if not gemeindeId: if not gemeindeId:
raise ValueError("DELETE operation requires entity ID") raise ValueError("DELETE operation requires entity ID")
@ -1424,7 +1569,7 @@ async def executeIntentBasedOperation(
"success": success "success": success
} }
elif entity == "Kanton": elif entity == "Kanton":
from modules.datamodels.datamodelRealEstate import Kanton from modules.features.realestate.datamodelFeatureRealEstate import Kanton
kantonId = parameters.get("id") kantonId = parameters.get("id")
if not kantonId: if not kantonId:
raise ValueError("DELETE operation requires entity ID") raise ValueError("DELETE operation requires entity ID")
@ -1436,7 +1581,7 @@ async def executeIntentBasedOperation(
"success": success "success": success
} }
elif entity == "Land": elif entity == "Land":
from modules.datamodels.datamodelRealEstate import Land from modules.features.realestate.datamodelFeatureRealEstate import Land
landId = parameters.get("id") landId = parameters.get("id")
if not landId: if not landId:
raise ValueError("DELETE operation requires entity ID") raise ValueError("DELETE operation requires entity ID")
@ -1448,7 +1593,7 @@ async def executeIntentBasedOperation(
"success": success "success": success
} }
elif entity == "Dokument": elif entity == "Dokument":
from modules.datamodels.datamodelRealEstate import Dokument from modules.features.realestate.datamodelFeatureRealEstate import Dokument
dokumentId = parameters.get("id") dokumentId = parameters.get("id")
if not dokumentId: if not dokumentId:
raise ValueError("DELETE operation requires entity ID") raise ValueError("DELETE operation requires entity ID")
@ -1474,6 +1619,7 @@ async def executeIntentBasedOperation(
async def create_project_with_parcel_data( async def create_project_with_parcel_data(
currentUser: User, currentUser: User,
mandateId: str,
projekt_label: str, projekt_label: str,
parzellen_data: List[Dict[str, Any]], parzellen_data: List[Dict[str, Any]],
status_prozess: Optional[str] = None, status_prozess: Optional[str] = None,
@ -1483,6 +1629,7 @@ async def create_project_with_parcel_data(
Args: Args:
currentUser: Current authenticated user currentUser: Current authenticated user
mandateId: Mandate context (from RequestContext / X-Mandate-Id header)
projekt_label: Label for the Projekt projekt_label: Label for the Projekt
parzellen_data: List of dictionaries containing parcel information from request parzellen_data: List of dictionaries containing parcel information from request
status_prozess: Optional project status (defaults to "Eingang") status_prozess: Optional project status (defaults to "Eingang")
@ -1496,8 +1643,8 @@ async def create_project_with_parcel_data(
try: try:
logger.info(f"Creating project '{projekt_label}' with {len(parzellen_data)} parcel(s) for user {currentUser.id}") logger.info(f"Creating project '{projekt_label}' with {len(parzellen_data)} parcel(s) for user {currentUser.id}")
# Get interface # Get interface with mandate context
realEstateInterface = getRealEstateInterface(currentUser) realEstateInterface = getRealEstateInterface(currentUser, mandateId=mandateId)
# Validate required fields # Validate required fields
if not projekt_label: if not projekt_label:
@ -1587,7 +1734,7 @@ async def create_project_with_parcel_data(
# Check if Parzelle with this label already exists # Check if Parzelle with this label already exists
existing_parzellen = realEstateInterface.getParzellen( 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: if existing_parzellen and len(existing_parzellen) > 0:
@ -1630,7 +1777,7 @@ async def create_project_with_parcel_data(
if not laender: if not laender:
logger.info("Creating Land 'Schweiz'") logger.info("Creating Land 'Schweiz'")
land = Land( land = Land(
mandateId=currentUser.mandateId, mandateId=mandateId,
label="Schweiz", label="Schweiz",
abk="CH" abk="CH"
) )
@ -1648,7 +1795,7 @@ async def create_project_with_parcel_data(
logger.info(f"Kanton '{canton_abk}' not found, creating it") 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_label = canton_names.get(canton_abk, canton_abk) # Use mapping or fallback to abk
kanton = Kanton( kanton = Kanton(
mandateId=currentUser.mandateId, mandateId=mandateId,
label=kanton_label, label=kanton_label,
abk=canton_abk, abk=canton_abk,
id_land=land.id id_land=land.id
@ -1668,7 +1815,7 @@ async def create_project_with_parcel_data(
if not gemeinden: if not gemeinden:
logger.info(f"Gemeinde '{municipality_name}' in Kanton '{canton_abk}' not found, creating it") logger.info(f"Gemeinde '{municipality_name}' in Kanton '{canton_abk}' not found, creating it")
gemeinde = Gemeinde( gemeinde = Gemeinde(
mandateId=currentUser.mandateId, mandateId=mandateId,
label=municipality_name, label=municipality_name,
id_kanton=kanton.id, id_kanton=kanton.id,
plz=parzelle_data.get("plz") # Use PLZ directly from Swiss Topo API plz=parzelle_data.get("plz") # Use PLZ directly from Swiss Topo API
@ -1837,7 +1984,7 @@ async def create_project_with_parcel_data(
# Build Parzelle data # Build Parzelle data
parzelle_create_data = { parzelle_create_data = {
"mandateId": currentUser.mandateId, "mandateId": mandateId,
"label": parcel_label, # Use the label we determined earlier for uniqueness check "label": parcel_label, # Use the label we determined earlier for uniqueness check
"parzellenAliasTags": alias_tags, "parzellenAliasTags": alias_tags,
"eigentuemerschaft": None, "eigentuemerschaft": None,
@ -1979,7 +2126,7 @@ async def create_project_with_parcel_data(
project_perimeter = created_parzellen[0].perimeter if created_parzellen else None project_perimeter = created_parzellen[0].perimeter if created_parzellen else None
projekt_create_data = { projekt_create_data = {
"mandateId": currentUser.mandateId, "mandateId": mandateId,
"label": projekt_label, "label": projekt_label,
"statusProzess": status_prozess_enum, "statusProzess": status_prozess_enum,
"perimeter": project_perimeter, # Use first parcel perimeter as project perimeter "perimeter": project_perimeter, # Use first parcel perimeter as project perimeter

View file

@ -10,12 +10,11 @@ from typing import Optional, Dict, Any, List, Union
from fastapi import APIRouter, HTTPException, Depends, Body, Request, Query, Path, status from fastapi import APIRouter, HTTPException, Depends, Body, Request, Query, Path, status
# Import auth modules # Import auth modules
from modules.auth import limiter, getCurrentUser from modules.auth import limiter, getRequestContext, RequestContext
# Import models # Import models
from modules.datamodels.datamodelUam import User
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata
from modules.datamodels.datamodelRealEstate import ( from .datamodelFeatureRealEstate import (
Projekt, Projekt,
Parzelle, Parzelle,
Dokument, Dokument,
@ -27,10 +26,10 @@ from modules.datamodels.datamodelRealEstate import (
) )
# Import interfaces # Import interfaces
from modules.interfaces.interfaceDbRealEstateObjects import getInterface as getRealEstateInterface from .interfaceFeatureRealEstate import getInterface as getRealEstateInterface
# Import feature logic for AI-powered commands # Import feature logic for AI-powered commands
from modules.features.realEstate.mainRealEstate import ( from .mainRealEstate import (
processNaturalLanguageCommand, processNaturalLanguageCommand,
create_project_with_parcel_data, create_project_with_parcel_data,
) )
@ -63,7 +62,7 @@ router = APIRouter(
async def process_command( async def process_command(
request: Request, request: Request,
userInput: str = Body(..., embed=True, description="Natural language command"), userInput: str = Body(..., embed=True, description="Natural language command"),
currentUser: User = Depends(getCurrentUser) context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Process natural language command and execute corresponding CRUD operation. Process natural language command and execute corresponding CRUD operation.
@ -73,9 +72,9 @@ async def process_command(
Example user inputs: Example user inputs:
- "Erstelle ein neues Projekt namens 'Hauptstrasse 42'" - "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'" - "Aktualisiere Projekt XYZ mit Status 'Planung'"
- "Lösche Parzelle ABC" - "Loesche Parzelle ABC"
- "SELECT * FROM Projekt WHERE plz = '8000'" - "SELECT * FROM Projekt WHERE plz = '8000'"
Headers: Headers:
@ -93,7 +92,7 @@ async def process_command(
# Validate CSRF token (middleware also checks, but explicit validation for better error messages) # 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") csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token")
if not 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( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="CSRF token missing. Please include X-CSRF-Token header." detail="CSRF token missing. Please include X-CSRF-Token header."
@ -101,7 +100,7 @@ async def process_command(
# Basic CSRF token format validation # Basic CSRF token format validation
if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64: 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( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid CSRF token format" detail="Invalid CSRF token format"
@ -111,18 +110,19 @@ async def process_command(
try: try:
int(csrf_token, 16) int(csrf_token, 16)
except ValueError: 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( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid CSRF token format" 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}") logger.debug(f"User input: {userInput}")
# Process natural language command with AI # Process natural language command with AI
result = await processNaturalLanguageCommand( result = await processNaturalLanguageCommand(
currentUser=currentUser, currentUser=context.user,
mandateId=str(context.mandateId),
userInput=userInput userInput=userInput
) )
@ -147,7 +147,7 @@ async def process_command(
@limiter.limit("120/minute") @limiter.limit("120/minute")
async def get_available_tables( async def get_available_tables(
request: Request, request: Request,
currentUser: User = Depends(getCurrentUser) context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Get all available real estate tables. Get all available real estate tables.
@ -164,7 +164,7 @@ async def get_available_tables(
# Validate CSRF token if provided # Validate CSRF token if provided
csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token") csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token")
if not 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( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="CSRF token missing. Please include X-CSRF-Token header." detail="CSRF token missing. Please include X-CSRF-Token header."
@ -172,7 +172,7 @@ async def get_available_tables(
# Basic CSRF token format validation # Basic CSRF token format validation
if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64: 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( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid CSRF token format" detail="Invalid CSRF token format"
@ -182,13 +182,13 @@ async def get_available_tables(
try: try:
int(csrf_token, 16) int(csrf_token, 16)
except ValueError: 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( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid CSRF token format" 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 # Define available tables with descriptions
tables = [ tables = [
@ -245,7 +245,7 @@ async def get_table_data(
request: Request, request: Request,
table: str = Path(..., description="Table name (Projekt, Parzelle, Dokument, Gemeinde, Kanton, Land)"), table: str = Path(..., description="Table name (Projekt, Parzelle, Dokument, Gemeinde, Kanton, Land)"),
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"), pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
currentUser: User = Depends(getCurrentUser) context: RequestContext = Depends(getRequestContext)
) -> PaginatedResponse[Dict[str, Any]]: ) -> PaginatedResponse[Dict[str, Any]]:
""" """
Get all data from a specific real estate table with optional pagination. 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 # Validate CSRF token if provided
csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token") csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token")
if not 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( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="CSRF token missing. Please include X-CSRF-Token header." detail="CSRF token missing. Please include X-CSRF-Token header."
@ -281,7 +281,7 @@ async def get_table_data(
# Basic CSRF token format validation # Basic CSRF token format validation
if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64: 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( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid CSRF token format" detail="Invalid CSRF token format"
@ -291,13 +291,13 @@ async def get_table_data(
try: try:
int(csrf_token, 16) int(csrf_token, 16)
except ValueError: 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( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid CSRF token format" 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 # Map table names to model classes and getter methods
table_mapping = { table_mapping = {
@ -317,7 +317,7 @@ async def get_table_data(
) )
# Get interface and fetch 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] model_class, method_name = table_mapping[table]
getter_method = getattr(realEstateInterface, method_name) getter_method = getattr(realEstateInterface, method_name)
@ -399,7 +399,7 @@ async def create_table_record(
request: Request, request: Request,
table: str = Path(..., description="Table name (Projekt, Parzelle, Dokument, Gemeinde, Kanton, Land)"), table: str = Path(..., description="Table name (Projekt, Parzelle, Dokument, Gemeinde, Kanton, Land)"),
data: Dict[str, Any] = Body(..., description="Record data to create"), data: Dict[str, Any] = Body(..., description="Record data to create"),
currentUser: User = Depends(getCurrentUser) context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Create a new record in a specific real estate table. Create a new record in a specific real estate table.
@ -442,7 +442,7 @@ async def create_table_record(
# Validate CSRF token # Validate CSRF token
csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token") csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token")
if not 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( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="CSRF token missing. Please include X-CSRF-Token header." detail="CSRF token missing. Please include X-CSRF-Token header."
@ -450,7 +450,7 @@ async def create_table_record(
# Basic CSRF token format validation # Basic CSRF token format validation
if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64: 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( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid CSRF token format" detail="Invalid CSRF token format"
@ -460,7 +460,7 @@ async def create_table_record(
try: try:
int(csrf_token, 16) int(csrf_token, 16)
except ValueError: 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( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid CSRF token format" detail="Invalid CSRF token format"
@ -468,7 +468,7 @@ async def create_table_record(
# Special handling for Projekt with parcel data # Special handling for Projekt with parcel data
if table == "Projekt" and ("parzelle" in data or "parzellen" in 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 # Extract fields
label = data.get("label") label = data.get("label")
@ -491,7 +491,7 @@ async def create_table_record(
detail="parzellen must be an array" detail="parzellen must be an array"
) )
elif "parzelle" in data: elif "parzelle" in data:
# Single parcel (backward compatibility) # Single parcel
parzelle_data = data.get("parzelle") parzelle_data = data.get("parzelle")
if parzelle_data: if parzelle_data:
parzellen_data = [parzelle_data] parzellen_data = [parzelle_data]
@ -505,7 +505,8 @@ async def create_table_record(
# Use helper function to create project with parcel data # Use helper function to create project with parcel data
try: try:
result = await create_project_with_parcel_data( result = await create_project_with_parcel_data(
currentUser=currentUser, currentUser=context.user,
mandateId=str(context.mandateId),
projekt_label=label, projekt_label=label,
parzellen_data=parzellen_data, parzellen_data=parzellen_data,
status_prozess=status_prozess, status_prozess=status_prozess,
@ -524,7 +525,7 @@ async def create_table_record(
) )
# Standard handling for other tables or Projekt without parcel data # 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}") logger.debug(f"Record data: {data}")
# Map table names to model classes and create methods # Map table names to model classes and create methods
@ -545,13 +546,13 @@ async def create_table_record(
) )
# Get interface # 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] model_class, method_name = table_mapping[table]
create_method = getattr(realEstateInterface, method_name) 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: if "mandateId" not in data:
data["mandateId"] = currentUser.mandateId data["mandateId"] = str(context.mandateId) if context.mandateId else None
# Create model instance from data # Create model instance from data
try: try:
@ -596,7 +597,7 @@ async def search_parcel(
request: Request, request: Request,
location: str = Query(..., description="Either coordinates as 'x,y' (LV95) or address string"), location: str = Query(..., description="Either coordinates as 'x,y' (LV95) or address string"),
include_adjacent: bool = Query(False, description="Include adjacent parcels information"), include_adjacent: bool = Query(False, description="Include adjacent parcels information"),
currentUser: User = Depends(getCurrentUser) context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Search for parcel information by address or coordinates. Search for parcel information by address or coordinates.
@ -614,50 +615,18 @@ async def search_parcel(
Headers: Headers:
- X-CSRF-Token: CSRF token (required for security) - 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: try:
# Validate CSRF token # Validate CSRF token
csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token") csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token")
if not 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( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="CSRF token missing. Please include X-CSRF-Token header." 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 # Initialize connector
connector = SwissTopoMapServerConnector() connector = SwissTopoMapServerConnector()
@ -762,15 +731,14 @@ async def search_parcel(
# Basic municipality lookup for common codes # Basic municipality lookup for common codes
common_municipalities = { common_municipalities = {
351: "Bern", 351: "Bern",
261: "Zürich", 261: "Zuerich",
6621: "Genève", 6621: "Geneve",
2701: "Basel", 2701: "Basel",
5586: "Lausanne", 5586: "Lausanne",
1061: "Luzern", 1061: "Luzern",
3203: "Winterthur", 3203: "Winterthur",
230: "St. Gallen", 230: "St. Gallen",
5192: "Lugano", 5192: "Lugano",
351: "Bern",
1367: "Schwyz" 1367: "Schwyz"
} }
@ -944,7 +912,7 @@ async def add_parcel_to_project(
request: Request, request: Request,
projekt_id: str = Path(..., description="Projekt ID"), projekt_id: str = Path(..., description="Projekt ID"),
body: Dict[str, Any] = Body(...), body: Dict[str, Any] = Body(...),
currentUser: User = Depends(getCurrentUser) context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Add a parcel to an existing project. Add a parcel to an existing project.
@ -961,7 +929,7 @@ async def add_parcel_to_project(
Option 2 - Create new parcel from location: 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: Option 3 - Create new parcel with custom data:
@ -988,7 +956,7 @@ async def add_parcel_to_project(
# Validate CSRF token # Validate CSRF token
csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token") csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token")
if not 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( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="CSRF token missing. Please include X-CSRF-Token header." 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" 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 # Get interface
realEstateInterface = getRealEstateInterface(currentUser) realEstateInterface = getRealEstateInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None)
# Fetch existing Projekt # Fetch existing Projekt - use mandateId from context
projekte = realEstateInterface.getProjekte( recordFilter = {"id": projekt_id}
recordFilter={"id": projekt_id, "mandateId": currentUser.mandateId} if context.mandateId:
) recordFilter["mandateId"] = str(context.mandateId)
projekte = realEstateInterface.getProjekte(recordFilter=recordFilter)
if not projekte: if not projekte:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
@ -1034,9 +1003,10 @@ async def add_parcel_to_project(
# Option 1: Link existing parcel # Option 1: Link existing parcel
if parcel_id: if parcel_id:
logger.info(f"Linking existing parcel {parcel_id}") logger.info(f"Linking existing parcel {parcel_id}")
parcels = realEstateInterface.getParzellen( parcelFilter = {"id": parcel_id}
recordFilter={"id": parcel_id, "mandateId": currentUser.mandateId} if context.mandateId:
) parcelFilter["mandateId"] = str(context.mandateId)
parcels = realEstateInterface.getParzellen(recordFilter=parcelFilter)
if not parcels: if not parcels:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, 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) extracted_attributes = connector.extract_parcel_attributes(parcel_data)
attributes = parcel_data.get("attributes", {}) attributes = parcel_data.get("attributes", {})
# Create Parzelle # Create Parzelle with mandateId from context
parzelle_create_data = { 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", "label": extracted_attributes.get("label") or attributes.get("number") or "Unknown",
"parzellenAliasTags": [attributes.get("egris_egrid")] if attributes.get("egris_egrid") else [], "parzellenAliasTags": [attributes.get("egris_egrid")] if attributes.get("egris_egrid") else [],
"eigentuemerschaft": None, "eigentuemerschaft": None,
@ -1111,7 +1081,7 @@ async def add_parcel_to_project(
# Option 3: Create from custom data # Option 3: Create from custom data
elif parcel_data_dict: elif parcel_data_dict:
logger.info(f"Creating parcel from custom data") 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_instance = Parzelle(**parcel_data_dict)
parzelle = realEstateInterface.createParzelle(parzelle_instance) parzelle = realEstateInterface.createParzelle(parzelle_instance)
@ -1150,4 +1120,3 @@ async def add_parcel_to_project(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error adding parcel to project: {str(e)}" detail=f"Error adding parcel to project: {str(e)}"
) )

View file

@ -44,6 +44,15 @@ class TrusteeOrganisation(BaseModel):
"frontend_required": False "frontend_required": False
} }
) )
featureInstanceId: Optional[str] = Field(
default=None,
description="Feature Instance ID for instance-level isolation",
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False
}
)
# System attributes are automatically set by DatabaseConnector: # System attributes are automatically set by DatabaseConnector:
# _createdAt, _modifiedAt, _createdBy, _modifiedBy # _createdAt, _modifiedAt, _createdBy, _modifiedBy
@ -56,6 +65,7 @@ registerModelLabels(
"label": {"en": "Label", "fr": "Libellé", "de": "Bezeichnung"}, "label": {"en": "Label", "fr": "Libellé", "de": "Bezeichnung"},
"enabled": {"en": "Enabled", "fr": "Activé", "de": "Aktiviert"}, "enabled": {"en": "Enabled", "fr": "Activé", "de": "Aktiviert"},
"mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"}, "mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"},
"featureInstanceId": {"en": "Feature Instance", "fr": "Instance de fonctionnalité", "de": "Feature-Instanz"},
}, },
) )
@ -87,6 +97,15 @@ class TrusteeRole(BaseModel):
"frontend_required": False "frontend_required": False
} }
) )
featureInstanceId: Optional[str] = Field(
default=None,
description="Feature Instance ID for instance-level isolation",
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False
}
)
# System attributes are automatically set by DatabaseConnector # System attributes are automatically set by DatabaseConnector
@ -97,6 +116,7 @@ registerModelLabels(
"id": {"en": "ID", "fr": "ID", "de": "ID"}, "id": {"en": "ID", "fr": "ID", "de": "ID"},
"desc": {"en": "Description", "fr": "Description", "de": "Beschreibung"}, "desc": {"en": "Description", "fr": "Description", "de": "Beschreibung"},
"mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"}, "mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"},
"featureInstanceId": {"en": "Feature Instance", "fr": "Instance de fonctionnalité", "de": "Feature-Instanz"},
}, },
) )
@ -118,7 +138,7 @@ class TrusteeAccess(BaseModel):
"frontend_type": "select", "frontend_type": "select",
"frontend_readonly": False, "frontend_readonly": False,
"frontend_required": True, "frontend_required": True,
"frontend_options": "TrusteeOrganisation" "frontend_options": "/api/trustee/{instanceId}/organisations/options"
} }
) )
roleId: str = Field( roleId: str = Field(
@ -127,7 +147,7 @@ class TrusteeAccess(BaseModel):
"frontend_type": "select", "frontend_type": "select",
"frontend_readonly": False, "frontend_readonly": False,
"frontend_required": True, "frontend_required": True,
"frontend_options": "TrusteeRole" "frontend_options": "/api/trustee/{instanceId}/roles/options"
} }
) )
userId: str = Field( userId: str = Field(
@ -136,7 +156,7 @@ class TrusteeAccess(BaseModel):
"frontend_type": "select", "frontend_type": "select",
"frontend_readonly": False, "frontend_readonly": False,
"frontend_required": True, "frontend_required": True,
"frontend_options": "User" "frontend_options": "/api/users/options"
} }
) )
contractId: Optional[str] = Field( contractId: Optional[str] = Field(
@ -146,7 +166,7 @@ class TrusteeAccess(BaseModel):
"frontend_type": "select", "frontend_type": "select",
"frontend_readonly": False, "frontend_readonly": False,
"frontend_required": False, "frontend_required": False,
"frontend_options": "TrusteeContract", "frontend_options": "/api/trustee/{instanceId}/contracts/options",
"frontend_depends_on": "organisationId" "frontend_depends_on": "organisationId"
} }
) )
@ -159,6 +179,15 @@ class TrusteeAccess(BaseModel):
"frontend_required": False "frontend_required": False
} }
) )
featureInstanceId: Optional[str] = Field(
default=None,
description="Feature Instance ID for instance-level isolation",
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False
}
)
# System attributes are automatically set by DatabaseConnector # System attributes are automatically set by DatabaseConnector
@ -172,6 +201,7 @@ registerModelLabels(
"userId": {"en": "User", "fr": "Utilisateur", "de": "Benutzer"}, "userId": {"en": "User", "fr": "Utilisateur", "de": "Benutzer"},
"contractId": {"en": "Contract (optional)", "fr": "Contrat (optionnel)", "de": "Vertrag (optional)"}, "contractId": {"en": "Contract (optional)", "fr": "Contrat (optionnel)", "de": "Vertrag (optional)"},
"mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"}, "mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"},
"featureInstanceId": {"en": "Feature Instance", "fr": "Instance de fonctionnalité", "de": "Feature-Instanz"},
}, },
) )
@ -193,7 +223,7 @@ class TrusteeContract(BaseModel):
"frontend_type": "select", "frontend_type": "select",
"frontend_readonly": False, # Editable at creation, then readonly "frontend_readonly": False, # Editable at creation, then readonly
"frontend_required": True, "frontend_required": True,
"frontend_options": "TrusteeOrganisation" "frontend_options": "/api/trustee/{instanceId}/organisations/options"
} }
) )
label: str = Field( label: str = Field(
@ -222,6 +252,15 @@ class TrusteeContract(BaseModel):
"frontend_required": False "frontend_required": False
} }
) )
featureInstanceId: Optional[str] = Field(
default=None,
description="Feature Instance ID for instance-level isolation",
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False
}
)
# System attributes are automatically set by DatabaseConnector # System attributes are automatically set by DatabaseConnector
@ -234,12 +273,21 @@ registerModelLabels(
"label": {"en": "Label", "fr": "Libellé", "de": "Bezeichnung"}, "label": {"en": "Label", "fr": "Libellé", "de": "Bezeichnung"},
"enabled": {"en": "Enabled", "fr": "Activé", "de": "Aktiviert"}, "enabled": {"en": "Enabled", "fr": "Activé", "de": "Aktiviert"},
"mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"}, "mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"},
"featureInstanceId": {"en": "Feature Instance", "fr": "Instance de fonctionnalité", "de": "Feature-Instanz"},
}, },
) )
class TrusteeDocument(BaseModel): class TrusteeDocument(BaseModel):
"""Contains document references and receipts for bookings.""" """Contains document references for bookings.
Documents reference files in the central Files table via fileId.
This allows file content to be stored once and referenced by multiple features.
Note: organisationId and contractId removed as per architecture decision:
- The feature instance IS the organisation
- Contracts are eliminated from the model
"""
id: str = Field( id: str = Field(
default_factory=lambda: str(uuid.uuid4()), default_factory=lambda: str(uuid.uuid4()),
description="Unique document ID", description="Unique document ID",
@ -249,30 +297,11 @@ class TrusteeDocument(BaseModel):
"frontend_required": False "frontend_required": False
} }
) )
organisationId: str = Field( fileId: Optional[str] = Field(
description="Reference to TrusteeOrganisation.id",
json_schema_extra={
"frontend_type": "select",
"frontend_readonly": False,
"frontend_required": True,
"frontend_options": "TrusteeOrganisation"
}
)
contractId: str = Field(
description="Reference to TrusteeContract.id",
json_schema_extra={
"frontend_type": "select",
"frontend_readonly": False,
"frontend_required": True,
"frontend_options": "TrusteeContract",
"frontend_depends_on": "organisationId"
}
)
documentData: Optional[bytes] = Field(
default=None, default=None,
description="The file content (binary)", description="Reference to central Files table (Files.id)",
json_schema_extra={ json_schema_extra={
"frontend_type": "file", "frontend_type": "file_reference",
"frontend_readonly": False, "frontend_readonly": False,
"frontend_required": False "frontend_required": False
} }
@ -292,23 +321,47 @@ class TrusteeDocument(BaseModel):
"frontend_type": "select", "frontend_type": "select",
"frontend_readonly": False, "frontend_readonly": False,
"frontend_required": True, "frontend_required": True,
"frontend_options": [ "frontend_options": "/api/trustee/mime-types/options"
{"value": "application/pdf", "label": {"en": "PDF", "fr": "PDF", "de": "PDF"}},
{"value": "image/jpeg", "label": {"en": "JPEG", "fr": "JPEG", "de": "JPEG"}},
{"value": "image/png", "label": {"en": "PNG", "fr": "PNG", "de": "PNG"}},
{"value": "application/octet-stream", "label": {"en": "Other", "fr": "Autre", "de": "Andere"}},
]
} }
) )
mandateId: Optional[str] = Field( sourceType: Optional[str] = Field(
default=None, default=None,
description="Mandate ID", description="Source type (e.g., 'sharepoint', 'upload', 'email')",
json_schema_extra={ json_schema_extra={
"frontend_type": "text", "frontend_type": "text",
"frontend_readonly": True, "frontend_readonly": True,
"frontend_required": False "frontend_required": False
} }
) )
sourceLocation: Optional[str] = Field(
default=None,
description="Original source location (e.g., SharePoint path)",
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False
}
)
mandateId: Optional[str] = Field(
default=None,
description="Mandate ID (auto-set from context)",
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
"frontend_hidden": True
}
)
featureInstanceId: Optional[str] = Field(
default=None,
description="Feature Instance ID for instance-level isolation (auto-set from context)",
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
"frontend_hidden": True
}
)
# System attributes are automatically set by DatabaseConnector # System attributes are automatically set by DatabaseConnector
@ -317,18 +370,24 @@ registerModelLabels(
{"en": "Document", "fr": "Document", "de": "Dokument"}, {"en": "Document", "fr": "Document", "de": "Dokument"},
{ {
"id": {"en": "ID", "fr": "ID", "de": "ID"}, "id": {"en": "ID", "fr": "ID", "de": "ID"},
"organisationId": {"en": "Organisation", "fr": "Organisation", "de": "Organisation"}, "fileId": {"en": "File Reference", "fr": "Référence du fichier", "de": "Datei-Referenz"},
"contractId": {"en": "Contract", "fr": "Contrat", "de": "Vertrag"},
"documentData": {"en": "Document Data", "fr": "Données du document", "de": "Dokumentdaten"},
"documentName": {"en": "Document Name", "fr": "Nom du document", "de": "Dokumentname"}, "documentName": {"en": "Document Name", "fr": "Nom du document", "de": "Dokumentname"},
"documentMimeType": {"en": "MIME Type", "fr": "Type MIME", "de": "MIME-Typ"}, "documentMimeType": {"en": "MIME Type", "fr": "Type MIME", "de": "MIME-Typ"},
"sourceType": {"en": "Source Type", "fr": "Type de source", "de": "Quelltyp"},
"sourceLocation": {"en": "Source Location", "fr": "Emplacement source", "de": "Quellort"},
"mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"}, "mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"},
"featureInstanceId": {"en": "Feature Instance", "fr": "Instance de fonctionnalité", "de": "Feature-Instanz"},
}, },
) )
class TrusteePosition(BaseModel): class TrusteePosition(BaseModel):
"""Contains booking positions (expense entries).""" """Contains booking positions (expense entries).
Note: organisationId and contractId removed as per architecture decision:
- The feature instance IS the organisation
- Contracts are eliminated from the model
"""
id: str = Field( id: str = Field(
default_factory=lambda: str(uuid.uuid4()), default_factory=lambda: str(uuid.uuid4()),
description="Unique position ID", description="Unique position ID",
@ -338,25 +397,6 @@ class TrusteePosition(BaseModel):
"frontend_required": False "frontend_required": False
} }
) )
organisationId: str = Field(
description="Reference to TrusteeOrganisation.id",
json_schema_extra={
"frontend_type": "select",
"frontend_readonly": False,
"frontend_required": True,
"frontend_options": "TrusteeOrganisation"
}
)
contractId: str = Field(
description="Reference to TrusteeContract.id",
json_schema_extra={
"frontend_type": "select",
"frontend_readonly": False,
"frontend_required": True,
"frontend_options": "TrusteeContract",
"frontend_depends_on": "organisationId"
}
)
valuta: Optional[str] = Field( valuta: Optional[str] = Field(
default=None, default=None,
description="Value date (ISO format: YYYY-MM-DD)", description="Value date (ISO format: YYYY-MM-DD)",
@ -470,11 +510,22 @@ class TrusteePosition(BaseModel):
) )
mandateId: Optional[str] = Field( mandateId: Optional[str] = Field(
default=None, default=None,
description="Mandate ID", description="Mandate ID (auto-set from context)",
json_schema_extra={ json_schema_extra={
"frontend_type": "text", "frontend_type": "text",
"frontend_readonly": True, "frontend_readonly": True,
"frontend_required": False "frontend_required": False,
"frontend_hidden": True
}
)
featureInstanceId: Optional[str] = Field(
default=None,
description="Feature Instance ID for instance-level isolation (auto-set from context)",
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
"frontend_hidden": True
} }
) )
# System attributes are automatically set by DatabaseConnector # System attributes are automatically set by DatabaseConnector
@ -485,8 +536,6 @@ registerModelLabels(
{"en": "Position", "fr": "Position", "de": "Position"}, {"en": "Position", "fr": "Position", "de": "Position"},
{ {
"id": {"en": "ID", "fr": "ID", "de": "ID"}, "id": {"en": "ID", "fr": "ID", "de": "ID"},
"organisationId": {"en": "Organisation", "fr": "Organisation", "de": "Organisation"},
"contractId": {"en": "Contract", "fr": "Contrat", "de": "Vertrag"},
"valuta": {"en": "Value Date", "fr": "Date de valeur", "de": "Valutadatum"}, "valuta": {"en": "Value Date", "fr": "Date de valeur", "de": "Valutadatum"},
"transactionDateTime": {"en": "Transaction Date/Time", "fr": "Date/Heure de transaction", "de": "Transaktionszeitpunkt"}, "transactionDateTime": {"en": "Transaction Date/Time", "fr": "Date/Heure de transaction", "de": "Transaktionszeitpunkt"},
"company": {"en": "Company", "fr": "Entreprise", "de": "Firma"}, "company": {"en": "Company", "fr": "Entreprise", "de": "Firma"},
@ -499,12 +548,18 @@ registerModelLabels(
"vatPercentage": {"en": "VAT Percentage", "fr": "Pourcentage TVA", "de": "MwSt-Prozentsatz"}, "vatPercentage": {"en": "VAT Percentage", "fr": "Pourcentage TVA", "de": "MwSt-Prozentsatz"},
"vatAmount": {"en": "VAT Amount", "fr": "Montant TVA", "de": "MwSt-Betrag"}, "vatAmount": {"en": "VAT Amount", "fr": "Montant TVA", "de": "MwSt-Betrag"},
"mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"}, "mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"},
"featureInstanceId": {"en": "Feature Instance", "fr": "Instance de fonctionnalité", "de": "Feature-Instanz"},
}, },
) )
class TrusteePositionDocument(BaseModel): class TrusteePositionDocument(BaseModel):
"""Cross-reference table linking positions to documents (many-to-many).""" """Cross-reference table linking positions to documents (many-to-many).
Note: organisationId and contractId removed as per architecture decision:
- The feature instance IS the organisation
- Contracts are eliminated from the model
"""
id: str = Field( id: str = Field(
default_factory=lambda: str(uuid.uuid4()), default_factory=lambda: str(uuid.uuid4()),
description="Unique link ID", description="Unique link ID",
@ -514,33 +569,13 @@ class TrusteePositionDocument(BaseModel):
"frontend_required": False "frontend_required": False
} }
) )
organisationId: str = Field(
description="Reference to TrusteeOrganisation.id",
json_schema_extra={
"frontend_type": "select",
"frontend_readonly": False,
"frontend_required": True,
"frontend_options": "TrusteeOrganisation"
}
)
contractId: str = Field(
description="Reference to TrusteeContract.id",
json_schema_extra={
"frontend_type": "select",
"frontend_readonly": False,
"frontend_required": True,
"frontend_options": "TrusteeContract",
"frontend_depends_on": "organisationId"
}
)
documentId: str = Field( documentId: str = Field(
description="Reference to TrusteeDocument.id", description="Reference to TrusteeDocument.id",
json_schema_extra={ json_schema_extra={
"frontend_type": "select", "frontend_type": "select",
"frontend_readonly": False, "frontend_readonly": False,
"frontend_required": True, "frontend_required": True,
"frontend_options": "TrusteeDocument", "frontend_options": "/api/trustee/{instanceId}/documents/options"
"frontend_depends_on": "contractId"
} }
) )
positionId: str = Field( positionId: str = Field(
@ -549,17 +584,27 @@ class TrusteePositionDocument(BaseModel):
"frontend_type": "select", "frontend_type": "select",
"frontend_readonly": False, "frontend_readonly": False,
"frontend_required": True, "frontend_required": True,
"frontend_options": "TrusteePosition", "frontend_options": "/api/trustee/{instanceId}/positions/options"
"frontend_depends_on": "contractId"
} }
) )
mandateId: Optional[str] = Field( mandateId: Optional[str] = Field(
default=None, default=None,
description="Mandate ID", description="Mandate ID (auto-set from context)",
json_schema_extra={ json_schema_extra={
"frontend_type": "text", "frontend_type": "text",
"frontend_readonly": True, "frontend_readonly": True,
"frontend_required": False "frontend_required": False,
"frontend_hidden": True
}
)
featureInstanceId: Optional[str] = Field(
default=None,
description="Feature Instance ID for instance-level isolation (auto-set from context)",
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
"frontend_hidden": True
} }
) )
# System attributes are automatically set by DatabaseConnector # System attributes are automatically set by DatabaseConnector
@ -570,10 +615,9 @@ registerModelLabels(
{"en": "Position-Document Link", "fr": "Lien Position-Document", "de": "Position-Dokument-Verknüpfung"}, {"en": "Position-Document Link", "fr": "Lien Position-Document", "de": "Position-Dokument-Verknüpfung"},
{ {
"id": {"en": "ID", "fr": "ID", "de": "ID"}, "id": {"en": "ID", "fr": "ID", "de": "ID"},
"organisationId": {"en": "Organisation", "fr": "Organisation", "de": "Organisation"},
"contractId": {"en": "Contract", "fr": "Contrat", "de": "Vertrag"},
"documentId": {"en": "Document", "fr": "Document", "de": "Dokument"}, "documentId": {"en": "Document", "fr": "Document", "de": "Dokument"},
"positionId": {"en": "Position", "fr": "Position", "de": "Position"}, "positionId": {"en": "Position", "fr": "Position", "de": "Position"},
"mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"}, "mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"},
"featureInstanceId": {"en": "Feature Instance", "fr": "Instance de fonctionnalité", "de": "Feature-Instanz"},
}, },
) )

View file

@ -0,0 +1,440 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Trustee Feature Container - Main Module.
Handles feature initialization and RBAC catalog registration.
"""
import logging
from typing import Dict, List, Any
logger = logging.getLogger(__name__)
# Feature metadata
FEATURE_CODE = "trustee"
FEATURE_LABEL = {"en": "Trustee", "de": "Treuhand", "fr": "Fiduciaire"}
FEATURE_ICON = "mdi-briefcase"
# UI Objects for RBAC catalog
# Note: organisations and contracts removed - feature instance = organisation
UI_OBJECTS = [
{
"objectKey": "ui.feature.trustee.dashboard",
"label": {"en": "Dashboard", "de": "Dashboard", "fr": "Tableau de bord"},
"meta": {"area": "dashboard"}
},
{
"objectKey": "ui.feature.trustee.positions",
"label": {"en": "Positions", "de": "Positionen", "fr": "Positions"},
"meta": {"area": "positions"}
},
{
"objectKey": "ui.feature.trustee.documents",
"label": {"en": "Documents", "de": "Dokumente", "fr": "Documents"},
"meta": {"area": "documents"}
},
{
"objectKey": "ui.feature.trustee.position-documents",
"label": {"en": "Position Documents", "de": "Positions-Dokumente", "fr": "Documents de position"},
"meta": {"area": "position-documents"}
},
{
"objectKey": "ui.feature.trustee.expense-import",
"label": {"en": "Expense Import", "de": "Spesen Import", "fr": "Import de dépenses"},
"meta": {"area": "expense-import"}
},
{
"objectKey": "ui.feature.trustee.instance-roles",
"label": {"en": "Instance Roles & Permissions", "de": "Instanz-Rollen & Berechtigungen", "fr": "Rôles et permissions d'instance"},
"meta": {"area": "admin", "admin_only": True}
},
]
# DATA Objects for RBAC catalog (tables/entities)
# Used for AccessRules on data-level permissions
DATA_OBJECTS = [
{
"objectKey": "data.feature.trustee.TrusteePosition",
"label": {"en": "Position", "de": "Position", "fr": "Position"},
"meta": {"table": "TrusteePosition", "fields": ["id", "label", "description", "organisationId"]}
},
{
"objectKey": "data.feature.trustee.TrusteeDocument",
"label": {"en": "Document", "de": "Dokument", "fr": "Document"},
"meta": {"table": "TrusteeDocument", "fields": ["id", "filename", "mimeType", "fileSize", "uploadDate"]}
},
{
"objectKey": "data.feature.trustee.TrusteePositionDocument",
"label": {"en": "Position-Document Assignment", "de": "Position-Dokument Zuordnung", "fr": "Assignation Position-Document"},
"meta": {"table": "TrusteePositionDocument", "fields": ["id", "positionId", "documentId"]}
},
{
"objectKey": "data.feature.trustee.*",
"label": {"en": "All Trustee Data", "de": "Alle Treuhand-Daten", "fr": "Toutes les données fiduciaires"},
"meta": {"wildcard": True, "description": "Wildcard for all trustee data tables"}
},
]
# Resource Objects for RBAC catalog
# Note: organisations and contracts removed - feature instance = organisation
RESOURCE_OBJECTS = [
{
"objectKey": "resource.feature.trustee.documents.create",
"label": {"en": "Upload Document", "de": "Dokument hochladen", "fr": "Télécharger document"},
"meta": {"endpoint": "/api/trustee/{instanceId}/documents", "method": "POST"}
},
{
"objectKey": "resource.feature.trustee.documents.update",
"label": {"en": "Update Document", "de": "Dokument aktualisieren", "fr": "Modifier document"},
"meta": {"endpoint": "/api/trustee/{instanceId}/documents/{documentId}", "method": "PUT"}
},
{
"objectKey": "resource.feature.trustee.documents.delete",
"label": {"en": "Delete Document", "de": "Dokument löschen", "fr": "Supprimer document"},
"meta": {"endpoint": "/api/trustee/{instanceId}/documents/{documentId}", "method": "DELETE"}
},
{
"objectKey": "resource.feature.trustee.positions.create",
"label": {"en": "Create Position", "de": "Position erstellen", "fr": "Créer position"},
"meta": {"endpoint": "/api/trustee/{instanceId}/positions", "method": "POST"}
},
{
"objectKey": "resource.feature.trustee.positions.update",
"label": {"en": "Update Position", "de": "Position aktualisieren", "fr": "Modifier position"},
"meta": {"endpoint": "/api/trustee/{instanceId}/positions/{positionId}", "method": "PUT"}
},
{
"objectKey": "resource.feature.trustee.positions.delete",
"label": {"en": "Delete Position", "de": "Position löschen", "fr": "Supprimer position"},
"meta": {"endpoint": "/api/trustee/{instanceId}/positions/{positionId}", "method": "DELETE"}
},
{
"objectKey": "resource.feature.trustee.instance-roles.manage",
"label": {"en": "Manage Instance Roles", "de": "Instanz-Rollen verwalten", "fr": "Gérer les rôles d'instance"},
"meta": {"endpoint": "/api/trustee/{instanceId}/instance-roles", "method": "ALL", "admin_only": True}
},
]
# Template roles for this feature with AccessRules
# Each role defines default UI and DATA permissions
# Note: UI item=None means ALL views, specific items restrict to named views
# IMPORTANT: item uses vollqualifizierte ObjectKeys (gemäss Navigation-API-Konzept)
TEMPLATE_ROLES = [
{
"roleLabel": "trustee-admin",
"description": {
"en": "Trustee Administrator - Full access to all trustee data and settings",
"de": "Treuhand-Administrator - Vollzugriff auf alle Treuhand-Daten und Einstellungen",
"fr": "Administrateur fiduciaire - Accès complet aux données et paramètres fiduciaires"
},
"accessRules": [
# Full UI access (all views including admin views)
{"context": "UI", "item": None, "view": True},
# Full DATA access
{"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"},
# Admin resource: manage instance roles
{"context": "RESOURCE", "item": "resource.feature.trustee.instance-roles.manage", "view": True},
]
},
{
"roleLabel": "trustee-accountant",
"description": {
"en": "Trustee Accountant - Manage accounting and financial data",
"de": "Treuhand-Buchhalter - Buchhaltungs- und Finanzdaten verwalten",
"fr": "Comptable fiduciaire - Gérer les données comptables et financières"
},
"accessRules": [
# UI access to main views (not admin views) - vollqualifizierte ObjectKeys
{"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True},
{"context": "UI", "item": "ui.feature.trustee.positions", "view": True},
{"context": "UI", "item": "ui.feature.trustee.documents", "view": True},
{"context": "UI", "item": "ui.feature.trustee.position-documents", "view": True},
{"context": "UI", "item": "ui.feature.trustee.expense-import", "view": True},
# Group-level DATA access
{"context": "DATA", "item": None, "view": True, "read": "g", "create": "g", "update": "g", "delete": "g"},
]
},
{
"roleLabel": "trustee-client",
"description": {
"en": "Trustee Client - View own accounting data and documents",
"de": "Treuhand-Kunde - Eigene Buchhaltungsdaten und Dokumente einsehen",
"fr": "Client fiduciaire - Consulter ses propres données comptables et documents"
},
"accessRules": [
# UI access to main views only (read-only focus) - vollqualifizierte ObjectKeys
{"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True},
{"context": "UI", "item": "ui.feature.trustee.positions", "view": True},
{"context": "UI", "item": "ui.feature.trustee.documents", "view": True},
{"context": "UI", "item": "ui.feature.trustee.position-documents", "view": True},
# Own records only (MY level) - explizite Regeln pro Tabelle
{"context": "DATA", "item": "data.feature.trustee.TrusteePosition", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
{"context": "DATA", "item": "data.feature.trustee.TrusteeDocument", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
{"context": "DATA", "item": "data.feature.trustee.TrusteePositionDocument", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
]
},
]
def getFeatureDefinition() -> Dict[str, Any]:
"""Return the feature definition for registration."""
return {
"code": FEATURE_CODE,
"label": FEATURE_LABEL,
"icon": FEATURE_ICON
}
def getUiObjects() -> List[Dict[str, Any]]:
"""Return UI objects for RBAC catalog registration."""
return UI_OBJECTS
def getResourceObjects() -> List[Dict[str, Any]]:
"""Return resource objects for RBAC catalog registration."""
return RESOURCE_OBJECTS
def getTemplateRoles() -> List[Dict[str, Any]]:
"""Return template roles for this feature."""
return TEMPLATE_ROLES
def getDataObjects() -> List[Dict[str, Any]]:
"""Return DATA objects for RBAC catalog registration."""
return DATA_OBJECTS
def registerFeature(catalogService) -> bool:
"""
Register this feature's RBAC objects in the catalog.
Args:
catalogService: The RBAC catalog service instance
Returns:
True if registration was successful
"""
try:
# Register UI objects
for uiObj in UI_OBJECTS:
catalogService.registerUiObject(
featureCode=FEATURE_CODE,
objectKey=uiObj["objectKey"],
label=uiObj["label"],
meta=uiObj.get("meta")
)
# Register Resource objects
for resObj in RESOURCE_OBJECTS:
catalogService.registerResourceObject(
featureCode=FEATURE_CODE,
objectKey=resObj["objectKey"],
label=resObj["label"],
meta=resObj.get("meta")
)
# Register DATA objects (tables/entities)
for dataObj in DATA_OBJECTS:
catalogService.registerDataObject(
featureCode=FEATURE_CODE,
objectKey=dataObj["objectKey"],
label=dataObj["label"],
meta=dataObj.get("meta")
)
# Sync template roles to database (with AccessRules)
_syncTemplateRolesToDb()
logger.info(f"Feature '{FEATURE_CODE}' registered {len(UI_OBJECTS)} UI, {len(RESOURCE_OBJECTS)} resource, {len(DATA_OBJECTS)} data objects")
return True
except Exception as e:
logger.error(f"Failed to register feature '{FEATURE_CODE}': {e}")
return False
def _syncTemplateRolesToDb() -> int:
"""
Sync template roles and their AccessRules to the database.
Creates global template roles (mandateId=None) if they don't exist.
Returns:
Number of roles created/updated
"""
try:
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext
rootInterface = getRootInterface()
db = rootInterface.db
# Get existing template roles for this feature
existingRoles = db.getRecordset(
Role,
recordFilter={"featureCode": FEATURE_CODE, "mandateId": None}
)
existingRoleLabels = {r.get("roleLabel"): r.get("id") for r in existingRoles}
createdCount = 0
for roleTemplate in TEMPLATE_ROLES:
roleLabel = roleTemplate["roleLabel"]
if roleLabel in existingRoleLabels:
roleId = existingRoleLabels[roleLabel]
logger.debug(f"Template role '{roleLabel}' already exists with ID {roleId}")
# Ensure AccessRules exist for this role
_ensureAccessRulesForRole(db, roleId, roleTemplate.get("accessRules", []))
else:
# Create new template role
newRole = Role(
roleLabel=roleLabel,
description=roleTemplate.get("description", {}),
featureCode=FEATURE_CODE,
mandateId=None, # Global template
featureInstanceId=None,
isSystemRole=False
)
createdRole = db.recordCreate(Role, newRole.model_dump())
roleId = createdRole.get("id")
# Create AccessRules for this role
_ensureAccessRulesForRole(db, roleId, roleTemplate.get("accessRules", []))
logger.info(f"Created template role '{roleLabel}' with ID {roleId}")
createdCount += 1
if createdCount > 0:
logger.info(f"Feature '{FEATURE_CODE}': Created {createdCount} template roles")
# Repair instance-specific roles that are missing AccessRules
_repairInstanceRolesAccessRules(db, existingRoleLabels)
return createdCount
except Exception as e:
logger.error(f"Error syncing template roles for feature '{FEATURE_CODE}': {e}")
return 0
def _repairInstanceRolesAccessRules(db, templateRoleLabels: Dict[str, str]) -> int:
"""
Repair instance-specific roles by copying AccessRules from their template roles.
This ensures instance roles created before AccessRules were defined get updated.
Args:
db: Database connector
templateRoleLabels: Dict mapping roleLabel to template role ID
Returns:
Number of instance roles repaired
"""
from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext
repairedCount = 0
# Get all instance-specific roles for this feature (mandateId is NOT None)
allRoles = db.getRecordset(Role, recordFilter={"featureCode": FEATURE_CODE})
instanceRoles = [r for r in allRoles if r.get("mandateId") is not None]
for instanceRole in instanceRoles:
roleLabel = instanceRole.get("roleLabel")
instanceRoleId = instanceRole.get("id")
# Find matching template role
templateRoleId = templateRoleLabels.get(roleLabel)
if not templateRoleId:
continue
# Check if instance role has AccessRules
existingRules = db.getRecordset(AccessRule, recordFilter={"roleId": instanceRoleId})
if existingRules:
continue # Already has rules, skip
# Copy AccessRules from template role
templateRules = db.getRecordset(AccessRule, recordFilter={"roleId": templateRoleId})
if not templateRules:
continue # Template has no rules
for rule in templateRules:
newRule = AccessRule(
roleId=instanceRoleId,
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"),
)
db.recordCreate(AccessRule, newRule.model_dump())
logger.info(f"Repaired instance role '{roleLabel}' (ID: {instanceRoleId}): copied {len(templateRules)} AccessRules from template")
repairedCount += 1
if repairedCount > 0:
logger.info(f"Feature '{FEATURE_CODE}': Repaired {repairedCount} instance roles with missing AccessRules")
return repairedCount
def _ensureAccessRulesForRole(db, roleId: str, ruleTemplates: List[Dict[str, Any]]) -> int:
"""
Ensure AccessRules exist for a role based on templates.
Args:
db: Database connector
roleId: Role ID
ruleTemplates: List of rule templates
Returns:
Number of rules created
"""
from modules.datamodels.datamodelRbac import AccessRule, AccessRuleContext
# Get existing rules for this role
existingRules = db.getRecordset(AccessRule, recordFilter={"roleId": roleId})
# Create a set of existing rule signatures to avoid duplicates
existingSignatures = set()
for rule in existingRules:
sig = (rule.get("context"), rule.get("item"))
existingSignatures.add(sig)
createdCount = 0
for template in ruleTemplates:
context = template.get("context", "UI")
item = template.get("item")
sig = (context, item)
if sig in existingSignatures:
continue
# Map context string to enum
if context == "UI":
contextEnum = AccessRuleContext.UI
elif context == "DATA":
contextEnum = AccessRuleContext.DATA
elif context == "RESOURCE":
contextEnum = AccessRuleContext.RESOURCE
else:
contextEnum = context
newRule = AccessRule(
roleId=roleId,
context=contextEnum,
item=item,
view=template.get("view", False),
read=template.get("read"),
create=template.get("create"),
update=template.get("update"),
delete=template.get("delete"),
)
db.recordCreate(AccessRule, newRule.model_dump())
createdCount += 1
if createdCount > 0:
logger.debug(f"Created {createdCount} AccessRules for role {roleId}")
return createdCount

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -3,6 +3,10 @@
""" """
Interface to the Gateway system. Interface to the Gateway system.
Manages users and mandates for authentication. 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 import logging
@ -32,11 +36,15 @@ from modules.datamodels.datamodelRbac import (
) )
from modules.datamodels.datamodelUam import AccessLevel from modules.datamodels.datamodelUam import AccessLevel
from modules.datamodels.datamodelSecurity import Token, AuthEvent, TokenStatus from modules.datamodels.datamodelSecurity import Token, AuthEvent, TokenStatus
from modules.datamodels.datamodelNeutralizer import (
DataNeutraliserConfig,
DataNeutralizerAttributes,
)
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResult 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__) logger = logging.getLogger(__name__)
@ -61,7 +69,7 @@ class AppObjects:
# Initialize variables # Initialize variables
self.currentUser = currentUser # Store User object directly self.currentUser = currentUser # Store User object directly
self.userId = currentUser.id if currentUser else None 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 # Initialize database
self._initializeDatabase() self._initializeDatabase()
@ -73,25 +81,40 @@ class AppObjects:
if currentUser: if currentUser:
self.setUserContext(currentUser) self.setUserContext(currentUser)
def setUserContext(self, currentUser: User): def setUserContext(self, currentUser: User, mandateId: Optional[str] = None):
"""Sets the user context for the interface.""" """
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: if not currentUser:
logger.info("Initializing interface without user context") logger.info("Initializing interface without user context")
return return
self.currentUser = currentUser # Store User object directly self.currentUser = currentUser # Store User object directly
self.userId = currentUser.id self.userId = currentUser.id
self.mandateId = currentUser.mandateId
if not self.userId or not self.mandateId: # mandateId comes from parameter only
raise ValueError("Invalid user context: id and mandateId are required") self.mandateId = mandateId
# Validate: userId is always required
if not self.userId:
raise ValueError("Invalid user context: id is required")
# Note: mandateId is ALWAYS optional here - it comes from Request-Context, not from User.
# Users are NOT assigned to mandates by design - they get mandate context from the request.
# sysAdmin users can additionally perform cross-mandate operations.
# Add language settings # Add language settings
self.userLanguage = currentUser.language # Default user language self.userLanguage = currentUser.language # Default user language
# Initialize RBAC interface # 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 # Pass self.db as dbApp since this interface uses DbApp database
self.rbac = RbacClass(self.db, dbApp=self.db) self.rbac = RbacClass(self.db, dbApp=self.db)
@ -110,11 +133,11 @@ class AppObjects:
"""Initializes the database connection directly.""" """Initializes the database connection directly."""
try: try:
# Get configuration values with defaults # Get configuration values with defaults
dbHost = APP_CONFIG.get("DB_APP_HOST", "_no_config_default_data") dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data")
dbDatabase = APP_CONFIG.get("DB_APP_DATABASE", "app") dbDatabase = "poweron_app"
dbUser = APP_CONFIG.get("DB_APP_USER") dbUser = APP_CONFIG.get("DB_USER")
dbPassword = APP_CONFIG.get("DB_APP_PASSWORD_SECRET") dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
dbPort = int(APP_CONFIG.get("DB_APP_PORT", 5432)) dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
# Create database connector directly # Create database connector directly
self.db = DatabaseConnector( self.db = DatabaseConnector(
@ -204,7 +227,8 @@ class AppObjects:
permissions = self.rbac.getUserPermissions( permissions = self.rbac.getUserPermissions(
self.currentUser, self.currentUser,
AccessRuleContext.DATA, AccessRuleContext.DATA,
tableName tableName,
mandateId=self.mandateId
) )
if operation == "create": if operation == "create":
@ -574,27 +598,52 @@ class AppObjects:
logger.error(f"Error getting user by ID: {str(e)}") logger.error(f"Error getting user by ID: {str(e)}")
return None return None
def _getUserForAuthentication(self, username: str) -> Optional[Dict[str, Any]]:
"""
Get user record by username for authentication purposes.
SECURITY NOTE: This method bypasses RBAC intentionally because:
1. Users are NOT mandate-bound (Multi-Tenant Design)
2. Authentication must work regardless of mandate context
3. RBAC filtering for User table requires mandate context which doesn't exist at login time
This method should ONLY be used for authentication flows.
For all other user queries, use getUserByUsername() which applies RBAC.
Returns:
Full UserInDB record as dict, or None if not found
"""
try:
users = self.db.getRecordset(UserInDB, recordFilter={"username": username})
if not users:
return None
return users[0]
except Exception as e:
logger.error(f"Error getting user for authentication: {str(e)}")
return None
def authenticateLocalUser(self, username: str, password: str) -> Optional[User]: def authenticateLocalUser(self, username: str, password: str) -> Optional[User]:
"""Authenticates a user by username and password using local authentication.""" """
# Clear the users table from cache and reload it Authenticates a user by username and password using local authentication.
# Get user by username SECURITY NOTE: Uses _getUserForAuthentication() which bypasses RBAC.
user = self.getUserByUsername(username) This is intentional because users are mandate-independent.
"""
# Get full user record directly (bypasses RBAC - see _getUserForAuthentication docstring)
userRecord = self._getUserForAuthentication(username)
if not user: if not userRecord:
raise ValueError("User not found") raise ValueError("User not found")
# Check if the user is enabled # Check if the user is enabled
if not user.enabled: if not userRecord.get("enabled", True):
raise ValueError("User is disabled") raise ValueError("User is disabled")
# Verify that the user has local authentication enabled # Verify that the user has local authentication enabled
if user.authenticationAuthority != AuthAuthority.LOCAL: authAuthority = userRecord.get("authenticationAuthority", AuthAuthority.LOCAL)
if authAuthority != AuthAuthority.LOCAL and authAuthority != AuthAuthority.LOCAL.value:
raise ValueError("User does not have local authentication enabled") raise ValueError("User does not have local authentication enabled")
# Get the full user record with password hash for verification
userRecord = self.db.getRecordset(UserInDB, recordFilter={"id": user.id})[0]
# Check if user has a reset token set (password reset required) # Check if user has a reset token set (password reset required)
if userRecord.get("resetToken"): if userRecord.get("resetToken"):
raise ValueError("Passwort-Zurücksetzung erforderlich. Bitte prüfen Sie Ihre E-Mail.") raise ValueError("Passwort-Zurücksetzung erforderlich. Bitte prüfen Sie Ihre E-Mail.")
@ -605,7 +654,12 @@ class AppObjects:
if not self._verifyPassword(password, userRecord["hashedPassword"]): if not self._verifyPassword(password, userRecord["hashedPassword"]):
raise ValueError("Invalid password") raise ValueError("Invalid password")
return user # Return clean User object (without password hash and internal fields)
cleanedUser = {k: v for k, v in userRecord.items() if not k.startswith("_") and k != "hashedPassword" and k != "resetToken" and k != "resetTokenExpires"}
# Ensure roleLabels is always a list
if cleanedUser.get("roleLabels") is None:
cleanedUser["roleLabels"] = []
return User(**cleanedUser)
def createUser( def createUser(
self, self,
@ -615,13 +669,17 @@ class AppObjects:
fullName: str = None, fullName: str = None,
language: str = "en", language: str = "en",
enabled: bool = True, enabled: bool = True,
roleLabels: List[str] = None,
authenticationAuthority: AuthAuthority = AuthAuthority.LOCAL, authenticationAuthority: AuthAuthority = AuthAuthority.LOCAL,
externalId: str = None, externalId: str = None,
externalUsername: str = None, externalUsername: str = None,
externalEmail: str = None, externalEmail: str = None,
isSysAdmin: bool = False,
) -> User: ) -> 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: try:
# Ensure username is a string # Ensure username is a string
username = str(username).strip() username = str(username).strip()
@ -638,28 +696,17 @@ class AppObjects:
if not password.strip(): if not password.strip():
raise ValueError("Password cannot be empty") 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 # Create user data using UserInDB model
# Note: mandateId and roleLabels are REMOVED - use UserMandate + UserMandateRole
userData = UserInDB( userData = UserInDB(
username=username, username=username,
email=email, email=email,
fullName=fullName, fullName=fullName,
language=language, language=language,
mandateId=mandateId,
enabled=enabled, enabled=enabled,
roleLabels=roleLabels, isSysAdmin=isSysAdmin,
authenticationAuthority=authenticationAuthority, authenticationAuthority=authenticationAuthority,
hashedPassword=self._getPasswordHash(password) if password else None, hashedPassword=self._getPasswordHash(password) if password else None,
connections=[],
) )
# Create user record # Create user record
@ -695,8 +742,16 @@ class AppObjects:
logger.error(f"Unexpected error creating user: {str(e)}") logger.error(f"Unexpected error creating user: {str(e)}")
raise ValueError(f"Failed to create user: {str(e)}") raise ValueError(f"Failed to create user: {str(e)}")
def updateUser(self, userId: str, updateData: Union[Dict[str, Any], User]) -> User: def updateUser(self, userId: str, updateData: Union[Dict[str, Any], User], allowSysAdminChange: bool = False) -> User:
"""Update a user's information""" """Update a user's information.
Args:
userId: ID of the user to update
updateData: User data to update (dict or User model)
allowSysAdminChange: If True, allows changing isSysAdmin field.
Only set to True when called by a SysAdmin explicitly
changing another user's admin status.
"""
try: try:
# Get user # Get user
user = self.getUser(userId) user = self.getUser(userId)
@ -712,25 +767,19 @@ class AppObjects:
# Remove id field from updateDict if present - we'll use userId from parameter # Remove id field from updateDict if present - we'll use userId from parameter
updateDict.pop("id", None) updateDict.pop("id", None)
# Ensure mandateId is set - if missing or None, use default mandate # SECURITY: Protect sensitive fields from being overwritten by profile updates.
if "mandateId" not in updateDict or not updateDict.get("mandateId"): # These fields should only be changed explicitly by admins, not through
if not user.mandateId: # profile forms where they might be sent as default values (e.g., isSysAdmin=False).
# User has no mandateId, set to default protectedFields = ["isSysAdmin"]
defaultMandateId = self._getDefaultMandateId() if not allowSysAdminChange:
updateDict["mandateId"] = defaultMandateId for field in protectedFields:
logger.warning(f"Setting default mandate ID {defaultMandateId} for user {userId}") updateDict.pop(field, None)
else:
# Keep existing mandateId if update doesn't provide one
updateDict["mandateId"] = user.mandateId
# Update user data using model # Update user data using model
updatedData = user.model_dump() updatedData = user.model_dump()
updatedData.update(updateDict) updatedData.update(updateDict)
# Ensure ID matches userId parameter # Ensure ID matches userId parameter
updatedData["id"] = userId updatedData["id"] = userId
# Ensure mandateId is set in final data
if not updatedData.get("mandateId"):
updatedData["mandateId"] = self._getDefaultMandateId()
updatedUser = User(**updatedData) updatedUser = User(**updatedData)
# Update user record # Update user record
@ -1184,10 +1233,10 @@ class AppObjects:
The created UserConnection object The created UserConnection object
""" """
try: try:
# Get the user # Note: User verification is skipped here because:
user = self.getUser(userId) # 1. The caller (route) already has an authenticated currentUser
if not user: # 2. Users should always be able to create connections for themselves
raise ValueError(f"User not found: {userId}") # 3. getUser() uses RBAC filtering which may fail for users without UserInDB view permissions
# Create new connection with all required fields # Create new connection with all required fields
connection = UserConnection( connection = UserConnection(
@ -1305,13 +1354,13 @@ class AppObjects:
return Mandate(**filteredMandates[0]) return Mandate(**filteredMandates[0])
def createMandate(self, name: str, language: str = "en") -> Mandate: def createMandate(self, name: str, description: str = None, enabled: bool = True) -> Mandate:
"""Creates a new mandate if user has permission.""" """Creates a new mandate if user has permission."""
if not self.checkRbacPermission(Mandate, "create"): if not self.checkRbacPermission(Mandate, "create"):
raise PermissionError("No permission to create mandates") raise PermissionError("No permission to create mandates")
# Create mandate data using model # Create mandate data using model
mandateData = Mandate(name=name, language=language) mandateData = Mandate(name=name, description=description, enabled=enabled)
# Create mandate record # Create mandate record
createdRecord = self.db.recordCreate(Mandate, mandateData) createdRecord = self.db.recordCreate(Mandate, mandateData)
@ -1382,6 +1431,325 @@ class AppObjects:
logger.error(f"Error deleting mandate: {str(e)}") logger.error(f"Error deleting mandate: {str(e)}")
raise ValueError(f"Failed to delete 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 # Token methods
def saveAccessToken(self, token: Token, replace_existing: bool = True) -> None: def saveAccessToken(self, token: Token, replace_existing: bool = True) -> None:
@ -1713,115 +2081,6 @@ class AppObjects:
logger.error(f"Error during logout: {str(e)}") logger.error(f"Error during logout: {str(e)}")
raise raise
# Neutralization methods
def getNeutralizationConfig(self) -> Optional[DataNeutraliserConfig]:
"""Get the data neutralization configuration for the current user's mandate"""
try:
# Use RBAC filtering
filtered_configs = getRecordsetWithRBAC(self.db,
DataNeutraliserConfig,
self.currentUser,
recordFilter={"mandateId": self.mandateId}
)
if not filtered_configs:
return None
# Filter out database-specific fields
configDict = filtered_configs[0]
cleanedConfig = {k: v for k, v in configDict.items() if not k.startswith("_")}
return DataNeutraliserConfig(**cleanedConfig)
except Exception as e:
logger.error(f"Error getting neutralization config: {str(e)}")
return None
def createOrUpdateNeutralizationConfig(
self, config_data: Dict[str, Any]
) -> DataNeutraliserConfig:
"""Create or update the data neutralization configuration"""
try:
# Check if config already exists
existing_config = self.getNeutralizationConfig()
if existing_config:
# Update existing config
update_data = existing_config.model_dump()
update_data.update(config_data)
update_data["updatedAt"] = getUtcTimestamp()
updated_config = DataNeutraliserConfig(**update_data)
self.db.recordModify(
DataNeutraliserConfig, existing_config.id, updated_config
)
return updated_config
else:
# Create new config
config_data["mandateId"] = self.mandateId
config_data["userId"] = self.userId
new_config = DataNeutraliserConfig(**config_data)
created_record = self.db.recordCreate(DataNeutraliserConfig, new_config)
return DataNeutraliserConfig(**created_record)
except Exception as e:
logger.error(f"Error creating/updating neutralization config: {str(e)}")
raise ValueError(f"Failed to create/update neutralization config: {str(e)}")
def getNeutralizationAttributes(
self, file_id: Optional[str] = None
) -> List[DataNeutralizerAttributes]:
"""Get neutralization attributes, optionally filtered by file ID"""
try:
filter_dict = {"mandateId": self.mandateId}
if file_id:
filter_dict["fileId"] = file_id
# Use RBAC filtering
filtered_attributes = getRecordsetWithRBAC(self.db,
DataNeutralizerAttributes,
self.currentUser,
recordFilter=filter_dict
)
# Filter out database-specific fields
cleaned_attributes = []
for attr in filtered_attributes:
cleanedAttr = {k: v for k, v in attr.items() if not k.startswith("_")}
cleaned_attributes.append(cleanedAttr)
return [
DataNeutralizerAttributes(**attr)
for attr in cleaned_attributes
]
except Exception as e:
logger.error(f"Error getting neutralization attributes: {str(e)}")
return []
def deleteNeutralizationAttributes(self, file_id: str) -> bool:
"""Delete all neutralization attributes for a specific file"""
try:
attributes = self.db.getRecordset(
DataNeutralizerAttributes,
recordFilter={"mandateId": self.mandateId, "fileId": file_id},
)
for attribute in attributes:
self.db.recordDelete(DataNeutralizerAttributes, attribute["id"])
logger.info(
f"Deleted {len(attributes)} neutralization attributes for file {file_id}"
)
return True
except Exception as e:
logger.error(f"Error deleting neutralization attributes: {str(e)}")
return False
# RBAC CRUD Methods # RBAC CRUD Methods
def createAccessRule(self, accessRule: AccessRule) -> AccessRule: def createAccessRule(self, accessRule: AccessRule) -> AccessRule:
@ -1902,6 +2161,7 @@ class AppObjects:
def getAccessRules( def getAccessRules(
self, self,
roleLabel: Optional[str] = None, roleLabel: Optional[str] = None,
roleId: Optional[str] = None,
context: Optional[AccessRuleContext] = None, context: Optional[AccessRuleContext] = None,
item: Optional[str] = None, item: Optional[str] = None,
pagination: Optional[PaginationParams] = None pagination: Optional[PaginationParams] = None
@ -1910,7 +2170,8 @@ class AppObjects:
Get access rules with optional filters and pagination. Get access rules with optional filters and pagination.
Args: Args:
roleLabel: Optional role label filter roleLabel: Optional role label filter (deprecated, use roleId)
roleId: Optional role ID filter
context: Optional context filter context: Optional context filter
item: Optional item filter item: Optional item filter
pagination: Optional pagination parameters. If None, returns all items. pagination: Optional pagination parameters. If None, returns all items.
@ -1921,7 +2182,9 @@ class AppObjects:
""" """
try: try:
recordFilter = {} recordFilter = {}
if roleLabel: if roleId:
recordFilter["roleId"] = roleId
elif roleLabel:
recordFilter["roleLabel"] = roleLabel recordFilter["roleLabel"] = roleLabel
if context: if context:
recordFilter["context"] = context.value recordFilter["context"] = context.value
@ -2134,6 +2397,29 @@ class AppObjects:
else: else:
return PaginatedResult(items=[], totalItems=0, totalPages=0) 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: def updateRole(self, roleId: str, role: Role) -> Role:
""" """
Update an existing role. Update an existing role.
@ -2185,14 +2471,13 @@ class AppObjects:
if role.isSystemRole: if role.isSystemRole:
raise ValueError(f"Cannot delete system role '{role.roleLabel}'") raise ValueError(f"Cannot delete system role '{role.roleLabel}'")
# Check if role is assigned to any users # Check if role is assigned to any users via UserMandateRole
allUsers = self.getUsersByMandate(None) # Get all users across all mandates roleAssignments = self.db.getRecordset(UserMandateRole, recordFilter={"roleId": roleId})
for user in allUsers: if roleAssignments:
if role.roleLabel in (user.roleLabels or []):
raise ValueError(f"Cannot delete role '{role.roleLabel}' - it is assigned to users") raise ValueError(f"Cannot delete role '{role.roleLabel}' - it is assigned to users")
# Check if role is used in any access rules # Check if role is used in any access rules
accessRules = self.getAccessRules(roleLabel=role.roleLabel) accessRules = self.getAccessRules(roleId=roleId)
if accessRules: if accessRules:
raise ValueError(f"Cannot delete role '{role.roleLabel}' - it is used in access rules") raise ValueError(f"Cannot delete role '{role.roleLabel}' - it is used in access rules")
@ -2207,20 +2492,34 @@ class AppObjects:
# Public Methods # Public Methods
def getInterface(currentUser: User) -> AppObjects: def getInterface(currentUser: User, mandateId: Optional[str] = None) -> AppObjects:
""" """
Returns a AppObjects instance for the current user. Returns a AppObjects instance for the current user.
Handles initialization of database and records. 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: if not currentUser:
raise ValueError("Invalid user context: user is required") raise ValueError("Invalid user context: user is required")
# Create context key effectiveMandateId = mandateId
contextKey = f"{currentUser.mandateId}_{currentUser.id}"
# Create context key (user + mandate combination)
contextKey = f"{effectiveMandateId}_{currentUser.id}"
# Create new instance if not exists # Create new instance if not exists
if contextKey not in _gatewayInterfaces: if contextKey not in _gatewayInterfaces:
_gatewayInterfaces[contextKey] = AppObjects(currentUser) instance = AppObjects(currentUser)
instance.setUserContext(currentUser, mandateId=effectiveMandateId)
_gatewayInterfaces[contextKey] = instance
return _gatewayInterfaces[contextKey] return _gatewayInterfaces[contextKey]

File diff suppressed because it is too large Load diff

View file

@ -76,14 +76,23 @@ class ComponentObjects:
# Initialize standard records if needed # Initialize standard records if needed
self._initRecords() self._initRecords()
def setUserContext(self, currentUser: User): def setUserContext(self, currentUser: User, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None):
"""Sets the user context for the interface.""" """Sets the user context for the interface.
Args:
currentUser: The authenticated user
mandateId: The mandate ID from RequestContext (X-Mandate-Id header)
featureInstanceId: The feature instance ID from RequestContext (X-Feature-Instance-Id header)
"""
if not currentUser: if not currentUser:
logger.info("Initializing interface without user context") logger.info("Initializing interface without user context")
return return
self.currentUser = currentUser # Store User object directly self.currentUser = currentUser # Store User object directly
self.userId = currentUser.id self.userId = currentUser.id
# Use mandateId from parameter (Request-Context), not from user object
self.mandateId = mandateId
self.featureInstanceId = featureInstanceId
if not self.userId: if not self.userId:
raise ValueError("Invalid user context: id is required") raise ValueError("Invalid user context: id is required")
@ -116,11 +125,11 @@ class ComponentObjects:
"""Initializes the database connection directly.""" """Initializes the database connection directly."""
try: try:
# Get configuration values with defaults # Get configuration values with defaults
dbHost = APP_CONFIG.get("DB_MANAGEMENT_HOST", "_no_config_default_data") dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data")
dbDatabase = APP_CONFIG.get("DB_MANAGEMENT_DATABASE", "management") dbDatabase = "poweron_management"
dbUser = APP_CONFIG.get("DB_MANAGEMENT_USER") dbUser = APP_CONFIG.get("DB_USER")
dbPassword = APP_CONFIG.get("DB_MANAGEMENT_PASSWORD_SECRET") dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
dbPort = int(APP_CONFIG.get("DB_MANAGEMENT_PORT")) dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
# Create database connector directly # Create database connector directly
self.db = DatabaseConnector( self.db = DatabaseConnector(
@ -206,7 +215,7 @@ class ComponentObjects:
# Get the root interface to access the initial mandate ID # Get the root interface to access the initial mandate ID
from modules.security.rootAccess import getRootUser from modules.security.rootAccess import getRootUser
from modules.interfaces.interfaceDbAppObjects import getInterface from modules.interfaces.interfaceDbApp import getInterface
rootUser = getRootUser() rootUser = getRootUser()
rootInterface = getInterface(rootUser) rootInterface = getInterface(rootUser)
@ -310,7 +319,9 @@ class ComponentObjects:
permissions = self.rbac.getUserPermissions( permissions = self.rbac.getUserPermissions(
self.currentUser, self.currentUser,
AccessRuleContext.DATA, AccessRuleContext.DATA,
tableName tableName,
mandateId=self.mandateId,
featureInstanceId=self.featureInstanceId
) )
if operation == "create": if operation == "create":
@ -979,12 +990,15 @@ class ComponentObjects:
fileSize = len(content) fileSize = len(content)
fileHash = hashlib.sha256(content).hexdigest() fileHash = hashlib.sha256(content).hexdigest()
# Ensure mandateId is valid # Use mandateId and featureInstanceId from context for proper data isolation
mandateId = self.currentUser.mandateId or "default" # Convert None to empty string to satisfy Pydantic validation
mandateId = self.mandateId or ""
featureInstanceId = self.featureInstanceId or ""
# Create FileItem instance # Create FileItem instance
fileItem = FileItem( fileItem = FileItem(
mandateId=mandateId, mandateId=mandateId,
featureInstanceId=featureInstanceId,
fileName=uniqueName, fileName=uniqueName,
mimeType=mimeType, mimeType=mimeType,
fileSize=fileSize, fileSize=fileSize,
@ -1320,9 +1334,11 @@ class ComponentObjects:
if "userId" not in settingsData: if "userId" not in settingsData:
settingsData["userId"] = self.userId settingsData["userId"] = self.userId
# Ensure mandateId is set # Ensure mandateId and featureInstanceId are set from context
if "mandateId" not in settingsData: if "mandateId" not in settingsData:
settingsData["mandateId"] = self.currentUser.mandateId if self.currentUser else "default" settingsData["mandateId"] = self.mandateId
if "featureInstanceId" not in settingsData:
settingsData["featureInstanceId"] = self.featureInstanceId
# Check if settings already exist for this user # Check if settings already exist for this user
existingSettings = self.getVoiceSettings(settingsData["userId"]) existingSettings = self.getVoiceSettings(settingsData["userId"])
@ -1406,7 +1422,7 @@ class ComponentObjects:
# Create default settings # Create default settings
defaultSettings = { defaultSettings = {
"userId": targetUserId, "userId": targetUserId,
"mandateId": self.currentUser.mandateId if self.currentUser else "default", "mandateId": self.mandateId,
"sttLanguage": "de-DE", "sttLanguage": "de-DE",
"ttsLanguage": "de-DE", "ttsLanguage": "de-DE",
"ttsVoice": "de-DE-KatjaNeural", "ttsVoice": "de-DE-KatjaNeural",
@ -1494,9 +1510,11 @@ class ComponentObjects:
if not all(c.isalpha() or c == "_" for c in subscriptionId): if not all(c.isalpha() or c == "_" for c in subscriptionId):
raise ValueError("subscriptionId must contain only letters and underscores") raise ValueError("subscriptionId must contain only letters and underscores")
# Set mandateId if not provided # Set mandateId and featureInstanceId from context for proper data isolation
if "mandateId" not in subscriptionData: if "mandateId" not in subscriptionData:
subscriptionData["mandateId"] = self.currentUser.mandateId if self.currentUser else "default" subscriptionData["mandateId"] = self.mandateId
if "featureInstanceId" not in subscriptionData:
subscriptionData["featureInstanceId"] = self.featureInstanceId
createdRecord = self.db.recordCreate(MessagingSubscription, subscriptionData) createdRecord = self.db.recordCreate(MessagingSubscription, subscriptionData)
if not createdRecord or not createdRecord.get("id"): if not createdRecord or not createdRecord.get("id"):
@ -1598,6 +1616,12 @@ class ComponentObjects:
if "userId" not in registrationData: if "userId" not in registrationData:
registrationData["userId"] = self.userId registrationData["userId"] = self.userId
# Set mandateId and featureInstanceId from context for proper data isolation
if "mandateId" not in registrationData:
registrationData["mandateId"] = self.mandateId
if "featureInstanceId" not in registrationData:
registrationData["featureInstanceId"] = self.featureInstanceId
createdRecord = self.db.recordCreate(MessagingSubscriptionRegistration, registrationData) createdRecord = self.db.recordCreate(MessagingSubscriptionRegistration, registrationData)
if not createdRecord or not createdRecord.get("id"): if not createdRecord or not createdRecord.get("id"):
raise ValueError("Failed to create registration record") raise ValueError("Failed to create registration record")
@ -1672,6 +1696,13 @@ class ComponentObjects:
def createDelivery(self, delivery: MessagingDelivery) -> Dict[str, Any]: def createDelivery(self, delivery: MessagingDelivery) -> Dict[str, Any]:
"""Creates a new delivery record.""" """Creates a new delivery record."""
deliveryData = delivery.model_dump() if isinstance(delivery, MessagingDelivery) else delivery deliveryData = delivery.model_dump() if isinstance(delivery, MessagingDelivery) else delivery
# Set mandateId and featureInstanceId from context for proper data isolation
if "mandateId" not in deliveryData or not deliveryData["mandateId"]:
deliveryData["mandateId"] = self.mandateId
if "featureInstanceId" not in deliveryData or not deliveryData["featureInstanceId"]:
deliveryData["featureInstanceId"] = self.featureInstanceId
createdRecord = self.db.recordCreate(MessagingDelivery, deliveryData) createdRecord = self.db.recordCreate(MessagingDelivery, deliveryData)
if not createdRecord or not createdRecord.get("id"): if not createdRecord or not createdRecord.get("id"):
raise ValueError("Failed to create delivery record") raise ValueError("Failed to create delivery record")
@ -1741,12 +1772,20 @@ class ComponentObjects:
return MessagingDelivery(**filteredDeliveries[0]) if filteredDeliveries else None 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, featureInstanceId: Optional[str] = None) -> 'ComponentObjects':
""" """
Returns a ComponentObjects instance. Returns a ComponentObjects instance.
If currentUser is provided, initializes with user context. If currentUser is provided, initializes with user context.
Otherwise, returns an instance with only database access. Otherwise, returns an instance with only database access.
Args:
currentUser: The authenticated user
mandateId: The mandate ID from RequestContext (X-Mandate-Id header). Required.
featureInstanceId: The feature instance ID from RequestContext (X-Feature-Instance-Id header).
""" """
effectiveMandateId = str(mandateId) if mandateId else None
effectiveFeatureInstanceId = str(featureInstanceId) if featureInstanceId else None
# Create new instance if not exists # Create new instance if not exists
if "default" not in _instancesManagement: if "default" not in _instancesManagement:
_instancesManagement["default"] = ComponentObjects() _instancesManagement["default"] = ComponentObjects()
@ -1754,7 +1793,7 @@ def getInterface(currentUser: Optional[User] = None) -> 'ComponentObjects':
interface = _instancesManagement["default"] interface = _instancesManagement["default"]
if currentUser: if currentUser:
interface.setUserContext(currentUser) interface.setUserContext(currentUser, mandateId=effectiveMandateId, featureInstanceId=effectiveFeatureInstanceId)
else: else:
logger.info("Returning interface without user context") logger.info("Returning interface without user context")

View file

@ -0,0 +1,515 @@
# 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,
enabled: bool = True,
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")
enabled: Whether the instance is enabled
copyTemplateRoles: Whether to copy template roles
Returns:
Created FeatureInstance object
"""
try:
# Create instance
instance = FeatureInstance(
featureCode=featureCode,
mandateId=mandateId,
label=label,
enabled=enabled
)
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 updateFeatureInstance(self, instanceId: str, updateData: Dict[str, Any]) -> Optional[FeatureInstance]:
"""
Update a feature instance.
Only label and enabled fields can be updated.
featureCode and mandateId are immutable.
Args:
instanceId: FeatureInstance ID
updateData: Dictionary with fields to update (label, enabled)
Returns:
Updated FeatureInstance object or None if not found
"""
try:
instance = self.getFeatureInstance(instanceId)
if not instance:
return None
# Only allow updating specific fields
allowedFields = {"label", "enabled"}
filteredData = {k: v for k, v in updateData.items() if k in allowedFields}
if not filteredData:
return instance
updated = self.db.recordModify(FeatureInstance, instanceId, filteredData)
if updated:
cleanedRecord = {k: v for k, v in updated.items() if not k.startswith("_")}
return FeatureInstance(**cleanedRecord)
return None
except Exception as e:
logger.error(f"Error updating feature instance {instanceId}: {e}")
raise ValueError(f"Failed to update feature instance: {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)

View file

@ -3,6 +3,22 @@
""" """
RBAC helper functions for interfaces. RBAC helper functions for interfaces.
Provides RBAC filtering for database queries without connectors importing security. 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
Data Namespace Structure:
- data.uam.{Table} User Access Management (mandantenübergreifend)
- data.chat.{Table} Chat/AI-Daten (benutzer-eigen, kein Mandantenkontext)
- data.files.{Table} Dateien (benutzer-eigen)
- data.automation.{Table} Automation (benutzer-eigen)
- data.feature.{code}.{Table} Mandanten-/Feature-spezifische Daten (dynamisch)
GROUP-Berechtigung:
- data.uam.*: GROUP filtert nach Mandant (via UserMandate)
- data.chat.*, data.files.*, data.automation.*: GROUP = MY (benutzer-eigen, kein Mandantenkontext)
- data.feature.*: GROUP filtert nach mandateId/featureInstanceId
""" """
import logging import logging
@ -17,6 +33,72 @@ from modules.security.rootAccess import getRootDbAppConnector
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# =============================================================================
# Namespace-Mapping für statische Tabellen
# =============================================================================
# Definiert, welcher Namespace für jede Tabelle verwendet wird.
# Tabellen ohne Eintrag fallen auf "system" zurück (Fallback für Rückwärtskompatibilität).
# =============================================================================
TABLE_NAMESPACE = {
# UAM (User Access Management) - mandantenübergreifend
"UserInDB": "uam",
"UserConnection": "uam",
"AuthEvent": "uam",
"Mandate": "uam",
"UserMandate": "uam",
"UserMandateRole": "uam",
"Invitation": "uam",
"Role": "uam",
"AccessRule": "uam",
"FeatureInstance": "uam",
"FeatureAccess": "uam",
"FeatureAccessRole": "uam",
# Chat - benutzer-eigen, kein Mandantenkontext
"ChatWorkflow": "chat",
"ChatMessage": "chat",
"ChatLog": "chat",
"ChatStat": "chat",
"ChatDocument": "chat",
"Prompt": "chat",
# Files - benutzer-eigen
"FileItem": "files",
"FileData": "files",
# Automation - benutzer-eigen
"AutomationDefinition": "automation",
}
# Namespaces ohne Mandantenkontext - GROUP wird auf MY gemappt
USER_OWNED_NAMESPACES = {"chat", "files", "automation"}
def buildDataObjectKey(tableName: str, featureCode: Optional[str] = None) -> str:
"""
Build the standardized objectKey for a DATA context item.
Format:
- UAM tables: data.uam.{TableName}
- Chat tables: data.chat.{TableName}
- File tables: data.files.{TableName}
- Automation tables: data.automation.{TableName}
- Feature tables: data.feature.{featureCode}.{TableName}
Args:
tableName: The database table name (e.g., "UserInDB", "ChatWorkflow")
featureCode: Optional feature code (e.g., "trustee", "realestate")
If provided, uses data.feature.{featureCode}.{tableName}
Returns:
Full objectKey string (e.g., "data.uam.UserInDB", "data.chat.ChatWorkflow",
or "data.feature.trustee.TrusteePosition")
"""
if featureCode:
return f"data.feature.{featureCode}.{tableName}"
namespace = TABLE_NAMESPACE.get(tableName, "system") # Fallback für unbekannte Tabellen
return f"data.{namespace}.{tableName}"
def getRecordsetWithRBAC( def getRecordsetWithRBAC(
connector, # DatabaseConnector instance connector, # DatabaseConnector instance
modelClass: Type[BaseModel], modelClass: Type[BaseModel],
@ -24,41 +106,72 @@ def getRecordsetWithRBAC(
recordFilter: Dict[str, Any] = None, recordFilter: Dict[str, Any] = None,
orderBy: str = None, orderBy: str = None,
limit: int = None, limit: int = None,
mandateId: Optional[str] = None,
featureInstanceId: Optional[str] = None,
enrichPermissions: bool = False,
featureCode: Optional[str] = None,
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
""" """
Get records with RBAC filtering applied at database level. Get records with RBAC filtering applied at database level.
This function wraps connector.getRecordset() with RBAC logic. This function wraps connector.getRecordset() with RBAC logic.
Multi-Tenant Design:
- mandateId wird explizit übergeben (aus Request-Context / X-Mandate-Id Header)
Args: Args:
connector: DatabaseConnector instance connector: DatabaseConnector instance
modelClass: Pydantic model class for the table modelClass: Pydantic model class for the table
currentUser: User object with roleLabels currentUser: User object
recordFilter: Additional record filters recordFilter: Additional record filters
orderBy: Field to order by (defaults to "id") orderBy: Field to order by (defaults to "id")
limit: Maximum number of records to return limit: Maximum number of records to return
mandateId: Explicit mandate context (from request header). Required for GROUP access.
featureInstanceId: Explicit feature instance context
enrichPermissions: If True, adds _permissions field to each record with row-level
permissions { canUpdate, canDelete } based on RBAC rules and _createdBy
featureCode: Optional feature code for feature-specific tables (e.g., "trustee").
If None, table is treated as a system table.
Returns: Returns:
List of filtered records List of filtered records (with _permissions if enrichPermissions=True)
""" """
table = modelClass.__name__ table = modelClass.__name__
# Build full objectKey for RBAC lookup
objectKey = buildDataObjectKey(table, featureCode)
effectiveMandateId = mandateId
try: try:
if not connector._ensureTableExists(modelClass): if not connector._ensureTableExists(modelClass):
return [] return []
# Get RBAC permissions for this table # SysAdmin bypass: SysAdmin users have full access to all tables
isSysAdmin = getattr(currentUser, 'isSysAdmin', False)
if isSysAdmin:
# Direct access without RBAC filtering
# Note: getRecordset doesn't support orderBy/limit - these are only used in RBAC path
records = connector.getRecordset(modelClass, recordFilter=recordFilter)
if enrichPermissions:
# SysAdmin has full permissions on all records
for record in records:
record["_permissions"] = {"canUpdate": True, "canDelete": True}
return records
# Get RBAC permissions for this table using full objectKey
# AccessRule table is always in DbApp database # AccessRule table is always in DbApp database
dbApp = getRootDbAppConnector() dbApp = getRootDbAppConnector()
rbacInstance = RbacClass(connector, dbApp=dbApp) rbacInstance = RbacClass(connector, dbApp=dbApp)
permissions = rbacInstance.getUserPermissions( permissions = rbacInstance.getUserPermissions(
currentUser, currentUser,
AccessRuleContext.DATA, AccessRuleContext.DATA,
table objectKey, # Use full objectKey (e.g., "data.uam.UserInDB", "data.chat.ChatWorkflow")
mandateId=effectiveMandateId,
featureInstanceId=featureInstanceId
) )
# Check view permission first # Check view permission first
if not permissions.view: if not permissions.view:
logger.debug(f"User {currentUser.id} has no view permission for table {table}") logger.debug(f"User {currentUser.id} has no view permission for {objectKey}")
return [] return []
# Build WHERE clause with RBAC filtering # Build WHERE clause with RBAC filtering
@ -66,7 +179,13 @@ def getRecordsetWithRBAC(
whereValues = [] whereValues = []
# Add RBAC WHERE clause based on read permission # Add RBAC WHERE clause based on read permission
rbacWhereClause = buildRbacWhereClause(permissions, currentUser, table, connector) rbacWhereClause = buildRbacWhereClause(
permissions,
currentUser,
table,
connector,
mandateId=effectiveMandateId
)
if rbacWhereClause: if rbacWhereClause:
whereConditions.append(rbacWhereClause["condition"]) whereConditions.append(rbacWhereClause["condition"])
whereValues.extend(rbacWhereClause["values"]) whereValues.extend(rbacWhereClause["values"])
@ -145,6 +264,12 @@ def getRecordsetWithRBAC(
f"Could not parse JSONB field {fieldName}, keeping as string: {record[fieldName]}" f"Could not parse JSONB field {fieldName}, keeping as string: {record[fieldName]}"
) )
# Enrich records with row-level permissions if requested
if enrichPermissions:
records = _enrichRecordsWithPermissions(
records, permissions, currentUser
)
return records return records
except Exception as e: except Exception as e:
logger.error(f"Error loading records with RBAC from table {table}: {e}") logger.error(f"Error loading records with RBAC from table {table}: {e}")
@ -155,17 +280,21 @@ def buildRbacWhereClause(
permissions: UserPermissions, permissions: UserPermissions,
currentUser: User, currentUser: User,
table: str, table: str,
connector # DatabaseConnector instance for connection access connector, # DatabaseConnector instance for connection access
mandateId: Optional[str] = None
) -> Optional[Dict[str, Any]]: ) -> Optional[Dict[str, Any]]:
""" """
Build RBAC WHERE clause based on permissions and access level. 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: Args:
permissions: UserPermissions object permissions: UserPermissions object
currentUser: User object currentUser: User object
table: Table name table: Table name
connector: DatabaseConnector instance (needed for GROUP queries) connector: DatabaseConnector instance (needed for GROUP queries)
mandateId: Explicit mandate context (from request header). Required for GROUP access.
Returns: Returns:
Dictionary with "condition" and "values" keys, or None if no filtering needed Dictionary with "condition" and "values" keys, or None if no filtering needed
@ -199,29 +328,70 @@ def buildRbacWhereClause(
"values": [currentUser.id] "values": [currentUser.id]
} }
# Group records - filter by mandateId # Group records - filter by mandateId or ownership based on namespace
if readLevel == AccessLevel.GROUP: if readLevel == AccessLevel.GROUP:
if not currentUser.mandateId: # Determine namespace for this table
namespace = TABLE_NAMESPACE.get(table, "system")
# For user-owned namespaces (chat, files, automation):
# GROUP has no meaning - these tables have no mandate context
# Simply ignore GROUP (no filtering)
if namespace in USER_OWNED_NAMESPACES:
return None
# For UAM and other namespaces: GROUP filters by mandate
effectiveMandateId = mandateId
if not effectiveMandateId:
# Fall back to Root mandate (first mandate in system) for GROUP access
# This allows system-level tables to be accessed without explicit mandate context
try:
from modules.datamodels.datamodelUam import Mandate
dbApp = getRootDbAppConnector()
allMandates = dbApp.getRecordset(Mandate)
if allMandates:
effectiveMandateId = allMandates[0].get("id")
except Exception as e:
logger.error(f"Error getting Root mandate: {e}")
if not effectiveMandateId:
logger.warning(f"User {currentUser.id} has no mandateId for GROUP access") logger.warning(f"User {currentUser.id} has no mandateId for GROUP access")
return {"condition": "1 = 0", "values": []} return {"condition": "1 = 0", "values": []}
# For UserInDB, filter by mandateId directly # For UserInDB: Filter via UserMandate junction table
# Multi-Tenant Design: Users do NOT have mandateId - they are linked via UserMandate
if table == "UserInDB": if table == "UserInDB":
return {
"condition": '"mandateId" = %s',
"values": [currentUser.mandateId]
}
# For UserConnection, need to join with UserInDB or filter by mandateId in user
elif table == "UserConnection":
# Get all user IDs in the same mandate using direct SQL query
try: try:
with connector.connection.cursor() as cursor: with connector.connection.cursor() as cursor:
# Get all user IDs that are members of the current mandate
cursor.execute( cursor.execute(
'SELECT "id" FROM "UserInDB" WHERE "mandateId" = %s', 'SELECT "userId" FROM "UserMandate" WHERE "mandateId" = %s AND "enabled" = true',
(currentUser.mandateId,) (effectiveMandateId,)
) )
users = cursor.fetchall() userMandates = cursor.fetchall()
userIds = [u["id"] for u in users] userIds = [um["userId"] for um in userMandates]
if not userIds:
return {"condition": "1 = 0", "values": []}
placeholders = ",".join(["%s"] * len(userIds))
return {
"condition": f'"id" IN ({placeholders})',
"values": userIds
}
except Exception as e:
logger.error(f"Error building GROUP filter for UserInDB via UserMandate: {e}")
return {"condition": "1 = 0", "values": []}
# For UserConnection: Filter via UserMandate junction table
elif table == "UserConnection":
try:
with connector.connection.cursor() as cursor:
# Get all user IDs that are members of the current mandate
cursor.execute(
'SELECT "userId" FROM "UserMandate" WHERE "mandateId" = %s AND "enabled" = true',
(effectiveMandateId,)
)
userMandates = cursor.fetchall()
userIds = [um["userId"] for um in userMandates]
if not userIds: if not userIds:
return {"condition": "1 = 0", "values": []} return {"condition": "1 = 0", "values": []}
placeholders = ",".join(["%s"] * len(userIds)) placeholders = ",".join(["%s"] * len(userIds))
@ -232,12 +402,102 @@ def buildRbacWhereClause(
except Exception as e: except Exception as e:
logger.error(f"Error building GROUP filter for UserConnection: {e}") logger.error(f"Error building GROUP filter for UserConnection: {e}")
return {"condition": "1 = 0", "values": []} return {"condition": "1 = 0", "values": []}
# For other tables, filter by mandateId
# For system tables without mandateId column (Mandate, Role, etc.):
# No row-level filtering - GROUP access = ALL access for these
elif table in ("Mandate", "Role"):
return None
# For other tables, filter by mandateId field
# Also include records with NULL mandateId for backwards compatibility
else: else:
return { return {
"condition": '"mandateId" = %s', "condition": '("mandateId" = %s OR "mandateId" IS NULL)',
"values": [currentUser.mandateId] "values": [effectiveMandateId]
} }
return None return None
def _enrichRecordsWithPermissions(
records: List[Dict[str, Any]],
permissions: UserPermissions,
currentUser: User
) -> List[Dict[str, Any]]:
"""
Enrich records with per-row permissions (_permissions field).
The _permissions field contains:
- canUpdate: bool - whether current user can update this record
- canDelete: bool - whether current user can delete this record
Logic:
- AccessLevel.ALL ('a'): User can update/delete all records
- AccessLevel.MY ('m'): User can only update/delete records where _createdBy == userId
- AccessLevel.GROUP ('g'): Same as MY for now (group-level ownership)
- AccessLevel.NONE ('n'): User cannot update/delete any records
Args:
records: List of record dicts
permissions: UserPermissions with update/delete levels
currentUser: Current user object
Returns:
Records with _permissions field added
"""
enriched = []
userId = currentUser.id if currentUser else None
for record in records:
recordCopy = dict(record)
createdBy = record.get("_createdBy")
# Determine canUpdate
canUpdate = _checkRowPermission(permissions.update, userId, createdBy)
# Determine canDelete
canDelete = _checkRowPermission(permissions.delete, userId, createdBy)
recordCopy["_permissions"] = {
"canUpdate": canUpdate,
"canDelete": canDelete
}
enriched.append(recordCopy)
return enriched
def _checkRowPermission(
accessLevel: Optional[AccessLevel],
userId: Optional[str],
recordCreatedBy: Optional[str]
) -> bool:
"""
Check if user has permission for a specific row based on access level.
Args:
accessLevel: The permission level (ALL, MY, GROUP, NONE)
userId: Current user's ID
recordCreatedBy: The _createdBy value of the record
Returns:
True if user has permission, False otherwise
"""
if not accessLevel or accessLevel == AccessLevel.NONE:
return False
if accessLevel == AccessLevel.ALL:
return True
# MY and GROUP: Check ownership via _createdBy
if accessLevel in (AccessLevel.MY, AccessLevel.GROUP):
# If record has no _createdBy, allow access (can't verify ownership)
if not recordCreatedBy:
return True
# If no userId, can't verify - deny
if not userId:
return False
# Check ownership
return recordCreatedBy == userId
# Unknown level - deny by default
return False

View file

@ -31,19 +31,26 @@ class VoiceObjects:
self.userId: Optional[str] = None self.userId: Optional[str] = None
self._google_speech_connector: Optional[ConnectorGoogleSpeech] = None self._google_speech_connector: Optional[ConnectorGoogleSpeech] = None
def setUserContext(self, currentUser: User): def setUserContext(self, currentUser: User, mandateId: Optional[str] = None):
"""Set the user context for the interface.""" """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: if not currentUser:
logger.info("Initializing voice interface without user context") logger.info("Initializing voice interface without user context")
return return
self.currentUser = currentUser self.currentUser = currentUser
self.userId = currentUser.id self.userId = currentUser.id
# Use mandateId from parameter (Request-Context), not from user object
self.mandateId = mandateId
if not self.userId: if not self.userId:
raise ValueError("Invalid user context: id is required") 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: def _getGoogleSpeechConnector(self) -> ConnectorGoogleSpeech:
"""Get or create Google Cloud Speech connector instance.""" """Get or create Google Cloud Speech connector instance."""
@ -308,11 +315,11 @@ class VoiceObjects:
try: try:
logger.info(f"Creating voice settings: {settingsData}") 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 "mandateId" not in settingsData or not settingsData["mandateId"]:
if not self.currentUser or not self.currentUser.mandateId: if not self.mandateId:
raise ValueError("mandateId is required but not provided and user context has no mandateId") raise ValueError("mandateId is required but not provided and context has no mandateId")
settingsData["mandateId"] = self.currentUser.mandateId settingsData["mandateId"] = self.mandateId
# Add timestamps # Add timestamps
currentTime = getUtcTimestamp() currentTime = getUtcTimestamp()
@ -376,7 +383,7 @@ class VoiceObjects:
# Create default settings if none exist # Create default settings if none exist
defaultSettings = { defaultSettings = {
"userId": userId, "userId": userId,
"mandateId": self.currentUser.mandateId, "mandateId": self.mandateId,
"sttLanguage": "de-DE", "sttLanguage": "de-DE",
"ttsLanguage": "de-DE", "ttsLanguage": "de-DE",
"ttsVoice": "de-DE-Wavenet-A", "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. Factory function to get or create Voice interface instance.
Args: Args:
currentUser: User object for context (optional) currentUser: User object for context (optional)
mandateId: The mandate ID from RequestContext (X-Mandate-Id header). Required.
Returns: Returns:
VoiceObjects instance VoiceObjects instance
""" """
# For now, create a new instance each time effectiveMandateId = str(mandateId) if mandateId else None
# In the future, this could be enhanced with singleton pattern per user
voiceInterface = VoiceObjects() voiceInterface = VoiceObjects()
if currentUser: if currentUser:
voiceInterface.setUserContext(currentUser) voiceInterface.setUserContext(currentUser, mandateId=effectiveMandateId)
return voiceInterface return voiceInterface

View file

@ -12,7 +12,7 @@ from fastapi import HTTPException, status
from modules.shared.configuration import APP_CONFIG from modules.shared.configuration import APP_CONFIG
from modules.auth import limiter, getCurrentUser from modules.auth import limiter, getCurrentUser
from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelUam import User
from modules.interfaces.interfaceDbAppObjects import getRootInterface from modules.interfaces.interfaceDbApp import getRootInterface
# Static folder setup - using absolute path from app root # Static folder setup - using absolute path from app root
baseDir = FilePath(__file__).parent.parent.parent # Go up to gateway root baseDir = FilePath(__file__).parent.parent.parent # Go up to gateway root

View file

@ -10,9 +10,9 @@ from typing import List, Dict, Any
from fastapi import status from fastapi import status
import logging import logging
# Import interfaces and models # Import interfaces and models from feature containers
import modules.interfaces.interfaceDbChatObjects as interfaceDbChatObjects import modules.interfaces.interfaceDbChat as interfaceDbChat
from modules.auth import getCurrentUser, limiter from modules.auth import limiter, getRequestContext, requireSysAdmin, RequestContext
from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelUam import User
# Configure logger # Configure logger
@ -31,26 +31,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("") @router.get("")
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def get_all_automation_events( async def get_all_automation_events(
request: Request, request: Request,
currentUser: User = Depends(getCurrentUser) currentUser: User = Depends(requireSysAdmin)
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
""" """
Get all automation events across all mandates (sysadmin only). Get all automation events across all mandates (sysadmin only).
Returns list of all registered events with their automation IDs and schedules. Returns list of all registered events with their automation IDs and schedules.
""" """
requireSysadmin(currentUser)
try: try:
from modules.shared.eventManagement import eventManager from modules.shared.eventManagement import eventManager
@ -79,18 +69,15 @@ async def get_all_automation_events(
@limiter.limit("5/minute") @limiter.limit("5/minute")
async def sync_all_automation_events( async def sync_all_automation_events(
request: Request, request: Request,
currentUser: User = Depends(getCurrentUser) currentUser: User = Depends(requireSysAdmin)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Manually trigger sync for all automations (sysadmin only). Manually trigger sync for all automations (sysadmin only).
This will register/remove events based on active flags. This will register/remove events based on active flags.
""" """
requireSysadmin(currentUser)
try: try:
from modules.interfaces.interfaceDbChatObjects import getInterface as getChatInterface from modules.interfaces.interfaceDbApp import getRootInterface
from modules.interfaces.interfaceDbAppObjects import getRootInterface from modules.workflows.automation import syncAutomationEvents
from modules.features.workflow import syncAutomationEvents
chatInterface = getChatInterface(currentUser) chatInterface = getChatInterface(currentUser)
# Get event user for sync operation (routes can import from interfaces) # Get event user for sync operation (routes can import from interfaces)
@ -124,14 +111,12 @@ async def sync_all_automation_events(
async def remove_event( async def remove_event(
request: Request, request: Request,
eventId: str = Path(..., description="Event ID to remove"), eventId: str = Path(..., description="Event ID to remove"),
currentUser: User = Depends(getCurrentUser) currentUser: User = Depends(requireSysAdmin)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Manually remove a specific event from scheduler (sysadmin only). Manually remove a specific event from scheduler (sysadmin only).
Used for debugging and manual event cleanup. Used for debugging and manual event cleanup.
""" """
requireSysadmin(currentUser)
try: try:
from modules.shared.eventManagement import eventManager from modules.shared.eventManagement import eventManager
@ -141,9 +126,9 @@ async def remove_event(
# Update automation's eventId if it exists # Update automation's eventId if it exists
if eventId.startswith("automation."): if eventId.startswith("automation."):
automation_id = eventId.replace("automation.", "") automation_id = eventId.replace("automation.", "")
chatInterface = interfaceDbChatObjects.getInterface(currentUser) chatInterface = interfaceDbChat.getInterface(currentUser)
automation = chatInterface.getAutomationDefinition(automation_id) 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}) chatInterface.updateAutomationDefinition(automation_id, {"eventId": None})
return { return {
@ -157,4 +142,3 @@ async def remove_event(
status_code=500, status_code=500,
detail=f"Error removing event: {str(e)}" detail=f"Error removing event: {str(e)}"
) )

File diff suppressed because it is too large Load diff

View file

@ -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.interfaceDbApp 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 export_global_rbac(
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 import_global_rbac(
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.recordModify(
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 export_mandate_rbac(
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 import_mandate_rbac(
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.recordModify(
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

View file

@ -3,20 +3,70 @@
""" """
Admin RBAC Roles Management routes. Admin RBAC Roles Management routes.
Provides endpoints for managing roles and role assignments to users. 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 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 import logging
from modules.auth import getCurrentUser, limiter from modules.auth import limiter, requireSysAdmin
from modules.datamodels.datamodelUam import User, UserInDB from modules.datamodels.datamodelUam import User, UserInDB
from modules.datamodels.datamodelRbac import Role from modules.datamodels.datamodelRbac import Role
from modules.interfaces.interfaceDbAppObjects import getInterface from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole
from modules.interfaces.interfaceDbApp import getInterface, getRootInterface
# Configure logger # Configure logger
logger = logging.getLogger(__name__) 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( router = APIRouter(
prefix="/api/admin/rbac/roles", prefix="/api/admin/rbac/roles",
tags=["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]]) @router.get("/", response_model=List[Dict[str, Any]])
@limiter.limit("60/minute") @limiter.limit("60/minute")
async def listRoles( async def list_roles(
request: Request, request: Request,
currentUser: User = Depends(getCurrentUser) currentUser: User = Depends(requireSysAdmin)
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
""" """
Get list of all available roles with metadata. Get list of all available roles with metadata.
MULTI-TENANT: SysAdmin-only (roles are system resources).
Returns: Returns:
- List of role dictionaries with role label, description, and user count - List of role dictionaries with role label, description, and user count
""" """
try: try:
_ensureAdminAccess(currentUser) interface = getRootInterface()
interface = getInterface(currentUser)
# Get all roles from database # Get all roles from database
dbRoles = interface.getAllRoles() dbRoles = interface.getAllRoles()
# Get all users to count role assignments # Count role assignments from UserMandateRole table
allUsers = interface.getUsers() roleCounts = interface.countRoleAssignments()
# 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
# Convert Role objects to dictionaries and add user counts # Convert Role objects to dictionaries and add user counts
result = [] result = []
@ -77,22 +103,10 @@ async def listRoles(
"id": role.id, "id": role.id,
"roleLabel": role.roleLabel, "roleLabel": role.roleLabel,
"description": role.description, "description": role.description,
"userCount": roleCounts.get(role.roleLabel, 0), "userCount": roleCounts.get(str(role.id), 0),
"isSystemRole": role.isSystemRole "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 return result
except HTTPException: except HTTPException:
@ -107,21 +121,19 @@ async def listRoles(
@router.get("/options", response_model=List[Dict[str, Any]]) @router.get("/options", response_model=List[Dict[str, Any]])
@limiter.limit("60/minute") @limiter.limit("60/minute")
async def getRoleOptions( async def get_role_options(
request: Request, request: Request,
currentUser: User = Depends(getCurrentUser) currentUser: User = Depends(requireSysAdmin)
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
""" """
Get role options for select dropdowns. Get role options for select dropdowns.
Returns roles in format suitable for frontend select components. MULTI-TENANT: SysAdmin-only.
Returns: Returns:
- List of role option dictionaries with value and label - List of role option dictionaries with value and label
""" """
try: try:
_ensureAdminAccess(currentUser) interface = getRootInterface()
interface = getInterface(currentUser)
# Get all roles from database # Get all roles from database
dbRoles = interface.getAllRoles() dbRoles = interface.getAllRoles()
@ -150,13 +162,14 @@ async def getRoleOptions(
@router.post("/", response_model=Dict[str, Any]) @router.post("/", response_model=Dict[str, Any])
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def createRole( async def create_role(
request: Request, request: Request,
role: Role = Body(...), role: Role = Body(...),
currentUser: User = Depends(getCurrentUser) currentUser: User = Depends(requireSysAdmin)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Create a new role. Create a new role.
MULTI-TENANT: SysAdmin-only (roles are system resources).
Request Body: Request Body:
- role: Role object to create - role: Role object to create
@ -165,9 +178,7 @@ async def createRole(
- Created role dictionary - Created role dictionary
""" """
try: try:
_ensureAdminAccess(currentUser) interface = getRootInterface()
interface = getInterface(currentUser)
createdRole = interface.createRole(role) createdRole = interface.createRole(role)
@ -195,13 +206,14 @@ async def createRole(
@router.get("/{roleId}", response_model=Dict[str, Any]) @router.get("/{roleId}", response_model=Dict[str, Any])
@limiter.limit("60/minute") @limiter.limit("60/minute")
async def getRole( async def get_role(
request: Request, request: Request,
roleId: str = Path(..., description="Role ID"), roleId: str = Path(..., description="Role ID"),
currentUser: User = Depends(getCurrentUser) currentUser: User = Depends(requireSysAdmin)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Get a role by ID. Get a role by ID.
MULTI-TENANT: SysAdmin-only.
Path Parameters: Path Parameters:
- roleId: Role ID - roleId: Role ID
@ -210,9 +222,7 @@ async def getRole(
- Role dictionary - Role dictionary
""" """
try: try:
_ensureAdminAccess(currentUser) interface = getRootInterface()
interface = getInterface(currentUser)
role = interface.getRole(roleId) role = interface.getRole(roleId)
if not role: if not role:
@ -240,14 +250,15 @@ async def getRole(
@router.put("/{roleId}", response_model=Dict[str, Any]) @router.put("/{roleId}", response_model=Dict[str, Any])
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def updateRole( async def update_role(
request: Request, request: Request,
roleId: str = Path(..., description="Role ID"), roleId: str = Path(..., description="Role ID"),
role: Role = Body(...), role: Role = Body(...),
currentUser: User = Depends(getCurrentUser) currentUser: User = Depends(requireSysAdmin)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Update an existing role. Update an existing role.
MULTI-TENANT: SysAdmin-only.
Path Parameters: Path Parameters:
- roleId: Role ID - roleId: Role ID
@ -259,9 +270,7 @@ async def updateRole(
- Updated role dictionary - Updated role dictionary
""" """
try: try:
_ensureAdminAccess(currentUser) interface = getRootInterface()
interface = getInterface(currentUser)
updatedRole = interface.updateRole(roleId, role) updatedRole = interface.updateRole(roleId, role)
@ -289,13 +298,14 @@ async def updateRole(
@router.delete("/{roleId}", response_model=Dict[str, str]) @router.delete("/{roleId}", response_model=Dict[str, str])
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def deleteRole( async def delete_role(
request: Request, request: Request,
roleId: str = Path(..., description="Role ID"), roleId: str = Path(..., description="Role ID"),
currentUser: User = Depends(getCurrentUser) currentUser: User = Depends(requireSysAdmin)
) -> Dict[str, str]: ) -> Dict[str, str]:
""" """
Delete a role. Delete a role.
MULTI-TENANT: SysAdmin-only.
Path Parameters: Path Parameters:
- roleId: Role ID - roleId: Role ID
@ -304,9 +314,7 @@ async def deleteRole(
- Success message - Success message
""" """
try: try:
_ensureAdminAccess(currentUser) interface = getRootInterface()
interface = getInterface(currentUser)
success = interface.deleteRole(roleId) success = interface.deleteRole(roleId)
if not success: if not success:
@ -334,51 +342,60 @@ async def deleteRole(
@router.get("/users", response_model=List[Dict[str, Any]]) @router.get("/users", response_model=List[Dict[str, Any]])
@limiter.limit("60/minute") @limiter.limit("60/minute")
async def listUsersWithRoles( async def list_users_with_roles(
request: Request, request: Request,
roleLabel: Optional[str] = Query(None, description="Filter by role label"), roleLabel: Optional[str] = Query(None, description="Filter by role label"),
mandateId: Optional[str] = Query(None, description="Filter by mandate ID"), mandateId: Optional[str] = Query(None, description="Filter by mandate ID (via UserMandate)"),
currentUser: User = Depends(getCurrentUser) currentUser: User = Depends(requireSysAdmin)
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
""" """
Get list of users with their role assignments. Get list of users with their role assignments.
MULTI-TENANT: SysAdmin-only, can see all users across mandates.
Query Parameters: Query Parameters:
- roleLabel: Optional filter by role label - roleLabel: Optional filter by role label
- mandateId: Optional filter by mandate ID - mandateId: Optional filter by mandate ID (via UserMandate table)
Returns: Returns:
- List of user dictionaries with role assignments - List of user dictionaries with role assignments
""" """
try: try:
_ensureAdminAccess(currentUser) interface = getRootInterface()
interface = getInterface(currentUser) # Get all users (SysAdmin sees all)
# Use db.getRecordset with UserInDB (the actual database model)
allUsersData = interface.db.getRecordset(UserInDB)
# Convert to User objects, filtering out sensitive fields
users = []
for u in allUsersData:
cleanedUser = {k: v for k, v in u.items() if not k.startswith("_") and k != "hashedPassword" and k != "resetToken" and k != "resetTokenExpires"}
if cleanedUser.get("roleLabels") is None:
cleanedUser["roleLabels"] = []
users.append(User(**cleanedUser))
# Get users based on filters # Filter by mandate if specified (via UserMandate table)
if mandateId: if mandateId:
# Filter by mandate (if user has permission) userMandates = interface.db.getRecordset(UserMandate, recordFilter={"mandateId": mandateId})
users = interface.getUsers() mandateUserIds = {str(um["userId"]) for um in userMandates}
users = [u for u in users if u.mandateId == mandateId] users = [u for u in users if str(u.id) in mandateUserIds]
else:
users = interface.getUsers()
# Filter by role if specified # Filter by role if specified (via UserMandateRole)
if roleLabel: 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 # Format response
result = [] result = []
for user in users: for user in users:
userRoleLabels = _getUserRoleLabels(interface, str(user.id))
result.append({ result.append({
"id": user.id, "id": user.id,
"username": user.username, "username": user.username,
"email": user.email, "email": user.email,
"fullName": user.fullName, "fullName": user.fullName,
"mandateId": user.mandateId, "isSysAdmin": user.isSysAdmin,
"enabled": user.enabled, "enabled": user.enabled,
"roleLabels": user.roleLabels or [], "roleLabels": userRoleLabels,
"roleCount": len(user.roleLabels or []) "roleCount": len(userRoleLabels)
}) })
return result return result
@ -395,13 +412,14 @@ async def listUsersWithRoles(
@router.get("/users/{userId}", response_model=Dict[str, Any]) @router.get("/users/{userId}", response_model=Dict[str, Any])
@limiter.limit("60/minute") @limiter.limit("60/minute")
async def getUserRoles( async def get_user_roles(
request: Request, request: Request,
userId: str = Path(..., description="User ID"), userId: str = Path(..., description="User ID"),
currentUser: User = Depends(getCurrentUser) currentUser: User = Depends(requireSysAdmin)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Get role assignments for a specific user. Get role assignments for a specific user.
MULTI-TENANT: SysAdmin-only.
Path Parameters: Path Parameters:
- userId: User ID - userId: User ID
@ -410,9 +428,7 @@ async def getUserRoles(
- User dictionary with role assignments - User dictionary with role assignments
""" """
try: try:
_ensureAdminAccess(currentUser) interface = getRootInterface()
interface = getInterface(currentUser)
# Get user # Get user
user = interface.getUser(userId) user = interface.getUser(userId)
@ -422,15 +438,16 @@ async def getUserRoles(
detail=f"User {userId} not found" detail=f"User {userId} not found"
) )
userRoleLabels = _getUserRoleLabels(interface, str(user.id))
return { return {
"id": user.id, "id": user.id,
"username": user.username, "username": user.username,
"email": user.email, "email": user.email,
"fullName": user.fullName, "fullName": user.fullName,
"mandateId": user.mandateId, "isSysAdmin": user.isSysAdmin,
"enabled": user.enabled, "enabled": user.enabled,
"roleLabels": user.roleLabels or [], "roleLabels": userRoleLabels,
"roleCount": len(user.roleLabels or []) "roleCount": len(userRoleLabels)
} }
except HTTPException: except HTTPException:
@ -445,28 +462,27 @@ async def getUserRoles(
@router.put("/users/{userId}/roles", response_model=Dict[str, Any]) @router.put("/users/{userId}/roles", response_model=Dict[str, Any])
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def updateUserRoles( async def update_user_roles(
request: Request, request: Request,
userId: str = Path(..., description="User ID"), userId: str = Path(..., description="User ID"),
roleLabels: List[str] = Body(..., description="List of role labels to assign"), newRoleLabels: List[str] = Body(..., description="List of role labels to assign"),
currentUser: User = Depends(getCurrentUser) currentUser: User = Depends(requireSysAdmin)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Update role assignments for a specific user. Update role assignments for a specific user.
MULTI-TENANT: SysAdmin-only. Updates roles in user's first mandate.
Path Parameters: Path Parameters:
- userId: User ID - userId: User ID
Request Body: 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: Returns:
- Updated user dictionary with role assignments - Updated user dictionary with role assignments
""" """
try: try:
_ensureAdminAccess(currentUser) interface = getRootInterface()
interface = getInterface(currentUser)
# Get user # Get user
user = interface.getUser(userId) user = interface.getUser(userId)
@ -478,28 +494,57 @@ async def updateUserRoles(
# Validate role labels (basic validation - check against standard roles) # Validate role labels (basic validation - check against standard roles)
standardRoles = ["sysadmin", "admin", "user", "viewer"] standardRoles = ["sysadmin", "admin", "user", "viewer"]
for roleLabel in roleLabels: for roleLabel in newRoleLabels:
if roleLabel not in standardRoles: if roleLabel not in standardRoles:
logger.warning(f"Non-standard role label assigned: {roleLabel}") logger.warning(f"Non-standard role label assigned: {roleLabel}")
# Update user roles # Get user's first mandate (for role assignment)
userData = { userMandates = interface.db.getRecordset(UserMandate, recordFilter={"userId": userId})
"roleLabels": roleLabels 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 {currentUser.id}")
userRoleLabels = _getUserRoleLabels(interface, userId)
return { return {
"id": updatedUser.id, "id": user.id,
"username": updatedUser.username, "username": user.username,
"email": updatedUser.email, "email": user.email,
"fullName": updatedUser.fullName, "fullName": user.fullName,
"mandateId": updatedUser.mandateId, "isSysAdmin": user.isSysAdmin,
"enabled": updatedUser.enabled, "enabled": user.enabled,
"roleLabels": updatedUser.roleLabels or [], "roleLabels": userRoleLabels,
"roleCount": len(updatedUser.roleLabels or []) "roleCount": len(userRoleLabels)
} }
except HTTPException: except HTTPException:
@ -514,14 +559,15 @@ async def updateUserRoles(
@router.post("/users/{userId}/roles/{roleLabel}", response_model=Dict[str, Any]) @router.post("/users/{userId}/roles/{roleLabel}", response_model=Dict[str, Any])
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def addUserRole( async def add_user_role(
request: Request, request: Request,
userId: str = Path(..., description="User ID"), userId: str = Path(..., description="User ID"),
roleLabel: str = Path(..., description="Role label to add"), roleLabel: str = Path(..., description="Role label to add"),
currentUser: User = Depends(getCurrentUser) currentUser: User = Depends(requireSysAdmin)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Add a role to a user (if not already assigned). Add a role to a user (if not already assigned).
MULTI-TENANT: SysAdmin-only. Adds role to user's first mandate.
Path Parameters: Path Parameters:
- userId: User ID - userId: User ID
@ -531,9 +577,7 @@ async def addUserRole(
- Updated user dictionary with role assignments - Updated user dictionary with role assignments
""" """
try: try:
_ensureAdminAccess(currentUser) interface = getRootInterface()
interface = getInterface(currentUser)
# Get user # Get user
user = interface.getUser(userId) user = interface.getUser(userId)
@ -543,33 +587,46 @@ async def addUserRole(
detail=f"User {userId} not found" detail=f"User {userId} not found"
) )
# Get current roles # Get role by label
currentRoles = list(user.roleLabels or []) role = interface.getRoleByLabel(roleLabel)
if not role:
raise HTTPException(
status_code=404,
detail=f"Role '{roleLabel}' not found"
)
# Add role if not already present # Get user's first mandate
if roleLabel not in currentRoles: userMandates = interface.db.getRecordset(UserMandate, recordFilter={"userId": userId})
currentRoles.append(roleLabel) if not userMandates:
raise HTTPException(
status_code=400,
detail=f"User {userId} has no mandate memberships. Add to mandate first."
)
# Update user roles userMandateId = str(userMandates[0].get("id"))
userData = {
"roleLabels": currentRoles
}
updatedUser = interface.updateUser(userId, userData) # Check if role is already assigned
existingAssignment = interface.db.getRecordset(
UserMandateRole,
recordFilter={"userMandateId": userMandateId, "roleId": str(role.id)}
)
logger.info(f"Added role {roleLabel} to user {userId}") if not existingAssignment:
else: # Add the role
updatedUser = user 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 {currentUser.id}")
userRoleLabels = _getUserRoleLabels(interface, userId)
return { return {
"id": updatedUser.id, "id": user.id,
"username": updatedUser.username, "username": user.username,
"email": updatedUser.email, "email": user.email,
"fullName": updatedUser.fullName, "fullName": user.fullName,
"mandateId": updatedUser.mandateId, "isSysAdmin": user.isSysAdmin,
"enabled": updatedUser.enabled, "enabled": user.enabled,
"roleLabels": updatedUser.roleLabels or [], "roleLabels": userRoleLabels,
"roleCount": len(updatedUser.roleLabels or []) "roleCount": len(userRoleLabels)
} }
except HTTPException: except HTTPException:
@ -584,14 +641,15 @@ async def addUserRole(
@router.delete("/users/{userId}/roles/{roleLabel}", response_model=Dict[str, Any]) @router.delete("/users/{userId}/roles/{roleLabel}", response_model=Dict[str, Any])
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def removeUserRole( async def remove_user_role(
request: Request, request: Request,
userId: str = Path(..., description="User ID"), userId: str = Path(..., description="User ID"),
roleLabel: str = Path(..., description="Role label to remove"), roleLabel: str = Path(..., description="Role label to remove"),
currentUser: User = Depends(getCurrentUser) currentUser: User = Depends(requireSysAdmin)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Remove a role from a user. Remove a role from a user.
MULTI-TENANT: SysAdmin-only. Removes role from all user's mandates.
Path Parameters: Path Parameters:
- userId: User ID - userId: User ID
@ -601,9 +659,7 @@ async def removeUserRole(
- Updated user dictionary with role assignments - Updated user dictionary with role assignments
""" """
try: try:
_ensureAdminAccess(currentUser) interface = getRootInterface()
interface = getInterface(currentUser)
# Get user # Get user
user = interface.getUser(userId) user = interface.getUser(userId)
@ -613,38 +669,44 @@ async def removeUserRole(
detail=f"User {userId} not found" detail=f"User {userId} not found"
) )
# Get current roles # Get role by label
currentRoles = list(user.roleLabels or []) role = interface.getRoleByLabel(roleLabel)
if not role:
raise HTTPException(
status_code=404,
detail=f"Role '{roleLabel}' not found"
)
# Remove role if present # Remove role from all user's mandates
if roleLabel in currentRoles: userMandates = interface.db.getRecordset(UserMandate, recordFilter={"userId": userId})
currentRoles.remove(roleLabel) roleRemoved = False
# Ensure user has at least one role (default to "user") for um in userMandates:
if not currentRoles: userMandateId = str(um.get("id"))
currentRoles = ["user"]
logger.warning(f"User {userId} had all roles removed, defaulting to 'user' role")
# Update user roles # Find and delete the role assignment
userData = { assignments = interface.db.getRecordset(
"roleLabels": currentRoles UserMandateRole,
} recordFilter={"userMandateId": userMandateId, "roleId": str(role.id)}
)
updatedUser = interface.updateUser(userId, userData) for assignment in assignments:
interface.db.recordDelete(UserMandateRole, str(assignment.get("id")))
roleRemoved = True
logger.info(f"Removed role {roleLabel} from user {userId}") if roleRemoved:
else: logger.info(f"Removed role {roleLabel} from user {userId} by SysAdmin {currentUser.id}")
updatedUser = user
userRoleLabels = _getUserRoleLabels(interface, userId)
return { return {
"id": updatedUser.id, "id": user.id,
"username": updatedUser.username, "username": user.username,
"email": updatedUser.email, "email": user.email,
"fullName": updatedUser.fullName, "fullName": user.fullName,
"mandateId": updatedUser.mandateId, "isSysAdmin": user.isSysAdmin,
"enabled": updatedUser.enabled, "enabled": user.enabled,
"roleLabels": updatedUser.roleLabels or [], "roleLabels": userRoleLabels,
"roleCount": len(updatedUser.roleLabels or []) "roleCount": len(userRoleLabels)
} }
except HTTPException: except HTTPException:
@ -659,51 +721,71 @@ async def removeUserRole(
@router.get("/roles/{roleLabel}/users", response_model=List[Dict[str, Any]]) @router.get("/roles/{roleLabel}/users", response_model=List[Dict[str, Any]])
@limiter.limit("60/minute") @limiter.limit("60/minute")
async def getUsersWithRole( async def get_users_with_role(
request: Request, request: Request,
roleLabel: str = Path(..., description="Role label"), roleLabel: str = Path(..., description="Role label"),
mandateId: Optional[str] = Query(None, description="Filter by mandate ID"), mandateId: Optional[str] = Query(None, description="Filter by mandate ID (via UserMandate)"),
currentUser: User = Depends(getCurrentUser) currentUser: User = Depends(requireSysAdmin)
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
""" """
Get all users with a specific role. Get all users with a specific role.
MULTI-TENANT: SysAdmin-only.
Path Parameters: Path Parameters:
- roleLabel: Role label - roleLabel: Role label
Query Parameters: Query Parameters:
- mandateId: Optional filter by mandate ID - mandateId: Optional filter by mandate ID (via UserMandate table)
Returns: Returns:
- List of users with the specified role - List of users with the specified role
""" """
try: 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 # Get all UserMandateRole assignments for this role
users = interface.getUsers() roleAssignments = interface.db.getRecordset(
UserMandateRole,
recordFilter={"roleId": str(role.id)}
)
# Filter by role # Get unique userMandateIds
users = [u for u in users if roleLabel in (u.roleLabels or [])] userMandateIds = {str(ra.get("userMandateId")) for ra in roleAssignments}
# 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 # Filter by mandate if specified
if mandateId: if mandateId and str(um.get("mandateId")) != mandateId:
users = [u for u in users if u.mandateId == mandateId] continue
userIds.add(str(um.get("userId")))
# Format response # Get users and format response
result = [] result = []
for user in users: for userId in userIds:
user = interface.getUser(userId)
if user:
userRoleLabels = _getUserRoleLabels(interface, userId)
result.append({ result.append({
"id": user.id, "id": user.id,
"username": user.username, "username": user.username,
"email": user.email, "email": user.email,
"fullName": user.fullName, "fullName": user.fullName,
"mandateId": user.mandateId, "isSysAdmin": user.isSysAdmin,
"enabled": user.enabled, "enabled": user.enabled,
"roleLabels": user.roleLabels or [], "roleLabels": userRoleLabels,
"roleCount": len(user.roleLabels or []) "roleCount": len(userRoleLabels)
}) })
return result return result

View file

@ -0,0 +1,503 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Admin User Access Overview routes.
Provides endpoints for viewing complete user access permissions.
MULTI-TENANT: These are SYSTEM-LEVEL operations requiring isSysAdmin=true.
Shows comprehensive view of what a user can see and access.
"""
from fastapi import APIRouter, HTTPException, Depends, Query, Path, Request
from typing import List, Dict, Any, Optional, Set
import logging
from modules.auth import limiter, requireSysAdmin
from modules.datamodels.datamodelUam import User, UserInDB
from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext
from modules.datamodels.datamodelMembership import (
UserMandate,
UserMandateRole,
FeatureAccess,
FeatureAccessRole,
)
from modules.datamodels.datamodelFeatures import FeatureInstance, Feature
from modules.interfaces.interfaceDbApp import getRootInterface
# Configure logger
logger = logging.getLogger(__name__)
router = APIRouter(
prefix="/api/admin/user-access-overview",
tags=["Admin User Access Overview"],
responses={404: {"description": "Not found"}}
)
def _getAccessLevelLabel(level: Optional[str]) -> str:
"""Convert access level code to human-readable label."""
labels = {
"a": "ALL",
"m": "MY",
"g": "GROUP",
"n": "NONE",
None: "-"
}
return labels.get(level, "-")
def _getRoleScope(role: Dict[str, Any]) -> str:
"""Determine the scope of a role."""
if role.get("featureInstanceId"):
return "instance"
elif role.get("mandateId"):
return "mandate"
else:
return "global"
def _getRoleScopePriority(scope: str) -> int:
"""Get priority for role scope (higher = more specific)."""
priorities = {"global": 1, "mandate": 2, "instance": 3}
return priorities.get(scope, 0)
@router.get("/users", response_model=List[Dict[str, Any]])
@limiter.limit("60/minute")
async def listUsersForOverview(
request: Request,
currentUser: User = Depends(requireSysAdmin)
) -> List[Dict[str, Any]]:
"""
Get list of all users for selection in the overview.
MULTI-TENANT: SysAdmin-only.
Returns:
- List of user dictionaries with basic info
"""
try:
interface = getRootInterface()
# Get all users
allUsersData = interface.db.getRecordset(UserInDB)
result = []
for u in allUsersData:
result.append({
"id": u.get("id"),
"username": u.get("username"),
"email": u.get("email"),
"fullName": u.get("fullName"),
"isSysAdmin": u.get("isSysAdmin", False),
"enabled": u.get("enabled", True),
})
# Sort by username
result.sort(key=lambda x: (x.get("username") or "").lower())
return result
except Exception as e:
logger.error(f"Error listing users for overview: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Failed to list users: {str(e)}"
)
@router.get("/{userId}", response_model=Dict[str, Any])
@limiter.limit("60/minute")
async def getUserAccessOverview(
request: Request,
userId: str = Path(..., description="User ID to get access overview for"),
mandateId: Optional[str] = Query(None, description="Filter by mandate ID"),
featureInstanceId: Optional[str] = Query(None, description="Filter by feature instance ID"),
currentUser: User = Depends(requireSysAdmin)
) -> Dict[str, Any]:
"""
Get comprehensive access overview for a specific user.
MULTI-TENANT: SysAdmin-only.
Path Parameters:
- userId: User ID
Query Parameters:
- mandateId: Optional filter by mandate ID
- featureInstanceId: Optional filter by feature instance ID
Returns:
- Comprehensive access overview including:
- User info
- All assigned roles with scope
- UI access (what pages/views the user can see)
- Data access (what tables/fields the user can access)
- Resource access (what resources the user can use)
"""
try:
interface = getRootInterface()
# Get user
user = interface.getUser(userId)
if not user:
raise HTTPException(
status_code=404,
detail=f"User {userId} not found"
)
# Build user info
userInfo = {
"id": user.id,
"username": user.username,
"email": user.email,
"fullName": user.fullName,
"isSysAdmin": user.isSysAdmin,
"enabled": user.enabled,
}
# If user is SysAdmin, they have full access to everything
if user.isSysAdmin:
return {
"user": userInfo,
"isSysAdmin": True,
"sysAdminNote": "SysAdmin users have full access to all system-level resources without mandate context.",
"roles": [],
"mandates": [],
"uiAccess": [],
"dataAccess": [],
"resourceAccess": [],
}
# Collect all roles for the user
allRoles = []
roleIdToInfo = {} # Map roleId to role info for later reference
# Get mandates for this user
mandateFilter = {"userId": userId, "enabled": True}
if mandateId:
mandateFilter["mandateId"] = mandateId
userMandates = interface.db.getRecordset(UserMandate, recordFilter=mandateFilter)
mandatesInfo = []
for um in userMandates:
umId = um.get("id")
umMandateId = um.get("mandateId")
# Get mandate name
mandate = interface.getMandate(umMandateId)
mandateName = mandate.name if mandate else umMandateId
# Get roles for this UserMandate
umRoles = interface.db.getRecordset(
UserMandateRole,
recordFilter={"userMandateId": umId}
)
mandateRoleIds = []
for umr in umRoles:
roleId = umr.get("roleId")
if roleId:
mandateRoleIds.append(roleId)
# Get role details
roleRecords = interface.db.getRecordset(Role, recordFilter={"id": roleId})
if roleRecords:
role = roleRecords[0]
scope = _getRoleScope(role)
roleInfo = {
"id": roleId,
"roleLabel": role.get("roleLabel"),
"description": role.get("description", {}),
"scope": scope,
"scopePriority": _getRoleScopePriority(scope),
"mandateId": role.get("mandateId"),
"featureInstanceId": role.get("featureInstanceId"),
"source": "mandate",
"sourceMandateId": umMandateId,
"sourceMandateName": mandateName,
}
allRoles.append(roleInfo)
roleIdToInfo[roleId] = roleInfo
# Get feature instances for this mandate
featureInstanceFilter = {"userId": userId, "enabled": True}
featureAccesses = interface.db.getRecordset(FeatureAccess, recordFilter=featureInstanceFilter)
featureInstancesInfo = []
for fa in featureAccesses:
faId = fa.get("id")
faInstanceId = fa.get("featureInstanceId")
# Check if instance belongs to this mandate
instance = interface.db.getRecordset(FeatureInstance, recordFilter={"id": faInstanceId})
if not instance:
continue
instance = instance[0]
if instance.get("mandateId") != umMandateId:
continue
# Filter by featureInstanceId if specified
if featureInstanceId and faInstanceId != featureInstanceId:
continue
# Get feature info
featureCode = instance.get("featureCode")
featureRecords = interface.db.getRecordset(Feature, recordFilter={"code": featureCode})
featureLabel = featureRecords[0].get("label", {}) if featureRecords else {}
# Get roles for this FeatureAccess
faRoles = interface.db.getRecordset(
FeatureAccessRole,
recordFilter={"featureAccessId": faId}
)
instanceRoleIds = []
for far in faRoles:
roleId = far.get("roleId")
if roleId:
instanceRoleIds.append(roleId)
# Get role details (if not already added)
if roleId not in roleIdToInfo:
roleRecords = interface.db.getRecordset(Role, recordFilter={"id": roleId})
if roleRecords:
role = roleRecords[0]
scope = _getRoleScope(role)
roleInfo = {
"id": roleId,
"roleLabel": role.get("roleLabel"),
"description": role.get("description", {}),
"scope": scope,
"scopePriority": _getRoleScopePriority(scope),
"mandateId": role.get("mandateId"),
"featureInstanceId": role.get("featureInstanceId"),
"source": "featureInstance",
"sourceInstanceId": faInstanceId,
"sourceInstanceLabel": instance.get("label"),
}
allRoles.append(roleInfo)
roleIdToInfo[roleId] = roleInfo
featureInstancesInfo.append({
"id": faInstanceId,
"label": instance.get("label"),
"featureCode": featureCode,
"featureLabel": featureLabel,
"roleIds": instanceRoleIds,
})
mandatesInfo.append({
"id": umMandateId,
"name": mandateName,
"roleIds": mandateRoleIds,
"featureInstances": featureInstancesInfo,
})
# Remove duplicate roles (keep most specific)
uniqueRoles = {}
for role in allRoles:
roleId = role["id"]
if roleId not in uniqueRoles or role["scopePriority"] > uniqueRoles[roleId]["scopePriority"]:
uniqueRoles[roleId] = role
allRoles = list(uniqueRoles.values())
# Get all AccessRules for all role IDs
allRoleIds = list(roleIdToInfo.keys())
# Collect access by context
uiAccess = []
dataAccess = []
resourceAccess = []
for roleId in allRoleIds:
roleInfo = roleIdToInfo.get(roleId, {})
roleLabel = roleInfo.get("roleLabel", "unknown")
roleScope = roleInfo.get("scope", "unknown")
# Get all rules for this role
rules = interface.db.getRecordset(AccessRule, recordFilter={"roleId": roleId})
for rule in rules:
context = rule.get("context")
item = rule.get("item")
accessEntry = {
"item": item or "(all)",
"grantedByRoleId": roleId,
"grantedByRoleLabel": roleLabel,
"roleScope": roleScope,
"scopePriority": roleInfo.get("scopePriority", 0),
}
if context == "UI":
accessEntry["view"] = rule.get("view", False)
if accessEntry["view"]:
uiAccess.append(accessEntry)
elif context == "DATA":
accessEntry["view"] = rule.get("view", False)
accessEntry["read"] = _getAccessLevelLabel(rule.get("read"))
accessEntry["create"] = _getAccessLevelLabel(rule.get("create"))
accessEntry["update"] = _getAccessLevelLabel(rule.get("update"))
accessEntry["delete"] = _getAccessLevelLabel(rule.get("delete"))
dataAccess.append(accessEntry)
elif context == "RESOURCE":
accessEntry["view"] = rule.get("view", False)
if accessEntry["view"]:
resourceAccess.append(accessEntry)
# Merge and deduplicate access entries (keep highest priority)
def _mergeAccessEntries(entries: List[Dict], isDataContext: bool = False) -> List[Dict]:
"""Merge entries for same item, keeping highest priority."""
merged = {}
for entry in entries:
item = entry["item"]
priority = entry.get("scopePriority", 0)
if item not in merged or priority > merged[item].get("scopePriority", 0):
merged[item] = entry
elif item in merged and priority == merged[item].get("scopePriority", 0):
# Same priority - merge grantedBy info
existingRoles = merged[item].get("grantedByRoleLabels", [merged[item].get("grantedByRoleLabel")])
if entry["grantedByRoleLabel"] not in existingRoles:
existingRoles.append(entry["grantedByRoleLabel"])
merged[item]["grantedByRoleLabels"] = existingRoles
# For DATA context, merge to most permissive
if isDataContext:
levelOrder = {"NONE": 0, "-": 0, "MY": 1, "GROUP": 2, "ALL": 3}
for field in ["read", "create", "update", "delete"]:
existingLevel = merged[item].get(field, "-")
newLevel = entry.get(field, "-")
if levelOrder.get(newLevel, 0) > levelOrder.get(existingLevel, 0):
merged[item][field] = newLevel
# Clean up and sort
result = list(merged.values())
for entry in result:
if "grantedByRoleLabels" not in entry:
entry["grantedByRoleLabels"] = [entry.get("grantedByRoleLabel")]
# Remove internal priority field from response
entry.pop("scopePriority", None)
result.sort(key=lambda x: x.get("item", ""))
return result
uiAccess = _mergeAccessEntries(uiAccess)
dataAccess = _mergeAccessEntries(dataAccess, isDataContext=True)
resourceAccess = _mergeAccessEntries(resourceAccess)
# Clean up roles for response
for role in allRoles:
role.pop("scopePriority", None)
# Sort roles by scope (instance > mandate > global) then by label
allRoles.sort(key=lambda r: (-_getRoleScopePriority(r.get("scope", "")), r.get("roleLabel", "").lower()))
return {
"user": userInfo,
"isSysAdmin": False,
"roles": allRoles,
"mandates": mandatesInfo,
"uiAccess": uiAccess,
"dataAccess": dataAccess,
"resourceAccess": resourceAccess,
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting user access overview: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Failed to get user access overview: {str(e)}"
)
@router.get("/{userId}/effective-permissions", response_model=Dict[str, Any])
@limiter.limit("60/minute")
async def getEffectivePermissions(
request: Request,
userId: str = Path(..., description="User ID"),
mandateId: str = Query(..., description="Mandate ID context"),
featureInstanceId: Optional[str] = Query(None, description="Feature instance ID context"),
context: str = Query("DATA", description="Context type: DATA, UI, or RESOURCE"),
item: Optional[str] = Query(None, description="Specific item to check permissions for"),
currentUser: User = Depends(requireSysAdmin)
) -> Dict[str, Any]:
"""
Get effective (resolved) permissions for a user in a specific context.
This uses the RBAC resolution logic to show what permissions actually apply.
MULTI-TENANT: SysAdmin-only.
Path Parameters:
- userId: User ID
Query Parameters:
- mandateId: Required mandate context
- featureInstanceId: Optional feature instance context
- context: Permission context (DATA, UI, RESOURCE)
- item: Optional specific item to check
Returns:
- Effective permissions after RBAC resolution
"""
try:
interface = getRootInterface()
# Get user
user = interface.getUser(userId)
if not user:
raise HTTPException(
status_code=404,
detail=f"User {userId} not found"
)
# Convert context string to enum
try:
contextEnum = AccessRuleContext(context)
except ValueError:
raise HTTPException(
status_code=400,
detail=f"Invalid context: {context}. Must be DATA, UI, or RESOURCE."
)
# Use RBAC interface to get actual permissions
from modules.security.rbac import RbacClass
rbac = RbacClass(interface.db, dbApp=interface.db)
permissions = rbac.getUserPermissions(
user=user,
context=contextEnum,
item=item or "",
mandateId=mandateId,
featureInstanceId=featureInstanceId
)
return {
"userId": userId,
"mandateId": mandateId,
"featureInstanceId": featureInstanceId,
"context": context,
"item": item,
"effectivePermissions": {
"view": permissions.view,
"read": _getAccessLevelLabel(permissions.read.value if permissions.read else None),
"create": _getAccessLevelLabel(permissions.create.value if permissions.create else None),
"update": _getAccessLevelLabel(permissions.update.value if permissions.update else None),
"delete": _getAccessLevelLabel(permissions.delete.value if permissions.delete else None),
}
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting effective permissions: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Failed to get effective permissions: {str(e)}"
)

View file

@ -10,17 +10,16 @@ from typing import Optional, Dict, Any
from fastapi import APIRouter, HTTPException, Depends, Body, Path, Query, Request from fastapi import APIRouter, HTTPException, Depends, Body, Path, Query, Request
# Import auth modules # Import auth modules
from modules.auth import limiter, getCurrentUser from modules.auth import limiter, getRequestContext, RequestContext
# Import interfaces # Import interfaces
import modules.interfaces.interfaceDbChatObjects as interfaceDbChatObjects from modules.interfaces import interfaceDbChat
# Import models # Import models
from modules.datamodels.datamodelChat import ChatWorkflow, UserInputRequest, WorkflowModeEnum from modules.datamodels.datamodelChat import ChatWorkflow, UserInputRequest, WorkflowModeEnum
from modules.datamodels.datamodelUam import User
# Import workflow control functions # Import workflow control functions
from modules.features.workflow import chatStart, chatStop from modules.workflows.automation import chatStart, chatStop
# Configure logger # Configure logger
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -32,8 +31,8 @@ router = APIRouter(
responses={404: {"description": "Not found"}} responses={404: {"description": "Not found"}}
) )
def getServiceChat(currentUser: User): def _getServiceChat(context: RequestContext):
return interfaceDbChatObjects.getInterface(currentUser) return interfaceDbChat.getInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None)
# Workflow start endpoint # Workflow start endpoint
@router.post("/start", response_model=ChatWorkflow) @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"), workflowId: Optional[str] = Query(None, description="Optional ID of the workflow to continue"),
workflowMode: WorkflowModeEnum = Query(..., description="Workflow mode: 'Dynamic' or 'Automation' (mandatory)"), workflowMode: WorkflowModeEnum = Query(..., description="Workflow mode: 'Dynamic' or 'Automation' (mandatory)"),
userInput: UserInputRequest = Body(...), userInput: UserInputRequest = Body(...),
currentUser: User = Depends(getCurrentUser) context: RequestContext = Depends(getRequestContext)
) -> ChatWorkflow: ) -> ChatWorkflow:
""" """
Starts a new workflow or continues an existing one. Starts a new workflow or continues an existing one.
@ -54,7 +53,8 @@ async def start_workflow(
""" """
try: try:
# Start or continue workflow using playground controller # Start or continue workflow using playground controller
workflow = await chatStart(currentUser, userInput, workflowMode, workflowId) mandateId = str(context.mandateId) if context.mandateId else None
workflow = await chatStart(context.user, userInput, workflowMode, workflowId, mandateId=mandateId)
return workflow return workflow
@ -71,12 +71,13 @@ async def start_workflow(
async def stop_workflow( async def stop_workflow(
request: Request, request: Request,
workflowId: str = Path(..., description="ID of the workflow to stop"), workflowId: str = Path(..., description="ID of the workflow to stop"),
currentUser: User = Depends(getCurrentUser) context: RequestContext = Depends(getRequestContext)
) -> ChatWorkflow: ) -> ChatWorkflow:
"""Stops a running workflow.""" """Stops a running workflow."""
try: try:
# Stop workflow using playground controller # Stop workflow using playground controller
workflow = await chatStop(currentUser, workflowId) mandateId = str(context.mandateId) if context.mandateId else None
workflow = await chatStop(context.user, workflowId, mandateId=mandateId)
return workflow return workflow
@ -94,7 +95,7 @@ async def get_workflow_chat_data(
request: Request, request: Request,
workflowId: str = Path(..., description="ID of the workflow"), workflowId: str = Path(..., description="ID of the workflow"),
afterTimestamp: Optional[float] = Query(None, description="Unix timestamp to get data after"), afterTimestamp: Optional[float] = Query(None, description="Unix timestamp to get data after"),
currentUser: User = Depends(getCurrentUser) context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Get unified chat data (messages, logs, stats) for a workflow with timestamp-based selective data transfer. Get unified chat data (messages, logs, stats) for a workflow with timestamp-based selective data transfer.
@ -102,7 +103,7 @@ async def get_workflow_chat_data(
""" """
try: try:
# Get service center # Get service center
interfaceDbChat = getServiceChat(currentUser) interfaceDbChat = _getServiceChat(context)
# Verify workflow exists # Verify workflow exists
workflow = interfaceDbChat.getWorkflow(workflowId) workflow = interfaceDbChat.getWorkflow(workflowId)

View file

@ -20,10 +20,11 @@ import math
from modules.datamodels.datamodelUam import User, UserConnection, AuthAuthority, ConnectionStatus from modules.datamodels.datamodelUam import User, UserConnection, AuthAuthority, ConnectionStatus
from modules.datamodels.datamodelSecurity import Token from modules.datamodels.datamodelSecurity import Token
from modules.auth import getCurrentUser, limiter from modules.auth import getCurrentUser, limiter
from modules.auth.tokenRefreshService import token_refresh_service
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
from modules.interfaces.interfaceDbAppObjects import getInterface from modules.interfaces.interfaceDbApp import getInterface
from modules.shared.timeUtils import getUtcTimestamp, parseTimestamp from modules.shared.timeUtils import getUtcTimestamp, parseTimestamp
from modules.interfaces.interfaceDbComponentObjects import ComponentObjects from modules.interfaces.interfaceDbManagement import ComponentObjects
# Configure logger # Configure logger
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -92,6 +93,52 @@ router = APIRouter(
responses={404: {"description": "Not found"}} responses={404: {"description": "Not found"}}
) )
# ============================================================================
# OPTIONS ENDPOINTS (for dropdowns)
# ============================================================================
@router.get("/statuses/options", response_model=List[Dict[str, Any]])
@limiter.limit("60/minute")
async def get_connection_status_options(
request: Request,
currentUser: User = Depends(getCurrentUser)
) -> List[Dict[str, Any]]:
"""
Get connection status options for select dropdowns.
Returns standardized format: [{ value, label }]
"""
return [
{"value": status.value, "label": status.value.capitalize()}
for status in ConnectionStatus
]
@router.get("/authorities/options", response_model=List[Dict[str, Any]])
@limiter.limit("60/minute")
async def get_auth_authority_options(
request: Request,
currentUser: User = Depends(getCurrentUser)
) -> List[Dict[str, Any]]:
"""
Get authentication authority options for select dropdowns.
Returns standardized format: [{ value, label }]
"""
authorityLabels = {
"local": "Local",
"google": "Google",
"msft": "Microsoft"
}
return [
{"value": auth.value, "label": authorityLabels.get(auth.value, auth.value)}
for auth in AuthAuthority
]
# ============================================================================
# CRUD ENDPOINTS
# ============================================================================
@router.get("/", response_model=PaginatedResponse[UserConnection]) @router.get("/", response_model=PaginatedResponse[UserConnection])
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def get_connections( async def get_connections(
@ -136,7 +183,6 @@ async def get_connections(
# Perform silent token refresh for expired OAuth connections # Perform silent token refresh for expired OAuth connections
try: try:
from modules.auth import token_refresh_service
refresh_result = await token_refresh_service.refresh_expired_tokens(currentUser.id) refresh_result = await token_refresh_service.refresh_expired_tokens(currentUser.id)
if refresh_result.get("refreshed", 0) > 0: if refresh_result.get("refreshed", 0) > 0:
logger.info(f"Silently refreshed {refresh_result['refreshed']} tokens for user {currentUser.id}") logger.info(f"Silently refreshed {refresh_result['refreshed']} tokens for user {currentUser.id}")
@ -285,13 +331,8 @@ async def create_connection(
detail=f"Unsupported connection type: {connection_data.get('type')}" detail=f"Unsupported connection type: {connection_data.get('type')}"
) )
# Get fresh copy of user from database # Note: currentUser is already authenticated via JWT - no need to re-verify from database
user = interface.getUser(currentUser.id) # The getCurrentUser dependency already validated the user exists
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
# Always create a new connection with PENDING status # Always create a new connection with PENDING status
connection = interface.addUserConnection( connection = interface.addUserConnection(

View file

@ -10,7 +10,7 @@ import json
from modules.auth import limiter, getCurrentUser from modules.auth import limiter, getCurrentUser
# Import interfaces # Import interfaces
import modules.interfaces.interfaceDbComponentObjects as interfaceDbComponentObjects import modules.interfaces.interfaceDbManagement as interfaceDbManagement
from modules.datamodels.datamodelFiles import FileItem, FilePreview from modules.datamodels.datamodelFiles import FileItem, FilePreview
from modules.shared.attributeUtils import getModelAttributeDefinitions from modules.shared.attributeUtils import getModelAttributeDefinitions
from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelUam import User
@ -69,7 +69,7 @@ async def get_files(
detail=f"Invalid pagination parameter: {str(e)}" detail=f"Invalid pagination parameter: {str(e)}"
) )
managementInterface = interfaceDbComponentObjects.getInterface(currentUser) managementInterface = interfaceDbManagement.getInterface(currentUser)
result = managementInterface.getAllFiles(pagination=paginationParams) result = managementInterface.getAllFiles(pagination=paginationParams)
# If pagination was requested, result is PaginatedResult # If pagination was requested, result is PaginatedResult
@ -112,17 +112,17 @@ async def upload_file(
file.fileName = file.filename file.fileName = file.filename
"""Upload a file""" """Upload a file"""
try: try:
managementInterface = interfaceDbComponentObjects.getInterface(currentUser) managementInterface = interfaceDbManagement.getInterface(currentUser)
# Read file # Read file
fileContent = await file.read() fileContent = await file.read()
# Check size limits # Check size limits
maxSize = int(interfaceDbComponentObjects.APP_CONFIG.get("File_Management_MAX_UPLOAD_SIZE_MB")) * 1024 * 1024 # in bytes maxSize = int(interfaceDbManagement.APP_CONFIG.get("File_Management_MAX_UPLOAD_SIZE_MB")) * 1024 * 1024 # in bytes
if len(fileContent) > maxSize: if len(fileContent) > maxSize:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f"File too large. Maximum size: {interfaceDbComponentObjects.APP_CONFIG.get('File_Management_MAX_UPLOAD_SIZE_MB')}MB" detail=f"File too large. Maximum size: {interfaceDbManagement.APP_CONFIG.get('File_Management_MAX_UPLOAD_SIZE_MB')}MB"
) )
# Save file via LucyDOM interface in the database # Save file via LucyDOM interface in the database
@ -136,15 +136,13 @@ async def upload_file(
else: # new_file else: # new_file
message = "File uploaded successfully" message = "File uploaded successfully"
# If workflowId is provided, update the file information
if workflowId:
updateData = {"workflowId": workflowId}
managementInterface.updateFile(fileItem.id, updateData)
fileItem.workflowId = workflowId
# Convert FileItem to dictionary for JSON response # Convert FileItem to dictionary for JSON response
fileMeta = fileItem.model_dump() fileMeta = fileItem.model_dump()
# If workflowId is provided, include it in the response (not stored in FileItem model)
if workflowId:
fileMeta["workflowId"] = workflowId
# Response with duplicate information # Response with duplicate information
return JSONResponse({ return JSONResponse({
"message": message, "message": message,
@ -155,7 +153,7 @@ async def upload_file(
"isDuplicate": duplicateType != "new_file" "isDuplicate": duplicateType != "new_file"
}) })
except interfaceDbComponentObjects.FileStorageError as e: except interfaceDbManagement.FileStorageError as e:
logger.error(f"Error during file upload (storage): {str(e)}") logger.error(f"Error during file upload (storage): {str(e)}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@ -177,7 +175,7 @@ async def get_file(
) -> FileItem: ) -> FileItem:
"""Get a file""" """Get a file"""
try: try:
managementInterface = interfaceDbComponentObjects.getInterface(currentUser) managementInterface = interfaceDbManagement.getInterface(currentUser)
# Get file via LucyDOM interface from the database # Get file via LucyDOM interface from the database
fileData = managementInterface.getFile(fileId) fileData = managementInterface.getFile(fileId)
@ -189,19 +187,19 @@ async def get_file(
return fileData return fileData
except interfaceDbComponentObjects.FileNotFoundError as e: except interfaceDbManagement.FileNotFoundError as e:
logger.warning(f"File not found: {str(e)}") logger.warning(f"File not found: {str(e)}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail=str(e) detail=str(e)
) )
except interfaceDbComponentObjects.FilePermissionError as e: except interfaceDbManagement.FilePermissionError as e:
logger.warning(f"No permission for file: {str(e)}") logger.warning(f"No permission for file: {str(e)}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail=str(e) detail=str(e)
) )
except interfaceDbComponentObjects.FileError as e: except interfaceDbManagement.FileError as e:
logger.error(f"Error retrieving file: {str(e)}") logger.error(f"Error retrieving file: {str(e)}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@ -224,7 +222,7 @@ async def update_file(
) -> FileItem: ) -> FileItem:
"""Update file info""" """Update file info"""
try: try:
managementInterface = interfaceDbComponentObjects.getInterface(currentUser) managementInterface = interfaceDbManagement.getInterface(currentUser)
# Get the file from the database # Get the file from the database
file = managementInterface.getFile(fileId) file = managementInterface.getFile(fileId)
@ -270,7 +268,7 @@ async def delete_file(
currentUser: User = Depends(getCurrentUser) currentUser: User = Depends(getCurrentUser)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Delete a file""" """Delete a file"""
managementInterface = interfaceDbComponentObjects.getInterface(currentUser) managementInterface = interfaceDbManagement.getInterface(currentUser)
# Check if the file exists # Check if the file exists
existingFile = managementInterface.getFile(fileId) existingFile = managementInterface.getFile(fileId)
@ -297,7 +295,7 @@ async def get_file_stats(
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Returns statistics about the stored files""" """Returns statistics about the stored files"""
try: try:
managementInterface = interfaceDbComponentObjects.getInterface(currentUser) managementInterface = interfaceDbManagement.getInterface(currentUser)
# Get all files - metadata only # Get all files - metadata only
allFiles = managementInterface.getAllFiles() allFiles = managementInterface.getAllFiles()
@ -336,7 +334,7 @@ async def download_file(
) -> Response: ) -> Response:
"""Download a file""" """Download a file"""
try: try:
managementInterface = interfaceDbComponentObjects.getInterface(currentUser) managementInterface = interfaceDbManagement.getInterface(currentUser)
# Get file data # Get file data
fileData = managementInterface.getFile(fileId) fileData = managementInterface.getFile(fileId)
@ -384,7 +382,7 @@ async def preview_file(
) -> FilePreview: ) -> FilePreview:
"""Preview a file's content""" """Preview a file's content"""
try: try:
managementInterface = interfaceDbComponentObjects.getInterface(currentUser) managementInterface = interfaceDbManagement.getInterface(currentUser)
# Get file preview using the correct method # Get file preview using the correct method
preview = managementInterface.getFileContent(fileId) preview = managementInterface.getFileContent(fileId)

View file

@ -3,6 +3,10 @@
""" """
Mandate routes for the backend API. Mandate routes for the backend API.
Implements the endpoints for mandate management. 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 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 from fastapi import status
import logging import logging
import json import json
from pydantic import BaseModel, Field
# Import auth module # Import auth module
from modules.auth import limiter, getCurrentUser from modules.auth import limiter, requireSysAdmin, getRequestContext, RequestContext
# Import interfaces # Import interfaces
import modules.interfaces.interfaceDbAppObjects as interfaceDbAppObjects import modules.interfaces.interfaceDbApp as interfaceDbApp
from modules.shared.attributeUtils import getModelAttributeDefinitions from modules.shared.attributeUtils import getModelAttributeDefinitions
from modules.shared.auditLogger import audit_logger
# Import the model classes # Import the model classes
from modules.datamodels.datamodelUam import Mandate, User 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 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"""
id: str # UserMandate ID as primary key
userId: str
mandateId: str
roleIds: List[str]
enabled: bool
class MandateUserInfo(BaseModel):
"""User info within a mandate context"""
id: str # UserMandate ID as primary key
userId: str
username: str
email: Optional[str]
fullName: Optional[str]
roleIds: List[str]
roleLabels: List[str] # Resolved role labels for display
enabled: bool
# Configure logger # Configure logger
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -40,10 +79,11 @@ router = APIRouter(
async def get_mandates( async def get_mandates(
request: Request, request: Request,
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"), pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
currentUser: User = Depends(getCurrentUser) currentUser: User = Depends(requireSysAdmin)
) -> PaginatedResponse[Mandate]: ) -> PaginatedResponse[Mandate]:
""" """
Get mandates with optional pagination, sorting, and filtering. Get mandates with optional pagination, sorting, and filtering.
MULTI-TENANT: SysAdmin-only (mandates are system resources).
Query Parameters: Query Parameters:
- pagination: JSON-encoded PaginationParams object, or None for no pagination - 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)}" detail=f"Invalid pagination parameter: {str(e)}"
) )
appInterface = interfaceDbAppObjects.getInterface(currentUser) appInterface = interfaceDbApp.getRootInterface()
result = appInterface.getAllMandates(pagination=paginationParams) result = appInterface.getAllMandates(pagination=paginationParams)
# If pagination was requested, result is PaginatedResult # If pagination was requested, result is PaginatedResult
@ -103,11 +143,14 @@ async def get_mandates(
async def get_mandate( async def get_mandate(
request: Request, request: Request,
mandateId: str = Path(..., description="ID of the mandate"), mandateId: str = Path(..., description="ID of the mandate"),
currentUser: User = Depends(getCurrentUser) currentUser: User = Depends(requireSysAdmin)
) -> Mandate: ) -> Mandate:
"""Get a specific mandate by ID""" """
Get a specific mandate by ID.
MULTI-TENANT: SysAdmin-only.
"""
try: try:
appInterface = interfaceDbAppObjects.getInterface(currentUser) appInterface = interfaceDbApp.getRootInterface()
mandate = appInterface.getMandate(mandateId) mandate = appInterface.getMandate(mandateId)
if not mandate: if not mandate:
@ -131,9 +174,12 @@ async def get_mandate(
async def create_mandate( async def create_mandate(
request: Request, request: Request,
mandateData: dict = Body(..., description="Mandate data with at least 'name' field"), mandateData: dict = Body(..., description="Mandate data with at least 'name' field"),
currentUser: User = Depends(getCurrentUser) currentUser: User = Depends(requireSysAdmin)
) -> Mandate: ) -> Mandate:
"""Create a new mandate""" """
Create a new mandate.
MULTI-TENANT: SysAdmin-only.
"""
try: try:
logger.debug(f"Creating mandate with data: {mandateData}") logger.debug(f"Creating mandate with data: {mandateData}")
@ -146,14 +192,16 @@ async def create_mandate(
) )
# Get optional fields with defaults # Get optional fields with defaults
language = mandateData.get('language', 'en') description = mandateData.get('description')
enabled = mandateData.get('enabled', True)
appInterface = interfaceDbAppObjects.getInterface(currentUser) appInterface = interfaceDbApp.getRootInterface()
# Create mandate # Create mandate
newMandate = appInterface.createMandate( newMandate = appInterface.createMandate(
name=name, name=name,
language=language description=description,
enabled=enabled
) )
if not newMandate: if not newMandate:
@ -162,6 +210,8 @@ async def create_mandate(
detail="Failed to create mandate" detail="Failed to create mandate"
) )
logger.info(f"Mandate {newMandate.id} created by SysAdmin {currentUser.id}")
return newMandate return newMandate
except HTTPException: except HTTPException:
raise raise
@ -178,13 +228,16 @@ async def update_mandate(
request: Request, request: Request,
mandateId: str = Path(..., description="ID of the mandate to update"), mandateId: str = Path(..., description="ID of the mandate to update"),
mandateData: dict = Body(..., description="Mandate update data"), mandateData: dict = Body(..., description="Mandate update data"),
currentUser: User = Depends(getCurrentUser) currentUser: User = Depends(requireSysAdmin)
) -> Mandate: ) -> Mandate:
"""Update an existing mandate""" """
Update an existing mandate.
MULTI-TENANT: SysAdmin-only.
"""
try: try:
logger.debug(f"Updating mandate {mandateId} with data: {mandateData}") logger.debug(f"Updating mandate {mandateId} with data: {mandateData}")
appInterface = interfaceDbAppObjects.getInterface(currentUser) appInterface = interfaceDbApp.getRootInterface()
# Check if mandate exists # Check if mandate exists
existingMandate = appInterface.getMandate(mandateId) existingMandate = appInterface.getMandate(mandateId)
@ -203,6 +256,8 @@ async def update_mandate(
detail="Failed to update mandate" detail="Failed to update mandate"
) )
logger.info(f"Mandate {mandateId} updated by SysAdmin {currentUser.id}")
return updatedMandate return updatedMandate
except HTTPException: except HTTPException:
raise raise
@ -218,11 +273,14 @@ async def update_mandate(
async def delete_mandate( async def delete_mandate(
request: Request, request: Request,
mandateId: str = Path(..., description="ID of the mandate to delete"), mandateId: str = Path(..., description="ID of the mandate to delete"),
currentUser: User = Depends(getCurrentUser) currentUser: User = Depends(requireSysAdmin)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Delete a mandate""" """
Delete a mandate.
MULTI-TENANT: SysAdmin-only.
"""
try: try:
appInterface = interfaceDbAppObjects.getInterface(currentUser) appInterface = interfaceDbApp.getRootInterface()
# Check if mandate exists # Check if mandate exists
existingMandate = appInterface.getMandate(mandateId) existingMandate = appInterface.getMandate(mandateId)
@ -232,6 +290,12 @@ async def delete_mandate(
detail=f"Mandate {mandateId} not found" detail=f"Mandate {mandateId} not found"
) )
# MULTI-TENANT: Delete all UserMandate entries for this mandate first
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 # Delete mandate
try: try:
appInterface.deleteMandate(mandateId) appInterface.deleteMandate(mandateId)
@ -241,6 +305,8 @@ async def delete_mandate(
detail=str(e) detail=str(e)
) )
logger.info(f"Mandate {mandateId} deleted by SysAdmin {currentUser.id}")
return {"message": f"Mandate {mandateId} deleted successfully"} return {"message": f"Mandate {mandateId} deleted successfully"}
except HTTPException: except HTTPException:
raise raise
@ -250,3 +316,557 @@ async def delete_mandate(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to delete mandate: {str(e)}" detail=f"Failed to delete mandate: {str(e)}"
) )
# =============================================================================
# User Management within Mandates (Mandate-Admin)
# =============================================================================
@router.get("/{targetMandateId}/users")
@limiter.limit("60/minute")
async def list_mandate_users(
request: Request,
targetMandateId: str = Path(..., description="ID of the mandate"),
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
context: RequestContext = Depends(getRequestContext)
):
"""
List all users in a mandate with pagination, search, and sorting support.
Requires Mandate-Admin role or SysAdmin.
Args:
pagination: Optional pagination parameters (page, pageSize, search, filters, sort)
"""
# Check permission
if not _hasMandateAdminRole(context, targetMandateId) and not context.isSysAdmin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Mandate-Admin role required"
)
try:
rootInterface = interfaceDbApp.getRootInterface()
# Verify mandate exists
mandate = rootInterface.getMandate(targetMandateId)
if not mandate:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Mandate {targetMandateId} not found"
)
# Parse pagination parameter
paginationParams = None
if pagination:
try:
paginationDict = json.loads(pagination)
if paginationDict:
# Normalize pagination dict
if 'sort' in paginationDict and paginationDict['sort']:
normalizedSort = []
for item in paginationDict['sort']:
if isinstance(item, dict):
normalizedSort.append(item)
paginationDict['sort'] = normalizedSort if normalizedSort else None
paginationParams = paginationDict
except (json.JSONDecodeError, ValueError) as e:
raise HTTPException(
status_code=400,
detail=f"Invalid pagination parameter: {str(e)}"
)
# Get all UserMandate entries for this mandate
userMandates = rootInterface.db.getRecordset(
UserMandate,
recordFilter={"mandateId": targetMandateId}
)
result = []
for um in userMandates:
# Get user info
user = rootInterface.getUser(um.get("userId"))
if not user:
continue
# Get roles for this membership
roleIds = rootInterface.getRoleIdsForUserMandate(um.get("id"))
# Resolve role labels for display
roleLabels = []
for roleId in roleIds:
role = rootInterface.getRole(roleId)
if role:
roleLabels.append(role.roleLabel)
else:
roleLabels.append(roleId) # Fallback to ID if not found
result.append({
"id": um.get("id"), # UserMandate ID as primary key
"userId": str(user.id),
"username": user.username,
"email": user.email,
"fullName": user.fullName,
"roleIds": roleIds,
"roleLabels": roleLabels,
"enabled": um.get("enabled", True)
})
# Apply search, filtering, and sorting if pagination requested
if paginationParams:
# Apply search (if search term provided)
searchTerm = paginationParams.get('search', '').lower() if paginationParams.get('search') else ''
if searchTerm:
searchedResult = []
for item in result:
username = (item.get("username") or "").lower()
email = (item.get("email") or "").lower()
fullName = (item.get("fullName") or "").lower()
roleLabelsStr = " ".join(item.get("roleLabels") or []).lower()
if searchTerm in username or searchTerm in email or searchTerm in fullName or searchTerm in roleLabelsStr:
searchedResult.append(item)
result = searchedResult
# Apply filters (if filters provided)
filters = paginationParams.get('filters')
if filters:
for fieldName, filterValue in filters.items():
if filterValue is not None and filterValue != '':
filterValueLower = str(filterValue).lower()
result = [
item for item in result
if str(item.get(fieldName, '')).lower() == filterValueLower
]
# Apply sorting
sortFields = paginationParams.get('sort')
if sortFields:
for sortItem in reversed(sortFields):
field = sortItem.get('field')
direction = sortItem.get('direction', 'asc')
if field:
result = sorted(
result,
key=lambda x: str(x.get(field, '') or '').lower(),
reverse=(direction == 'desc')
)
# Apply pagination
page = paginationParams.get('page', 1)
pageSize = paginationParams.get('pageSize', 25)
totalItems = len(result)
totalPages = (totalItems + pageSize - 1) // pageSize if totalItems > 0 else 0
startIdx = (page - 1) * pageSize
endIdx = startIdx + pageSize
paginatedResult = result[startIdx:endIdx]
return {
"items": paginatedResult,
"pagination": {
"currentPage": page,
"pageSize": pageSize,
"totalItems": totalItems,
"totalPages": totalPages
}
}
# No pagination - return all users as list
return result
except HTTPException:
raise
except Exception as e:
logger.error(f"Error listing users for mandate {targetMandateId}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to list users: {str(e)}"
)
@router.post("/{targetMandateId}/users", response_model=UserMandateResponse)
@limiter.limit("30/minute")
async def add_user_to_mandate(
request: Request,
targetMandateId: 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:
targetMandateId: 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, targetMandateId):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Mandate-Admin role required to add users"
)
try:
rootInterface = interfaceDbApp.getRootInterface()
# 3. Verify mandate exists
mandate = rootInterface.getMandate(targetMandateId)
if not mandate:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Mandate {targetMandateId} not found"
)
# 4. Verify target user exists
targetUser = rootInterface.getUser(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, targetMandateId)
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(targetMandateId):
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=targetMandateId,
roleIds=data.roleIds
)
# 8. Audit - Log permission change with IP address
audit_logger.logPermissionChange(
userId=str(context.user.id),
mandateId=targetMandateId,
action="user_added_to_mandate",
targetUserId=data.targetUserId,
details=f"Roles assigned: {data.roleIds}",
resourceType="UserMandate",
resourceId=str(userMandate.id)
)
logger.info(
f"User {context.user.id} added user {data.targetUserId} to mandate {targetMandateId} "
f"with roles {data.roleIds}"
)
return UserMandateResponse(
id=str(userMandate.id), # UserMandate ID as primary key
userId=data.targetUserId,
mandateId=targetMandateId,
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("/{targetMandateId}/users/{targetUserId}", response_model=Dict[str, str])
@limiter.limit("30/minute")
async def remove_user_from_mandate(
request: Request,
targetMandateId: 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:
targetMandateId: Target mandate ID
targetUserId: User ID to remove
"""
# Check Mandate-Admin permission
if not _hasMandateAdminRole(context, targetMandateId):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Mandate-Admin role required"
)
try:
rootInterface = interfaceDbApp.getRootInterface()
# Verify mandate exists
mandate = rootInterface.getMandate(targetMandateId)
if not mandate:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Mandate {targetMandateId} not found"
)
# Get user's membership
membership = rootInterface.getUserMandate(targetUserId, targetMandateId)
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, targetMandateId, 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, targetMandateId)
# Audit - Log permission change
audit_logger.logPermissionChange(
userId=str(context.user.id),
mandateId=targetMandateId,
action="user_removed_from_mandate",
targetUserId=targetUserId,
details="User removed from mandate",
resourceType="UserMandate"
)
logger.info(f"User {context.user.id} removed user {targetUserId} from mandate {targetMandateId}")
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("/{targetMandateId}/users/{targetUserId}/roles", response_model=UserMandateResponse)
@limiter.limit("30/minute")
async def update_user_roles_in_mandate(
request: Request,
targetMandateId: 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:
targetMandateId: Target mandate ID
targetUserId: User ID to update
roleIds: New set of role IDs
"""
# Check Mandate-Admin permission
if not _hasMandateAdminRole(context, targetMandateId):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Mandate-Admin role required"
)
try:
rootInterface = interfaceDbApp.getRootInterface()
# Get user's membership
membership = rootInterface.getUserMandate(targetUserId, targetMandateId)
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(targetMandateId):
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, targetMandateId)
willBeAdmin = _hasAdminRoleInList(rootInterface, roleIds, targetMandateId)
if isCurrentlyAdmin and not willBeAdmin:
if _isLastMandateAdmin(rootInterface, targetMandateId, 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 - Log role assignment change
audit_logger.logPermissionChange(
userId=str(context.user.id),
mandateId=targetMandateId,
action="role_assigned",
targetUserId=targetUserId,
details=f"New roles: {roleIds}",
resourceType="UserMandateRole",
resourceId=str(membership.id)
)
logger.info(
f"User {context.user.id} updated roles for user {targetUserId} "
f"in mandate {targetMandateId} to {roleIds}"
)
return UserMandateResponse(
id=str(membership.id), # UserMandate ID as primary key
userId=targetUserId,
mandateId=targetMandateId,
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 = interfaceDbApp.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

View file

@ -10,7 +10,7 @@ import json
from modules.auth import limiter, getCurrentUser from modules.auth import limiter, getCurrentUser
# Import interfaces # Import interfaces
import modules.interfaces.interfaceDbComponentObjects as interfaceDbComponentObjects import modules.interfaces.interfaceDbManagement as interfaceDbManagement
from modules.datamodels.datamodelUtils import Prompt from modules.datamodels.datamodelUtils import Prompt
from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelUam import User
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
@ -58,7 +58,7 @@ async def get_prompts(
detail=f"Invalid pagination parameter: {str(e)}" detail=f"Invalid pagination parameter: {str(e)}"
) )
managementInterface = interfaceDbComponentObjects.getInterface(currentUser) managementInterface = interfaceDbManagement.getInterface(currentUser)
result = managementInterface.getAllPrompts(pagination=paginationParams) result = managementInterface.getAllPrompts(pagination=paginationParams)
# If pagination was requested, result is PaginatedResult # If pagination was requested, result is PaginatedResult
@ -89,7 +89,7 @@ async def create_prompt(
currentUser: User = Depends(getCurrentUser) currentUser: User = Depends(getCurrentUser)
) -> Prompt: ) -> Prompt:
"""Create a new prompt""" """Create a new prompt"""
managementInterface = interfaceDbComponentObjects.getInterface(currentUser) managementInterface = interfaceDbManagement.getInterface(currentUser)
# Create prompt # Create prompt
newPrompt = managementInterface.createPrompt(prompt) newPrompt = managementInterface.createPrompt(prompt)
@ -104,7 +104,7 @@ async def get_prompt(
currentUser: User = Depends(getCurrentUser) currentUser: User = Depends(getCurrentUser)
) -> Prompt: ) -> Prompt:
"""Get a specific prompt""" """Get a specific prompt"""
managementInterface = interfaceDbComponentObjects.getInterface(currentUser) managementInterface = interfaceDbManagement.getInterface(currentUser)
# Get prompt # Get prompt
prompt = managementInterface.getPrompt(promptId) prompt = managementInterface.getPrompt(promptId)
@ -125,7 +125,7 @@ async def update_prompt(
currentUser: User = Depends(getCurrentUser) currentUser: User = Depends(getCurrentUser)
) -> Prompt: ) -> Prompt:
"""Update an existing prompt""" """Update an existing prompt"""
managementInterface = interfaceDbComponentObjects.getInterface(currentUser) managementInterface = interfaceDbManagement.getInterface(currentUser)
# Check if the prompt exists # Check if the prompt exists
existingPrompt = managementInterface.getPrompt(promptId) existingPrompt = managementInterface.getPrompt(promptId)
@ -160,7 +160,7 @@ async def delete_prompt(
currentUser: User = Depends(getCurrentUser) currentUser: User = Depends(getCurrentUser)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Delete a prompt""" """Delete a prompt"""
managementInterface = interfaceDbComponentObjects.getInterface(currentUser) managementInterface = interfaceDbManagement.getInterface(currentUser)
# Check if the prompt exists # Check if the prompt exists
existingPrompt = managementInterface.getPrompt(promptId) existingPrompt = managementInterface.getPrompt(promptId)

View file

@ -1,849 +0,0 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Routes for Trustee feature data management.
Implements CRUD operations for organisations, roles, access, contracts, documents, and positions.
"""
from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Query, Response
from fastapi.responses import StreamingResponse
from typing import List, Dict, Any, Optional
from fastapi import status
import logging
import json
import io
from modules.auth import limiter, getCurrentUser
from modules.interfaces.interfaceDbTrusteeObjects import getInterface
from modules.datamodels.datamodelTrustee import (
TrusteeOrganisation,
TrusteeRole,
TrusteeAccess,
TrusteeContract,
TrusteeDocument,
TrusteePosition,
TrusteePositionDocument,
)
from modules.datamodels.datamodelUam import User
from modules.datamodels.datamodelPagination import (
PaginationParams,
PaginatedResponse,
PaginationMetadata,
normalize_pagination_dict,
)
logger = logging.getLogger(__name__)
router = APIRouter(
prefix="/api/trustee",
tags=["Trustee"],
responses={404: {"description": "Not found"}}
)
# ===== Helper Functions =====
def _parsePagination(pagination: Optional[str]) -> Optional[PaginationParams]:
"""Parse pagination parameter from JSON string."""
if not pagination:
return None
try:
paginationDict = json.loads(pagination)
if paginationDict:
paginationDict = normalize_pagination_dict(paginationDict)
return PaginationParams(**paginationDict)
except (json.JSONDecodeError, ValueError) as e:
raise HTTPException(
status_code=400,
detail=f"Invalid pagination parameter: {str(e)}"
)
return None
# ===== Organisation Routes =====
@router.get("/organisations", response_model=PaginatedResponse[TrusteeOrganisation])
@limiter.limit("30/minute")
async def getOrganisations(
request: Request,
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams"),
currentUser: User = Depends(getCurrentUser)
) -> PaginatedResponse[TrusteeOrganisation]:
"""Get all organisations with optional pagination."""
logger = logging.getLogger(__name__)
logger.debug(f"getOrganisations called for user {currentUser.id}, roles: {currentUser.roleLabels}")
paginationParams = _parsePagination(pagination)
interface = getInterface(currentUser)
result = interface.getAllOrganisations(paginationParams)
logger.debug(f"getOrganisations returned {len(result.items)} items")
if paginationParams:
return PaginatedResponse(
items=result.items,
pagination=PaginationMetadata(
currentPage=paginationParams.page or 1,
pageSize=paginationParams.pageSize or 20,
totalItems=result.totalItems,
totalPages=result.totalPages,
sort=paginationParams.sort if paginationParams else [],
filters=paginationParams.filters if paginationParams else None
)
)
return PaginatedResponse(items=result.items, pagination=None)
@router.get("/organisations/{orgId}", response_model=TrusteeOrganisation)
@limiter.limit("30/minute")
async def getOrganisation(
request: Request,
orgId: str = Path(..., description="Organisation ID"),
currentUser: User = Depends(getCurrentUser)
) -> TrusteeOrganisation:
"""Get a single organisation by ID."""
interface = getInterface(currentUser)
org = interface.getOrganisation(orgId)
if not org:
raise HTTPException(status_code=404, detail=f"Organisation {orgId} not found")
return TrusteeOrganisation(**org)
@router.post("/organisations", response_model=TrusteeOrganisation, status_code=201)
@limiter.limit("10/minute")
async def createOrganisation(
request: Request,
data: TrusteeOrganisation = Body(...),
currentUser: User = Depends(getCurrentUser)
) -> TrusteeOrganisation:
"""Create a new organisation."""
interface = getInterface(currentUser)
result = interface.createOrganisation(data.model_dump())
if not result:
raise HTTPException(status_code=400, detail="Failed to create organisation")
return TrusteeOrganisation(**result)
@router.put("/organisations/{orgId}", response_model=TrusteeOrganisation)
@limiter.limit("10/minute")
async def updateOrganisation(
request: Request,
orgId: str = Path(..., description="Organisation ID"),
data: TrusteeOrganisation = Body(...),
currentUser: User = Depends(getCurrentUser)
) -> TrusteeOrganisation:
"""Update an organisation."""
interface = getInterface(currentUser)
existing = interface.getOrganisation(orgId)
if not existing:
raise HTTPException(status_code=404, detail=f"Organisation {orgId} not found")
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)
@router.delete("/organisations/{orgId}")
@limiter.limit("10/minute")
async def deleteOrganisation(
request: Request,
orgId: str = Path(..., description="Organisation ID"),
currentUser: User = Depends(getCurrentUser)
) -> Dict[str, Any]:
"""Delete an organisation."""
interface = getInterface(currentUser)
existing = interface.getOrganisation(orgId)
if not existing:
raise HTTPException(status_code=404, detail=f"Organisation {orgId} not found")
success = interface.deleteOrganisation(orgId)
if not success:
raise HTTPException(status_code=400, detail="Failed to delete organisation")
return {"message": f"Organisation {orgId} deleted"}
# ===== Role Routes =====
@router.get("/roles", response_model=PaginatedResponse[TrusteeRole])
@limiter.limit("30/minute")
async def getRoles(
request: Request,
pagination: Optional[str] = Query(None),
currentUser: User = Depends(getCurrentUser)
) -> PaginatedResponse[TrusteeRole]:
"""Get all roles with optional pagination."""
paginationParams = _parsePagination(pagination)
interface = getInterface(currentUser)
result = interface.getAllRoles(paginationParams)
if paginationParams:
return PaginatedResponse(
items=result.items,
pagination=PaginationMetadata(
currentPage=paginationParams.page or 1,
pageSize=paginationParams.pageSize or 20,
totalItems=result.totalItems,
totalPages=result.totalPages,
sort=paginationParams.sort if paginationParams else [],
filters=paginationParams.filters if paginationParams else None
)
)
return PaginatedResponse(items=result.items, pagination=None)
@router.get("/roles/{roleId}", response_model=TrusteeRole)
@limiter.limit("30/minute")
async def getRole(
request: Request,
roleId: str = Path(..., description="Role ID"),
currentUser: User = Depends(getCurrentUser)
) -> TrusteeRole:
"""Get a single role by ID."""
interface = getInterface(currentUser)
role = interface.getRole(roleId)
if not role:
raise HTTPException(status_code=404, detail=f"Role {roleId} not found")
return TrusteeRole(**role)
@router.post("/roles", response_model=TrusteeRole, status_code=201)
@limiter.limit("10/minute")
async def createRole(
request: Request,
data: TrusteeRole = Body(...),
currentUser: User = Depends(getCurrentUser)
) -> TrusteeRole:
"""Create a new role (sysadmin only)."""
interface = getInterface(currentUser)
result = interface.createRole(data.model_dump())
if not result:
raise HTTPException(status_code=400, detail="Failed to create role")
return TrusteeRole(**result)
@router.put("/roles/{roleId}", response_model=TrusteeRole)
@limiter.limit("10/minute")
async def updateRole(
request: Request,
roleId: str = Path(...),
data: TrusteeRole = Body(...),
currentUser: User = Depends(getCurrentUser)
) -> TrusteeRole:
"""Update a role (sysadmin only)."""
interface = getInterface(currentUser)
existing = interface.getRole(roleId)
if not existing:
raise HTTPException(status_code=404, detail=f"Role {roleId} not found")
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)
@router.delete("/roles/{roleId}")
@limiter.limit("10/minute")
async def deleteRole(
request: Request,
roleId: str = Path(...),
currentUser: User = Depends(getCurrentUser)
) -> Dict[str, Any]:
"""Delete a role (sysadmin only, fails if in use)."""
interface = getInterface(currentUser)
existing = interface.getRole(roleId)
if not existing:
raise HTTPException(status_code=404, detail=f"Role {roleId} not found")
success = interface.deleteRole(roleId)
if not success:
raise HTTPException(status_code=400, detail="Failed to delete role (may be in use)")
return {"message": f"Role {roleId} deleted"}
# ===== Access Routes =====
@router.get("/access", response_model=PaginatedResponse[TrusteeAccess])
@limiter.limit("30/minute")
async def getAllAccess(
request: Request,
pagination: Optional[str] = Query(None),
currentUser: User = Depends(getCurrentUser)
) -> PaginatedResponse[TrusteeAccess]:
"""Get all access records with optional pagination."""
paginationParams = _parsePagination(pagination)
interface = getInterface(currentUser)
result = interface.getAllAccess(paginationParams)
if paginationParams:
return PaginatedResponse(
items=result.items,
pagination=PaginationMetadata(
currentPage=paginationParams.page or 1,
pageSize=paginationParams.pageSize or 20,
totalItems=result.totalItems,
totalPages=result.totalPages,
sort=paginationParams.sort if paginationParams else [],
filters=paginationParams.filters if paginationParams else None
)
)
return PaginatedResponse(items=result.items, pagination=None)
@router.get("/access/{accessId}", response_model=TrusteeAccess)
@limiter.limit("30/minute")
async def getAccess(
request: Request,
accessId: str = Path(...),
currentUser: User = Depends(getCurrentUser)
) -> TrusteeAccess:
"""Get a single access record by ID."""
interface = getInterface(currentUser)
access = interface.getAccess(accessId)
if not access:
raise HTTPException(status_code=404, detail=f"Access {accessId} not found")
return TrusteeAccess(**access)
@router.get("/access/organisation/{orgId}", response_model=List[TrusteeAccess])
@limiter.limit("30/minute")
async def getAccessByOrganisation(
request: Request,
orgId: str = Path(...),
currentUser: User = Depends(getCurrentUser)
) -> List[TrusteeAccess]:
"""Get all access records for an organisation."""
interface = getInterface(currentUser)
return [TrusteeAccess(**a) for a in interface.getAccessByOrganisation(orgId)]
@router.get("/access/user/{userId}", response_model=List[TrusteeAccess])
@limiter.limit("30/minute")
async def getAccessByUser(
request: Request,
userId: str = Path(...),
currentUser: User = Depends(getCurrentUser)
) -> List[TrusteeAccess]:
"""Get all access records for a user."""
interface = getInterface(currentUser)
return [TrusteeAccess(**a) for a in interface.getAccessByUser(userId)]
@router.post("/access", response_model=TrusteeAccess, status_code=201)
@limiter.limit("10/minute")
async def createAccess(
request: Request,
data: TrusteeAccess = Body(...),
currentUser: User = Depends(getCurrentUser)
) -> TrusteeAccess:
"""Create a new access record."""
interface = getInterface(currentUser)
result = interface.createAccess(data.model_dump())
if not result:
raise HTTPException(status_code=400, detail="Failed to create access")
return TrusteeAccess(**result)
@router.put("/access/{accessId}", response_model=TrusteeAccess)
@limiter.limit("10/minute")
async def updateAccess(
request: Request,
accessId: str = Path(...),
data: TrusteeAccess = Body(...),
currentUser: User = Depends(getCurrentUser)
) -> TrusteeAccess:
"""Update an access record."""
interface = getInterface(currentUser)
existing = interface.getAccess(accessId)
if not existing:
raise HTTPException(status_code=404, detail=f"Access {accessId} not found")
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)
@router.delete("/access/{accessId}")
@limiter.limit("10/minute")
async def deleteAccess(
request: Request,
accessId: str = Path(...),
currentUser: User = Depends(getCurrentUser)
) -> Dict[str, Any]:
"""Delete an access record."""
interface = getInterface(currentUser)
existing = interface.getAccess(accessId)
if not existing:
raise HTTPException(status_code=404, detail=f"Access {accessId} not found")
success = interface.deleteAccess(accessId)
if not success:
raise HTTPException(status_code=400, detail="Failed to delete access")
return {"message": f"Access {accessId} deleted"}
# ===== Contract Routes =====
@router.get("/contracts", response_model=PaginatedResponse[TrusteeContract])
@limiter.limit("30/minute")
async def getContracts(
request: Request,
pagination: Optional[str] = Query(None),
currentUser: User = Depends(getCurrentUser)
) -> PaginatedResponse[TrusteeContract]:
"""Get all contracts with optional pagination."""
paginationParams = _parsePagination(pagination)
interface = getInterface(currentUser)
result = interface.getAllContracts(paginationParams)
if paginationParams:
return PaginatedResponse(
items=result.items,
pagination=PaginationMetadata(
currentPage=paginationParams.page or 1,
pageSize=paginationParams.pageSize or 20,
totalItems=result.totalItems,
totalPages=result.totalPages,
sort=paginationParams.sort if paginationParams else [],
filters=paginationParams.filters if paginationParams else None
)
)
return PaginatedResponse(items=result.items, pagination=None)
@router.get("/contracts/{contractId}", response_model=TrusteeContract)
@limiter.limit("30/minute")
async def getContract(
request: Request,
contractId: str = Path(...),
currentUser: User = Depends(getCurrentUser)
) -> TrusteeContract:
"""Get a single contract by ID."""
interface = getInterface(currentUser)
contract = interface.getContract(contractId)
if not contract:
raise HTTPException(status_code=404, detail=f"Contract {contractId} not found")
return TrusteeContract(**contract)
@router.get("/contracts/organisation/{orgId}", response_model=List[TrusteeContract])
@limiter.limit("30/minute")
async def getContractsByOrganisation(
request: Request,
orgId: str = Path(...),
currentUser: User = Depends(getCurrentUser)
) -> List[TrusteeContract]:
"""Get all contracts for an organisation."""
interface = getInterface(currentUser)
return [TrusteeContract(**c) for c in interface.getContractsByOrganisation(orgId)]
@router.post("/contracts", response_model=TrusteeContract, status_code=201)
@limiter.limit("10/minute")
async def createContract(
request: Request,
data: TrusteeContract = Body(...),
currentUser: User = Depends(getCurrentUser)
) -> TrusteeContract:
"""Create a new contract."""
interface = getInterface(currentUser)
result = interface.createContract(data.model_dump())
if not result:
raise HTTPException(status_code=400, detail="Failed to create contract")
return TrusteeContract(**result)
@router.put("/contracts/{contractId}", response_model=TrusteeContract)
@limiter.limit("10/minute")
async def updateContract(
request: Request,
contractId: str = Path(...),
data: TrusteeContract = Body(...),
currentUser: User = Depends(getCurrentUser)
) -> TrusteeContract:
"""Update a contract (organisationId is immutable)."""
interface = getInterface(currentUser)
existing = interface.getContract(contractId)
if not existing:
raise HTTPException(status_code=404, detail=f"Contract {contractId} not found")
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)
@router.delete("/contracts/{contractId}")
@limiter.limit("10/minute")
async def deleteContract(
request: Request,
contractId: str = Path(...),
currentUser: User = Depends(getCurrentUser)
) -> Dict[str, Any]:
"""Delete a contract."""
interface = getInterface(currentUser)
existing = interface.getContract(contractId)
if not existing:
raise HTTPException(status_code=404, detail=f"Contract {contractId} not found")
success = interface.deleteContract(contractId)
if not success:
raise HTTPException(status_code=400, detail="Failed to delete contract")
return {"message": f"Contract {contractId} deleted"}
# ===== Document Routes =====
@router.get("/documents", response_model=PaginatedResponse[TrusteeDocument])
@limiter.limit("30/minute")
async def getDocuments(
request: Request,
pagination: Optional[str] = Query(None),
currentUser: User = Depends(getCurrentUser)
) -> PaginatedResponse[TrusteeDocument]:
"""Get all documents (metadata only) with optional pagination."""
paginationParams = _parsePagination(pagination)
interface = getInterface(currentUser)
result = interface.getAllDocuments(paginationParams)
if paginationParams:
return PaginatedResponse(
items=result.items,
pagination=PaginationMetadata(
currentPage=paginationParams.page or 1,
pageSize=paginationParams.pageSize or 20,
totalItems=result.totalItems,
totalPages=result.totalPages,
sort=paginationParams.sort if paginationParams else [],
filters=paginationParams.filters if paginationParams else None
)
)
return PaginatedResponse(items=result.items, pagination=None)
@router.get("/documents/{documentId}", response_model=TrusteeDocument)
@limiter.limit("30/minute")
async def getDocument(
request: Request,
documentId: str = Path(...),
currentUser: User = Depends(getCurrentUser)
) -> TrusteeDocument:
"""Get document metadata by ID."""
interface = getInterface(currentUser)
doc = interface.getDocument(documentId)
if not doc:
raise HTTPException(status_code=404, detail=f"Document {documentId} not found")
return TrusteeDocument(**doc)
@router.get("/documents/{documentId}/data")
@limiter.limit("10/minute")
async def getDocumentData(
request: Request,
documentId: str = Path(...),
currentUser: User = Depends(getCurrentUser)
):
"""Download document binary data."""
interface = getInterface(currentUser)
doc = interface.getDocument(documentId)
if not doc:
raise HTTPException(status_code=404, detail=f"Document {documentId} not found")
data = interface.getDocumentData(documentId)
if not data:
raise HTTPException(status_code=404, detail="Document data not found")
return StreamingResponse(
io.BytesIO(data),
media_type=doc.get("documentMimeType", "application/octet-stream"),
headers={"Content-Disposition": f"attachment; filename={doc.get('documentName', 'document')}"}
)
@router.get("/documents/contract/{contractId}", response_model=List[TrusteeDocument])
@limiter.limit("30/minute")
async def getDocumentsByContract(
request: Request,
contractId: str = Path(...),
currentUser: User = Depends(getCurrentUser)
) -> List[TrusteeDocument]:
"""Get all documents for a contract."""
interface = getInterface(currentUser)
return [TrusteeDocument(**d) for d in interface.getDocumentsByContract(contractId)]
@router.post("/documents", response_model=TrusteeDocument, status_code=201)
@limiter.limit("10/minute")
async def createDocument(
request: Request,
data: TrusteeDocument = Body(...),
currentUser: User = Depends(getCurrentUser)
) -> TrusteeDocument:
"""Create a new document."""
interface = getInterface(currentUser)
result = interface.createDocument(data.model_dump())
if not result:
raise HTTPException(status_code=400, detail="Failed to create document")
return TrusteeDocument(**result)
@router.put("/documents/{documentId}", response_model=TrusteeDocument)
@limiter.limit("10/minute")
async def updateDocument(
request: Request,
documentId: str = Path(...),
data: TrusteeDocument = Body(...),
currentUser: User = Depends(getCurrentUser)
) -> TrusteeDocument:
"""Update document metadata."""
interface = getInterface(currentUser)
existing = interface.getDocument(documentId)
if not existing:
raise HTTPException(status_code=404, detail=f"Document {documentId} not found")
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)
@router.delete("/documents/{documentId}")
@limiter.limit("10/minute")
async def deleteDocument(
request: Request,
documentId: str = Path(...),
currentUser: User = Depends(getCurrentUser)
) -> Dict[str, Any]:
"""Delete a document."""
interface = getInterface(currentUser)
existing = interface.getDocument(documentId)
if not existing:
raise HTTPException(status_code=404, detail=f"Document {documentId} not found")
success = interface.deleteDocument(documentId)
if not success:
raise HTTPException(status_code=400, detail="Failed to delete document")
return {"message": f"Document {documentId} deleted"}
# ===== Position Routes =====
@router.get("/positions", response_model=PaginatedResponse[TrusteePosition])
@limiter.limit("30/minute")
async def getPositions(
request: Request,
pagination: Optional[str] = Query(None),
currentUser: User = Depends(getCurrentUser)
) -> PaginatedResponse[TrusteePosition]:
"""Get all positions with optional pagination."""
paginationParams = _parsePagination(pagination)
interface = getInterface(currentUser)
result = interface.getAllPositions(paginationParams)
if paginationParams:
return PaginatedResponse(
items=result.items,
pagination=PaginationMetadata(
currentPage=paginationParams.page or 1,
pageSize=paginationParams.pageSize or 20,
totalItems=result.totalItems,
totalPages=result.totalPages,
sort=paginationParams.sort if paginationParams else [],
filters=paginationParams.filters if paginationParams else None
)
)
return PaginatedResponse(items=result.items, pagination=None)
@router.get("/positions/{positionId}", response_model=TrusteePosition)
@limiter.limit("30/minute")
async def getPosition(
request: Request,
positionId: str = Path(...),
currentUser: User = Depends(getCurrentUser)
) -> TrusteePosition:
"""Get a single position by ID."""
interface = getInterface(currentUser)
position = interface.getPosition(positionId)
if not position:
raise HTTPException(status_code=404, detail=f"Position {positionId} not found")
return TrusteePosition(**position)
@router.get("/positions/contract/{contractId}", response_model=List[TrusteePosition])
@limiter.limit("30/minute")
async def getPositionsByContract(
request: Request,
contractId: str = Path(...),
currentUser: User = Depends(getCurrentUser)
) -> List[TrusteePosition]:
"""Get all positions for a contract."""
interface = getInterface(currentUser)
return [TrusteePosition(**p) for p in interface.getPositionsByContract(contractId)]
@router.get("/positions/organisation/{orgId}", response_model=List[TrusteePosition])
@limiter.limit("30/minute")
async def getPositionsByOrganisation(
request: Request,
orgId: str = Path(...),
currentUser: User = Depends(getCurrentUser)
) -> List[TrusteePosition]:
"""Get all positions for an organisation."""
interface = getInterface(currentUser)
return [TrusteePosition(**p) for p in interface.getPositionsByOrganisation(orgId)]
@router.post("/positions", response_model=TrusteePosition, status_code=201)
@limiter.limit("10/minute")
async def createPosition(
request: Request,
data: TrusteePosition = Body(...),
currentUser: User = Depends(getCurrentUser)
) -> TrusteePosition:
"""Create a new position."""
interface = getInterface(currentUser)
result = interface.createPosition(data.model_dump())
if not result:
raise HTTPException(status_code=400, detail="Failed to create position")
return TrusteePosition(**result)
@router.put("/positions/{positionId}", response_model=TrusteePosition)
@limiter.limit("10/minute")
async def updatePosition(
request: Request,
positionId: str = Path(...),
data: TrusteePosition = Body(...),
currentUser: User = Depends(getCurrentUser)
) -> TrusteePosition:
"""Update a position."""
interface = getInterface(currentUser)
existing = interface.getPosition(positionId)
if not existing:
raise HTTPException(status_code=404, detail=f"Position {positionId} not found")
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)
@router.delete("/positions/{positionId}")
@limiter.limit("10/minute")
async def deletePosition(
request: Request,
positionId: str = Path(...),
currentUser: User = Depends(getCurrentUser)
) -> Dict[str, Any]:
"""Delete a position."""
interface = getInterface(currentUser)
existing = interface.getPosition(positionId)
if not existing:
raise HTTPException(status_code=404, detail=f"Position {positionId} not found")
success = interface.deletePosition(positionId)
if not success:
raise HTTPException(status_code=400, detail="Failed to delete position")
return {"message": f"Position {positionId} deleted"}
# ===== Position-Document Link Routes =====
@router.get("/position-documents", response_model=PaginatedResponse[TrusteePositionDocument])
@limiter.limit("30/minute")
async def getPositionDocuments(
request: Request,
pagination: Optional[str] = Query(None),
currentUser: User = Depends(getCurrentUser)
) -> PaginatedResponse[TrusteePositionDocument]:
"""Get all position-document links with optional pagination."""
paginationParams = _parsePagination(pagination)
interface = getInterface(currentUser)
result = interface.getAllPositionDocuments(paginationParams)
if paginationParams:
return PaginatedResponse(
items=result.items,
pagination=PaginationMetadata(
currentPage=paginationParams.page or 1,
pageSize=paginationParams.pageSize or 20,
totalItems=result.totalItems,
totalPages=result.totalPages,
sort=paginationParams.sort if paginationParams else [],
filters=paginationParams.filters if paginationParams else None
)
)
return PaginatedResponse(items=result.items, pagination=None)
@router.get("/position-documents/{linkId}", response_model=TrusteePositionDocument)
@limiter.limit("30/minute")
async def getPositionDocument(
request: Request,
linkId: str = Path(...),
currentUser: User = Depends(getCurrentUser)
) -> TrusteePositionDocument:
"""Get a single position-document link by ID."""
interface = getInterface(currentUser)
link = interface.getPositionDocument(linkId)
if not link:
raise HTTPException(status_code=404, detail=f"Link {linkId} not found")
return TrusteePositionDocument(**link)
@router.get("/position-documents/position/{positionId}", response_model=List[TrusteePositionDocument])
@limiter.limit("30/minute")
async def getDocumentsForPosition(
request: Request,
positionId: str = Path(...),
currentUser: User = Depends(getCurrentUser)
) -> List[TrusteePositionDocument]:
"""Get all document links for a position."""
interface = getInterface(currentUser)
return [TrusteePositionDocument(**l) for l in interface.getDocumentsForPosition(positionId)]
@router.get("/position-documents/document/{documentId}", response_model=List[TrusteePositionDocument])
@limiter.limit("30/minute")
async def getPositionsForDocument(
request: Request,
documentId: str = Path(...),
currentUser: User = Depends(getCurrentUser)
) -> List[TrusteePositionDocument]:
"""Get all position links for a document."""
interface = getInterface(currentUser)
return [TrusteePositionDocument(**l) for l in interface.getPositionsForDocument(documentId)]
@router.post("/position-documents", response_model=TrusteePositionDocument, status_code=201)
@limiter.limit("10/minute")
async def createPositionDocument(
request: Request,
data: TrusteePositionDocument = Body(...),
currentUser: User = Depends(getCurrentUser)
) -> TrusteePositionDocument:
"""Create a new position-document link."""
interface = getInterface(currentUser)
result = interface.createPositionDocument(data.model_dump())
if not result:
raise HTTPException(status_code=400, detail="Failed to create link")
return TrusteePositionDocument(**result)
@router.delete("/position-documents/{linkId}")
@limiter.limit("10/minute")
async def deletePositionDocument(
request: Request,
linkId: str = Path(...),
currentUser: User = Depends(getCurrentUser)
) -> Dict[str, Any]:
"""Delete a position-document link."""
interface = getInterface(currentUser)
existing = interface.getPositionDocument(linkId)
if not existing:
raise HTTPException(status_code=404, detail=f"Link {linkId} not found")
success = interface.deletePositionDocument(linkId)
if not success:
raise HTTPException(status_code=400, detail="Failed to delete link")
return {"message": f"Link {linkId} deleted"}

View file

@ -3,48 +3,207 @@
""" """
User routes for the backend API. User routes for the backend API.
Implements the endpoints for user management. 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 from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Response, Query
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional
from fastapi import status from fastapi import status
from pydantic import BaseModel
import logging import logging
import json import json
# Import interfaces and models # Import interfaces and models
import modules.interfaces.interfaceDbAppObjects as interfaceDbAppObjects import modules.interfaces.interfaceDbApp as interfaceDbApp
from modules.auth import getCurrentUser, limiter from modules.auth import limiter, getRequestContext, RequestContext
# Import the attribute definition and helper functions # Import the attribute definition and helper functions
from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelUam import User, UserInDB
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
# Configure logger # Configure logger
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _applyFiltersAndSort(items: List[Dict[str, Any]], paginationParams: Optional[PaginationParams]) -> List[Dict[str, Any]]:
"""
Apply filters and sorting to a list of items.
This is used when we can't do server-side filtering in the database (e.g., SysAdmin view).
Args:
items: List of dictionaries to filter/sort
paginationParams: Pagination parameters with filters and sort
Returns:
Filtered and sorted list
"""
if not paginationParams:
return items
result = items.copy()
# Apply filters
if paginationParams.filters:
filters = paginationParams.filters
# Handle general search
searchTerm = filters.get('search', '').lower() if filters.get('search') else None
if searchTerm:
def matchesSearch(item: Dict[str, Any]) -> bool:
for value in item.values():
if value is not None and searchTerm in str(value).lower():
return True
return False
result = [item for item in result if matchesSearch(item)]
# Handle field-specific filters
for field, filterValue in filters.items():
if field == 'search':
continue # Already handled
if isinstance(filterValue, dict) and 'operator' in filterValue:
operator = filterValue.get('operator', 'equals')
value = filterValue.get('value')
else:
operator = 'equals'
value = filterValue
if value is None or value == '':
continue
def matchesFilter(item: Dict[str, Any], f: str, op: str, v: Any) -> bool:
itemValue = item.get(f)
if itemValue is None:
return False
# Convert to string for comparison if needed
itemStr = str(itemValue).lower()
valueStr = str(v).lower()
if op in ('equals', 'eq'):
return itemStr == valueStr
elif op == 'contains':
return valueStr in itemStr
elif op == 'startsWith':
return itemStr.startswith(valueStr)
elif op == 'endsWith':
return itemStr.endswith(valueStr)
elif op in ('gt', 'gte', 'lt', 'lte'):
try:
itemNum = float(itemValue)
valueNum = float(v)
if op == 'gt':
return itemNum > valueNum
elif op == 'gte':
return itemNum >= valueNum
elif op == 'lt':
return itemNum < valueNum
elif op == 'lte':
return itemNum <= valueNum
except (ValueError, TypeError):
return False
elif op == 'in':
if isinstance(v, list):
return itemStr in [str(x).lower() for x in v]
return False
elif op == 'notIn':
if isinstance(v, list):
return itemStr not in [str(x).lower() for x in v]
return True
return True
result = [item for item in result if matchesFilter(item, field, operator, value)]
# Apply sorting
if paginationParams.sort:
for sortField in reversed(paginationParams.sort):
fieldName = sortField.field
ascending = sortField.direction == 'asc'
def getSortKey(item: Dict[str, Any]):
value = item.get(fieldName)
if value is None:
return (1, '') # Nulls last
if isinstance(value, bool):
return (0, not value if ascending else value)
if isinstance(value, (int, float)):
return (0, value)
return (0, str(value).lower())
result = sorted(result, key=getSortKey, reverse=not ascending)
return result
router = APIRouter( router = APIRouter(
prefix="/api/users", prefix="/api/users",
tags=["Manage Users"], tags=["Manage Users"],
responses={404: {"description": "Not found"}} responses={404: {"description": "Not found"}}
) )
# ============================================================================
# OPTIONS ENDPOINTS (for dropdowns)
# ============================================================================
@router.get("/options", response_model=List[Dict[str, Any]])
@limiter.limit("60/minute")
async def get_user_options(
request: Request,
context: RequestContext = Depends(getRequestContext)
) -> List[Dict[str, Any]]:
"""
Get user options for select dropdowns.
MULTI-TENANT: mandateId from X-Mandate-Id header determines scope.
Returns standardized format: [{ value, label }]
"""
try:
appInterface = interfaceDbApp.getInterface(context.user)
if context.mandateId:
result = appInterface.getUsersByMandate(str(context.mandateId), None)
users = result.items if hasattr(result, 'items') else result
elif context.isSysAdmin:
users = appInterface.getAllUsers()
else:
raise HTTPException(status_code=403, detail="Access denied")
return [
{"value": user.id, "label": user.fullName or user.username or user.email or user.id}
for user in users
]
except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting user options: {str(e)}")
raise HTTPException(status_code=500, detail=f"Failed to get user options: {str(e)}")
# ============================================================================
# CRUD ENDPOINTS
# ============================================================================
@router.get("/", response_model=PaginatedResponse[User]) @router.get("/", response_model=PaginatedResponse[User])
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def get_users( async def get_users(
request: Request, request: Request,
mandateId: Optional[str] = Query(None, description="Mandate ID to filter users"),
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"), pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
currentUser: User = Depends(getCurrentUser) context: RequestContext = Depends(getRequestContext)
) -> PaginatedResponse[User]: ) -> PaginatedResponse[User]:
""" """
Get users with optional pagination, sorting, and filtering. 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: Query Parameters:
- mandateId: Optional mandate ID to filter users
- pagination: JSON-encoded PaginationParams object, or None for no pagination - pagination: JSON-encoded PaginationParams object, or None for no pagination
Examples: 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":[]} - GET /api/users/?pagination={"page":1,"pageSize":10,"sort":[]}
""" """
try: try:
@ -62,20 +221,21 @@ async def get_users(
detail=f"Invalid pagination parameter: {str(e)}" detail=f"Invalid pagination parameter: {str(e)}"
) )
appInterface = interfaceDbAppObjects.getInterface(currentUser) appInterface = interfaceDbApp.getInterface(context.user)
# 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)
# If pagination was requested, result is PaginatedResult # MULTI-TENANT: Use mandateId from context (header)
# If no pagination, result is List[User] # SysAdmin without mandateId can see all users
if paginationParams: if context.mandateId:
# Get users for specific mandate using getUsersByMandate
result = appInterface.getUsersByMandate(str(context.mandateId), paginationParams)
# getUsersByMandate returns PaginatedResult if pagination was provided
if paginationParams and hasattr(result, 'items'):
return PaginatedResponse( return PaginatedResponse(
items=result.items, items=result.items,
pagination=PaginationMetadata( pagination=PaginationMetadata(
currentPage=paginationParams.page, currentPage=result.currentPage,
pageSize=paginationParams.pageSize, pageSize=result.pageSize,
totalItems=result.totalItems, totalItems=result.totalItems,
totalPages=result.totalPages, totalPages=result.totalPages,
sort=paginationParams.sort, sort=paginationParams.sort,
@ -83,10 +243,61 @@ async def get_users(
) )
) )
else: else:
# No pagination - result is a list
users = result if isinstance(result, list) else result.items if hasattr(result, 'items') else []
return PaginatedResponse( return PaginatedResponse(
items=result, items=users,
pagination=None pagination=None
) )
elif context.isSysAdmin:
# SysAdmin without mandateId sees all users
# Get all users directly from database using UserInDB (the actual database model)
allUsers = appInterface.db.getRecordset(UserInDB)
# Convert to cleaned dictionaries first for filtering
cleanedUsers = []
for u in allUsers:
cleanedUser = {k: v for k, v in u.items() if not k.startswith("_") and k != "hashedPassword" and k != "resetToken" and k != "resetTokenExpires"}
# Ensure roleLabels is always a list
if cleanedUser.get("roleLabels") is None:
cleanedUser["roleLabels"] = []
cleanedUsers.append(cleanedUser)
# Apply server-side filtering and sorting
filteredUsers = _applyFiltersAndSort(cleanedUsers, paginationParams)
# Convert to User objects
users = [User(**u) for u in filteredUsers]
if paginationParams:
import math
totalItems = len(users)
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
)
else:
# 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: except HTTPException:
raise raise
except Exception as e: except Exception as e:
@ -101,11 +312,14 @@ async def get_users(
async def get_user( async def get_user(
request: Request, request: Request,
userId: str = Path(..., description="ID of the user"), userId: str = Path(..., description="ID of the user"),
currentUser: User = Depends(getCurrentUser) context: RequestContext = Depends(getRequestContext)
) -> User: ) -> 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: try:
appInterface = interfaceDbAppObjects.getInterface(currentUser) appInterface = interfaceDbApp.getInterface(context.user)
# Get user without filtering by enabled status # Get user without filtering by enabled status
user = appInterface.getUser(userId) user = appInterface.getUser(userId)
@ -115,6 +329,19 @@ async def get_user(
detail=f"User with ID {userId} 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 return user
except HTTPException: except HTTPException:
raise raise
@ -125,30 +352,55 @@ async def get_user(
detail=f"Failed to get user: {str(e)}" detail=f"Failed to get user: {str(e)}"
) )
class CreateUserRequest(BaseModel):
"""Request body for creating a new user"""
username: str
email: Optional[str] = None
fullName: Optional[str] = None
language: str = "en"
enabled: bool = True
isSysAdmin: bool = False
password: Optional[str] = None
@router.post("", response_model=User) @router.post("", response_model=User)
@limiter.limit("10/minute") @limiter.limit("10/minute")
async def create_user( async def create_user(
request: Request, request: Request,
user_data: User = Body(...), userData: CreateUserRequest = Body(...),
password: Optional[str] = Body(None, embed=True), context: RequestContext = Depends(getRequestContext)
currentUser: User = Depends(getCurrentUser)
) -> User: ) -> 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 = interfaceDbApp.getInterface(context.user)
# Extract fields from User model and call createUser with individual parameters # Extract fields from request model and call createUser with individual parameters
from modules.datamodels.datamodelUam import AuthAuthority
newUser = appInterface.createUser( newUser = appInterface.createUser(
username=user_data.username, username=userData.username,
password=password, password=userData.password,
email=user_data.email, email=userData.email,
fullName=user_data.fullName, fullName=userData.fullName,
language=user_data.language, language=userData.language,
enabled=user_data.enabled, enabled=userData.enabled,
roleLabels=user_data.roleLabels if user_data.roleLabels else ["user"], authenticationAuthority=AuthAuthority.LOCAL,
authenticationAuthority=user_data.authenticationAuthority isSysAdmin=userData.isSysAdmin
) )
# 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 return newUser
@router.put("/{userId}", response_model=User) @router.put("/{userId}", response_model=User)
@ -157,10 +409,13 @@ async def update_user(
request: Request, request: Request,
userId: str = Path(..., description="ID of the user to update"), userId: str = Path(..., description="ID of the user to update"),
userData: User = Body(...), userData: User = Body(...),
currentUser: User = Depends(getCurrentUser) context: RequestContext = Depends(getRequestContext)
) -> User: ) -> 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 = interfaceDbApp.getInterface(context.user)
# Check if the user exists # Check if the user exists
existingUser = appInterface.getUser(userId) existingUser = appInterface.getUser(userId)
@ -170,6 +425,19 @@ async def update_user(
detail=f"User with ID {userId} 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="Cannot update user outside your mandate"
)
# Update user # Update user
updatedUser = appInterface.updateUser(userId, userData) updatedUser = appInterface.updateUser(userId, userData)
@ -187,28 +455,44 @@ async def reset_user_password(
request: Request, request: Request,
userId: str = Path(..., description="ID of the user to reset password for"), userId: str = Path(..., description="ID of the user to reset password for"),
newPassword: str = Body(..., embed=True), newPassword: str = Body(..., embed=True),
currentUser: User = Depends(getCurrentUser) context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]: ) -> 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: try:
# Check if current user is admin # 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( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Only administrators can reset passwords" detail="Only administrators can reset passwords"
) )
# Get user interface # Get user interface
appInterface = interfaceDbAppObjects.getInterface(currentUser) appInterface = interfaceDbApp.getInterface(context.user)
# Get target user # Get target user
target_user = appInterface.getUserById(userId) target_user = appInterface.getUser(userId)
if not target_user: if not target_user:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail="User not found" 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 # Validate password strength
if len(newPassword) < 8: if len(newPassword) < 8:
raise HTTPException( raise HTTPException(
@ -226,12 +510,11 @@ async def reset_user_password(
# SECURITY: Automatically revoke all tokens for the user after password reset # SECURITY: Automatically revoke all tokens for the user after password reset
try: try:
from modules.datamodels.datamodelUam import AuthAuthority
revoked_count = appInterface.revokeTokensByUser( revoked_count = appInterface.revokeTokensByUser(
userId=userId, userId=userId,
authority=None, # Revoke all authorities authority=None, # Revoke all authorities
mandateId=None, # Revoke across all mandates mandateId=None, # Revoke across all mandates
revokedBy=currentUser.id, revokedBy=context.user.id,
reason="password_reset" reason="password_reset"
) )
logger.info(f"Revoked {revoked_count} tokens for user {userId} after password reset") logger.info(f"Revoked {revoked_count} tokens for user {userId} after password reset")
@ -243,10 +526,12 @@ async def reset_user_password(
try: try:
from modules.shared.auditLogger import audit_logger from modules.shared.auditLogger import audit_logger
audit_logger.logSecurityEvent( audit_logger.logSecurityEvent(
userId=str(currentUser.id), userId=str(context.user.id),
mandateId=str(currentUser.mandateId), mandateId=str(context.mandateId) if context.mandateId else "system",
action="password_reset", action="password_reset",
details=f"Reset password for user {userId}" details=f"Reset password for user {userId}",
ipAddress=request.client.host if request.client else None,
success=True
) )
except Exception: except Exception:
pass pass
@ -271,15 +556,18 @@ async def change_password(
request: Request, request: Request,
currentPassword: str = Body(..., embed=True), currentPassword: str = Body(..., embed=True),
newPassword: str = Body(..., embed=True), newPassword: str = Body(..., embed=True),
currentUser: User = Depends(getCurrentUser) context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Change current user's password""" """
Change current user's password.
MULTI-TENANT: User changes their own password (no mandate restriction).
"""
try: try:
# Get user interface # Get user interface
appInterface = interfaceDbAppObjects.getInterface(currentUser) appInterface = interfaceDbApp.getInterface(context.user)
# Verify current password # Verify current password
if not appInterface.verifyPassword(currentPassword, currentUser.passwordHash): if not appInterface.verifyPassword(currentPassword, context.user.passwordHash):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Current password is incorrect" detail="Current password is incorrect"
@ -293,7 +581,7 @@ async def change_password(
) )
# Change password # Change password
success = appInterface.resetUserPassword(str(currentUser.id), newPassword) success = appInterface.resetUserPassword(str(context.user.id), newPassword)
if not success: if not success:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@ -302,27 +590,28 @@ async def change_password(
# SECURITY: Automatically revoke all tokens for the user after password change # SECURITY: Automatically revoke all tokens for the user after password change
try: try:
from modules.datamodels.datamodelUam import AuthAuthority
revoked_count = appInterface.revokeTokensByUser( revoked_count = appInterface.revokeTokensByUser(
userId=str(currentUser.id), userId=str(context.user.id),
authority=None, # Revoke all authorities authority=None, # Revoke all authorities
mandateId=None, # Revoke across all mandates mandateId=None, # Revoke across all mandates
revokedBy=currentUser.id, revokedBy=context.user.id,
reason="password_change" 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: 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 # Don't fail the password change if token revocation fails
# Log password change # Log password change
try: try:
from modules.shared.auditLogger import audit_logger from modules.shared.auditLogger import audit_logger
audit_logger.logSecurityEvent( audit_logger.logSecurityEvent(
userId=str(currentUser.id), userId=str(context.user.id),
mandateId=str(currentUser.mandateId), mandateId=str(context.mandateId) if context.mandateId else "system",
action="password_change", action="password_change",
details="User changed their own password" details="User changed their own password",
ipAddress=request.client.host if request.client else None,
success=True
) )
except Exception: except Exception:
pass pass
@ -342,13 +631,15 @@ async def change_password(
@router.post("/{userId}/send-password-link") @router.post("/{userId}/send-password-link")
@limiter.limit("10/minute") @limiter.limit("10/minute")
async def sendPasswordLink( async def send_password_link(
request: Request, request: Request,
userId: str = Path(..., description="ID of the user to send password setup link"), userId: str = Path(..., description="ID of the user to send password setup link"),
frontendUrl: str = Body(..., embed=True), frontendUrl: str = Body(..., embed=True),
currentUser: User = Depends(getCurrentUser) context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]: ) -> 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. 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. Used when creating users without password or to help users who forgot their password.
@ -359,10 +650,9 @@ async def sendPasswordLink(
""" """
try: try:
from modules.shared.configuration import APP_CONFIG from modules.shared.configuration import APP_CONFIG
from modules.interfaces.interfaceDbAppObjects import getRootInterface
# Get user interface # Get user interface
appInterface = interfaceDbAppObjects.getInterface(currentUser) appInterface = interfaceDbApp.getInterface(context.user)
# Get target user # Get target user
targetUser = appInterface.getUser(userId) targetUser = appInterface.getUser(userId)
@ -372,6 +662,19 @@ async def sendPasswordLink(
detail="User not found" 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 # Check if user has an email
if not targetUser.email: if not targetUser.email:
raise HTTPException( raise HTTPException(
@ -440,15 +743,15 @@ Falls Sie diese Anforderung nicht erwartet haben, kontaktieren Sie bitte Ihren A
try: try:
from modules.shared.auditLogger import audit_logger from modules.shared.auditLogger import audit_logger
audit_logger.logSecurityEvent( audit_logger.logSecurityEvent(
userId=str(currentUser.id), userId=str(context.user.id),
mandateId=str(currentUser.mandateId), mandateId=str(context.mandateId) if context.mandateId else "system",
action="send_password_link", action="send_password_link",
details=f"Sent password setup link to user {userId} ({targetUser.email})" details=f"Sent password setup link to user {userId} ({targetUser.email})"
) )
except Exception: except Exception:
pass 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 { return {
"message": f"Password setup link sent to {targetUser.email}", "message": f"Password setup link sent to {targetUser.email}",
@ -470,10 +773,13 @@ Falls Sie diese Anforderung nicht erwartet haben, kontaktieren Sie bitte Ihren A
async def delete_user( async def delete_user(
request: Request, request: Request,
userId: str = Path(..., description="ID of the user to delete"), userId: str = Path(..., description="ID of the user to delete"),
currentUser: User = Depends(getCurrentUser) context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]: ) -> 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 = interfaceDbApp.getInterface(context.user)
# Check if the user exists # Check if the user exists
existingUser = appInterface.getUser(userId) existingUser = appInterface.getUser(userId)
@ -483,6 +789,25 @@ async def delete_user(
detail=f"User with ID {userId} 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="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) success = appInterface.deleteUser(userId)
if not success: if not success:
raise HTTPException( raise HTTPException(

View file

@ -13,12 +13,12 @@ from fastapi import APIRouter, HTTPException, Depends, Body, Path, Query, Respon
# Import auth modules # Import auth modules
from modules.auth import limiter, getCurrentUser from modules.auth import limiter, getCurrentUser
# Import interfaces # Import interfaces from feature containers
import modules.interfaces.interfaceDbChatObjects as interfaceDbChatObjects import modules.interfaces.interfaceDbChat as interfaceDbChat
from modules.interfaces.interfaceDbChatObjects import getInterface from modules.interfaces.interfaceDbChat import getInterface
from modules.interfaces.interfaceRbac import getRecordsetWithRBAC from modules.interfaces.interfaceRbac import getRecordsetWithRBAC
# Import models # Import models from feature containers
from modules.datamodels.datamodelChat import ( from modules.datamodels.datamodelChat import (
ChatWorkflow, ChatWorkflow,
ChatMessage, ChatMessage,
@ -45,7 +45,7 @@ router = APIRouter(
) )
def getServiceChat(currentUser: User): def getServiceChat(currentUser: User):
return interfaceDbChatObjects.getInterface(currentUser) return interfaceDbChat.getInterface(currentUser)
# Consolidated endpoint for getting all workflows # Consolidated endpoint for getting all workflows
@router.get("/", response_model=PaginatedResponse[ChatWorkflow]) @router.get("/", response_model=PaginatedResponse[ChatWorkflow])

474
modules/routes/routeGdpr.py Normal file
View file

@ -0,0 +1,474 @@
# 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, UserInDB, Mandate, UserConnection
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.shared.timeUtils import getUtcTimestamp
from modules.shared.auditLogger import audit_logger
from modules.shared.gdprDeletion import deleteUserDataAcrossAllDatabases, buildDeletionSummary
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 export_user_data(
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,
"fullName": getattr(currentUser, "fullName", None),
"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
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 - GDPR Article 15 data export
audit_logger.logGdprEvent(
userId=str(currentUser.id),
mandateId="system",
action="gdpr_data_export",
details="User requested data export (GDPR Article 15 - Right of Access)",
ipAddress=request.client.host if request.client else None
)
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 export_portable_data(
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": getattr(currentUser, "fullName", None) or currentUser.username,
"email": currentUser.email,
"additionalProperty": []
}
# 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:
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 - GDPR Article 20 data portability
audit_logger.logGdprEvent(
userId=str(currentUser.id),
mandateId="system",
action="gdpr_data_portability",
details="User requested portable data export (GDPR Article 20 - Right to Data Portability)",
ipAddress=request.client.host if request.client else None
)
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 delete_account(
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:
# Step 1: Audit log BEFORE deletion (audit needs userId)
audit_logger.logGdprEvent(
userId=str(currentUser.id),
mandateId="system",
action="gdpr_account_deletion_started",
details=f"User initiated account deletion (GDPR Article 17 - Right to Erasure)",
ipAddress=request.client.host if request.client else None
)
# Step 2: Revoke invitations BEFORE generic deletion (business logic)
rootInterface = getRootInterface()
from modules.datamodels.datamodelInvitation import Invitation
userInvitations = rootInterface.db.getRecordset(
Invitation,
recordFilter={"createdBy": str(currentUser.id)}
)
for inv in userInvitations:
rootInterface.db.recordModify(
Invitation,
inv.get("id"),
{"revokedAt": getUtcTimestamp()}
)
logger.info(f"Revoked {len(userInvitations)} invitations for user {currentUser.id}")
# Step 3: Generic deletion across ALL databases
deletionStats = deleteUserDataAcrossAllDatabases(str(currentUser.id), currentUser)
# Step 4: Delete the user account from UserInDB (authentication table)
# This must be done AFTER all other deletions to maintain audit trail
deletedAt = getUtcTimestamp()
rootInterface.db.recordDelete(UserInDB, str(currentUser.id))
# Build summary for response
deletedData = buildDeletionSummary(deletionStats)
deletedData.insert(0, f"Invitations revoked: {len(userInvitations)}")
deletedData.append("User account deleted from authentication system")
logger.info(f"User {currentUser.id} deleted own account (GDPR Art. 17). "
f"Stats: {deletionStats['totalRecordsDeleted']} deleted, "
f"{deletionStats['totalRecordsAnonymized']} anonymized")
return DeletionResult(
success=True,
userId=str(currentUser.id),
deletedAt=deletedAt,
deletedData=deletedData,
message="Account and all associated data have been permanently deleted or anonymized."
)
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 get_consent_info(
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()

View file

@ -0,0 +1,777 @@
# 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.interfaceDbApp 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"""
targetUsername: str = Field(..., description="Username of the user to invite (must match on acceptance)")
email: Optional[str] = Field(None, description="Email address to send invitation link (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]
targetUsername: 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
emailSent: bool = False # Whether invitation email was sent
class InvitationValidation(BaseModel):
"""Response model for invitation validation"""
valid: bool
reason: Optional[str]
mandateId: Optional[str]
mandateName: Optional[str] = None
featureInstanceId: Optional[str]
roleIds: List[str]
roleLabels: List[str] = []
targetUsername: Optional[str] = None
# =============================================================================
# Invitation CRUD Endpoints
# =============================================================================
@router.post("/", response_model=InvitationResponse)
@limiter.limit("30/minute")
async def create_invitation(
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()
# Note: targetUsername does NOT need to exist yet!
# The invitation can be for a user who will register later.
# When they register with this username (or accept the invitation),
# they will get the assigned roles.
# 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,
targetUsername=data.targetUsername,
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}"
# Send email if email address is provided
emailSent = False
if data.email:
try:
from modules.connectors.connectorMessagingEmail import ConnectorMessagingEmail
from modules.datamodels.datamodelUam import Mandate
# Get mandate name for the email
mandateRecords = rootInterface.db.getRecordset(
Mandate,
recordFilter={"id": str(context.mandateId)}
)
mandateName = mandateRecords[0].get("name", "PowerOn") if mandateRecords else "PowerOn"
emailConnector = ConnectorMessagingEmail()
emailSubject = f"Einladung zu {mandateName}"
emailBody = f"""
<html>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<h2>Sie wurden eingeladen!</h2>
<p>Hallo <strong>{data.targetUsername}</strong>,</p>
<p>Sie wurden eingeladen, dem Mandanten <strong>{mandateName}</strong> beizutreten.</p>
<p>Klicken Sie auf den folgenden Link, um die Einladung anzunehmen:</p>
<p style="margin: 20px 0;">
<a href="{inviteUrl}" style="background-color: #007bff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px;">
Einladung annehmen
</a>
</p>
<p style="color: #666; font-size: 0.9em;">
Oder kopieren Sie diesen Link in Ihren Browser:<br>
<code>{inviteUrl}</code>
</p>
<p style="color: #666; font-size: 0.9em;">
Diese Einladung ist {data.expiresInHours} Stunden gültig.
</p>
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
<p style="color: #999; font-size: 0.8em;">
Diese E-Mail wurde automatisch von PowerOn gesendet.
</p>
</body>
</html>
"""
emailConnector.send(
recipient=data.email,
subject=emailSubject,
message=emailBody
)
emailSent = True
logger.info(f"Invitation email sent to {data.email} for user {data.targetUsername}")
except Exception as emailError:
logger.warning(f"Failed to send invitation email to {data.email}: {emailError}")
# Don't fail the invitation creation if email fails
# Update the invitation record with emailSent status
if emailSent:
rootInterface.db.recordModify(
Invitation,
createdRecord.get("id"),
{"emailSent": True}
)
createdRecord["emailSent"] = True
# If the target user already exists, create an in-app notification
try:
existingUser = rootInterface.getUserByUsername(data.targetUsername)
if existingUser:
from modules.routes.routeNotifications import createInvitationNotification
from modules.datamodels.datamodelUam import Mandate
# Get mandate name for notification
mandateRecords = rootInterface.db.getRecordset(
Mandate,
recordFilter={"id": str(context.mandateId)}
)
mandateName = mandateRecords[0].get("mandateLabel", "PowerOn") if mandateRecords else "PowerOn"
inviterName = context.user.fullName or context.user.username
createInvitationNotification(
userId=str(existingUser.id),
invitationId=str(createdRecord.get("id")),
mandateName=mandateName,
inviterName=inviterName
)
logger.info(f"Created notification for existing user {data.targetUsername}")
except Exception as notifError:
logger.warning(f"Failed to create notification for user {data.targetUsername}: {notifError}")
# Don't fail the invitation if notification fails
logger.info(
f"User {context.user.id} created invitation for user {data.targetUsername} "
f"to mandate {context.mandateId}, 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", []),
targetUsername=createdRecord.get("targetUsername"),
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,
emailSent=emailSent
)
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 list_invitations(
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 revoke_invitation(
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.recordModify(
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 validate_invitation(
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=[]
)
# Get additional info for display
mandateId = invitation.get("mandateId")
mandateName = None
roleLabels = []
targetUsername = invitation.get("targetUsername")
# Get mandate name
from modules.datamodels.datamodelUam import Mandate
mandateRecords = rootInterface.db.getRecordset(
Mandate,
recordFilter={"id": mandateId}
)
if mandateRecords:
mandateName = mandateRecords[0].get("name")
# Get role names
roleIds = invitation.get("roleIds", [])
from modules.datamodels.datamodelRbac import Role
for roleId in roleIds:
roleRecords = rootInterface.db.getRecordset(Role, recordFilter={"id": roleId})
if roleRecords:
roleLabels.append(roleRecords[0].get("roleLabel", roleId))
return InvitationValidation(
valid=True,
reason=None,
mandateId=mandateId,
mandateName=mandateName,
featureInstanceId=invitation.get("featureInstanceId"),
roleIds=roleIds,
roleLabels=roleLabels,
targetUsername=targetUsername
)
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 accept_invitation(
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"
)
# Validate username matches - the invitation is bound to a specific user
targetUsername = invitation.get("targetUsername")
if targetUsername and currentUser.username != targetUsername:
logger.warning(
f"User {currentUser.username} tried to accept invitation meant for {targetUsername}"
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Diese Einladung ist für Benutzer '{targetUsername}' bestimmt"
)
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.recordModify(
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)}"
)
# =============================================================================
# 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

View file

@ -7,10 +7,11 @@ import logging
import json import json
# Import auth module # 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 interfaces
import modules.interfaces.interfaceDbComponentObjects as interfaceDbComponentObjects import modules.interfaces.interfaceDbManagement as interfaceDbManagement
from modules.datamodels.datamodelMessaging import ( from modules.datamodels.datamodelMessaging import (
MessagingSubscription, MessagingSubscription,
MessagingSubscriptionRegistration, MessagingSubscriptionRegistration,
@ -37,7 +38,7 @@ router = APIRouter(
@router.get("/subscriptions", response_model=PaginatedResponse[MessagingSubscription]) @router.get("/subscriptions", response_model=PaginatedResponse[MessagingSubscription])
@limiter.limit("60/minute") @limiter.limit("60/minute")
async def getSubscriptions( async def get_subscriptions(
request: Request, request: Request,
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"), pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
currentUser: User = Depends(getCurrentUser) currentUser: User = Depends(getCurrentUser)
@ -54,7 +55,7 @@ async def getSubscriptions(
detail=f"Invalid pagination parameter: {str(e)}" detail=f"Invalid pagination parameter: {str(e)}"
) )
managementInterface = interfaceDbComponentObjects.getInterface(currentUser) managementInterface = interfaceDbManagement.getInterface(currentUser)
result = managementInterface.getAllSubscriptions(pagination=paginationParams) result = managementInterface.getAllSubscriptions(pagination=paginationParams)
if paginationParams: if paginationParams:
@ -78,13 +79,13 @@ async def getSubscriptions(
@router.post("/subscriptions", response_model=MessagingSubscription) @router.post("/subscriptions", response_model=MessagingSubscription)
@limiter.limit("60/minute") @limiter.limit("60/minute")
async def createSubscription( async def create_subscription(
request: Request, request: Request,
subscription: MessagingSubscription, subscription: MessagingSubscription,
currentUser: User = Depends(getCurrentUser) currentUser: User = Depends(getCurrentUser)
) -> MessagingSubscription: ) -> MessagingSubscription:
"""Create a new subscription""" """Create a new subscription"""
managementInterface = interfaceDbComponentObjects.getInterface(currentUser) managementInterface = interfaceDbManagement.getInterface(currentUser)
subscriptionData = subscription.model_dump(exclude={"id"}) subscriptionData = subscription.model_dump(exclude={"id"})
newSubscription = managementInterface.createSubscription(subscriptionData) newSubscription = managementInterface.createSubscription(subscriptionData)
@ -94,13 +95,13 @@ async def createSubscription(
@router.get("/subscriptions/{subscriptionId}", response_model=MessagingSubscription) @router.get("/subscriptions/{subscriptionId}", response_model=MessagingSubscription)
@limiter.limit("60/minute") @limiter.limit("60/minute")
async def getSubscription( async def get_subscription(
request: Request, request: Request,
subscriptionId: str = Path(..., description="ID of the subscription"), subscriptionId: str = Path(..., description="ID of the subscription"),
currentUser: User = Depends(getCurrentUser) currentUser: User = Depends(getCurrentUser)
) -> MessagingSubscription: ) -> MessagingSubscription:
"""Get a specific subscription""" """Get a specific subscription"""
managementInterface = interfaceDbComponentObjects.getInterface(currentUser) managementInterface = interfaceDbManagement.getInterface(currentUser)
subscription = managementInterface.getSubscription(subscriptionId) subscription = managementInterface.getSubscription(subscriptionId)
if not subscription: if not subscription:
@ -114,14 +115,14 @@ async def getSubscription(
@router.put("/subscriptions/{subscriptionId}", response_model=MessagingSubscription) @router.put("/subscriptions/{subscriptionId}", response_model=MessagingSubscription)
@limiter.limit("60/minute") @limiter.limit("60/minute")
async def updateSubscription( async def update_subscription(
request: Request, request: Request,
subscriptionId: str = Path(..., description="ID of the subscription to update"), subscriptionId: str = Path(..., description="ID of the subscription to update"),
subscriptionData: MessagingSubscription = Body(...), subscriptionData: MessagingSubscription = Body(...),
currentUser: User = Depends(getCurrentUser) currentUser: User = Depends(getCurrentUser)
) -> MessagingSubscription: ) -> MessagingSubscription:
"""Update an existing subscription""" """Update an existing subscription"""
managementInterface = interfaceDbComponentObjects.getInterface(currentUser) managementInterface = interfaceDbManagement.getInterface(currentUser)
existingSubscription = managementInterface.getSubscription(subscriptionId) existingSubscription = managementInterface.getSubscription(subscriptionId)
if not existingSubscription: if not existingSubscription:
@ -144,13 +145,13 @@ async def updateSubscription(
@router.delete("/subscriptions/{subscriptionId}", response_model=Dict[str, Any]) @router.delete("/subscriptions/{subscriptionId}", response_model=Dict[str, Any])
@limiter.limit("60/minute") @limiter.limit("60/minute")
async def deleteSubscription( async def delete_subscription(
request: Request, request: Request,
subscriptionId: str = Path(..., description="ID of the subscription to delete"), subscriptionId: str = Path(..., description="ID of the subscription to delete"),
currentUser: User = Depends(getCurrentUser) currentUser: User = Depends(getCurrentUser)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Delete a subscription""" """Delete a subscription"""
managementInterface = interfaceDbComponentObjects.getInterface(currentUser) managementInterface = interfaceDbManagement.getInterface(currentUser)
existingSubscription = managementInterface.getSubscription(subscriptionId) existingSubscription = managementInterface.getSubscription(subscriptionId)
if not existingSubscription: if not existingSubscription:
@ -173,7 +174,7 @@ async def deleteSubscription(
@router.get("/subscriptions/{subscriptionId}/registrations", response_model=PaginatedResponse[MessagingSubscriptionRegistration]) @router.get("/subscriptions/{subscriptionId}/registrations", response_model=PaginatedResponse[MessagingSubscriptionRegistration])
@limiter.limit("60/minute") @limiter.limit("60/minute")
async def getSubscriptionRegistrations( async def get_subscription_registrations(
request: Request, request: Request,
subscriptionId: str = Path(..., description="ID of the subscription"), subscriptionId: str = Path(..., description="ID of the subscription"),
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"), pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
@ -191,7 +192,7 @@ async def getSubscriptionRegistrations(
detail=f"Invalid pagination parameter: {str(e)}" detail=f"Invalid pagination parameter: {str(e)}"
) )
managementInterface = interfaceDbComponentObjects.getInterface(currentUser) managementInterface = interfaceDbManagement.getInterface(currentUser)
result = managementInterface.getAllRegistrations( result = managementInterface.getAllRegistrations(
subscriptionId=subscriptionId, subscriptionId=subscriptionId,
pagination=paginationParams pagination=paginationParams
@ -218,7 +219,7 @@ async def getSubscriptionRegistrations(
@router.post("/subscriptions/{subscriptionId}/subscribe", response_model=MessagingSubscriptionRegistration) @router.post("/subscriptions/{subscriptionId}/subscribe", response_model=MessagingSubscriptionRegistration)
@limiter.limit("60/minute") @limiter.limit("60/minute")
async def subscribeUser( async def subscribe_user(
request: Request, request: Request,
subscriptionId: str = Path(..., description="ID of the subscription"), subscriptionId: str = Path(..., description="ID of the subscription"),
channel: MessagingChannel = Body(..., embed=True), channel: MessagingChannel = Body(..., embed=True),
@ -226,7 +227,7 @@ async def subscribeUser(
currentUser: User = Depends(getCurrentUser) currentUser: User = Depends(getCurrentUser)
) -> MessagingSubscriptionRegistration: ) -> MessagingSubscriptionRegistration:
"""Subscribe user to a subscription with a specific channel""" """Subscribe user to a subscription with a specific channel"""
managementInterface = interfaceDbComponentObjects.getInterface(currentUser) managementInterface = interfaceDbManagement.getInterface(currentUser)
registration = managementInterface.subscribeUser( registration = managementInterface.subscribeUser(
subscriptionId=subscriptionId, subscriptionId=subscriptionId,
@ -240,14 +241,14 @@ async def subscribeUser(
@router.delete("/subscriptions/{subscriptionId}/unsubscribe", response_model=Dict[str, Any]) @router.delete("/subscriptions/{subscriptionId}/unsubscribe", response_model=Dict[str, Any])
@limiter.limit("60/minute") @limiter.limit("60/minute")
async def unsubscribeUser( async def unsubscribe_user(
request: Request, request: Request,
subscriptionId: str = Path(..., description="ID of the subscription"), subscriptionId: str = Path(..., description="ID of the subscription"),
channel: MessagingChannel = Body(..., embed=True), channel: MessagingChannel = Body(..., embed=True),
currentUser: User = Depends(getCurrentUser) currentUser: User = Depends(getCurrentUser)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Unsubscribe user from a subscription for a specific channel""" """Unsubscribe user from a subscription for a specific channel"""
managementInterface = interfaceDbComponentObjects.getInterface(currentUser) managementInterface = interfaceDbManagement.getInterface(currentUser)
success = managementInterface.unsubscribeUser( success = managementInterface.unsubscribeUser(
subscriptionId=subscriptionId, subscriptionId=subscriptionId,
@ -266,7 +267,7 @@ async def unsubscribeUser(
@router.get("/registrations", response_model=PaginatedResponse[MessagingSubscriptionRegistration]) @router.get("/registrations", response_model=PaginatedResponse[MessagingSubscriptionRegistration])
@limiter.limit("60/minute") @limiter.limit("60/minute")
async def getMyRegistrations( async def get_my_registrations(
request: Request, request: Request,
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"), pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
currentUser: User = Depends(getCurrentUser) currentUser: User = Depends(getCurrentUser)
@ -283,7 +284,7 @@ async def getMyRegistrations(
detail=f"Invalid pagination parameter: {str(e)}" detail=f"Invalid pagination parameter: {str(e)}"
) )
managementInterface = interfaceDbComponentObjects.getInterface(currentUser) managementInterface = interfaceDbManagement.getInterface(currentUser)
result = managementInterface.getAllRegistrations( result = managementInterface.getAllRegistrations(
userId=currentUser.id, userId=currentUser.id,
pagination=paginationParams pagination=paginationParams
@ -310,14 +311,14 @@ async def getMyRegistrations(
@router.put("/registrations/{registrationId}", response_model=MessagingSubscriptionRegistration) @router.put("/registrations/{registrationId}", response_model=MessagingSubscriptionRegistration)
@limiter.limit("60/minute") @limiter.limit("60/minute")
async def updateRegistration( async def update_registration(
request: Request, request: Request,
registrationId: str = Path(..., description="ID of the registration to update"), registrationId: str = Path(..., description="ID of the registration to update"),
registrationData: MessagingSubscriptionRegistration = Body(...), registrationData: MessagingSubscriptionRegistration = Body(...),
currentUser: User = Depends(getCurrentUser) currentUser: User = Depends(getCurrentUser)
) -> MessagingSubscriptionRegistration: ) -> MessagingSubscriptionRegistration:
"""Update a registration""" """Update a registration"""
managementInterface = interfaceDbComponentObjects.getInterface(currentUser) managementInterface = interfaceDbManagement.getInterface(currentUser)
existingRegistration = managementInterface.getRegistration(registrationId) existingRegistration = managementInterface.getRegistration(registrationId)
if not existingRegistration: if not existingRegistration:
@ -340,13 +341,13 @@ async def updateRegistration(
@router.delete("/registrations/{registrationId}", response_model=Dict[str, Any]) @router.delete("/registrations/{registrationId}", response_model=Dict[str, Any])
@limiter.limit("60/minute") @limiter.limit("60/minute")
async def deleteRegistration( async def delete_registration(
request: Request, request: Request,
registrationId: str = Path(..., description="ID of the registration to delete"), registrationId: str = Path(..., description="ID of the registration to delete"),
currentUser: User = Depends(getCurrentUser) currentUser: User = Depends(getCurrentUser)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Delete a registration""" """Delete a registration"""
managementInterface = interfaceDbComponentObjects.getInterface(currentUser) managementInterface = interfaceDbManagement.getInterface(currentUser)
existingRegistration = managementInterface.getRegistration(registrationId) existingRegistration = managementInterface.getRegistration(registrationId)
if not existingRegistration: if not existingRegistration:
@ -375,20 +376,27 @@ def _getTriggerKey(request: Request) -> str:
@router.post("/trigger/{subscriptionId}", response_model=MessagingSubscriptionExecutionResult) @router.post("/trigger/{subscriptionId}", response_model=MessagingSubscriptionExecutionResult)
@limiter.limit("60/minute", key_func=_getTriggerKey) @limiter.limit("60/minute", key_func=_getTriggerKey)
async def triggerSubscription( async def trigger_subscription(
request: Request, request: Request,
subscriptionId: str = Path(..., description="ID of the subscription to trigger"), subscriptionId: str = Path(..., description="ID of the subscription to trigger"),
eventParameters: Dict[str, Any] = Body(...), eventParameters: Dict[str, Any] = Body(...),
currentUser: User = Depends(getCurrentUser) context: RequestContext = Depends(getRequestContext)
) -> MessagingSubscriptionExecutionResult: ) -> MessagingSubscriptionExecutionResult:
"""Trigger a subscription with event parameters""" """
# RBAC-Check: Nur Admin/Mandate-Admin kann triggern Trigger a subscription with event parameters.
# TODO: Add proper RBAC check here
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 # Get messaging service from request app state
# We need to access services through the request
from modules.services import getInterface as getServicesInterface 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 # Konvertiere Dict zu Pydantic Model
eventParams = MessagingEventParameters(triggerData=eventParameters) eventParams = MessagingEventParameters(triggerData=eventParameters)
@ -397,11 +405,42 @@ async def triggerSubscription(
return executionResult 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.interfaceDbApp 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 # Delivery Endpoints
@router.get("/deliveries", response_model=PaginatedResponse[MessagingDelivery]) @router.get("/deliveries", response_model=PaginatedResponse[MessagingDelivery])
@limiter.limit("60/minute") @limiter.limit("60/minute")
async def getDeliveries( async def get_deliveries(
request: Request, request: Request,
subscriptionId: Optional[str] = Query(None, description="Filter by subscription ID"), subscriptionId: Optional[str] = Query(None, description="Filter by subscription ID"),
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"), pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
@ -419,7 +458,7 @@ async def getDeliveries(
detail=f"Invalid pagination parameter: {str(e)}" detail=f"Invalid pagination parameter: {str(e)}"
) )
managementInterface = interfaceDbComponentObjects.getInterface(currentUser) managementInterface = interfaceDbManagement.getInterface(currentUser)
result = managementInterface.getDeliveries( result = managementInterface.getDeliveries(
subscriptionId=subscriptionId, subscriptionId=subscriptionId,
userId=currentUser.id, # Users can only see their own deliveries userId=currentUser.id, # Users can only see their own deliveries
@ -447,13 +486,13 @@ async def getDeliveries(
@router.get("/deliveries/{deliveryId}", response_model=MessagingDelivery) @router.get("/deliveries/{deliveryId}", response_model=MessagingDelivery)
@limiter.limit("60/minute") @limiter.limit("60/minute")
async def getDelivery( async def get_delivery(
request: Request, request: Request,
deliveryId: str = Path(..., description="ID of the delivery"), deliveryId: str = Path(..., description="ID of the delivery"),
currentUser: User = Depends(getCurrentUser) currentUser: User = Depends(getCurrentUser)
) -> MessagingDelivery: ) -> MessagingDelivery:
"""Get a specific delivery""" """Get a specific delivery"""
managementInterface = interfaceDbComponentObjects.getInterface(currentUser) managementInterface = interfaceDbManagement.getInterface(currentUser)
delivery = managementInterface.getDelivery(deliveryId) delivery = managementInterface.getDelivery(deliveryId)
if not delivery: if not delivery:

View file

@ -0,0 +1,587 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Notification routes for in-app notifications.
Provides user-specific notification inbox with support for actionable notifications.
"""
from fastapi import APIRouter, HTTPException, Depends, Request
from typing import List, Dict, Any, Optional
from fastapi import status
import logging
from pydantic import BaseModel, Field
from modules.auth import limiter, getCurrentUser
from modules.datamodels.datamodelUam import User
from modules.datamodels.datamodelNotification import (
UserNotification,
NotificationType,
NotificationStatus,
NotificationAction
)
from modules.datamodels.datamodelRbac import Role
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.shared.timeUtils import getUtcTimestamp
logger = logging.getLogger(__name__)
router = APIRouter(
prefix="/api/notifications",
tags=["Notifications"],
responses={404: {"description": "Not found"}}
)
# =============================================================================
# Request/Response Models
# =============================================================================
class NotificationActionRequest(BaseModel):
"""Request model for executing a notification action"""
actionId: str = Field(..., description="ID of the action to execute (e.g., 'accept', 'decline')")
class UnreadCountResponse(BaseModel):
"""Response model for unread count"""
count: int
# =============================================================================
# Helper Functions
# =============================================================================
def _createNotification(
userId: str,
notificationType: NotificationType,
title: str,
message: str,
referenceType: Optional[str] = None,
referenceId: Optional[str] = None,
actions: Optional[List[NotificationAction]] = None,
icon: Optional[str] = None,
expiresAt: Optional[float] = None
) -> UserNotification:
"""
Create a notification for a user.
This is a helper function that can be imported by other modules.
"""
rootInterface = getRootInterface()
notification = UserNotification(
userId=userId,
type=notificationType,
title=title,
message=message,
referenceType=referenceType,
referenceId=referenceId,
actions=actions,
icon=icon,
expiresAt=expiresAt
)
# Store in database
rootInterface.db.recordCreate(
model_class=UserNotification,
record=notification.model_dump()
)
logger.info(f"Created notification {notification.id} for user {userId}: {title}")
return notification
def createInvitationNotification(
userId: str,
invitationId: str,
mandateName: str,
inviterName: str
) -> UserNotification:
"""
Create a notification for a pending invitation.
Called when an invitation is created for an existing user.
"""
return _createNotification(
userId=userId,
notificationType=NotificationType.INVITATION,
title="Neue Einladung",
message=f"{inviterName} hat Sie zu '{mandateName}' eingeladen.",
referenceType="Invitation",
referenceId=invitationId,
icon="mail",
actions=[
NotificationAction(actionId="accept", label="Annehmen", style="primary"),
NotificationAction(actionId="decline", label="Ablehnen", style="danger")
]
)
# =============================================================================
# API Endpoints
# =============================================================================
@router.get("", response_model=List[Dict[str, Any]])
@limiter.limit("60/minute")
async def getNotifications(
request: Request,
currentUser: User = Depends(getCurrentUser),
status: Optional[str] = None,
type: Optional[str] = None,
limit: int = 50
) -> List[Dict[str, Any]]:
"""
Get all notifications for the current user.
Optionally filter by status (unread, read, actioned, dismissed) or type.
"""
try:
rootInterface = getRootInterface()
# Build filter
recordFilter = {"userId": str(currentUser.id)}
if status:
recordFilter["status"] = status
if type:
recordFilter["type"] = type
# Get notifications
notifications = rootInterface.db.getRecordset(
model_class=UserNotification,
recordFilter=recordFilter
)
# Sort by creation date (newest first) and limit
notifications = sorted(notifications, key=lambda x: x.get("createdAt", 0), reverse=True)
if limit:
notifications = notifications[:limit]
return notifications
except Exception as e:
logger.error(f"Error getting notifications: {e}")
raise HTTPException(
status_code=500,
detail=f"Failed to get notifications: {str(e)}"
)
@router.get("/unread-count", response_model=UnreadCountResponse)
@limiter.limit("120/minute")
async def getUnreadCount(
request: Request,
currentUser: User = Depends(getCurrentUser)
) -> UnreadCountResponse:
"""
Get the count of unread notifications for the current user.
Used for the notification badge in the header.
"""
try:
rootInterface = getRootInterface()
notifications = rootInterface.db.getRecordset(
model_class=UserNotification,
recordFilter={
"userId": str(currentUser.id),
"status": NotificationStatus.UNREAD.value
}
)
return UnreadCountResponse(count=len(notifications))
except Exception as e:
logger.error(f"Error getting unread count: {e}")
raise HTTPException(
status_code=500,
detail=f"Failed to get unread count: {str(e)}"
)
@router.put("/{notificationId}/read", response_model=Dict[str, Any])
@limiter.limit("60/minute")
async def markAsRead(
request: Request,
notificationId: str,
currentUser: User = Depends(getCurrentUser)
) -> Dict[str, Any]:
"""
Mark a notification as read.
"""
try:
rootInterface = getRootInterface()
# Get the notification
notifications = rootInterface.db.getRecordset(
model_class=UserNotification,
recordFilter={"id": notificationId}
)
if not notifications:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Notification not found"
)
notification = notifications[0]
# Verify ownership
if notification.get("userId") != currentUser.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to access this notification"
)
# Update status
rootInterface.db.recordModify(
model_class=UserNotification,
recordId=notificationId,
record={
"status": NotificationStatus.READ.value,
"readAt": getUtcTimestamp()
}
)
return {"message": "Notification marked as read", "id": notificationId}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error marking notification as read: {e}")
raise HTTPException(
status_code=500,
detail=f"Failed to mark notification as read: {str(e)}"
)
@router.put("/mark-all-read", response_model=Dict[str, Any])
@limiter.limit("10/minute")
async def markAllAsRead(
request: Request,
currentUser: User = Depends(getCurrentUser)
) -> Dict[str, Any]:
"""
Mark all notifications as read for the current user.
"""
try:
rootInterface = getRootInterface()
# Get all unread notifications
notifications = rootInterface.db.getRecordset(
model_class=UserNotification,
recordFilter={
"userId": currentUser.id,
"status": NotificationStatus.UNREAD.value
}
)
currentTime = getUtcTimestamp()
updatedCount = 0
for notification in notifications:
rootInterface.db.recordModify(
model_class=UserNotification,
recordId=notification.get("id"),
record={
"status": NotificationStatus.READ.value,
"readAt": currentTime
}
)
updatedCount += 1
return {"message": f"Marked {updatedCount} notifications as read", "count": updatedCount}
except Exception as e:
logger.error(f"Error marking all notifications as read: {e}")
raise HTTPException(
status_code=500,
detail=f"Failed to mark notifications as read: {str(e)}"
)
@router.post("/{notificationId}/action", response_model=Dict[str, Any])
@limiter.limit("30/minute")
async def executeAction(
request: Request,
notificationId: str,
actionRequest: NotificationActionRequest,
currentUser: User = Depends(getCurrentUser)
) -> Dict[str, Any]:
"""
Execute an action on a notification (e.g., accept/decline invitation).
"""
try:
rootInterface = getRootInterface()
# Get the notification
notifications = rootInterface.db.getRecordset(
model_class=UserNotification,
recordFilter={"id": notificationId}
)
if not notifications:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Notification not found"
)
notification = notifications[0]
# Verify ownership
if notification.get("userId") != currentUser.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to access this notification"
)
# Check if already actioned
if notification.get("status") == NotificationStatus.ACTIONED.value:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Notification has already been actioned"
)
# Validate action exists
actions = notification.get("actions", [])
validActionIds = [a.get("actionId") if isinstance(a, dict) else a.actionId for a in (actions or [])]
if actionRequest.actionId not in validActionIds:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid action. Valid actions: {validActionIds}"
)
# Execute action based on notification type
actionResult = None
if notification.get("type") == NotificationType.INVITATION.value:
actionResult = await _handleInvitationAction(
notification=notification,
actionId=actionRequest.actionId,
currentUser=currentUser,
rootInterface=rootInterface
)
else:
# Generic action handling
actionResult = f"Action '{actionRequest.actionId}' executed"
# Update notification status
rootInterface.db.recordModify(
model_class=UserNotification,
recordId=notificationId,
record={
"status": NotificationStatus.ACTIONED.value,
"actionTaken": actionRequest.actionId,
"actionResult": actionResult,
"actionedAt": getUtcTimestamp()
}
)
return {
"message": actionResult,
"action": actionRequest.actionId,
"notificationId": notificationId
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error executing notification action: {e}")
raise HTTPException(
status_code=500,
detail=f"Failed to execute action: {str(e)}"
)
async def _handleInvitationAction(
notification: Dict[str, Any],
actionId: str,
currentUser: User,
rootInterface
) -> str:
"""Handle accept/decline actions for invitation notifications."""
from modules.datamodels.datamodelInvitation import Invitation
from modules.datamodels.datamodelUam import Mandate
from modules.datamodels.datamodelMembership import UserMandate
invitationId = notification.get("referenceId")
if not invitationId:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No invitation reference found"
)
# Get the invitation
invitations = rootInterface.db.getRecordset(
model_class=Invitation,
recordFilter={"id": invitationId}
)
if not invitations:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Invitation not found"
)
invitation = invitations[0]
# Verify username matches
if invitation.get("targetUsername") != currentUser.username:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="This invitation is for a different user"
)
# Check if invitation is still valid
currentTime = getUtcTimestamp()
if invitation.get("expiresAt", 0) < currentTime:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invitation has expired"
)
if invitation.get("revokedAt"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invitation has been revoked"
)
if invitation.get("currentUses", 0) >= invitation.get("maxUses", 1):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invitation has reached maximum uses"
)
if actionId == "accept":
# Accept the invitation - assign roles and mandate access
mandateId = invitation.get("mandateId")
roleIds = invitation.get("roleIds", [])
# Ensure user gets the system "user" role for access to public UI elements (e.g. playground)
userRoles = rootInterface.db.getRecordset(
model_class=Role,
recordFilter={"roleLabel": "user"}
)
if userRoles:
userRoleId = userRoles[0].get("id")
if userRoleId and userRoleId not in roleIds:
roleIds = roleIds + [userRoleId]
logger.debug(f"Added system 'user' role {userRoleId} to invitation roles")
# Get mandate name for result message
mandates = rootInterface.db.getRecordset(
model_class=Mandate,
recordFilter={"id": mandateId}
)
mandateName = mandates[0].get("mandateLabel", mandateId) if mandates else mandateId
# Check if user already has this mandate
existingMemberships = rootInterface.db.getRecordset(
model_class=UserMandate,
recordFilter={
"userId": currentUser.id,
"mandateId": mandateId
}
)
if existingMemberships:
# Update existing membership with new roles
existingMembership = existingMemberships[0]
existingRoles = existingMembership.get("roleIds", [])
mergedRoles = list(set(existingRoles + roleIds))
rootInterface.db.recordModify(
model_class=UserMandate,
recordId=existingMembership.get("id"),
record={"roleIds": mergedRoles}
)
logger.info(f"Updated UserMandate for user {currentUser.id} in mandate {mandateId}")
else:
# Create new user-mandate relationship
userMandate = UserMandate(
userId=currentUser.id,
mandateId=mandateId,
roleIds=roleIds
)
rootInterface.db.recordCreate(
model_class=UserMandate,
record=userMandate.model_dump()
)
logger.info(f"Created UserMandate for user {currentUser.id} in mandate {mandateId}")
# Mark invitation as used
rootInterface.db.recordModify(
model_class=Invitation,
recordId=invitationId,
record={
"usedBy": currentUser.id,
"usedAt": currentTime,
"currentUses": invitation.get("currentUses", 0) + 1
}
)
logger.info(f"User {currentUser.id} accepted invitation {invitationId} for mandate {mandateId}")
return f"Einladung angenommen. Sie haben jetzt Zugang zu '{mandateName}'."
elif actionId == "decline":
# Decline the invitation
# We don't revoke it, just mark the notification as declined
logger.info(f"User {currentUser.id} declined invitation {invitationId}")
return "Einladung abgelehnt."
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unknown action: {actionId}"
)
@router.delete("/{notificationId}", response_model=Dict[str, Any])
@limiter.limit("30/minute")
async def deleteNotification(
request: Request,
notificationId: str,
currentUser: User = Depends(getCurrentUser)
) -> Dict[str, Any]:
"""
Delete/dismiss a notification.
"""
try:
rootInterface = getRootInterface()
# Get the notification
notifications = rootInterface.db.getRecordset(
model_class=UserNotification,
recordFilter={"id": notificationId}
)
if not notifications:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Notification not found"
)
notification = notifications[0]
# Verify ownership
if notification.get("userId") != currentUser.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to delete this notification"
)
# Mark as dismissed (soft delete)
rootInterface.db.recordModify(
model_class=UserNotification,
recordId=notificationId,
record={
"status": NotificationStatus.DISMISSED.value
}
)
return {"message": "Notification dismissed", "id": notificationId}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error deleting notification: {e}")
raise HTTPException(
status_code=500,
detail=f"Failed to delete notification: {str(e)}"
)

View file

@ -1,88 +0,0 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Options API routes for dynamic frontend options.
Provides endpoints for fetching options for select/multiselect fields.
"""
from fastapi import APIRouter, HTTPException, Depends, Query, Request
from typing import List, Dict, Any
import logging
from modules.auth import getCurrentUser, limiter
from modules.datamodels.datamodelUam import User
from modules.features.dynamicOptions.mainDynamicOptions import getOptions, getAvailableOptionsNames
from modules.services import getInterface as getServices
# Configure logger
logger = logging.getLogger(__name__)
router = APIRouter(
prefix="/api/options",
tags=["Options"],
responses={404: {"description": "Not found"}}
)
@router.get("/{optionsName}", response_model=List[Dict[str, Any]])
@limiter.limit("120/minute")
async def getOptionsEndpoint(
request: Request,
optionsName: str,
currentUser: User = Depends(getCurrentUser)
) -> List[Dict[str, Any]]:
"""
Get options for a given options name.
Path Parameters:
- optionsName: Name of the options set (e.g., "user.role", "user.connection")
Returns:
- List of option dictionaries with "value" and "label" keys
Examples:
- GET /api/options/user.role
- GET /api/options/user.connection
- GET /api/options/auth.authority
- GET /api/options/connection.status
"""
try:
logger.debug(f"Options request: {optionsName} for user {currentUser.id}")
services = getServices(currentUser, None)
options = getOptions(optionsName, services, currentUser)
logger.debug(f"Options response: {optionsName} returned {len(options)} items")
return options
except ValueError as e:
logger.error(f"ValueError for options {optionsName}: {str(e)}")
raise HTTPException(
status_code=400,
detail=str(e)
)
except Exception as e:
logger.error(f"Error getting options for {optionsName}: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Failed to get options: {str(e)}"
)
@router.get("/", response_model=List[str])
@limiter.limit("30/minute")
async def listAvailableOptions(
request: Request,
currentUser: User = Depends(getCurrentUser)
) -> List[str]:
"""
Get list of all available options names.
Returns:
- List of available options names
"""
try:
return getAvailableOptionsNames()
except Exception as e:
logger.error(f"Error listing available options: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Failed to list options: {str(e)}"
)

View file

@ -1,13 +1,19 @@
# Copyright (c) 2025 Patrick Motsch # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # 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 import APIRouter, HTTPException, Depends, status, Request, Body
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import FileResponse, JSONResponse
from typing import Optional, Dict, Any, List from typing import Optional, Dict, Any, List
import os import os
import logging import logging
from modules.auth import getCurrentUser, limiter from modules.auth import getCurrentUser, limiter, requireSysAdmin
from modules.interfaces.interfaceDbAppObjects import getInterface, getRootInterface from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelUam import User, UserInDB, AuthAuthority from modules.datamodels.datamodelUam import User, UserInDB, AuthAuthority
from modules.datamodels.datamodelSecurity import Token from modules.datamodels.datamodelSecurity import Token
from modules.shared.configuration import APP_CONFIG 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 [] def _getPoweronDatabases() -> List[str]:
if "admin" not in roleLabels and "sysadmin" not in roleLabels: """Load databases from PostgreSQL host matching poweron_%."""
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin privileges required") dbHost = APP_CONFIG.get("DB_HOST")
if "admin" in roleLabels and "sysadmin" not in roleLabels: dbUser = APP_CONFIG.get("DB_USER")
if target_mandate_id and str(target_mandate_id) != str(current_user.mandateId): dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden for target mandate") 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") @limiter.limit("30/minute")
async def list_tokens( async def list_tokens(
request: Request, request: Request,
currentUser: User = Depends(getCurrentUser), currentUser: User = Depends(requireSysAdmin),
userId: Optional[str] = None, userId: Optional[str] = None,
authority: Optional[str] = None, authority: Optional[str] = None,
sessionId: Optional[str] = None, sessionId: Optional[str] = None,
statusFilter: Optional[str] = None, statusFilter: Optional[str] = None,
connectionId: Optional[str] = None, connectionId: Optional[str] = None,
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
"""
List all tokens in the system.
MULTI-TENANT: SysAdmin-only, no mandate filter (system-level view).
"""
try: try:
appInterface = getRootInterface() appInterface = getRootInterface()
target_mandate = currentUser.mandateId
_ensure_admin_scope(currentUser, target_mandate)
recordFilter: Dict[str, Any] = {} recordFilter: Dict[str, Any] = {}
if userId: if userId:
@ -66,9 +124,7 @@ async def list_tokens(
recordFilter["connectionId"] = connectionId recordFilter["connectionId"] = connectionId
if statusFilter: if statusFilter:
recordFilter["status"] = statusFilter recordFilter["status"] = statusFilter
roleLabels = currentUser.roleLabels or [] # MULTI-TENANT: SysAdmin sees ALL tokens (no mandate filter)
if "admin" in roleLabels and "sysadmin" not in roleLabels:
recordFilter["mandateId"] = str(currentUser.mandateId)
tokens = appInterface.db.getRecordset(Token, recordFilter=recordFilter) tokens = appInterface.db.getRecordset(Token, recordFilter=recordFilter)
return tokens return tokens
@ -83,27 +139,26 @@ async def list_tokens(
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def revoke_tokens_by_user( async def revoke_tokens_by_user(
request: Request, request: Request,
currentUser: User = Depends(getCurrentUser), currentUser: User = Depends(requireSysAdmin),
payload: Dict[str, Any] = Body(...) payload: Dict[str, Any] = Body(...)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""
Revoke all tokens for a user.
MULTI-TENANT: SysAdmin-only, can revoke across all mandates.
"""
try: try:
userId = payload.get("userId") userId = payload.get("userId")
authority = payload.get("authority") authority = payload.get("authority")
reason = payload.get("reason", "admin revoke") reason = payload.get("reason", "sysadmin revoke")
if not userId: if not userId:
raise HTTPException(status_code=400, detail="userId is required") raise HTTPException(status_code=400, detail="userId is required")
appInterface = getRootInterface() appInterface = getRootInterface()
# Tenant scope check # MULTI-TENANT: SysAdmin can revoke any user's tokens (no mandate restriction)
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 []
count = appInterface.revokeTokensByUser( count = appInterface.revokeTokensByUser(
userId=userId, userId=userId,
authority=AuthAuthority(authority) if authority else None, 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, revokedBy=currentUser.id,
reason=reason reason=reason
) )
@ -119,22 +174,23 @@ async def revoke_tokens_by_user(
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def revoke_tokens_by_session( async def revoke_tokens_by_session(
request: Request, request: Request,
currentUser: User = Depends(getCurrentUser), currentUser: User = Depends(requireSysAdmin),
payload: Dict[str, Any] = Body(...) payload: Dict[str, Any] = Body(...)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""
Revoke all tokens for a specific session.
MULTI-TENANT: SysAdmin-only.
"""
try: try:
userId = payload.get("userId") userId = payload.get("userId")
sessionId = payload.get("sessionId") sessionId = payload.get("sessionId")
authority = payload.get("authority", "local") 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: if not userId or not sessionId:
raise HTTPException(status_code=400, detail="userId and sessionId are required") raise HTTPException(status_code=400, detail="userId and sessionId are required")
appInterface = getRootInterface() appInterface = getRootInterface()
target_user = appInterface.db.getRecordset(User, recordFilter={"id": userId}) # MULTI-TENANT: SysAdmin can revoke any session (no mandate check)
target_mandate = target_user[0].get("mandateId") if target_user else None
_ensure_admin_scope(currentUser, target_mandate)
count = appInterface.revokeTokensBySessionId( count = appInterface.revokeTokensBySessionId(
sessionId=sessionId, sessionId=sessionId,
userId=userId, userId=userId,
@ -154,22 +210,20 @@ async def revoke_tokens_by_session(
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def revoke_token_by_id( async def revoke_token_by_id(
request: Request, request: Request,
currentUser: User = Depends(getCurrentUser), currentUser: User = Depends(requireSysAdmin),
payload: Dict[str, Any] = Body(...) payload: Dict[str, Any] = Body(...)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""
Revoke a specific token by ID.
MULTI-TENANT: SysAdmin-only.
"""
try: try:
tokenId = payload.get("tokenId") tokenId = payload.get("tokenId")
reason = payload.get("reason", "admin revoke") reason = payload.get("reason", "sysadmin revoke")
if not tokenId: if not tokenId:
raise HTTPException(status_code=400, detail="tokenId is required") raise HTTPException(status_code=400, detail="tokenId is required")
appInterface = getRootInterface() appInterface = getRootInterface()
# Load token to check tenant scope for admins # MULTI-TENANT: SysAdmin can revoke any token (no mandate check)
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)
ok = appInterface.revokeTokenById(tokenId, revokedBy=currentUser.id, reason=reason) ok = appInterface.revokeTokenById(tokenId, revokedBy=currentUser.id, reason=reason)
return {"revoked": 1 if ok else 0} return {"revoked": 1 if ok else 0}
except HTTPException: except HTTPException:
@ -183,29 +237,34 @@ async def revoke_token_by_id(
@limiter.limit("10/minute") @limiter.limit("10/minute")
async def revoke_tokens_by_mandate( async def revoke_tokens_by_mandate(
request: Request, request: Request,
currentUser: User = Depends(getCurrentUser), currentUser: User = Depends(requireSysAdmin),
payload: Dict[str, Any] = Body(...) payload: Dict[str, Any] = Body(...)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""
Revoke all tokens for users in a mandate.
MULTI-TENANT: SysAdmin-only, can revoke tokens for any mandate.
"""
try: try:
mandateId = payload.get("mandateId") mandateId = payload.get("mandateId")
authority = payload.get("authority", "local") authority = payload.get("authority", "local")
reason = payload.get("reason", "admin mandate revoke") reason = payload.get("reason", "sysadmin mandate revoke")
if not mandateId: if not mandateId:
raise HTTPException(status_code=400, detail="mandateId is required") raise HTTPException(status_code=400, detail="mandateId is required")
_ensure_admin_scope(currentUser, mandateId) # MULTI-TENANT: SysAdmin can revoke tokens for any mandate
# Revoke for all users in mandate
appInterface = getRootInterface() 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 total = 0
for u in users: for um in userMandates:
# Revoke regardless of token.mandateId to also catch legacy tokens without mandateId
total += appInterface.revokeTokensByUser( total += appInterface.revokeTokensByUser(
userId=u["id"], userId=um["userId"],
authority=AuthAuthority(authority), authority=AuthAuthority(authority) if authority else None,
mandateId=None, mandateId=None, # Revoke all tokens for user
revokedBy=currentUser.id, revokedBy=currentUser.id,
reason=reason reason=reason
) )
@ -225,10 +284,13 @@ async def revoke_tokens_by_mandate(
@limiter.limit("60/minute") @limiter.limit("60/minute")
async def download_log( async def download_log(
request: Request, request: Request,
currentUser: User = Depends(getCurrentUser), currentUser: User = Depends(requireSysAdmin),
log_name: str = "poweron" 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 = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# base_dir -> gateway # base_dir -> gateway
if log_name == "poweron": if log_name == "poweron":
@ -251,33 +313,18 @@ async def download_log(
@limiter.limit("10/minute") @limiter.limit("10/minute")
async def list_databases( async def list_databases(
request: Request, request: Request,
currentUser: User = Depends(getCurrentUser) currentUser: User = Depends(requireSysAdmin)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
_ensure_admin_scope(currentUser) """
List all poweron_* databases.
# Get database names from configuration for each interface MULTI-TENANT: SysAdmin-only (infrastructure management).
databases = [] """
try:
# App database (interfaceDbAppObjects.py) databases = _getPoweronDatabases()
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} 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") @router.get("/databases/{database_name}/tables")
@ -285,48 +332,28 @@ async def list_databases(
async def get_database_tables( async def get_database_tables(
request: Request, request: Request,
database_name: str, database_name: str,
currentUser: User = Depends(getCurrentUser) currentUser: User = Depends(requireSysAdmin)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
_ensure_admin_scope(currentUser) """
List tables in a database.
# Get all configured database names MULTI-TENANT: SysAdmin-only (infrastructure management).
configured_dbs = [] """
app_db = APP_CONFIG.get("DB_APP_DATABASE") if not database_name.startswith("poweron_"):
if app_db: raise HTTPException(status_code=400, detail="Invalid database name format")
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}")
connector = None
try: try:
# Use the appropriate interface based on database name connector = _getDatabaseConnector(database_name, currentUser.id)
if database_name == app_db: tables = connector.getTables()
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")
return {"tables": tables} return {"tables": tables}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
logger.error(f"Error getting database tables: {str(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") @router.post("/databases/{database_name}/tables/{table_name}/drop")
@ -335,43 +362,20 @@ async def drop_table(
request: Request, request: Request,
database_name: str, database_name: str,
table_name: str, table_name: str,
currentUser: User = Depends(getCurrentUser), currentUser: User = Depends(requireSysAdmin),
payload: Dict[str, Any] = Body(...) payload: Dict[str, Any] = Body(...)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
_ensure_admin_scope(currentUser) """
Drop a table from a database.
# Get all configured database names MULTI-TENANT: SysAdmin-only (infrastructure management).
configured_dbs = [] """
app_db = APP_CONFIG.get("DB_APP_DATABASE") if not database_name.startswith("poweron_"):
if app_db: raise HTTPException(status_code=400, detail="Invalid database name format")
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}")
connector = None
try: try:
# Use the appropriate interface based on database name connector = _getDatabaseConnector(database_name, currentUser.id)
if database_name == app_db: conn = connector.connection
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
with conn.cursor() as cursor: with conn.cursor() as cursor:
# Check if table exists # Check if table exists
cursor.execute(""" cursor.execute("""
@ -388,57 +392,50 @@ async def drop_table(
return {"message": f"Table '{table_name}' dropped successfully from database '{database_name}'"} return {"message": f"Table '{table_name}' dropped successfully from database '{database_name}'"}
except HTTPException: except HTTPException:
raise raise
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
logger.error(f"Error dropping table: {str(e)}") logger.error(f"Error dropping table: {str(e)}")
if 'interface' in locals() and interface and interface.db and interface.db.connection: if connector and connector.connection:
interface.db.connection.rollback() connector.connection.rollback()
raise HTTPException(status_code=500, detail="Failed to drop table") raise HTTPException(status_code=500, detail="Failed to drop table")
finally:
if connector:
connector.close()
@router.post("/databases/drop") @router.post("/databases/drop")
@limiter.limit("5/minute") @limiter.limit("5/minute")
async def drop_database( async def drop_database(
request: Request, request: Request,
currentUser: User = Depends(getCurrentUser), currentUser: User = Depends(requireSysAdmin),
payload: Dict[str, Any] = Body(...) payload: Dict[str, Any] = Body(...)
) -> Dict[str, Any]: ) -> 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 if not dbName or not dbName.startswith("poweron_"):
configured_dbs = [] raise HTTPException(status_code=400, detail="Invalid database name")
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 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: try:
# Use the appropriate interface based on database name configuredDbs = _getPoweronDatabases()
if db_name == app_db: except Exception as e:
interface = getRootInterface() logger.warning(f"Failed to load databases from host: {e}")
elif db_name == chat_db: configuredDbs = []
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 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: with conn.cursor() as cursor:
# Drop all user tables (public schema) except system table # Drop all user tables (public schema)
cursor.execute(""" cursor.execute("""
SELECT table_name FROM information_schema.tables SELECT table_name FROM information_schema.tables
WHERE table_schema = 'public' AND table_type = 'BASE TABLE' 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') cursor.execute(f'DROP TABLE IF EXISTS "{tbl}" CASCADE')
dropped.append(tbl) dropped.append(tbl)
conn.commit() 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} return {"droppedTables": dropped}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
logger.error(f"Error dropping database tables: {str(e)}") logger.error(f"Error dropping database tables: {str(e)}")
if 'interface' in locals() and interface and interface.db and interface.db.connection: if connector and connector.connection:
interface.db.connection.rollback() connector.connection.rollback()
raise HTTPException(status_code=500, detail="Failed to drop database tables") raise HTTPException(status_code=500, detail="Failed to drop database tables")
finally:
if connector:
connector.close()

View file

@ -13,10 +13,11 @@ from requests_oauthlib import OAuth2Session
import httpx import httpx
from modules.shared.configuration import APP_CONFIG from modules.shared.configuration import APP_CONFIG
from modules.interfaces.interfaceDbAppObjects import getInterface, getRootInterface from modules.interfaces.interfaceDbApp import getInterface, getRootInterface
from modules.datamodels.datamodelUam import AuthAuthority, User, ConnectionStatus, UserConnection from modules.datamodels.datamodelUam import AuthAuthority, User, ConnectionStatus, UserConnection
from modules.auth import getCurrentUser, limiter from modules.auth import getCurrentUser, limiter
from modules.auth import createAccessToken, setAccessTokenCookie, createRefreshToken, setRefreshTokenCookie from modules.auth import createAccessToken, setAccessTokenCookie, createRefreshToken, setRefreshTokenCookie
from modules.auth.tokenManager import TokenManager
from modules.shared.timeUtils import createExpirationTimestamp, getUtcTimestamp, parseTimestamp from modules.shared.timeUtils import createExpirationTimestamp, getUtcTimestamp, parseTimestamp
# Configure logger # Configure logger
@ -170,7 +171,6 @@ async def login(
try: try:
if connectionId: if connectionId:
rootInterface = getRootInterface() rootInterface = getRootInterface()
from modules.datamodels.datamodelUam import UserConnection
records = rootInterface.db.getRecordset(UserConnection, recordFilter={"id": connectionId}) records = rootInterface.db.getRecordset(UserConnection, recordFilter={"id": connectionId})
if records: if records:
record = records[0] record = records[0]
@ -340,11 +340,12 @@ async def auth_callback(code: str, state: str, request: Request, response: Respo
) )
# Create JWT token data (like Microsoft does) # Create JWT token data (like Microsoft does)
# MULTI-TENANT: Token does NOT contain mandateId anymore
jwt_token_data = { jwt_token_data = {
"sub": user.username, "sub": user.username,
"mandateId": str(user.mandateId),
"userId": str(user.id), "userId": str(user.id),
"authenticationAuthority": AuthAuthority.GOOGLE.value "authenticationAuthority": AuthAuthority.GOOGLE.value
# NO mandateId in token - stateless multi-tenant design
} }
# Create JWT access token # Create JWT access token
@ -355,11 +356,11 @@ async def auth_callback(code: str, state: str, request: Request, response: Respo
# Decode token to get jti for database record # Decode token to get jti for database record
from jose import jwt from jose import jwt
from modules.auth import SECRET_KEY, ALGORITHM
payload = jwt.decode(jwt_token, SECRET_KEY, algorithms=[ALGORITHM]) payload = jwt.decode(jwt_token, SECRET_KEY, algorithms=[ALGORITHM])
jti = payload.get("jti") jti = payload.get("jti")
# Create JWT token with matching id # Create JWT token with matching id
# MULTI-TENANT: Token model no longer has mandateId field
token = Token( token = Token(
id=jti, id=jti,
userId=user.id, # Use local user's ID userId=user.id, # Use local user's ID
@ -368,8 +369,8 @@ async def auth_callback(code: str, state: str, request: Request, response: Respo
tokenRefresh=token_response.get("refresh_token", ""), tokenRefresh=token_response.get("refresh_token", ""),
tokenType="bearer", tokenType="bearer",
expiresAt=jwt_expires_at.timestamp(), expiresAt=jwt_expires_at.timestamp(),
createdAt=getUtcTimestamp(), createdAt=getUtcTimestamp()
mandateId=str(user.mandateId) # NO mandateId - Token is not mandate-bound
) )
# Save access token (no connectionId) # Save access token (no connectionId)
@ -492,7 +493,6 @@ async def auth_callback(code: str, state: str, request: Request, response: Respo
connection.externalEmail = user_info.get("email") connection.externalEmail = user_info.get("email")
# Update connection record directly # Update connection record directly
from modules.datamodels.datamodelUam import UserConnection
rootInterface.db.recordModify(UserConnection, connection_id, connection.model_dump()) rootInterface.db.recordModify(UserConnection, connection_id, connection.model_dump())
@ -615,13 +615,17 @@ async def logout(
appInterface.logout() appInterface.logout()
# Log successful logout # Log successful logout
# MULTI-TENANT: Logout is a system-level function, no mandate context
try: try:
from modules.shared.auditLogger import audit_logger from modules.shared.auditLogger import audit_logger
audit_logger.logUserAccess( audit_logger.logUserAccess(
userId=str(currentUser.id), userId=str(currentUser.id),
mandateId=str(currentUser.mandateId), mandateId="system",
action="logout", action="logout",
successInfo="google_auth_logout" successInfo="google_auth_logout",
ipAddress=request.client.host if request.client else None,
userAgent=request.headers.get("user-agent"),
success=True
) )
except Exception: except Exception:
# Don't fail if audit logging fails # Don't fail if audit logging fails
@ -661,7 +665,6 @@ async def verify_token(
) )
# Get a fresh token via TokenManager convenience method # Get a fresh token via TokenManager convenience method
from modules.auth import TokenManager
current_token = TokenManager().getFreshToken(google_connection.id) current_token = TokenManager().getFreshToken(google_connection.id)
if not current_token: if not current_token:
@ -735,7 +738,6 @@ async def refresh_token(
logger.debug(f"Found Google connection: {google_connection.id}, status={google_connection.status}") logger.debug(f"Found Google connection: {google_connection.id}, status={google_connection.status}")
# Get the token for this specific connection (fresh if expiring soon) # Get the token for this specific connection (fresh if expiring soon)
from modules.auth import TokenManager
current_token = TokenManager().getFreshToken(google_connection.id) current_token = TokenManager().getFreshToken(google_connection.id)
if not current_token: if not current_token:

View file

@ -16,14 +16,64 @@ from jose import jwt
# Import auth modules # Import auth modules
from modules.auth import getCurrentUser, limiter, SECRET_KEY, ALGORITHM from modules.auth import getCurrentUser, limiter, SECRET_KEY, ALGORITHM
from modules.auth import createAccessToken, createRefreshToken, setAccessTokenCookie, setRefreshTokenCookie, clearAccessTokenCookie, clearRefreshTokenCookie from modules.auth import createAccessToken, createRefreshToken, setAccessTokenCookie, setRefreshTokenCookie, clearAccessTokenCookie, clearRefreshTokenCookie
from modules.interfaces.interfaceDbAppObjects import getInterface, getRootInterface from modules.interfaces.interfaceDbApp import getInterface, getRootInterface
from modules.datamodels.datamodelUam import User, UserInDB, AuthAuthority from modules.datamodels.datamodelUam import User, UserInDB, AuthAuthority, Mandate
from modules.datamodels.datamodelSecurity import Token from modules.datamodels.datamodelSecurity import Token
from modules.shared.configuration import APP_CONFIG from modules.shared.configuration import APP_CONFIG
from modules.shared.timeUtils import getUtcTimestamp
# Configure logger # Configure logger
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _sendAuthEmail(recipient: str, subject: str, message: str, userId: str = None) -> bool:
"""
Send authentication-related email directly without requiring full Services initialization.
Used for registration, password reset, and other auth flows.
Args:
recipient: Email address
subject: Email subject
message: Plain text message (will be converted to HTML)
userId: Optional user ID for logging
Returns:
bool: True if email was sent successfully
"""
try:
import html
from modules.interfaces.interfaceMessaging import getInterface as getMessagingInterface
from modules.datamodels.datamodelMessaging import MessagingChannel
# Convert plain text to simple HTML
escaped = html.escape(message)
escaped = escaped.replace('\n', '<br>\n')
htmlMessage = f"""<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="font-family: Arial, sans-serif; line-height: 1.6;">
{escaped}
</body>
</html>"""
messagingInterface = getMessagingInterface()
success = messagingInterface.send(
channel=MessagingChannel.EMAIL,
recipient=recipient,
subject=subject,
message=htmlMessage
)
if success:
logger.info(f"Auth email sent successfully to {recipient} (userId: {userId})")
else:
logger.warning(f"Failed to send auth email to {recipient} (userId: {userId})")
return success
except Exception as e:
logger.error(f"Error sending auth email to {recipient}: {str(e)}", exc_info=True)
return False
# Create router for Local Security endpoints # Create router for Local Security endpoints
router = APIRouter( router = APIRouter(
prefix="/api/local", prefix="/api/local",
@ -57,19 +107,9 @@ async def login(
# Get gateway interface with root privileges for authentication # Get gateway interface with root privileges for authentication
rootInterface = getRootInterface() rootInterface = getRootInterface()
# Get default mandate ID
from modules.datamodels.datamodelUam import Mandate
defaultMandateId = rootInterface.getInitialId(Mandate)
if not defaultMandateId:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="No default mandate found"
)
# Set the mandate ID on the interface
rootInterface.mandateId = defaultMandateId
# Authenticate user # Authenticate user
# Note: authenticateLocalUser uses _getUserForAuthentication which bypasses RBAC
# This is correct because users are mandate-independent (Multi-Tenant Design)
user = rootInterface.authenticateLocalUser( user = rootInterface.authenticateLocalUser(
username=formData.username, username=formData.username,
password=formData.password password=formData.password
@ -83,11 +123,13 @@ async def login(
) )
# Create token data # Create token data
# MULTI-TENANT: Token does NOT contain mandateId anymore
# Mandate context is determined per request via X-Mandate-Id header
token_data = { token_data = {
"sub": user.username, "sub": user.username,
"mandateId": str(user.mandateId),
"userId": str(user.id), "userId": str(user.id),
"authenticationAuthority": AuthAuthority.LOCAL "authenticationAuthority": AuthAuthority.LOCAL
# NO mandateId in token - stateless multi-tenant design
} }
# Create session id and include in token claims for session-scoped logout # Create session id and include in token claims for session-scoped logout
@ -116,7 +158,8 @@ async def login(
# Get jti from already decoded payload # Get jti from already decoded payload
jti = payload.get("jti") jti = payload.get("jti")
# Create token # Create token record in database
# MULTI-TENANT: Token model no longer has mandateId field
token = Token( token = Token(
id=jti, id=jti,
userId=user.id, userId=user.id,
@ -124,21 +167,25 @@ async def login(
tokenAccess=access_token, tokenAccess=access_token,
tokenType="bearer", tokenType="bearer",
expiresAt=expires_at.timestamp(), expiresAt=expires_at.timestamp(),
sessionId=session_id, sessionId=session_id
mandateId=str(user.mandateId) # NO mandateId - Token is not mandate-bound
) )
# Save access token # Save access token
userInterface.saveAccessToken(token) userInterface.saveAccessToken(token)
# Log successful login # Log successful login
# MULTI-TENANT: Login is a system-level function, no mandate context
try: try:
from modules.shared.auditLogger import audit_logger from modules.shared.auditLogger import audit_logger
audit_logger.logUserAccess( audit_logger.logUserAccess(
userId=str(user.id), userId=str(user.id),
mandateId=str(user.mandateId), mandateId="system",
action="login", action="login",
successInfo="local_auth_success" successInfo="local_auth_success",
ipAddress=request.client.host if request.client else None,
userAgent=request.headers.get("user-agent"),
success=True
) )
except Exception: except Exception:
# Don't fail if audit logging fails # Don't fail if audit logging fails
@ -167,10 +214,13 @@ async def login(
try: try:
from modules.shared.auditLogger import audit_logger from modules.shared.auditLogger import audit_logger
audit_logger.logUserAccess( audit_logger.logUserAccess(
userId="unknown", userId=formData.username or "unknown",
mandateId="unknown", mandateId="system",
action="login", action="login_failed",
successInfo=f"failed: {error_msg}" successInfo=f"failed: {error_msg}",
ipAddress=request.client.host if request.client else None,
userAgent=request.headers.get("user-agent"),
success=False
) )
except Exception: except Exception:
# Don't fail if audit logging fails # Don't fail if audit logging fails
@ -207,17 +257,9 @@ async def register_user(
# Get gateway interface with root privileges since this is a public endpoint # Get gateway interface with root privileges since this is a public endpoint
appInterface = getRootInterface() appInterface = getRootInterface()
# Get default mandate ID # Note: User registration does NOT require mandateId context
from modules.datamodels.datamodelUam import Mandate # Users are mandate-independent (Multi-Tenant Design)
defaultMandateId = appInterface.getInitialId(Mandate) # Mandate assignment happens via createUserMandate() after registration
if not defaultMandateId:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="No default mandate found"
)
# Set the mandate ID on the interface
appInterface.mandateId = defaultMandateId
# Frontend URL is required - no fallback # Frontend URL is required - no fallback
baseUrl = frontendUrl.rstrip("/") baseUrl = frontendUrl.rstrip("/")
@ -236,7 +278,6 @@ async def register_user(
fullName=userData.fullName, fullName=userData.fullName,
language=userData.language, language=userData.language,
enabled=True, # Users are enabled by default (can login after setting password) enabled=True, # Users are enabled by default (can login after setting password)
roleLabels=["user"], # Default role for new registrations
authenticationAuthority=AuthAuthority.LOCAL authenticationAuthority=AuthAuthority.LOCAL
) )
@ -252,15 +293,11 @@ async def register_user(
# Send registration email with magic link # Send registration email with magic link
try: try:
from modules.services import Services
services = Services(user)
magicLink = f"{baseUrl}/reset?token={token}" magicLink = f"{baseUrl}/reset?token={token}"
expiryHours = int(APP_CONFIG.get("Auth_RESET_TOKEN_EXPIRY_HOURS", "24")) expiryHours = int(APP_CONFIG.get("Auth_RESET_TOKEN_EXPIRY_HOURS", "24"))
emailSubject = "PowerOn Registrierung - Passwort setzen" emailSubject = "PowerOn Registrierung - Passwort setzen"
emailBody = f""" emailBody = f"""Hallo {user.fullName or user.username},
Hallo {user.fullName or user.username},
Vielen Dank für Ihre Registrierung bei PowerOn. Vielen Dank für Ihre Registrierung bei PowerOn.
@ -271,10 +308,9 @@ Klicken Sie auf den folgenden Link, um Ihr Passwort zu setzen:
Dieser Link ist {expiryHours} Stunden gültig. Dieser Link ist {expiryHours} Stunden gültig.
Falls Sie sich nicht registriert haben, können Sie diese E-Mail ignorieren. Falls Sie sich nicht registriert haben, können Sie diese E-Mail ignorieren."""
"""
emailSent = services.messaging.sendEmailDirect( emailSent = _sendAuthEmail(
recipient=user.email, recipient=user.email,
subject=emailSubject, subject=emailSubject,
message=emailBody, message=emailBody,
@ -287,6 +323,52 @@ Falls Sie sich nicht registriert haben, können Sie diese E-Mail ignorieren.
logger.error(f"Error sending registration email: {str(emailErr)}") logger.error(f"Error sending registration email: {str(emailErr)}")
# Don't fail registration if email fails - user can request reset later # Don't fail registration if email fails - user can request reset later
# Check for pending invitations and create notifications
try:
from modules.datamodels.datamodelInvitation import Invitation
from modules.routes.routeNotifications import createInvitationNotification
from modules.datamodels.datamodelUam import Mandate
currentTime = getUtcTimestamp()
pendingInvitations = appInterface.db.getRecordset(
model_class=Invitation,
recordFilter={"targetUsername": userData.username}
)
for invitation in pendingInvitations:
# Skip expired, revoked, or fully used invitations
if invitation.get("expiresAt", 0) < currentTime:
continue
if invitation.get("revokedAt"):
continue
if invitation.get("currentUses", 0) >= invitation.get("maxUses", 1):
continue
# Get mandate name for notification
mandateId = invitation.get("mandateId")
mandateRecords = appInterface.db.getRecordset(
Mandate,
recordFilter={"id": mandateId}
)
mandateName = mandateRecords[0].get("mandateLabel", "PowerOn") if mandateRecords else "PowerOn"
# Get inviter name
inviterId = invitation.get("createdBy")
inviter = appInterface.getUserById(inviterId) if inviterId else None
inviterName = (inviter.fullName or inviter.username) if inviter else "PowerOn"
createInvitationNotification(
userId=str(user.id),
invitationId=str(invitation.get("id")),
mandateName=mandateName,
inviterName=inviterName
)
logger.info(f"Created notification for new user {userData.username} for invitation {invitation.get('id')}")
except Exception as notifErr:
logger.warning(f"Failed to create notifications for pending invitations: {notifErr}")
# Don't fail registration if notification creation fails
return { return {
"message": "Registrierung erfolgreich! Bitte prüfen Sie Ihre E-Mail für den Link zum Setzen Ihres Passworts." "message": "Registrierung erfolgreich! Bitte prüfen Sie Ihre E-Mail für den Link zum Setzen Ihres Passworts."
} }
@ -358,11 +440,12 @@ async def refresh_token(
raise HTTPException(status_code=500, detail="Failed to validate user") raise HTTPException(status_code=500, detail="Failed to validate user")
# Create new token data # Create new token data
# MULTI-TENANT: Token does NOT contain mandateId anymore
token_data = { token_data = {
"sub": current_user.username, "sub": current_user.username,
"mandateId": str(current_user.mandateId),
"userId": str(current_user.id), "userId": str(current_user.id),
"authenticationAuthority": current_user.authenticationAuthority "authenticationAuthority": current_user.authenticationAuthority
# NO mandateId in token
} }
# Create new access token + set cookie # Create new access token + set cookie
@ -427,13 +510,17 @@ async def logout(request: Request, response: Response, currentUser: User = Depen
revoked = 1 revoked = 1
# Log successful logout # Log successful logout
# MULTI-TENANT: Logout is a system-level function, no mandate context
try: try:
from modules.shared.auditLogger import audit_logger from modules.shared.auditLogger import audit_logger
audit_logger.logUserAccess( audit_logger.logUserAccess(
userId=str(currentUser.id), userId=str(currentUser.id),
mandateId=str(currentUser.mandateId), mandateId="system",
action="logout", action="logout",
successInfo=f"revoked_tokens: {revoked}" successInfo=f"revoked_tokens: {revoked}",
ipAddress=request.client.host if request.client else None,
userAgent=request.headers.get("user-agent"),
success=True
) )
except Exception: except Exception:
# Don't fail if audit logging fails # Don't fail if audit logging fails
@ -492,7 +579,7 @@ async def check_username_availability(
@router.post("/password-reset-request") @router.post("/password-reset-request")
@limiter.limit("5/minute") @limiter.limit("5/minute")
async def passwordResetRequest( async def password_reset_request(
request: Request, request: Request,
username: str = Body(..., embed=True), username: str = Body(..., embed=True),
frontendUrl: str = Body(..., embed=True) frontendUrl: str = Body(..., embed=True)
@ -515,7 +602,6 @@ async def passwordResetRequest(
user = rootInterface.findUserByUsernameLocalAuth(username) user = rootInterface.findUserByUsernameLocalAuth(username)
if user and user.email: if user and user.email:
from modules.services import Services
expiryHours = int(APP_CONFIG.get("Auth_RESET_TOKEN_EXPIRY_HOURS", "24")) expiryHours = int(APP_CONFIG.get("Auth_RESET_TOKEN_EXPIRY_HOURS", "24"))
try: try:
@ -525,16 +611,12 @@ async def passwordResetRequest(
# Set reset token (clears password) # Set reset token (clears password)
rootInterface.setResetToken(user.id, token, expires) rootInterface.setResetToken(user.id, token, expires)
# Get services for email sending
services = Services(user)
# Generate magic link using provided frontend URL # Generate magic link using provided frontend URL
magicLink = f"{baseUrl}/reset?token={token}" magicLink = f"{baseUrl}/reset?token={token}"
# Send email # Send email using dedicated auth email function
emailSubject = "PowerOn - Passwort zurücksetzen" emailSubject = "PowerOn - Passwort zurücksetzen"
emailBody = f""" emailBody = f"""Hallo {user.fullName or user.username},
Hallo {user.fullName or user.username},
Sie haben eine Passwort-Zurücksetzung für Ihren PowerOn Account angefordert. Sie haben eine Passwort-Zurücksetzung für Ihren PowerOn Account angefordert.
@ -545,17 +627,19 @@ Klicken Sie auf den folgenden Link, um Ihr Passwort zurückzusetzen:
Dieser Link ist {expiryHours} Stunden gültig. Dieser Link ist {expiryHours} Stunden gültig.
Falls Sie diese Anforderung nicht gestellt haben, können Sie diese E-Mail ignorieren. Falls Sie diese Anforderung nicht gestellt haben, können Sie diese E-Mail ignorieren."""
"""
services.messaging.sendEmailDirect( emailSent = _sendAuthEmail(
recipient=user.email, recipient=user.email,
subject=emailSubject, subject=emailSubject,
message=emailBody, message=emailBody,
userId=str(user.id) userId=str(user.id)
) )
if emailSent:
logger.info(f"Password reset email sent to {user.email} for user {user.username}") logger.info(f"Password reset email sent to {user.email} for user {user.username}")
else:
logger.warning(f"Failed to send password reset email to {user.email}")
except Exception as userErr: except Exception as userErr:
logger.error(f"Failed to send reset email for user {username}: {str(userErr)}") logger.error(f"Failed to send reset email for user {username}: {str(userErr)}")
else: else:
@ -575,7 +659,7 @@ Falls Sie diese Anforderung nicht gestellt haben, können Sie diese E-Mail ignor
@router.post("/password-reset") @router.post("/password-reset")
@limiter.limit("10/minute") @limiter.limit("10/minute")
async def passwordReset( async def password_reset(
request: Request, request: Request,
token: str = Body(..., embed=True), token: str = Body(..., embed=True),
password: str = Body(..., embed=True) password: str = Body(..., embed=True)

View file

@ -13,11 +13,12 @@ import msal
import httpx import httpx
from modules.shared.configuration import APP_CONFIG from modules.shared.configuration import APP_CONFIG
from modules.interfaces.interfaceDbAppObjects import getInterface, getRootInterface from modules.interfaces.interfaceDbApp import getInterface, getRootInterface
from modules.datamodels.datamodelUam import AuthAuthority, User, ConnectionStatus, UserConnection from modules.datamodels.datamodelUam import AuthAuthority, User, ConnectionStatus, UserConnection
from modules.datamodels.datamodelSecurity import Token from modules.datamodels.datamodelSecurity import Token
from modules.auth import getCurrentUser, limiter from modules.auth import getCurrentUser, limiter
from modules.auth import createAccessToken, setAccessTokenCookie, createRefreshToken, setRefreshTokenCookie from modules.auth import createAccessToken, setAccessTokenCookie, createRefreshToken, setRefreshTokenCookie
from modules.auth.tokenManager import TokenManager
from modules.shared.timeUtils import createExpirationTimestamp, getUtcTimestamp, parseTimestamp from modules.shared.timeUtils import createExpirationTimestamp, getUtcTimestamp, parseTimestamp
# Configure logger # Configure logger
@ -348,11 +349,12 @@ async def auth_callback(code: str, state: str, request: Request, response: Respo
appInterface.saveAccessToken(token) appInterface.saveAccessToken(token)
# Create JWT token data # Create JWT token data
# MULTI-TENANT: Token does NOT contain mandateId anymore
jwt_token_data = { jwt_token_data = {
"sub": user.username, "sub": user.username,
"mandateId": str(user.mandateId),
"userId": str(user.id), "userId": str(user.id),
"authenticationAuthority": AuthAuthority.MSFT.value "authenticationAuthority": AuthAuthority.MSFT.value
# NO mandateId in token - stateless multi-tenant design
} }
# Create JWT access token # Create JWT access token
@ -363,11 +365,11 @@ async def auth_callback(code: str, state: str, request: Request, response: Respo
# Decode token to get jti for database record # Decode token to get jti for database record
from jose import jwt from jose import jwt
from modules.auth import SECRET_KEY, ALGORITHM
payload = jwt.decode(jwt_token, SECRET_KEY, algorithms=[ALGORITHM]) payload = jwt.decode(jwt_token, SECRET_KEY, algorithms=[ALGORITHM])
jti = payload.get("jti") jti = payload.get("jti")
# Create JWT token with matching id # Create JWT token with matching id
# MULTI-TENANT: Token model no longer has mandateId field
jwt_token_obj = Token( jwt_token_obj = Token(
id=jti, id=jti,
userId=user.id, userId=user.id,
@ -375,8 +377,8 @@ async def auth_callback(code: str, state: str, request: Request, response: Respo
tokenAccess=jwt_token, tokenAccess=jwt_token,
tokenType="bearer", tokenType="bearer",
expiresAt=jwt_expires_at.timestamp(), expiresAt=jwt_expires_at.timestamp(),
createdAt=getUtcTimestamp(), createdAt=getUtcTimestamp()
mandateId=str(user.mandateId) # NO mandateId - Token is not mandate-bound
) )
# Save JWT access token # Save JWT access token
@ -625,13 +627,17 @@ async def logout(
appInterface.logout() appInterface.logout()
# Log successful logout # Log successful logout
# MULTI-TENANT: Logout is a system-level function, no mandate context
try: try:
from modules.shared.auditLogger import audit_logger from modules.shared.auditLogger import audit_logger
audit_logger.logUserAccess( audit_logger.logUserAccess(
userId=str(currentUser.id), userId=str(currentUser.id),
mandateId=str(currentUser.mandateId), mandateId="system",
action="logout", action="logout",
successInfo="microsoft_auth_logout" successInfo="microsoft_auth_logout",
ipAddress=request.client.host if request.client else None,
userAgent=request.headers.get("user-agent"),
success=True
) )
except Exception: except Exception:
# Don't fail if audit logging fails # Don't fail if audit logging fails
@ -720,7 +726,6 @@ async def refresh_token(
logger.debug(f"Found Microsoft connection: {msft_connection.id}, status={msft_connection.status}") logger.debug(f"Found Microsoft connection: {msft_connection.id}, status={msft_connection.status}")
# Get a fresh token via TokenManager convenience method # Get a fresh token via TokenManager convenience method
from modules.auth import TokenManager
current_token = TokenManager().getFreshToken(msft_connection.id) current_token = TokenManager().getFreshToken(msft_connection.id)
if not current_token: if not current_token:
@ -732,7 +737,6 @@ async def refresh_token(
# Always attempt refresh (as per your requirement) # Always attempt refresh (as per your requirement)
from modules.auth import TokenManager
token_manager = TokenManager() token_manager = TokenManager()
refreshedToken = token_manager.refreshToken(current_token) refreshedToken = token_manager.refreshToken(current_token)

View file

@ -11,7 +11,7 @@ from fastapi import APIRouter, HTTPException, Depends, Path, Query, Request, sta
from modules.auth import limiter, getCurrentUser from modules.auth import limiter, getCurrentUser
from modules.datamodels.datamodelUam import User, UserConnection from modules.datamodels.datamodelUam import User, UserConnection
from modules.interfaces.interfaceDbAppObjects import getInterface from modules.interfaces.interfaceDbApp import getInterface
from modules.services import getInterface as getServices from modules.services import getInterface as getServices
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -146,3 +146,108 @@ async def list_sharepoint_folders(
detail=f"Error listing SharePoint folders: {str(e)}" detail=f"Error listing SharePoint folders: {str(e)}"
) )
@router.get("/{connectionId}/folder-options", response_model=List[Dict[str, Any]])
@limiter.limit("30/minute")
async def getSharepointFolderOptions(
request: Request,
connectionId: str = Path(..., description="Microsoft connection ID"),
siteId: Optional[str] = Query(None, description="Specific site ID to browse (if omitted, returns sites only)"),
path: Optional[str] = Query(None, description="Folder path within site to browse"),
currentUser: User = Depends(getCurrentUser)
) -> List[Dict[str, Any]]:
"""
Get SharePoint folders formatted as dropdown options.
Two modes:
1. If siteId is not provided: Returns list of sites (for site selection)
2. If siteId is provided: Returns folders within that site (optionally at specific path)
This avoids expensive iteration through all sites and folders.
"""
try:
interface = getInterface(currentUser)
# Get the connection and verify it belongs to the user
connection = _getUserConnection(interface, connectionId, currentUser.id)
if not connection:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Connection {connectionId} not found or does not belong to user"
)
# Verify it's a Microsoft connection
authority = connection.authority.value if hasattr(connection.authority, 'value') else str(connection.authority)
if authority.lower() != 'msft':
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Connection {connectionId} is not a Microsoft connection"
)
# Initialize services
services = getServices(currentUser, None)
# Set access token on SharePoint service
if not services.sharepoint.setAccessTokenFromConnection(connection):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Failed to set SharePoint access token. Connection may be expired or invalid."
)
# Mode 1: Return sites list if no siteId specified
if not siteId:
sites = await services.sharepoint.discoverSites()
return [
{
"type": "site",
"value": site.get("id"),
"label": site.get("displayName", "Unknown Site"),
"siteId": site.get("id"),
"siteName": site.get("displayName", "Unknown Site"),
"webUrl": site.get("webUrl", ""),
"path": _extractSitePath(site.get("webUrl", ""))
}
for site in sites
]
# Mode 2: Return folders within specific site
folderPath = path or ""
items = await services.sharepoint.listFolderContents(siteId, folderPath)
if not items:
return []
folderOptions = []
for item in items:
if item.get("type") == "folder":
folderName = item.get("name", "")
itemPath = f"{folderPath}/{folderName}" if folderPath else folderName
folderOptions.append({
"type": "folder",
"value": itemPath,
"label": folderName,
"siteId": siteId,
"folderName": folderName,
"path": itemPath,
"hasChildren": True # Assume folders may have children
})
return folderOptions
except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting SharePoint folder options: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error getting SharePoint folder options: {str(e)}"
)
def _extractSitePath(webUrl: str) -> str:
"""Extract site path from webUrl (e.g., https://company.sharepoint.com/sites/MySite -> /sites/MySite)"""
if "/sites/" in webUrl:
return "/sites/" + webUrl.split("/sites/")[1].split("/")[0]
return ""

View file

@ -0,0 +1,515 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
System Routes - Navigation and system-level API endpoints.
Navigation API Konzept:
- Single Source of Truth für Navigation im Gateway
- UI rendert nur was es erhält (keine Permission-Logik im UI)
- Keine Icons in API Response - UI mappt selbst via uiComponent
- Blocks statt Sections mit order auf allen Ebenen
"""
import logging
from typing import Dict, List, Any, Optional
from fastapi import APIRouter, Depends, Request, Query
from slowapi import Limiter
from slowapi.util import get_remote_address
from modules.auth.authentication import getRequestContext, RequestContext
from modules.system.mainSystem import NAVIGATION_SECTIONS, _objectKeyToUiComponent
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.interfaces.interfaceFeatures import getFeatureInterface
from modules.datamodels.datamodelRbac import AccessRule, AccessRuleContext
from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole, FeatureAccess, FeatureAccessRole
logger = logging.getLogger(__name__)
limiter = Limiter(key_func=get_remote_address)
# Main system router (for other system endpoints if needed)
router = APIRouter(prefix="/api/system", tags=["System"])
# Navigation router at /api/navigation (gemäss Navigation-API-Konzept)
navigationRouter = APIRouter(prefix="/api", tags=["Navigation"])
def _getUserRoleIds(userId: str) -> List[str]:
"""Get all role IDs for a user across all their mandates."""
rootInterface = getRootInterface()
roleIds = []
userMandates = rootInterface.db.getRecordset(
UserMandate,
recordFilter={"userId": userId, "enabled": True}
)
for um in userMandates:
mandateRoleIds = rootInterface.getRoleIdsForUserMandate(um.get("id"))
for rid in mandateRoleIds:
if rid not in roleIds:
roleIds.append(rid)
return roleIds
def _checkUiPermission(roleIds: List[str], objectKey: str) -> bool:
"""Check if any of the given roles has view permission for the UI object."""
if not roleIds:
return False
rootInterface = getRootInterface()
for roleId in roleIds:
# Get UI rules for this role
rules = rootInterface.db.getRecordset(
AccessRule,
recordFilter={"roleId": roleId, "context": "UI"}
)
for rule in rules:
ruleItem = rule.get("item")
ruleView = rule.get("view", False)
if not ruleView:
continue
# Global rule (item=None) grants access to all UI
if ruleItem is None:
return True
# Exact match
if ruleItem == objectKey:
return True
# Wildcard match (e.g., ui.system.* matches ui.system.playground)
if ruleItem.endswith(".*"):
prefix = ruleItem[:-2]
if objectKey.startswith(prefix):
return True
return False
# =============================================================================
# Navigation API (gemäss Navigation-API-Konzept)
# Endpoint: GET /api/navigation
# =============================================================================
def _getFeatureUiObjects(featureCode: str) -> List[Dict[str, Any]]:
"""
Get UI objects for a feature from its main module.
Returns list of UI objects with objectKey, label, meta (including path).
"""
try:
# Dynamic import based on feature code
if featureCode == "trustee":
from modules.features.trustee.mainTrustee import UI_OBJECTS
return UI_OBJECTS
elif featureCode == "realestate":
from modules.features.realestate.mainRealEstate import UI_OBJECTS
return UI_OBJECTS
else:
logger.warning(f"Unknown feature code: {featureCode}")
return []
except ImportError as e:
logger.error(f"Failed to import UI_OBJECTS for feature {featureCode}: {e}")
return []
def _buildDynamicBlock(
userId: str,
language: str,
isSysAdmin: bool
) -> Optional[Dict[str, Any]]:
"""
Build the dynamic features block with mandates, features, and instances.
Returns None if user has no feature instances.
"""
try:
rootInterface = getRootInterface()
featureInterface = getFeatureInterface(rootInterface.db)
# Get all feature accesses for this user
featureAccesses = rootInterface.getFeatureAccessesForUser(userId)
if not featureAccesses:
return None
# Build hierarchical structure: mandate -> feature -> instances
mandatesMap: Dict[str, Dict[str, Any]] = {}
featuresMap: Dict[str, Dict[str, Any]] = {} # key: mandateId_featureCode
mandateOrder = 10
for access in featureAccesses:
if not access.enabled:
continue
instance = featureInterface.getFeatureInstance(str(access.featureInstanceId))
if not instance or not instance.enabled:
continue
# Get mandate info
mandateId = str(instance.mandateId)
if mandateId not in mandatesMap:
mandate = rootInterface.getMandate(mandateId)
mandateName = mandate.name if mandate and hasattr(mandate, 'name') else mandateId
mandatesMap[mandateId] = {
"id": mandateId,
"uiLabel": mandateName,
"order": mandateOrder,
"features": []
}
mandateOrder += 10
# Get feature info
featureKey = f"{mandateId}_{instance.featureCode}"
if featureKey not in featuresMap:
feature = featureInterface.getFeature(instance.featureCode)
# Handle featureLabel - could be a dict or a Pydantic model (TextMultilingual)
if feature and hasattr(feature, 'label'):
featureLabel = feature.label
# Convert Pydantic model to dict if needed
if hasattr(featureLabel, 'model_dump'):
featureLabel = featureLabel.model_dump()
elif hasattr(featureLabel, 'dict'):
featureLabel = featureLabel.dict()
elif not isinstance(featureLabel, dict):
# Fallback: try to access as attributes
featureLabel = {"de": getattr(featureLabel, 'de', instance.featureCode), "en": getattr(featureLabel, 'en', instance.featureCode)}
else:
featureLabel = {"de": instance.featureCode, "en": instance.featureCode}
featuresMap[featureKey] = {
"uiComponent": f"feature.{instance.featureCode}",
"uiLabel": featureLabel.get(language, featureLabel.get("en", instance.featureCode)),
"order": 10,
"instances": [],
"_mandateId": mandateId,
"_featureCode": instance.featureCode
}
# Get user's permissions for this instance to filter views
permissions = _getInstanceViewPermissions(rootInterface, userId, str(instance.id), isSysAdmin)
# Get feature UI objects to build views
featureUiObjects = _getFeatureUiObjects(instance.featureCode)
# Build views for this instance
views = []
viewOrder = 10
for uiObj in featureUiObjects:
objectKey = uiObj.get("objectKey", "")
# Extract view name from objectKey for path building
viewName = objectKey.split(".")[-1] if objectKey else ""
# Check permission using full objectKey (as per Navigation-API-Konzept)
if not isSysAdmin and not permissions.get("_all") and not permissions.get(objectKey, False):
continue
# Skip admin-only views for non-admins
meta = uiObj.get("meta", {})
if meta.get("admin_only") and not isSysAdmin and not permissions.get("isAdmin", False):
continue
# Build path for this view
viewPath = f"/mandates/{mandateId}/{instance.featureCode}/{instance.id}/{viewName}"
# Get label in requested language
label = uiObj.get("label", {})
uiLabel = label.get(language, label.get("en", viewName))
views.append({
"uiComponent": f"page.feature.{instance.featureCode}.{viewName}",
"uiLabel": uiLabel,
"uiPath": viewPath,
"order": viewOrder,
"objectKey": objectKey
})
viewOrder += 10
# Sort views by order
views.sort(key=lambda v: v["order"])
# Add instance to feature
featuresMap[featureKey]["instances"].append({
"id": str(instance.id),
"uiLabel": instance.label,
"order": 10,
"views": views
})
# Build final structure
for featureKey, featureData in featuresMap.items():
mandateId = featureData.pop("_mandateId")
featureData.pop("_featureCode")
mandatesMap[mandateId]["features"].append(featureData)
# Sort features within each mandate
for mandate in mandatesMap.values():
mandate["features"].sort(key=lambda f: f["order"])
# Convert to list and sort by order
mandatesList = list(mandatesMap.values())
mandatesList.sort(key=lambda m: m["order"])
if not mandatesList:
return None
return {
"type": "dynamic",
"id": "features",
"title": "MEINE FEATURES",
"order": 15, # Between system (10) and workflows (20)
"mandates": mandatesList
}
except Exception as e:
logger.error(f"Error building dynamic block: {e}")
return None
def _getInstanceViewPermissions(
rootInterface,
userId: str,
instanceId: str,
isSysAdmin: bool
) -> Dict[str, Any]:
"""
Get view permissions for a user in a feature instance.
Returns dict with view names as keys and True/False as values.
Also includes "_all" if user has global view access.
"""
if isSysAdmin:
return {"_all": True, "isAdmin": True}
permissions = {"_all": False, "isAdmin": False}
try:
from modules.datamodels.datamodelRbac import AccessRule, AccessRuleContext, Role
# Get FeatureAccess for this user and instance
featureAccesses = rootInterface.db.getRecordset(
FeatureAccess,
recordFilter={"userId": userId, "featureInstanceId": instanceId}
)
if not featureAccesses:
return permissions
# Get role IDs via FeatureAccessRole junction table
featureAccessId = featureAccesses[0].get("id")
featureAccessRoles = rootInterface.db.getRecordset(
FeatureAccessRole,
recordFilter={"featureAccessId": featureAccessId}
)
roleIds = [far.get("roleId") for far in featureAccessRoles]
if not roleIds:
return permissions
# Check if user has admin role
for roleId in roleIds:
roles = rootInterface.db.getRecordset(Role, recordFilter={"id": roleId})
if roles:
roleLabel = roles[0].get("roleLabel", "").lower()
if "admin" in roleLabel:
permissions["isAdmin"] = True
break
# Get UI permissions from AccessRules
# Permissions are stored with full objectKey (e.g., ui.feature.trustee.dashboard)
for roleId in roleIds:
accessRules = rootInterface.db.getRecordset(
AccessRule,
recordFilter={"roleId": roleId, "context": "UI"}
)
logger.debug(f"_getInstanceViewPermissions: roleId={roleId}, UI rules count={len(accessRules)}")
for rule in accessRules:
if not rule.get("view", False):
continue
item = rule.get("item")
logger.debug(f"_getInstanceViewPermissions: rule item={item}, view={rule.get('view')}")
if item is None:
# item=None means all views
permissions["_all"] = True
else:
# Store full objectKey as per Navigation-API-Konzept
permissions[item] = True
logger.debug(f"_getInstanceViewPermissions: final permissions={permissions}")
return permissions
except Exception as e:
logger.debug(f"Error getting instance view permissions: {e}")
return permissions
def _buildStaticBlocks(
language: str,
isSysAdmin: bool,
roleIds: List[str],
hasGlobalPermission: bool
) -> List[Dict[str, Any]]:
"""
Build static navigation blocks from NAVIGATION_SECTIONS.
Returns list of blocks with items filtered by permissions.
"""
blocks = []
for section in NAVIGATION_SECTIONS:
# Skip admin-only sections for non-admins
if section.get("adminOnly") and not isSysAdmin:
continue
# Filter items based on permissions
filteredItems = []
for item in section.get("items", []):
# Skip admin-only items for non-admins
if item.get("adminOnly") and not isSysAdmin:
continue
# Public items are always visible
if item.get("public"):
filteredItems.append(_formatBlockItem(item, language))
continue
# SysAdmin sees everything
if isSysAdmin:
filteredItems.append(_formatBlockItem(item, language))
continue
# Check permission for this item
if hasGlobalPermission or _checkUiPermission(roleIds, item["objectKey"]):
filteredItems.append(_formatBlockItem(item, language))
# Only include section if it has visible items
if filteredItems:
# Sort items by order
filteredItems.sort(key=lambda i: i["order"])
blocks.append({
"type": "static",
"id": section["id"],
"title": section["title"].get(language, section["title"].get("en", section["id"])),
"order": section.get("order", 50),
"items": filteredItems,
})
return blocks
def _formatBlockItem(item: Dict[str, Any], language: str) -> Dict[str, Any]:
"""
Format a navigation item for the new API response.
Uses new field names: uiComponent, uiLabel, uiPath
Does NOT include icon (UI maps via uiComponent)
"""
objectKey = item["objectKey"]
uiComponent = _objectKeyToUiComponent(objectKey)
return {
"uiComponent": uiComponent,
"uiLabel": item["label"].get(language, item["label"].get("en", item["id"])),
"uiPath": item["path"],
"order": item.get("order", 50),
"objectKey": objectKey,
}
@navigationRouter.get("/navigation")
@limiter.limit("60/minute")
async def get_navigation(
request: Request,
language: str = Query("de", description="Language for labels (en, de, fr)"),
reqContext: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""
Get unified navigation structure with blocks.
Single Source of Truth für Navigation - UI rendert nur was es erhält.
Endpoint: GET /api/navigation
Block order:
- System (10)
- Dynamic/Features (15) - only if user has feature instances
- Workflows (20)
- Basisdaten (30)
- Migrate (40)
- Administration (200)
Response format:
{
"language": "de",
"blocks": [
{
"type": "static",
"id": "system",
"title": "SYSTEM",
"order": 10,
"items": [
{
"uiComponent": "page.system.home",
"uiLabel": "Übersicht",
"uiPath": "/",
"order": 10,
"objectKey": "ui.system.home"
}
]
},
{
"type": "dynamic",
"id": "features",
"title": "MEINE FEATURES",
"order": 15,
"mandates": [...]
}
]
}
"""
try:
isSysAdmin = reqContext.isSysAdmin
userId = str(reqContext.user.id) if reqContext.user else None
# Get user's role IDs for permission checking
roleIds = []
if userId and not isSysAdmin:
roleIds = _getUserRoleIds(userId)
# Check if user has global UI permission
hasGlobalPermission = isSysAdmin
if not hasGlobalPermission and roleIds:
hasGlobalPermission = _checkUiPermission(roleIds, "_global_check")
# Build static blocks from NAVIGATION_SECTIONS
blocks = _buildStaticBlocks(language, isSysAdmin, roleIds, hasGlobalPermission)
# Build dynamic block (features) if user has feature instances
if userId:
dynamicBlock = _buildDynamicBlock(userId, language, isSysAdmin)
if dynamicBlock:
blocks.append(dynamicBlock)
# Sort all blocks by order
blocks.sort(key=lambda b: b["order"])
return {
"language": language,
"blocks": blocks,
}
except Exception as e:
logger.error(f"Error getting navigation: {e}")
return {
"language": language,
"blocks": [],
"error": str(e),
}

View file

@ -39,7 +39,7 @@ class ConnectionManager:
del activeConnections[connectionId] del activeConnections[connectionId]
logger.info(f"WebSocket disconnected: {connectionId}") logger.info(f"WebSocket disconnected: {connectionId}")
async def sendPersonalMessage(self, message: dict, websocket: WebSocket): async def send_personal_message(self, message: dict, websocket: WebSocket):
try: try:
await websocket.send_text(json.dumps(message)) await websocket.send_text(json.dumps(message))
except Exception as e: except Exception as e:

View file

@ -0,0 +1,54 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Password utility functions for secure password handling.
Uses Argon2 for password hashing.
"""
from typing import Optional
from passlib.context import CryptContext
# Password hashing context using Argon2
_pwdContext = CryptContext(schemes=["argon2"], deprecated="auto")
def hashPassword(password: str) -> str:
"""
Hash a password using Argon2.
Args:
password: Plain text password to hash
Returns:
Hashed password string
"""
return _pwdContext.hash(password)
def verifyPassword(plainPassword: str, hashedPassword: str) -> bool:
"""
Verify a plain password against a hashed password.
Args:
plainPassword: Plain text password to verify
hashedPassword: Hashed password to compare against
Returns:
True if password matches, False otherwise
"""
return _pwdContext.verify(plainPassword, hashedPassword)
def getPasswordHash(password: Optional[str]) -> Optional[str]:
"""
Hash a password, returning None if password is None.
Args:
password: Plain text password or None
Returns:
Hashed password or None if input was None
"""
if password is None:
return None
return _pwdContext.hash(password)

View file

@ -2,14 +2,24 @@
# All rights reserved. # All rights reserved.
""" """
RBAC interface: Core RBAC logic and permission resolution. 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 import logging
from typing import List, Optional, Dict, Any, TYPE_CHECKING from typing import List, Optional, TYPE_CHECKING
from modules.datamodels.datamodelRbac import AccessRule, AccessRuleContext from modules.datamodels.datamodelRbac import AccessRule, AccessRuleContext, Role
from modules.datamodels.datamodelUam import User, UserPermissions, AccessLevel from modules.datamodels.datamodelUam import User, UserPermissions, AccessLevel, Mandate
from modules.datamodels.datamodelMembership import (
UserMandate,
UserMandateRole,
FeatureAccess,
FeatureAccessRole
)
if TYPE_CHECKING: if TYPE_CHECKING:
from modules.connectors.connectorDbPostgre import DatabaseConnector from modules.connectors.connectorDbPostgre import DatabaseConnector
@ -20,6 +30,11 @@ logger = logging.getLogger(__name__)
class RbacClass: class RbacClass:
""" """
RBAC interface for permission resolution and rule validation. 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"): def __init__(self, db: "DatabaseConnector", dbApp: "DatabaseConnector"):
@ -34,14 +49,27 @@ class RbacClass:
self.db = db self.db = db
self.dbApp = dbApp 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. 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: Args:
user: User object with roleLabels user: User object
context: Access rule context (DATA, UI, RESOURCE) context: Access rule context (DATA, UI, RESOURCE)
item: Item identifier (table name, UI path, resource path) item: Item identifier (table name, UI path, resource path)
mandateId: Optional mandate context for role lookup
featureInstanceId: Optional feature instance context
Returns: Returns:
UserPermissions object with combined permissions UserPermissions object with combined permissions
@ -54,29 +82,51 @@ class RbacClass:
delete=AccessLevel.NONE 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 return permissions
# Step 1: For each role, find the most specific matching rule (most specific wins within role) # Lade alle relevanten Regeln für alle Rollen
allRulesWithPriority = self._getRulesForRoleIds(roleIds, context, mandateId, featureInstanceId)
# Für jede Rolle die spezifischste Regel finden
rolePermissions = {} rolePermissions = {}
for roleLabel in user.roleLabels: for priority, rule in allRulesWithPriority:
# Get all rules for this role and context # Find most specific rule for this item
allRules = self._getRulesForRole(roleLabel, context) 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)
# Find most specific rule for this item (longest matching prefix) # Find highest priority among matching rules
mostSpecificRule = self.findMostSpecificRule(allRules, item) highestPriority = max((p for p, _ in rolePermissions.values()), default=0)
if mostSpecificRule: # Combine permissions ONLY from rules with highest priority
rolePermissions[roleLabel] = mostSpecificRule # This ensures instance-specific rules (Priority 3) override global rules (Priority 1)
for roleId, (priority, rule) in rolePermissions.items():
# Only use rules with highest priority
if priority < highestPriority:
continue
# Step 2: Combine permissions across roles using opening (union) logic
for roleLabel, rule in rolePermissions.items():
# View: union logic - if ANY role has view=true, then view=true # View: union logic - if ANY role has view=true, then view=true
if rule.view: if rule.view:
permissions.view = True permissions.view = True
if context == AccessRuleContext.DATA: if context == AccessRuleContext.DATA:
# For DATA context, use most permissive access level across roles # For DATA context, use most permissive access level across roles at same priority
if rule.read and self._isMorePermissive(rule.read, permissions.read): if rule.read and self._isMorePermissive(rule.read, permissions.read):
permissions.read = rule.read permissions.read = rule.read
if rule.create and self._isMorePermissive(rule.create, permissions.create): if rule.create and self._isMorePermissive(rule.create, permissions.create):
@ -88,6 +138,301 @@ class RbacClass:
return permissions 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.
Also includes roles from the Root mandate (first mandate) if different
from the requested mandate, so system-level permissions are always available.
Args:
user: User object
mandateId: Mandate context
featureInstanceId: Feature instance context
Returns:
List of role IDs
"""
roleIds = set() # Use set to avoid duplicates
try:
# Get Root mandate ID (first mandate in system)
allMandates = self.dbApp.getRecordset(Mandate)
rootMandateId = allMandates[0].get("id") if allMandates else None
# Collect mandates to check:
# - If mandateId provided: current mandate + Root mandate (if different)
# - If no mandateId: just Root mandate (for system-level access)
mandatesToCheck = []
if mandateId:
mandatesToCheck.append(mandateId)
if rootMandateId and rootMandateId not in mandatesToCheck:
mandatesToCheck.append(rootMandateId)
# Load roles from each mandate
for checkMandateId in mandatesToCheck:
userMandates = self.dbApp.getRecordset(
UserMandate,
recordFilter={"userId": user.id, "mandateId": checkMandateId, "enabled": True}
)
if userMandates:
userMandateId = userMandates[0].get("id")
# Lade UserMandateRoles (Mandate-level roles)
userMandateRoles = self.dbApp.getRecordset(
UserMandateRole,
recordFilter={"userMandateId": userMandateId}
)
foundRoles = [r.get("roleId") for r in userMandateRoles if r.get("roleId")]
roleIds.update(foundRoles)
# Load FeatureAccess + FeatureAccessRole (Instance-level roles)
if featureInstanceId:
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.update([r.get("roleId") for r in featureAccessRoles if r.get("roleId")])
except Exception as e:
logger.error(f"Error loading role IDs for user {user.id}: {e}")
return list(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.
Matching rules (in order of specificity):
1. Generic rule (item=None) matches everything
2. Exact match (rule.item == item)
3. Prefix match (item starts with rule.item + ".")
Example: rule "data.feature.trustee" matches item "data.feature.trustee.TrusteePosition"
All items MUST use the full objectKey format:
- UAM: data.uam.{TableName} (e.g., "data.uam.UserInDB")
- Chat: data.chat.{TableName} (e.g., "data.chat.ChatWorkflow")
- Files: data.files.{TableName} (e.g., "data.files.FileItem")
- Automation: data.automation.{TableName} (e.g., "data.automation.AutomationDefinition")
- Feature: data.feature.{featureCode}.{TableName} (e.g., "data.feature.trustee.TrusteePosition")
- UI: ui.{area}.{page} (e.g., "ui.admin.users")
Args:
rule: Access rule to check
item: Full objectKey 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., "data.feature.trustee" matches "data.feature.trustee.TrusteePosition")
if item.startswith(rule.item + "."):
return True
return False
def findMostSpecificRule(self, rules: List[AccessRule], item: str) -> Optional[AccessRule]: def findMostSpecificRule(self, rules: List[AccessRule], item: str) -> Optional[AccessRule]:
""" """
Find the most specific rule for an item (longest matching prefix wins). Find the most specific rule for an item (longest matching prefix wins).
@ -105,7 +450,6 @@ class RbacClass:
return genericRules[0] if genericRules else None return genericRules[0] if genericRules else None
# Find longest matching prefix # Find longest matching prefix
itemParts = item.split(".")
bestMatch = None bestMatch = None
bestMatchLength = -1 bestMatchLength = -1
@ -176,39 +520,3 @@ class RbacClass:
AccessLevel.ALL: 3 AccessLevel.ALL: 3
} }
return hierarchy.get(level1, 0) > hierarchy.get(level2, 0) 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 []

View file

@ -0,0 +1,192 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
RBAC Catalog Service.
Central registry for Feature RBAC objects (UI and RESOURCE).
Feature-Container register their RBAC objects via mainXxx.py at startup.
"""
import logging
from typing import Dict, List, Any, Optional
from threading import Lock
logger = logging.getLogger(__name__)
class RbacCatalogService:
"""
Central RBAC Catalog for Feature UI and RESOURCE objects.
Singleton service that stores all registered RBAC objects from feature containers.
"""
_instance = None
_lock = Lock()
def __new__(cls):
if cls._instance is None:
with cls._lock:
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._initialized = False
return cls._instance
def __init__(self):
if self._initialized:
return
self._uiObjects: Dict[str, Dict[str, Any]] = {}
self._resourceObjects: Dict[str, Dict[str, Any]] = {}
self._dataObjects: Dict[str, Dict[str, Any]] = {} # DATA objects (tables/entities)
self._featureDefinitions: Dict[str, Dict[str, Any]] = {}
self._templateRoles: Dict[str, List[Dict[str, Any]]] = {}
self._initialized = True
logger.info("RBAC Catalog Service initialized")
def registerUiObject(self, featureCode: str, objectKey: str, label: Dict[str, str], meta: Optional[Dict[str, Any]] = None) -> bool:
"""Register a UI object for a feature."""
try:
self._uiObjects[objectKey] = {"objectKey": objectKey, "featureCode": featureCode, "label": label, "meta": meta or {}, "type": "UI"}
return True
except Exception as e:
logger.error(f"Failed to register UI object {objectKey}: {e}")
return False
def registerResourceObject(self, featureCode: str, objectKey: str, label: Dict[str, str], meta: Optional[Dict[str, Any]] = None) -> bool:
"""Register a RESOURCE object for a feature."""
try:
self._resourceObjects[objectKey] = {"objectKey": objectKey, "featureCode": featureCode, "label": label, "meta": meta or {}, "type": "RESOURCE"}
return True
except Exception as e:
logger.error(f"Failed to register RESOURCE object {objectKey}: {e}")
return False
def registerDataObject(self, featureCode: str, objectKey: str, label: Dict[str, str], meta: Optional[Dict[str, Any]] = None) -> bool:
"""
Register a DATA object (table/entity) for a feature.
Args:
featureCode: Feature code (e.g., "trustee", "system")
objectKey: Dot-notation key (e.g., "data.feature.trustee.TrusteeContract")
label: Multilingual label dict
meta: Optional metadata (e.g., table name, fields list)
"""
try:
self._dataObjects[objectKey] = {
"objectKey": objectKey,
"featureCode": featureCode,
"label": label,
"meta": meta or {},
"type": "DATA"
}
return True
except Exception as e:
logger.error(f"Failed to register DATA object {objectKey}: {e}")
return False
def registerFeatureDefinition(self, featureCode: str, label: Dict[str, str], icon: str) -> bool:
"""Register a feature definition."""
try:
self._featureDefinitions[featureCode] = {"code": featureCode, "label": label, "icon": icon}
return True
except Exception as e:
logger.error(f"Failed to register feature definition {featureCode}: {e}")
return False
def registerTemplateRoles(self, featureCode: str, roles: List[Dict[str, Any]]) -> bool:
"""Register template roles for a feature."""
try:
self._templateRoles[featureCode] = roles
return True
except Exception as e:
logger.error(f"Failed to register template roles for {featureCode}: {e}")
return False
def getUiObjects(self, featureCode: Optional[str] = None) -> List[Dict[str, Any]]:
"""Get all UI objects, optionally filtered by feature."""
if featureCode:
return [obj for obj in self._uiObjects.values() if obj["featureCode"] == featureCode]
return list(self._uiObjects.values())
def getResourceObjects(self, featureCode: Optional[str] = None) -> List[Dict[str, Any]]:
"""Get all RESOURCE objects, optionally filtered by feature."""
if featureCode:
return [obj for obj in self._resourceObjects.values() if obj["featureCode"] == featureCode]
return list(self._resourceObjects.values())
def getDataObjects(self, featureCode: Optional[str] = None) -> List[Dict[str, Any]]:
"""Get all DATA objects (tables/entities), optionally filtered by feature."""
if featureCode:
return [obj for obj in self._dataObjects.values() if obj["featureCode"] == featureCode]
return list(self._dataObjects.values())
def getAllObjects(self, featureCode: Optional[str] = None) -> List[Dict[str, Any]]:
"""Get all RBAC objects (UI + RESOURCE + DATA), optionally filtered by feature."""
return self.getUiObjects(featureCode) + self.getResourceObjects(featureCode) + self.getDataObjects(featureCode)
def getAllCatalogObjects(self, featureCode: Optional[str] = None) -> Dict[str, List[Dict[str, Any]]]:
"""Get all catalog objects grouped by type (DATA, UI, RESOURCE)."""
return {
"DATA": self.getDataObjects(featureCode),
"UI": self.getUiObjects(featureCode),
"RESOURCE": self.getResourceObjects(featureCode)
}
def getFeatureDefinitions(self) -> List[Dict[str, Any]]:
"""Get all registered feature definitions."""
return list(self._featureDefinitions.values())
def getFeatureDefinition(self, featureCode: str) -> Optional[Dict[str, Any]]:
"""Get a specific feature definition."""
return self._featureDefinitions.get(featureCode)
def getTemplateRoles(self, featureCode: str) -> List[Dict[str, Any]]:
"""Get template roles for a feature."""
return self._templateRoles.get(featureCode, [])
def getAllTemplateRoles(self) -> Dict[str, List[Dict[str, Any]]]:
"""Get all template roles grouped by feature."""
return self._templateRoles.copy()
def getRegisteredFeatures(self) -> List[str]:
"""Get list of all registered feature codes."""
return list(self._featureDefinitions.keys())
def unregisterFeature(self, featureCode: str) -> bool:
"""Unregister all objects for a feature."""
try:
for key in [k for k, v in self._uiObjects.items() if v["featureCode"] == featureCode]:
del self._uiObjects[key]
for key in [k for k, v in self._resourceObjects.items() if v["featureCode"] == featureCode]:
del self._resourceObjects[key]
for key in [k for k, v in self._dataObjects.items() if v["featureCode"] == featureCode]:
del self._dataObjects[key]
self._featureDefinitions.pop(featureCode, None)
self._templateRoles.pop(featureCode, None)
logger.info(f"Unregistered feature: {featureCode}")
return True
except Exception as e:
logger.error(f"Failed to unregister feature {featureCode}: {e}")
return False
def getCatalogStats(self) -> Dict[str, Any]:
"""Get statistics about the catalog."""
return {
"features": len(self._featureDefinitions),
"uiObjects": len(self._uiObjects),
"resourceObjects": len(self._resourceObjects),
"dataObjects": len(self._dataObjects),
"templateRoles": sum(len(roles) for roles in self._templateRoles.values())
}
# Singleton accessor
_catalogService: Optional[RbacCatalogService] = None
def getCatalogService() -> RbacCatalogService:
"""Get the singleton RBAC Catalog Service instance."""
global _catalogService
if _catalogService is None:
_catalogService = RbacCatalogService()
return _catalogService

View file

@ -3,6 +3,8 @@
""" """
Root access management for system-level operations. Root access management for system-level operations.
Provides secure access to root user and DbApp database connector. Provides secure access to root user and DbApp database connector.
Bei leerer Datenbank wird automatisch Bootstrap ausgeführt.
""" """
import logging import logging
@ -14,6 +16,7 @@ logger = logging.getLogger(__name__)
_rootDbAppConnector = None _rootDbAppConnector = None
_rootUser = None _rootUser = None
_bootstrapExecuted = False
def getRootDbAppConnector() -> DatabaseConnector: def getRootDbAppConnector() -> DatabaseConnector:
""" """
@ -24,29 +27,62 @@ def getRootDbAppConnector() -> DatabaseConnector:
if _rootDbAppConnector is None: if _rootDbAppConnector is None:
_rootDbAppConnector = DatabaseConnector( _rootDbAppConnector = DatabaseConnector(
dbHost=APP_CONFIG.get("DB_APP_HOST"), dbHost=APP_CONFIG.get("DB_HOST"),
dbDatabase=APP_CONFIG.get("DB_APP_DATABASE", "app"), dbDatabase="poweron_app",
dbUser=APP_CONFIG.get("DB_APP_USER"), dbUser=APP_CONFIG.get("DB_USER"),
dbPassword=APP_CONFIG.get("DB_APP_PASSWORD_SECRET"), dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET"),
dbPort=int(APP_CONFIG.get("DB_APP_PORT", 5432)), dbPort=int(APP_CONFIG.get("DB_PORT", 5432)),
userId=None # No user context for root connector userId=None # No user context for root connector
) )
_rootDbAppConnector.initDbSystem() _rootDbAppConnector.initDbSystem()
return _rootDbAppConnector 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: def getRootUser() -> User:
""" """
Returns the root user (initial user from database). Returns the root user (initial user from database).
Used for system-level operations that require root privileges. Used for system-level operations that require root privileges.
Falls kein User existiert, wird Bootstrap automatisch ausgeführt.
""" """
global _rootUser global _rootUser
if _rootUser is None: if _rootUser is None:
dbApp = getRootDbAppConnector() dbApp = getRootDbAppConnector()
initialUserId = dbApp.getInitialId(UserInDB) initialUserId = dbApp.getInitialId(UserInDB)
# Wenn kein User existiert, Bootstrap ausführen
if not initialUserId: 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}) users = dbApp.getRecordset(UserInDB, recordFilter={"id": initialUserId})
if not users: if not users:
@ -56,4 +92,3 @@ def getRootUser() -> User:
_rootUser = User(**user_data) _rootUser = User(**user_data)
return _rootUser return _rootUser

Some files were not shown because too many files have changed in this diff Show more