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

156
app.py
View file

@ -19,8 +19,9 @@ from datetime import datetime
from modules.shared.configuration import APP_CONFIG
from modules.shared.eventManagement import eventManager
from modules.features import featuresLifecycle as featuresLifecycle
from modules.interfaces.interfaceDbAppObjects import getRootInterface
from modules.workflows.automation import subAutomationSchedule
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.system.registry import loadFeatureMainModules
class DailyRotatingFileHandler(RotatingFileHandler):
"""
@ -46,6 +47,9 @@ class DailyRotatingFileHandler(RotatingFileHandler):
def _updateFileIfNeeded(self):
"""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")
if self.currentDate != today:
@ -145,21 +149,24 @@ def initLogging():
def filter(self, record):
if isinstance(record.msg, str):
# Remove only emojis, preserve other Unicode characters like quotes
# Remove emoji characters specifically
record.msg = "".join(
char
for char in record.msg
if unicodedata.category(char) != "So"
or not (
0x1F600 <= ord(char) <= 0x1F64F
or 0x1F300 <= ord(char) <= 0x1F5FF
or 0x1F680 <= ord(char) <= 0x1F6FF
or 0x1F1E0 <= ord(char) <= 0x1F1FF
or 0x2600 <= ord(char) <= 0x26FF
or 0x2700 <= ord(char) <= 0x27BF
# Guard against None characters during shutdown
try:
record.msg = "".join(
char
for char in record.msg
if char is not None and unicodedata.category(char) != "So"
or (char is not None and not (
0x1F600 <= ord(char) <= 0x1F64F
or 0x1F300 <= ord(char) <= 0x1F5FF
or 0x1F680 <= ord(char) <= 0x1F6FF
or 0x1F1E0 <= ord(char) <= 0x1F1FF
or 0x2600 <= ord(char) <= 0x26FF
or 0x2700 <= ord(char) <= 0x27BF
))
)
)
except (TypeError, AttributeError):
# Handle edge cases during shutdown
pass
return True
# 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):
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)
rootInterface = getRootInterface()
eventUser = rootInterface.getUserByUsername("event")
if not eventUser:
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 ---
await featuresLifecycle.start(eventUser)
await subAutomationSchedule.start(eventUser) # Automation scheduler
eventManager.start()
# Register audit log cleanup scheduler
from modules.shared.auditLogger import registerAuditLogCleanupScheduler
registerAuditLogCleanupScheduler()
yield
# --- Stop Managers ---
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")
# 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
app = FastAPI(
title="PowerOn | Data Platform API",
description=f"Backend API for the Multi-Agent Platform by ValueOn AG ({instanceLabel})",
title="PowerOn AG | Workflow Engine",
description=f"API for dynamic SaaS platforms ({instanceLabel})",
lifespan=lifespan,
swagger_ui_init_oauth={
"usePkceWithAuthorizationCodeGrant": True,
},
generate_unique_id_function=_generateOperationId,
)
# Configure OpenAPI security scheme for Swagger UI
@ -365,7 +409,7 @@ app.add_middleware(
CORSMiddleware,
allow_origins=getAllowedOrigins(),
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["*"],
expose_headers=["*"],
max_age=86400, # Increased caching for preflight requests
@ -405,23 +449,14 @@ app.include_router(userRouter)
from modules.routes.routeDataFiles import router as 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
app.include_router(promptRouter)
from modules.routes.routeDataConnections import router as connectionsRouter
app.include_router(connectionsRouter)
from modules.routes.routeWorkflows import router as workflowRouter
app.include_router(workflowRouter)
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.routeDataWorkflows import router as dataWorkflowsRouter
app.include_router(dataWorkflowsRouter)
from modules.routes.routeSecurityLocal import router as localRouter
app.include_router(localRouter)
@ -441,24 +476,49 @@ app.include_router(adminSecurityRouter)
from modules.routes.routeSharepoint import router as 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
app.include_router(adminAutomationEventsRouter)
from modules.routes.routeRbac import router as rbacRouter
app.include_router(rbacRouter)
from modules.routes.routeOptions import router as optionsRouter
app.include_router(optionsRouter)
from modules.routes.routeAdminRbacRules import router as rbacAdminRulesRouter
app.include_router(rbacAdminRulesRouter)
from modules.routes.routeMessaging import router as messagingRouter
app.include_router(messagingRouter)
from modules.routes.routeChatbot import router as chatbotRouter
app.include_router(chatbotRouter)
# Phase 8: New Feature Routes
from modules.routes.routeAdminFeatures import router as featuresAdminRouter
app.include_router(featuresAdminRouter)
from modules.routes.routeDataTrustee import router as trusteeRouter
app.include_router(trusteeRouter)
from modules.routes.routeInvitations import router as invitationsRouter
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_EVENT_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERzZjNm56WGVBdjJTeG5Udjd6OGQwUVotYXUzQjJ1YVNyVXVBa3NZVml3ODU0MVNkZjhWWmJwNUFkc19BcHlHMTU1Q3BRcHU0cDBoZkFlR2l6UEZQU3d2U3MtMDh5UDZteGFoQ0EyMUE1ckE9
# PostgreSQL Storage (new)
DB_APP_HOST=localhost
DB_APP_DATABASE=poweron_app
DB_APP_USER=poweron_dev
DB_APP_PASSWORD_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEcUIxNEFfQ2xnS0RrSC1KNnUxTlVvTGZoMHgzaEI4Z3NlVzVROTVLak5Ubi1vaEZubFZaMTFKMGd6MXAxekN2d2NvMy1hRjg2UVhybktlcFA5anZ1WjFlQmZhcXdwaGhWdzRDc3ExeUhzWTg9
DB_APP_PORT=5432
# PostgreSQL Storage (new)
DB_CHAT_HOST=localhost
DB_CHAT_DATABASE=poweron_chat
DB_CHAT_USER=poweron_dev
DB_CHAT_PASSWORD_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERFNzNVhoalpCR0QxYXAwdEpXWXVVOTdZdWtqWW5FNXFGcFl2amNYLWYwYl9STXltRlFxLWNzVWlMVnNYdXk0RklnRExFT0FaQjg2aGswNnhhSGhCN29KN2VEb2FlUV9NTlV3b0tLelplSVU9
DB_CHAT_PORT=5432
# PostgreSQL Storage (new)
DB_MANAGEMENT_HOST=localhost
DB_MANAGEMENT_DATABASE=poweron_management
DB_MANAGEMENT_USER=poweron_dev
DB_MANAGEMENT_PASSWORD_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEUldqSTVpUnFqdGhITDYzT3RScGlMYVdTMmZhOXdudDRCc3dhdllOd3l6MS1vWHY2MjVsTUF1Sk9saEJOSk9ONUlBZjQwb2c2T1gtWWJhcXFzVVVXd01xc0U0b0lJX0JyVDRxaDhNS01JcWs9
DB_MANAGEMENT_PORT=5432
# PostgreSQL Storage (new)
DB_REALESTATE_HOST=localhost
DB_REALESTATE_DATABASE=poweron_realestate
DB_REALESTATE_USER=poweron_dev
DB_REALESTATE_PASSWORD_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEcUIxNEFfQ2xnS0RrSC1KNnUxTlVvTGZoMHgzaEI4Z3NlVzVROTVLak5Ubi1vaEZubFZaMTFKMGd6MXAxekN2d2NvMy1hRjg2UVhybktlcFA5anZ1WjFlQmZhcXdwaGhWdzRDc3ExeUhzWTg9
DB_REALESTATE_PORT=5432
# PostgreSQL DB Host
DB_HOST=localhost
DB_USER=poweron_dev
DB_PASSWORD_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEcUIxNEFfQ2xnS0RrSC1KNnUxTlVvTGZoMHgzaEI4Z3NlVzVROTVLak5Ubi1vaEZubFZaMTFKMGd6MXAxekN2d2NvMy1hRjg2UVhybktlcFA5anZ1WjFlQmZhcXdwaGhWdzRDc3ExeUhzWTg9
DB_PORT=5432
# Security Configuration
APP_JWT_KEY_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERjlrSktmZHVuQnJ1VVJDdndLaUcxZGJsT2ZlUFRlcFdOZ001RnlzM2FhLWhRV2tjWWFhaWQwQ3hkcUFvbThMcndxSjFpYTdfRV9OZGhTcksxbXFTZWg5MDZvOHpCVXBHcDJYaHlJM0tyNWRZckZsVHpQcmxTZHJoZUs1M3lfU2ljRnJaTmNSQ0w0X085OXI0QW80M2xfQnJqZmZ6VEh3TUltX0xzeE42SGtZPQ==
APP_TOKEN_EXPIRY=300
# 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
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_EVENT_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjdmtrakgxa0djekZVNGtTZV8wM2I5UUpCZllveVBMWXROYk5yS3BiV3JEelJSM09VYTRONHpnY3VtMGxDRk5JTEZSRFhtcDZ0RVRmZ1RicTFhb3c5dVZRQ1o4SmlkLVpPTW5MMTU2eTQ0Vkk9
# PostgreSQL Storage (new)
DB_APP_HOST=gateway-int-server.postgres.database.azure.com
DB_APP_DATABASE=poweron_app
DB_APP_USER=heeshkdlby
DB_APP_PASSWORD_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjb2dka2pnN0tUbW1EU0w1Rk1jNERKQ0Z1U3JkVDhuZWZDM0g5M0kwVDE5VHdubkZna3gtZVAxTnl4MDdrR1c1ZXJ3ejJHYkZvcGUwbHJaajBGOWJob0EzRXVHc0JnZkJyNGhHZTZHOXBxd2c9
DB_APP_PORT=5432
# PostgreSQL Storage (new)
DB_CHAT_HOST=gateway-int-server.postgres.database.azure.com
DB_CHAT_DATABASE=poweron_chat
DB_CHAT_USER=heeshkdlby
DB_CHAT_PASSWORD_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjczYzOUtTa21MMGJVTUQ5UmFfdWc3YlhCbWZOeXFaNEE1QzdJV3BLVjhnalBkLVVCMm5BZzdxdlFXQXc2RHYzLWtPSFZkZE1iWG9rQ1NkVWlpRnF5TURVbnl1cm9iYXlSMGYxd1BGYVc0VDA9
DB_CHAT_PORT=5432
# PostgreSQL Storage (new)
DB_MANAGEMENT_HOST=gateway-int-server.postgres.database.azure.com
DB_MANAGEMENT_DATABASE=poweron_management
DB_MANAGEMENT_USER=heeshkdlby
DB_MANAGEMENT_PASSWORD_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjTnJKNlJMNmEwQ0Y5dVNrR3pkZk9SQXVvLTRTNW9lQ1g3TTE5cFhBNTd5UENqWW9qdWd3NWNseWhnUHJveDJyd1Z3X1czS3VuZnAwZHBXYVNQWlZsRy12ME42NndEVlR5X3ZPdFBNNmhLYm89
DB_MANAGEMENT_PORT=5432
# PostgreSQL Storage (new)
DB_REALESTATE_HOST=localhost
DB_REALESTATE_DATABASE=poweron_realestate
DB_REALESTATE_USER=poweron_dev
DB_REALESTATE_PASSWORD_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjTnJKNlJMNmEwQ0Y5dVNrR3pkZk9SQXVvLTRTNW9lQ1g3TTE5cFhBNTd5UENqWW9qdWd3NWNseWhnUHJveDJyd1Z3X1czS3VuZnAwZHBXYVNQWlZsRy12ME42NndEVlR5X3ZPdFBNNmhLYm89
DB_REALESTATE_PORT=5432
# PostgreSQL DB Host
DB_HOST=gateway-int-server.postgres.database.azure.com
DB_USER=heeshkdlby
DB_PASSWORD_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjczYzOUtTa21MMGJVTUQ5UmFfdWc3YlhCbWZOeXFaNEE1QzdJV3BLVjhnalBkLVVCMm5BZzdxdlFXQXc2RHYzLWtPSFZkZE1iWG9rQ1NkVWlpRnF5TURVbnl1cm9iYXlSMGYxd1BGYVc0VDA9
DB_PORT=5432
# Security Configuration
APP_JWT_KEY_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNUctb2RwU25iR3ZnanBOdHZhWUtIajZ1RnZzTEp4aDR0MktWRjNoeVBrY1Npd1R0VE9YVHp3M2w1cXRzbUxNaU82QUJvaDNFeVQyN05KblRWblBvbWtoT0VXbkNBbDQ5OHhwSUFnaDZGRG10Vmgtdm1YUkRsYUhFMzRVZURmSFlDTFIzVWg4MXNueDZyMGc5aVpFdWRxY3dkTExGM093ZTVUZVl5LUhGWnlRPQ==

View file

@ -8,33 +8,11 @@ APP_INIT_PASS_ADMIN_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3UnJRV0sySFlDblpXUlREclREaW
APP_INIT_PASS_EVENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3QVpIY19DQVZSSzJmc2F0VEZvQlU1cHBhTEgxdHdnR3g4eW01aTEzYTUxc1gxTDR1RVVpSHRXYjV6N1BLZUdCUGlfOW1qdy0xSHFVRkNBcGZvaGlSSkZycXRuUllaWnpyVGRoeFg1dGEyNUk9
APP_API_URL = https://gateway-prod.poweron-center.net
# PostgreSQL Storage (new)
DB_APP_HOST=gateway-prod-server.postgres.database.azure.com
DB_APP_DATABASE=poweron_app
DB_APP_USER=gzxxmcrdhn
DB_APP_PASSWORD_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3cm5LQWV1OURQanVyTklVaVhJbDI2Y1Itb29pTWFmR2RYM0pyYUhhRUpWZ29tWWwzSmdQeVhScHlHQWVyY0xUTElIdVBJUjh5Zm9ZMzg1ZERNQXZ6TXlGb2tYOGpDX1gzXzB3UUlCM1ZaYWM9
DB_APP_PORT=5432
# PostgreSQL Storage (new)
DB_CHAT_HOST=gateway-prod-server.postgres.database.azure.com
DB_CHAT_DATABASE=poweron_chat
DB_CHAT_USER=gzxxmcrdhn
DB_CHAT_PASSWORD_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3Y1JScGxjZG9TdUkwaHRzSHZhRHpNcDV3N1U2TnIwZ21PRG5TWFFfR1k0N3BiRk5WelVadjlnXzVSTDZ6NXFQNFpqbnJ1R3dNVkJocm1zVEgtSk0xaDRiR19zNDBEbVIzSk51ekNlQ0Z3b0U9
DB_CHAT_PORT=5432
# PostgreSQL Storage (new)
DB_MANAGEMENT_HOST=gateway-prod-server.postgres.database.azure.com
DB_MANAGEMENT_DATABASE=poweron_management
DB_MANAGEMENT_USER=gzxxmcrdhn
DB_MANAGEMENT_PASSWORD_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3ZWpySThqdlVmWWd5dGxmWE91RVBsenZrQmNhSzVxbktmYzZ1RlM3cXhTMUdXRV9wX1lfLTJXLTFzeUo0R3pWLXlmUWdrZ2x6QkFlZVRXaEF6aUdRbDlzb1FfcWtub0dxSGp3OVVQWGg3enM9
DB_MANAGEMENT_PORT=5432
# PostgreSQL Storage (new)
DB_REALESTATE_HOST=localhost
DB_REALESTATE_DATABASE=poweron_realestate
DB_REALESTATE_USER=poweron_dev
DB_REALESTATE_PASSWORD_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3ZWpySThqdlVmWWd5dGxmWE91RVBsenZrQmNhSzVxbktmYzZ1RlM3cXhTMUdXRV9wX1lfLTJXLTFzeUo0R3pWLXlmUWdrZ2x6QkFlZVRXaEF6aUdRbDlzb1FfcWtub0dxSGp3OVVQWGg3enM9
DB_REALESTATE_PORT=5432
# PostgreSQL DB Host
DB_HOST=gateway-prod-server.postgres.database.azure.com
DB_USER=gzxxmcrdhn
DB_PASSWORD_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3Y1JScGxjZG9TdUkwaHRzSHZhRHpNcDV3N1U2TnIwZ21PRG5TWFFfR1k0N3BiRk5WelVadjlnXzVSTDZ6NXFQNFpqbnJ1R3dNVkJocm1zVEgtSk0xaDRiR19zNDBEbVIzSk51ekNlQ0Z3b0U9
DB_PORT=5432
# Security Configuration
APP_JWT_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3elhfV0Rnd2pQRjlMdkVwX1FnSmRhSzNZUlV5SVpaWXBNX1hpa2xPZGdMSWpnN2ZINHQxeGZnNHJweU5pZjlyYlY5Qm9zOUZEbl9wUEgtZHZXd1NhR19JSG9kbFU4MnFGQnllbFhRQVphRGQyNHlFVWR5VHQyUUpqN0stUmRuY2QyTi1oalczRHpLTEJqWURjZWs4YjZvT2U5YnFqcXEwdEpxV05fX05QMmtrPQ==

View file

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

View file

@ -72,10 +72,16 @@ class ModelSelector:
promptSize = len(prompt.encode("utf-8"))
contextSize = len(context.encode("utf-8"))
totalSize = promptSize + contextSize
# Convert bytes to approximate tokens (1 token ≈ 4 bytes)
promptTokens = promptSize / 4
contextTokens = contextSize / 4
totalTokens = totalSize / 4
# Convert bytes to approximate tokens
# Conservative estimate: 1 token ≈ 2 bytes (for safety margin)
# Note: Actual tokenization varies by content type and model
# - 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)")

View file

@ -6,7 +6,7 @@ import os
from typing import Dict, Any, List
from fastapi import HTTPException
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
# Configure logger

View file

@ -2,7 +2,7 @@
# All rights reserved.
import logging
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
# Configure logger

View file

@ -5,7 +5,7 @@ import httpx
from typing import List
from fastapi import HTTPException
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
# Configure logger
@ -298,7 +298,6 @@ class AiOpenai(BaseConnectorAi):
promptContent = messages[0]["content"] if messages else ""
# Parse prompt using AiCallPromptImage model
from modules.datamodels.datamodelAi import AiCallPromptImage
import json
try:

View file

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

View file

@ -10,7 +10,7 @@ from dataclasses import dataclass
from typing import Optional, List, Dict
from tavily import AsyncTavilyClient
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.datamodelTools import CountryCodes
@ -728,8 +728,7 @@ class AiTavily(BaseConnectorAi):
maxBreadth=webCrawlPrompt.maxWidth or 40 # Use same as limit for breadth
)
# If we got multiple pages from the crawl, we need to format them differently
# Return the first result for backwards compatibility, but include total page count
# Format multiple pages from the crawl into a single response
if crawlResults and len(crawlResults) > 0:
# Get all pages content with error handling
allContent = ""

View file

@ -3,9 +3,23 @@
"""
Authentication and authorization modules for routes and services.
High-level security functionality that depends on FastAPI and interfaces.
Multi-Tenant Design:
- RequestContext: Per-request context with user, mandate, feature instance, roles
- getRequestContext: FastAPI dependency to extract context from X-Mandate-Id header
- requireSysAdmin: FastAPI dependency for system-level admin operations
"""
from .authentication import getCurrentUser, limiter, SECRET_KEY, ALGORITHM, cookieAuth
from .authentication import (
getCurrentUser,
limiter,
SECRET_KEY,
ALGORITHM,
cookieAuth,
RequestContext,
getRequestContext,
requireSysAdmin,
)
from .jwtService import (
createAccessToken,
createRefreshToken,
@ -20,22 +34,30 @@ from .tokenRefreshMiddleware import TokenRefreshMiddleware, ProactiveTokenRefres
from .csrf import CSRFMiddleware
__all__ = [
# Authentication
"getCurrentUser",
"limiter",
"SECRET_KEY",
"ALGORITHM",
"cookieAuth",
# Multi-Tenant Context
"RequestContext",
"getRequestContext",
"requireSysAdmin",
# JWT Service
"createAccessToken",
"createRefreshToken",
"setAccessTokenCookie",
"setRefreshTokenCookie",
"clearAccessTokenCookie",
"clearRefreshTokenCookie",
# Token Management
"TokenManager",
"token_refresh_service",
"TokenRefreshService",
"TokenRefreshMiddleware",
"ProactiveTokenRefreshMiddleware",
# CSRF
"CSRFMiddleware",
]

View file

@ -3,10 +3,16 @@
"""
Authentication module for backend API.
Handles JWT-based authentication, token generation, and user context.
Multi-Tenant Design:
- Token ist NICHT an einen Mandanten gebunden
- User arbeitet parallel in mehreren Mandanten (z.B. mehrere Browser-Tabs)
- Mandant-Kontext wird per Request-Header (X-Mandate-Id) bestimmt
- Request-Context kapselt User + Mandant + Feature-Instanz + geladene Rollen
"""
from typing import Optional, Dict, Any, Tuple
from fastapi import Depends, HTTPException, status, Request, Response
from typing import Optional, Dict, Any, Tuple, List
from fastapi import Depends, HTTPException, status, Request, Response, Header
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import JWTError, jwt
import logging
@ -15,9 +21,10 @@ from slowapi.util import get_remote_address
from modules.shared.configuration import APP_CONFIG
from modules.security.rootAccess import getRootDbAppConnector, getRootUser
from modules.interfaces.interfaceDbAppObjects import getInterface
from modules.datamodels.datamodelUam import User, AuthAuthority
from modules.interfaces.interfaceDbApp import getInterface, getRootInterface
from modules.datamodels.datamodelUam import User, AuthAuthority, AccessLevel
from modules.datamodels.datamodelSecurity import Token
from modules.datamodels.datamodelRbac import AccessRule
# Get Config Data
SECRET_KEY = APP_CONFIG.get("APP_JWT_KEY_SECRET")
@ -98,15 +105,16 @@ def _getUserBase(token: str = Depends(cookieAuth)) -> User:
if username is None:
raise credentialsException
# Extract mandate ID and user ID from token
mandateId: str = payload.get("mandateId")
# Extract user ID from token
# MULTI-TENANT: mandateId is NO LONGER in the token - it comes from X-Mandate-Id header
userId: str = payload.get("userId")
authority: str = payload.get("authenticationAuthority")
tokenId: Optional[str] = payload.get("jti")
sessionId: Optional[str] = payload.get("sid") or payload.get("sessionId")
if not mandateId or not userId:
logger.error(f"Missing context in token: mandateId={mandateId}, userId={userId}")
# Only userId is required in token now (no mandateId)
if not userId:
logger.error(f"Missing userId in token")
raise credentialsException
except JWTError:
@ -129,9 +137,10 @@ def _getUserBase(token: str = Depends(cookieAuth)) -> User:
logger.warning(f"User {username} is disabled")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is disabled")
# Ensure the user has the correct context
if str(user.mandateId) != str(mandateId) or str(user.id) != str(userId):
logger.error(f"User context mismatch: token(mandateId={mandateId}, userId={userId}) vs user(mandateId={user.mandateId}, id={user.id})")
# Ensure the user ID in token matches the user in database
# MULTI-TENANT: mandateId is NO LONGER checked here - it comes from headers
if str(user.id) != str(userId):
logger.error(f"User ID mismatch: token(userId={userId}) vs user(id={user.id})")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User context has changed. Please log in again.",
@ -166,17 +175,18 @@ def _getUserBase(token: str = Depends(cookieAuth)) -> User:
db_token = db_tokens[0]
token_authority = str(db_token.get("authority", "")).lower()
if token_authority == str(AuthAuthority.LOCAL.value):
# Must be active and match user/session/mandate
# Must be active and match user/session
# MULTI-TENANT: mandateId is NOT checked here - tokens are no longer mandate-bound
active_token = appInterface.findActiveTokenById(
tokenId=tokenId,
userId=user.id,
authority=AuthAuthority.LOCAL,
sessionId=sessionId,
mandateId=str(mandateId) if mandateId else None,
mandateId=None, # Token is no longer mandate-bound
)
if not active_token:
logger.info(
f"Local JWT db record not active/valid: jti={tokenId}, userId={user.id}, mandateId={mandateId}, sessionId={sessionId}"
f"Local JWT db record not active/valid: jti={tokenId}, userId={user.id}, sessionId={sessionId}"
)
raise credentialsException
else:
@ -203,3 +213,183 @@ def getCurrentUser(currentUser: User = Depends(_getUserBase)) -> User:
return currentUser
# =============================================================================
# MULTI-TENANT: Request Context System
# =============================================================================
class RequestContext:
"""
Request context for multi-tenant operations.
Contains user, mandate context, feature instance context, and loaded role IDs.
This context is per-request (not persisted) - follows stateless design.
IMPORTANT: SysAdmin also needs explicit membership for mandate context!
isSysAdmin flag does NOT give implicit access to mandate data.
"""
def __init__(self, user: User):
self.user: User = user
self.mandateId: Optional[str] = None
self.featureInstanceId: Optional[str] = None
self.roleIds: List[str] = []
# Request-scoped cache: rules loaded only once per request
self._cachedRules: Optional[List[tuple]] = None
def getRules(self) -> List[tuple]:
"""
Loads rules once per request (not across requests).
Returns list of (priority, AccessRule) tuples.
"""
if self._cachedRules is None:
if not self.mandateId:
# No mandate context = no rules
self._cachedRules = []
else:
try:
rootUser = getRootUser()
appInterface = getInterface(rootUser)
self._cachedRules = appInterface.rbac.getRulesForUserBulk(
self.user.id,
self.mandateId,
self.featureInstanceId
)
except Exception as e:
logger.error(f"Error loading RBAC rules: {e}")
self._cachedRules = []
return self._cachedRules
@property
def isSysAdmin(self) -> bool:
"""Convenience property to check if user is a system admin."""
return getattr(self.user, 'isSysAdmin', False)
def getRequestContext(
request: Request,
mandateId: Optional[str] = Header(None, alias="X-Mandate-Id"),
featureInstanceId: Optional[str] = Header(None, alias="X-Instance-Id"),
currentUser: User = Depends(getCurrentUser)
) -> RequestContext:
"""
Determines request context from headers.
Checks authorization and loads role IDs.
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:
if interface is None:
from modules.security.rootAccess import getRootUser
from modules.interfaces.interfaceDbAppObjects import getInterface
from modules.interfaces.interfaceDbApp import getInterface
rootUser = getRootUser()
interface = getInterface(rootUser)

View file

@ -159,7 +159,7 @@ class TokenRefreshService:
# Get user interface
from modules.security.rootAccess import getRootUser
from modules.interfaces.interfaceDbAppObjects import getInterface
from modules.interfaces.interfaceDbApp import getInterface
rootUser = getRootUser()
root_interface = getInterface(rootUser)
@ -228,7 +228,7 @@ class TokenRefreshService:
# Get user interface
from modules.security.rootAccess import getRootUser
from modules.interfaces.interfaceDbAppObjects import getInterface
from modules.interfaces.interfaceDbApp import getInterface
rootUser = getRootUser()
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]:
"""Get all fields from Pydantic model and map to SQL types."""
# Pydantic v2
@ -52,20 +80,7 @@ def _get_model_fields(model_class) -> Dict[str, str]:
# Check for JSONB fields (Dict, List, or complex types)
# Purely type-based detection - no hardcoded field names
if (
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)))
):
if _isJsonbType(field_type):
fields[field_name] = "JSONB"
# Simple type mapping
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)
if self.userId:
record["_createdBy"] = self.userId
else:
logger.warning(f"Attempting to create record with empty userId - _createdBy will not be set")
# No warning - empty userId is normal during bootstrap
# Also ensure _createdBy is set even if _createdAt exists but _createdBy is missing/empty
elif "_createdBy" not in record or not record.get("_createdBy"):
if self.userId:
record["_createdBy"] = self.userId
else:
logger.warning(f"Attempting to set _createdBy with empty userId for record {recordId}")
# No warning - empty userId is normal during bootstrap
# Always update modification metadata
record["_modifiedAt"] = currentTime
if self.userId:
@ -855,8 +868,12 @@ class DatabaseConnector:
if recordFilter:
for field, value in recordFilter.items():
where_conditions.append(f'"{field}" = %s')
where_values.append(value)
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_values.append(value)
# Build the query
if where_conditions:
@ -968,7 +985,10 @@ class DatabaseConnector:
record["id"] = str(uuid.uuid4())
# 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
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 datamodelUam as uam
from . import datamodelSecurity as security
from . import datamodelNeutralizer as neutralizer
from . import datamodelChat as chat
from . import datamodelFiles as files
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):
"""Statistics for chat operations. User-owned, no mandate context."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()), description="Primary key"
)
@ -46,6 +47,7 @@ registerModelLabels(
class ChatLog(BaseModel):
"""Log entries for chat workflows. User-owned, no mandate context."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()), description="Primary key"
)
@ -91,6 +93,7 @@ registerModelLabels(
class ChatDocument(BaseModel):
"""Documents attached to chat messages. User-owned, no mandate context."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()), description="Primary key"
)
@ -197,6 +200,7 @@ registerModelLabels(
class ChatMessage(BaseModel):
"""Messages in chat workflows. User-owned, no mandate context."""
id: str = Field(
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")
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)"
)
publishedAt: float = Field(
default_factory=getUtcTimestamp,
publishedAt: Optional[float] = Field(
default=None,
description="When the message was published (UTC timestamp in seconds)",
)
success: Optional[bool] = Field(
@ -294,8 +299,8 @@ registerModelLabels(
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})
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": [
{"value": "running", "label": {"en": "Running", "fr": "En cours"}},
{"value": "completed", "label": {"en": "Completed", "fr": "Terminé"}},
@ -369,7 +374,6 @@ registerModelLabels(
{"en": "Chat Workflow", "fr": "Flux de travail de chat"},
{
"id": {"en": "ID", "fr": "ID"},
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat"},
"status": {"en": "Status", "fr": "Statut"},
"name": {"en": "Name", "fr": "Nom"},
"currentRound": {"en": "Current Round", "fr": "Tour actuel"},
@ -988,38 +992,3 @@ registerModelLabels(
"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):
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})
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})
@ -25,6 +26,7 @@ registerModelLabels(
{
"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é"},
"fileName": {"en": "fileName", "fr": "Nom de fichier"},
"mimeType": {"en": "MIME Type", "fr": "Type MIME"},
"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",
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(
default=None,
description="Description of the subscription",
@ -92,6 +96,7 @@ registerModelLabels(
"subscriptionId": {"en": "Subscription ID", "fr": "ID d'abonnement"},
"subscriptionLabel": {"en": "Subscription Label", "fr": "Label d'abonnement"},
"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"},
"isSystemSubscription": {"en": "System Subscription", "fr": "Abonnement système"},
"enabled": {"en": "Enabled", "fr": "Activé"},
@ -110,6 +115,14 @@ class MessagingSubscriptionRegistration(BaseModel):
description="Unique ID of the registration",
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(
description="ID of the subscription this registration belongs to",
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True}
@ -161,6 +174,8 @@ registerModelLabels(
{"en": "Messaging Registration", "fr": "Inscription à la messagerie"},
{
"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"},
"userId": {"en": "User ID", "fr": "ID utilisateur"},
"channel": {"en": "Channel", "fr": "Canal"},
@ -179,6 +194,14 @@ class MessagingDelivery(BaseModel):
description="Unique ID of the delivery",
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(
description="ID of the subscription this delivery belongs to",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
@ -239,6 +262,8 @@ registerModelLabels(
{"en": "Messaging Delivery", "fr": "Livraison de messagerie"},
{
"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"},
"userId": {"en": "User ID", "fr": "ID utilisateur"},
"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
# All rights reserved.
"""RBAC models: AccessRule, AccessRuleContext, Role."""
"""
RBAC models: AccessRule, AccessRuleContext, Role.
Multi-Tenant Design:
- Role hat einen unveränderlichen Kontext (mandateId, featureInstanceId, featureCode)
- AccessRule referenziert Role via roleId (FK), nicht via roleLabel
- Kontext-Felder sind IMMUTABLE nach Erstellung
"""
import uuid
from typing import Optional, Dict
from typing import Optional
from enum import Enum
from pydantic import BaseModel, Field
from modules.shared.attributeUtils import registerModelLabels
@ -19,11 +26,21 @@ class AccessRuleContext(str, Enum):
class Role(BaseModel):
"""Data model for RBAC roles"""
"""
Data model for RBAC roles.
Kernkonzept: Eine Rolle existiert immer in einem spezifischen Kontext.
Der Kontext ist IMMUTABLE nach Erstellung.
Kontext-Typen:
- mandateId=None, featureInstanceId=None GLOBAL (Template-Rolle)
- mandateId=X, featureInstanceId=None MANDATE-Rolle
- mandateId=X, featureInstanceId=Y INSTANCE-Rolle
"""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the role",
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(
description="Unique role label identifier (e.g., 'admin', 'user', 'viewer')",
@ -33,106 +50,163 @@ class Role(BaseModel):
description="Role description in multiple languages",
json_schema_extra={"frontend_type": "multilingual", "frontend_readonly": False, "frontend_required": True}
)
# KONTEXT - IMMUTABLE nach Create (nur Create/Delete, kein Update!)
mandateId: Optional[str] = Field(
default=None,
description="FK → Mandate.id (CASCADE DELETE). Null = Global/Template role.",
json_schema_extra={"frontend_type": "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(
False,
default=False,
description="Whether this is a system role that cannot be deleted",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": False}
)
registerModelLabels(
"Role",
{"en": "Role", "fr": "Rôle"},
{"en": "Role", "de": "Rolle", "fr": "Rôle"},
{
"id": {"en": "ID", "fr": "ID"},
"roleLabel": {"en": "Role Label", "fr": "Label du rôle"},
"description": {"en": "Description", "fr": "Description"},
"isSystemRole": {"en": "System Role", "fr": "Rôle système"},
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"roleLabel": {"en": "Role Label", "de": "Rollen-Label", "fr": "Label du rôle"},
"description": {"en": "Description", "de": "Beschreibung", "fr": "Description"},
"mandateId": {"en": "Mandate", "de": "Mandant", "fr": "Mandat"},
"featureInstanceId": {"en": "Feature Instance", "de": "Feature-Instanz", "fr": "Instance"},
"featureCode": {"en": "Feature Code", "de": "Feature-Code", "fr": "Code fonctionnalité"},
"isSystemRole": {"en": "System Role", "de": "System-Rolle", "fr": "Rôle système"},
},
)
class AccessRule(BaseModel):
"""Data model for access control rules"""
"""
Data model for access control rules.
WICHTIG: roleId referenziert die Role via FK (nicht mehr roleLabel!)
Die Felder 'context' und 'roleId' sind IMMUTABLE nach Erstellung.
"""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the access rule",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False}
)
roleLabel: str = Field(
description="Role label this rule applies to",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_options": "user.role"}
roleId: str = Field(
description="FK → Role.id (CASCADE DELETE!)",
json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": True, "frontend_fk_source": "/api/rbac/roles", "frontend_fk_display_field": "roleLabel"}
)
context: AccessRuleContext = Field(
description="Context type: DATA (database), UI (interface), RESOURCE (system resources)",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_options": [
{"value": "DATA", "label": {"en": "Data", "fr": "Données"}},
{"value": "UI", "label": {"en": "UI", "fr": "Interface"}},
{"value": "RESOURCE", "label": {"en": "Resource", "fr": "Ressource"}}
description="Context type: DATA (database), UI (interface), RESOURCE (system resources). IMMUTABLE!",
json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": True, "frontend_options": [
{"value": "DATA", "label": {"en": "Data", "de": "Daten", "fr": "Données"}},
{"value": "UI", "label": {"en": "UI", "de": "Oberfläche", "fr": "Interface"}},
{"value": "RESOURCE", "label": {"en": "Resource", "de": "Ressource", "fr": "Ressource"}}
]}
)
item: Optional[str] = Field(
None,
default=None,
description="Item identifier (null = all items in context). Format: DATA: '<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}
)
view: bool = Field(
False,
default=False,
description="View permission: if true, item is visible/enabled. Only objects with view=true are shown.",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": True}
)
read: Optional[AccessLevel] = Field(
None,
default=None,
description="Read permission level (only for DATA context)",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
{"value": "a", "label": {"en": "All Records", "fr": "Tous les enregistrements"}},
{"value": "m", "label": {"en": "My Records", "fr": "Mes enregistrements"}},
{"value": "g", "label": {"en": "Group Records", "fr": "Enregistrements du groupe"}},
{"value": "n", "label": {"en": "No Access", "fr": "Aucun accès"}}
{"value": "a", "label": {"en": "All Records", "de": "Alle Datensätze", "fr": "Tous les enregistrements"}},
{"value": "m", "label": {"en": "My Records", "de": "Meine Datensätze", "fr": "Mes enregistrements"}},
{"value": "g", "label": {"en": "Group Records", "de": "Gruppen-Datensätze", "fr": "Enregistrements du groupe"}},
{"value": "n", "label": {"en": "No Access", "de": "Kein Zugriff", "fr": "Aucun accès"}}
]}
)
create: Optional[AccessLevel] = Field(
None,
default=None,
description="Create permission level (only for DATA context)",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
{"value": "a", "label": {"en": "All Records", "fr": "Tous les enregistrements"}},
{"value": "m", "label": {"en": "My Records", "fr": "Mes enregistrements"}},
{"value": "g", "label": {"en": "Group Records", "fr": "Enregistrements du groupe"}},
{"value": "n", "label": {"en": "No Access", "fr": "Aucun accès"}}
{"value": "a", "label": {"en": "All Records", "de": "Alle Datensätze", "fr": "Tous les enregistrements"}},
{"value": "m", "label": {"en": "My Records", "de": "Meine Datensätze", "fr": "Mes enregistrements"}},
{"value": "g", "label": {"en": "Group Records", "de": "Gruppen-Datensätze", "fr": "Enregistrements du groupe"}},
{"value": "n", "label": {"en": "No Access", "de": "Kein Zugriff", "fr": "Aucun accès"}}
]}
)
update: Optional[AccessLevel] = Field(
None,
default=None,
description="Update permission level (only for DATA context)",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
{"value": "a", "label": {"en": "All Records", "fr": "Tous les enregistrements"}},
{"value": "m", "label": {"en": "My Records", "fr": "Mes enregistrements"}},
{"value": "g", "label": {"en": "Group Records", "fr": "Enregistrements du groupe"}},
{"value": "n", "label": {"en": "No Access", "fr": "Aucun accès"}}
{"value": "a", "label": {"en": "All Records", "de": "Alle Datensätze", "fr": "Tous les enregistrements"}},
{"value": "m", "label": {"en": "My Records", "de": "Meine Datensätze", "fr": "Mes enregistrements"}},
{"value": "g", "label": {"en": "Group Records", "de": "Gruppen-Datensätze", "fr": "Enregistrements du groupe"}},
{"value": "n", "label": {"en": "No Access", "de": "Kein Zugriff", "fr": "Aucun accès"}}
]}
)
delete: Optional[AccessLevel] = Field(
None,
default=None,
description="Delete permission level (only for DATA context)",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
{"value": "a", "label": {"en": "All Records", "fr": "Tous les enregistrements"}},
{"value": "m", "label": {"en": "My Records", "fr": "Mes enregistrements"}},
{"value": "g", "label": {"en": "Group Records", "fr": "Enregistrements du groupe"}},
{"value": "n", "label": {"en": "No Access", "fr": "Aucun accès"}}
{"value": "a", "label": {"en": "All Records", "de": "Alle Datensätze", "fr": "Tous les enregistrements"}},
{"value": "m", "label": {"en": "My Records", "de": "Meine Datensätze", "fr": "Mes enregistrements"}},
{"value": "g", "label": {"en": "Group Records", "de": "Gruppen-Datensätze", "fr": "Enregistrements du groupe"}},
{"value": "n", "label": {"en": "No Access", "de": "Kein Zugriff", "fr": "Aucun accès"}}
]}
)
registerModelLabels(
"AccessRule",
{"en": "Access Rule", "fr": "Règle d'accès"},
{"en": "Access Rule", "de": "Zugriffsregel", "fr": "Règle d'accès"},
{
"id": {"en": "ID", "fr": "ID"},
"roleLabel": {"en": "Role Label", "fr": "Label du rôle"},
"context": {"en": "Context", "fr": "Contexte"},
"item": {"en": "Item", "fr": "Élément"},
"view": {"en": "View", "fr": "Vue"},
"read": {"en": "Read", "fr": "Lecture"},
"create": {"en": "Create", "fr": "Créer"},
"update": {"en": "Update", "fr": "Mettre à jour"},
"delete": {"en": "Delete", "fr": "Supprimer"},
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"roleId": {"en": "Role", "de": "Rolle", "fr": "Rôle"},
"context": {"en": "Context", "de": "Kontext", "fr": "Contexte"},
"item": {"en": "Item", "de": "Element", "fr": "Élément"},
"view": {"en": "View", "de": "Anzeigen", "fr": "Vue"},
"read": {"en": "Read", "de": "Lesen", "fr": "Lecture"},
"create": {"en": "Create", "de": "Erstellen", "fr": "Créer"},
"update": {"en": "Update", "de": "Aktualisieren", "fr": "Mettre à jour"},
"delete": {"en": "Delete", "de": "Löschen", "fr": "Supprimer"},
},
)
# IMMUTABLE Fields Definition - für Enforcement auf Application-Level
IMMUTABLE_FIELDS = {
"Role": ["mandateId", "featureInstanceId", "featureCode"],
"AccessRule": ["context", "roleId"]
}
def validateUpdateNotImmutable(model: str, updateData: dict):
"""
Blockiert Updates auf immutable Felder.
Wirft ValueError wenn versucht wird, Kontext-Felder zu ändern.
Args:
model: Model name (z.B. "Role", "AccessRule")
updateData: Dictionary mit Update-Daten
Raises:
ValueError: Wenn immutable Felder im Update enthalten sind
"""
forbidden = IMMUTABLE_FIELDS.get(model, [])
violations = [f for f in forbidden if f in updateData]
if violations:
raise ValueError(
f"Cannot update immutable fields on {model}: {violations}. "
f"Delete and recreate instead."
)

View file

@ -1,6 +1,13 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""Security models: Token and AuthEvent."""
"""
Security models: Token and AuthEvent.
Multi-Tenant Design:
- Token ist NICHT an einen Mandanten gebunden
- User arbeitet parallel in mehreren Mandanten (z.B. mehrere Browser-Tabs)
- Mandant-Kontext wird per Request-Header (X-Mandate-Id) bestimmt
"""
from typing import Optional
from pydantic import BaseModel, Field, ConfigDict
@ -17,6 +24,14 @@ class TokenStatus(str, Enum):
class Token(BaseModel):
"""
Authentication Token model.
Multi-Tenant Design:
- Token ist User-gebunden, NICHT Mandant-gebunden
- Ermöglicht parallele Arbeit in mehreren Mandanten
- Mandant-Kontext wird per Request-Header bestimmt
"""
id: Optional[str] = None
userId: str
authority: AuthAuthority
@ -45,37 +60,36 @@ class Token(BaseModel):
sessionId: Optional[str] = Field(
None, description="Logical session grouping for logout revocation"
)
mandateId: Optional[str] = Field(
None, description="Mandate ID for tenant scoping of the token"
)
# ENTFERNT: mandateId - Token ist nicht mehr Mandant-spezifisch
# Mandant-Kontext wird per Request-Header (X-Mandate-Id) bestimmt
model_config = ConfigDict(use_enum_values=True)
registerModelLabels(
"Token",
{"en": "Token", "fr": "Jeton"},
{"en": "Token", "de": "Token", "fr": "Jeton"},
{
"id": {"en": "ID", "fr": "ID"},
"userId": {"en": "User ID", "fr": "ID utilisateur"},
"authority": {"en": "Authority", "fr": "Autorité"},
"connectionId": {"en": "Connection ID", "fr": "ID de connexion"},
"tokenAccess": {"en": "Access Token", "fr": "Jeton d'accès"},
"tokenType": {"en": "Token Type", "fr": "Type de jeton"},
"expiresAt": {"en": "Expires At", "fr": "Expire le"},
"tokenRefresh": {"en": "Refresh Token", "fr": "Jeton de rafraîchissement"},
"createdAt": {"en": "Created At", "fr": "Créé le"},
"status": {"en": "Status", "fr": "Statut"},
"revokedAt": {"en": "Revoked At", "fr": "Révoqué le"},
"revokedBy": {"en": "Revoked By", "fr": "Révoqué par"},
"reason": {"en": "Reason", "fr": "Raison"},
"sessionId": {"en": "Session ID", "fr": "ID de session"},
"mandateId": {"en": "Mandate ID", "fr": "ID de mandat"},
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"userId": {"en": "User ID", "de": "Benutzer-ID", "fr": "ID utilisateur"},
"authority": {"en": "Authority", "de": "Autorität", "fr": "Autorité"},
"connectionId": {"en": "Connection ID", "de": "Verbindungs-ID", "fr": "ID de connexion"},
"tokenAccess": {"en": "Access Token", "de": "Zugriffstoken", "fr": "Jeton d'accès"},
"tokenType": {"en": "Token Type", "de": "Token-Typ", "fr": "Type de jeton"},
"expiresAt": {"en": "Expires At", "de": "Läuft ab am", "fr": "Expire le"},
"tokenRefresh": {"en": "Refresh Token", "de": "Refresh-Token", "fr": "Jeton de rafraîchissement"},
"createdAt": {"en": "Created At", "de": "Erstellt am", "fr": "Créé le"},
"status": {"en": "Status", "de": "Status", "fr": "Statut"},
"revokedAt": {"en": "Revoked At", "de": "Widerrufen am", "fr": "Révoqué le"},
"revokedBy": {"en": "Revoked By", "de": "Widerrufen von", "fr": "Révoqué par"},
"reason": {"en": "Reason", "de": "Grund", "fr": "Raison"},
"sessionId": {"en": "Session ID", "de": "Sitzungs-ID", "fr": "ID de session"},
},
)
class AuthEvent(BaseModel):
"""Authentication event for audit logging."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the auth event", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
userId: str = Field(description="ID of the user this event belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
eventType: str = Field(description="Type of authentication event (e.g., 'login', 'logout', 'token_refresh')", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
@ -88,15 +102,15 @@ class AuthEvent(BaseModel):
registerModelLabels(
"AuthEvent",
{"en": "Authentication Event", "fr": "Événement d'authentification"},
{"en": "Authentication Event", "de": "Authentifizierungsereignis", "fr": "Événement d'authentification"},
{
"id": {"en": "ID", "fr": "ID"},
"userId": {"en": "User ID", "fr": "ID utilisateur"},
"eventType": {"en": "Event Type", "fr": "Type d'événement"},
"timestamp": {"en": "Timestamp", "fr": "Horodatage"},
"ipAddress": {"en": "IP Address", "fr": "Adresse IP"},
"userAgent": {"en": "User Agent", "fr": "Agent utilisateur"},
"success": {"en": "Success", "fr": "Succès"},
"details": {"en": "Details", "fr": "Détails"},
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"userId": {"en": "User ID", "de": "Benutzer-ID", "fr": "ID utilisateur"},
"eventType": {"en": "Event Type", "de": "Ereignistyp", "fr": "Type d'événement"},
"timestamp": {"en": "Timestamp", "de": "Zeitstempel", "fr": "Horodatage"},
"ipAddress": {"en": "IP Address", "de": "IP-Adresse", "fr": "Adresse IP"},
"userAgent": {"en": "User Agent", "de": "User-Agent", "fr": "Agent utilisateur"},
"success": {"en": "Success", "de": "Erfolgreich", "fr": "Succès"},
"details": {"en": "Details", "de": "Details", "fr": "Détails"},
},
)

View file

@ -1,11 +1,18 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""UAM models: User, Mandate, UserConnection."""
"""
UAM models: User, Mandate, UserConnection.
Multi-Tenant Design:
- User gehört NICHT direkt zu einem Mandanten
- Zugehörigkeit wird über UserMandate gesteuert (siehe datamodelMembership.py)
- isSysAdmin ist globales Admin-Flag für System-Zugriff (KEIN Daten-Zugriff!)
"""
import uuid
from typing import Optional, List
from enum import Enum
from pydantic import BaseModel, Field, EmailStr
from pydantic import BaseModel, Field, EmailStr, field_validator
from modules.shared.attributeUtils import registerModelLabels
from modules.shared.timeUtils import getUtcTimestamp
@ -51,55 +58,53 @@ class UserPermissions(BaseModel):
description="Delete permission level"
)
class Mandate(BaseModel):
"""
Mandate (Mandant/Tenant) model.
Ein Mandant ist ein isolierter Bereich für Daten und Berechtigungen.
"""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the mandate",
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(
description="Name of the mandate",
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True}
)
language: str = Field(
default="en",
description="Default language of the mandate",
json_schema_extra={
"frontend_type": "select",
"frontend_readonly": False,
"frontend_required": True,
"frontend_options": [
{"value": "de", "label": {"en": "Deutsch", "fr": "Allemand"}},
{"value": "en", "label": {"en": "English", "fr": "Anglais"}},
{"value": "fr", "label": {"en": "Français", "fr": "Français"}},
{"value": "it", "label": {"en": "Italiano", "fr": "Italien"}},
]
}
description: Optional[str] = Field(
default=None,
description="Description of the mandate",
json_schema_extra={"frontend_type": "textarea", "frontend_readonly": False, "frontend_required": False}
)
enabled: bool = Field(
default=True,
description="Indicates whether the mandate is enabled",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
)
registerModelLabels(
"Mandate",
{"en": "Mandate", "fr": "Mandat"},
{"en": "Mandate", "de": "Mandant", "fr": "Mandat"},
{
"id": {"en": "ID", "fr": "ID"},
"name": {"en": "Name", "fr": "Nom"},
"language": {"en": "Language", "fr": "Langue"},
"enabled": {"en": "Enabled", "fr": "Activé"},
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"name": {"en": "Name", "de": "Name", "fr": "Nom"},
"description": {"en": "Description", "de": "Beschreibung", "fr": "Description"},
"enabled": {"en": "Enabled", "de": "Aktiviert", "fr": "Activé"},
},
)
class UserConnection(BaseModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the connection", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
userId: str = Field(description="ID of the user this connection belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
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})
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})
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})
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})
@ -109,70 +114,146 @@ class UserConnection(BaseModel):
{"value": "none", "label": {"en": "None", "fr": "Aucun"}},
]})
tokenExpiresAt: Optional[float] = Field(None, description="When the current token expires (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False})
registerModelLabels(
"UserConnection",
{"en": "User Connection", "fr": "Connexion utilisateur"},
{"en": "User Connection", "de": "Benutzerverbindung", "fr": "Connexion utilisateur"},
{
"id": {"en": "ID", "fr": "ID"},
"userId": {"en": "User ID", "fr": "ID utilisateur"},
"authority": {"en": "Authority", "fr": "Autorité"},
"externalId": {"en": "External ID", "fr": "ID externe"},
"externalUsername": {"en": "External Username", "fr": "Nom d'utilisateur externe"},
"externalEmail": {"en": "External Email", "fr": "Email externe"},
"status": {"en": "Status", "fr": "Statut"},
"connectedAt": {"en": "Connected At", "fr": "Connecté le"},
"lastChecked": {"en": "Last Checked", "fr": "Dernière vérification"},
"expiresAt": {"en": "Expires At", "fr": "Expire le"},
"tokenStatus": {"en": "Connection Status", "fr": "Statut de connexion"},
"tokenExpiresAt": {"en": "Expires At", "fr": "Expire le"},
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"userId": {"en": "User ID", "de": "Benutzer-ID", "fr": "ID utilisateur"},
"authority": {"en": "Authority", "de": "Autorität", "fr": "Autorité"},
"externalId": {"en": "External ID", "de": "Externe ID", "fr": "ID externe"},
"externalUsername": {"en": "External Username", "de": "Externer Benutzername", "fr": "Nom d'utilisateur externe"},
"externalEmail": {"en": "External Email", "de": "Externe E-Mail", "fr": "Email externe"},
"status": {"en": "Status", "de": "Status", "fr": "Statut"},
"connectedAt": {"en": "Connected At", "de": "Verbunden am", "fr": "Connecté le"},
"lastChecked": {"en": "Last Checked", "de": "Zuletzt geprüft", "fr": "Dernière vérification"},
"expiresAt": {"en": "Expires At", "de": "Läuft ab am", "fr": "Expire le"},
"tokenStatus": {"en": "Connection Status", "de": "Verbindungsstatus", "fr": "Statut de connexion"},
"tokenExpiresAt": {"en": "Expires At", "de": "Läuft ab am", "fr": "Expire le"},
},
)
class User(BaseModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the user", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
username: str = Field(description="Username for login", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True})
email: Optional[EmailStr] = Field(None, description="Email address of the user", json_schema_extra={"frontend_type": "email", "frontend_readonly": False, "frontend_required": True})
fullName: Optional[str] = Field(None, description="Full name of the user", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False})
language: str = Field(default="en", description="Preferred language of the user", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_options": [
{"value": "de", "label": {"en": "Deutsch", "fr": "Allemand"}},
{"value": "en", "label": {"en": "English", "fr": "Anglais"}},
{"value": "fr", "label": {"en": "Français", "fr": "Français"}},
{"value": "it", "label": {"en": "Italiano", "fr": "Italien"}},
]})
enabled: bool = Field(default=True, description="Indicates whether the user is enabled", json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False})
roleLabels: List[str] = Field(
default_factory=list,
description="List of role labels assigned to this user. All roles are opening roles (union) - if one role enables something, it is enabled.",
json_schema_extra={"frontend_type": "multiselect", "frontend_readonly": False, "frontend_required": True, "frontend_options": "user.role"}
"""
User model.
Multi-Tenant Design:
- User gehört NICHT direkt zu einem Mandanten
- Zugehörigkeit wird über UserMandate gesteuert (siehe datamodelMembership.py)
- Rollen werden über UserMandateRole gesteuert
- isSysAdmin = System-Zugriff, KEIN Daten-Zugriff
"""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the user",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False}
)
authenticationAuthority: AuthAuthority = Field(default=AuthAuthority.LOCAL, description="Primary authentication authority", json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": False, "frontend_options": "auth.authority"})
mandateId: Optional[str] = Field(None, description="ID of the mandate this user belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
username: str = Field(
description="Username for login (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(
"User",
{"en": "User", "fr": "Utilisateur"},
{"en": "User", "de": "Benutzer", "fr": "Utilisateur"},
{
"id": {"en": "ID", "fr": "ID"},
"username": {"en": "Username", "fr": "Nom d'utilisateur"},
"email": {"en": "Email", "fr": "Email"},
"fullName": {"en": "Full Name", "fr": "Nom complet"},
"language": {"en": "Language", "fr": "Langue"},
"enabled": {"en": "Enabled", "fr": "Activé"},
"roleLabels": {"en": "Role Labels", "fr": "Labels de rôle"},
"authenticationAuthority": {"en": "Auth Authority", "fr": "Autorité d'authentification"},
"mandateId": {"en": "Mandate ID", "fr": "ID de mandat"},
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"username": {"en": "Username", "de": "Benutzername", "fr": "Nom d'utilisateur"},
"email": {"en": "Email", "de": "E-Mail", "fr": "Email"},
"fullName": {"en": "Full Name", "de": "Vollständiger Name", "fr": "Nom complet"},
"language": {"en": "Language", "de": "Sprache", "fr": "Langue"},
"enabled": {"en": "Enabled", "de": "Aktiviert", "fr": "Activé"},
"isSysAdmin": {"en": "System Admin", "de": "System-Admin", "fr": "Admin système"},
"authenticationAuthority": {"en": "Auth Authority", "de": "Authentifizierung", "fr": "Autorité d'authentification"},
},
)
class UserInDB(User):
"""User model with password hash for database storage."""
hashedPassword: Optional[str] = Field(None, description="Hash of the user password")
resetToken: Optional[str] = Field(None, description="Password reset token (UUID)")
resetTokenExpires: Optional[float] = Field(None, description="Reset token expiration (UTC timestamp in seconds)")
registerModelLabels(
"UserInDB",
{"en": "User Access", "fr": "Accès de l'utilisateur"},
{"en": "User Access", "de": "Benutzerzugang", "fr": "Accès de l'utilisateur"},
{
"hashedPassword": {"en": "Password hash", "fr": "Hachage de mot de passe"},
"resetToken": {"en": "Reset Token", "fr": "Jeton de réinitialisation"},
"resetTokenExpires": {"en": "Reset Token Expires", "fr": "Expiration du jeton"},
"hashedPassword": {"en": "Password hash", "de": "Passwort-Hash", "fr": "Hachage de mot de passe"},
"resetToken": {"en": "Reset Token", "de": "Reset-Token", "fr": "Jeton de réinitialisation"},
"resetTokenExpires": {"en": "Reset Token Expires", "de": "Token läuft ab", "fr": "Expiration du jeton"},
},
)

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})
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})
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})
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})
@ -28,6 +29,7 @@ registerModelLabels(
"id": {"en": "ID", "fr": "ID"},
"userId": {"en": "User ID", "fr": "ID utilisateur"},
"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"},
"ttsLanguage": {"en": "TTS Language", "fr": "Langue 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)
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):
"""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 interfaces and models
from modules.interfaces.interfaceDbChatObjects import getInterface as getChatInterface
from modules.auth import getCurrentUser, limiter
from modules.datamodels.datamodelChat import AutomationDefinition, ChatWorkflow
from modules.interfaces.interfaceDbChat import getInterface as getChatInterface
from modules.auth import limiter, getRequestContext, RequestContext
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.shared.attributeUtils import getModelAttributeDefinitions
from modules.features.workflow import executeAutomation
from modules.features.workflow.subAutomationTemplates import getAutomationTemplates
from modules.workflows.automation import executeAutomation
from .subAutomationTemplates import getAutomationTemplates
# Configure logger
logger = logging.getLogger(__name__)
@ -45,7 +46,7 @@ router = APIRouter(
async def get_automations(
request: Request,
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
currentUser = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> PaginatedResponse[AutomationDefinition]:
"""
Get automation definitions with optional pagination, sorting, and filtering.
@ -68,7 +69,7 @@ async def get_automations(
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)
# If pagination was requested, result is PaginatedResult
@ -110,14 +111,14 @@ async def get_automations(
async def create_automation(
request: Request,
automation: AutomationDefinition,
currentUser = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> AutomationDefinition:
"""Create a new automation definition"""
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()
created = chatInterface.createAutomationDefinition(automationData)
return AutomationDefinition(**created)
return created
except HTTPException:
raise
except Exception as e:
@ -131,7 +132,7 @@ async def create_automation(
@limiter.limit("30/minute")
async def get_automation_templates(
request: Request,
currentUser = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> JSONResponse:
"""
Get automation templates from backend module.
@ -159,11 +160,11 @@ async def get_automation_attributes(
async def get_automation(
request: Request,
automationId: str = Path(..., description="Automation ID"),
currentUser = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> AutomationDefinition:
"""Get a single automation definition by ID"""
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)
if not automation:
raise HTTPException(
@ -171,7 +172,7 @@ async def get_automation(
detail=f"Automation {automationId} not found"
)
return AutomationDefinition(**automation)
return automation
except HTTPException:
raise
except Exception as e:
@ -187,14 +188,14 @@ async def update_automation(
request: Request,
automationId: str = Path(..., description="Automation ID"),
automation: AutomationDefinition = Body(...),
currentUser = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> AutomationDefinition:
"""Update an automation definition"""
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()
updated = chatInterface.updateAutomationDefinition(automationId, automationData)
return AutomationDefinition(**updated)
return updated
except HTTPException:
raise
except PermissionError as e:
@ -209,16 +210,57 @@ async def update_automation(
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}")
@limiter.limit("10/minute")
async def delete_automation(
request: Request,
automationId: str = Path(..., description="Automation ID"),
currentUser = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> Response:
"""Delete an automation definition"""
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)
if success:
return Response(status_code=204)
@ -243,15 +285,15 @@ async def delete_automation(
@router.post("/{automationId}/execute", response_model=ChatWorkflow)
@limiter.limit("5/minute")
async def execute_automation(
async def execute_automation_route(
request: Request,
automationId: str = Path(..., description="Automation ID"),
currentUser = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> ChatWorkflow:
"""Execute an automation immediately (test mode)"""
try:
from modules.services import getInterface as getServices
services = getServices(currentUser, None)
services = getServices(context.user, context.mandateId)
workflow = await executeAutomation(automationId, services)
return workflow
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.
Moved from interfaces/interfaceDbChatObjects.py.
Moved from interfaces/interfaceDbChat.py.
"""
import json

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,8 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Interface to LucyDOM database and AI Connectors.
Uses the JSON connector for data access with added language support.
Interface to Chatbot database and AI Connectors.
Uses the PostgreSQL connector for data access with user/mandate filtering.
"""
import logging
@ -16,16 +16,16 @@ from modules.security.rbac import RbacClass
from modules.datamodels.datamodelRbac import AccessRuleContext
from modules.datamodels.datamodelUam import AccessLevel
from modules.datamodels.datamodelChat import (
from .datamodelFeatureChatbot import (
ChatDocument,
ChatStat,
ChatLog,
ChatMessage,
ChatWorkflow,
WorkflowModeEnum,
AutomationDefinition,
UserInputRequest
)
from modules.features.automation.datamodelFeatureAutomation import AutomationDefinition
import json
from modules.datamodels.datamodelUam import User
@ -59,7 +59,7 @@ def storeDebugMessageAndDocuments(message, currentUser) -> None:
import os
from datetime import datetime, UTC
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)
baseDebugDir = _getBaseDebugDir()
@ -178,12 +178,20 @@ class ChatObjects:
Uses the JSON connector for data access with added language support.
"""
def __init__(self, currentUser: Optional[User] = None):
"""Initializes the Chat Interface."""
def __init__(self, currentUser: Optional[User] = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None):
"""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
self.currentUser = currentUser # Store User object directly
self.userId = currentUser.id if currentUser else None
self.mandateId = currentUser.mandateId if currentUser else None
# Use mandateId from parameter (Request-Context), not from user object
self.mandateId = mandateId
self.featureInstanceId = featureInstanceId
self.rbac = None # RBAC interface
# Initialize services
@ -194,7 +202,7 @@ class ChatObjects:
# Set user context if provided
if currentUser:
self.setUserContext(currentUser)
self.setUserContext(currentUser, mandateId=mandateId, featureInstanceId=featureInstanceId)
# ===== Generic Utility Methods =====
@ -257,14 +265,27 @@ class ChatObjects:
def _initializeServices(self):
pass
def setUserContext(self, currentUser: User):
"""Sets the user context for the interface."""
def setUserContext(self, currentUser: User, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None):
"""Sets the user context for the interface.
Args:
currentUser: The authenticated user
mandateId: The mandate ID from RequestContext (X-Mandate-Id header)
featureInstanceId: The feature instance ID from RequestContext (X-Feature-Instance-Id header)
"""
self.currentUser = currentUser # Store User object directly
self.userId = currentUser.id
self.mandateId = currentUser.mandateId
# Use mandateId from parameter (Request-Context), not from user object
self.mandateId = mandateId
self.featureInstanceId = featureInstanceId
if not self.userId or not self.mandateId:
raise ValueError("Invalid user context: id and mandateId are required")
if not self.userId:
raise ValueError("Invalid user context: id is required")
# 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
self.userLanguage = currentUser.language # Default user language
@ -293,11 +314,11 @@ class ChatObjects:
"""Initializes the database connection directly."""
try:
# Get configuration values with defaults
dbHost = APP_CONFIG.get("DB_CHAT_HOST", "_no_config_default_data")
dbDatabase = APP_CONFIG.get("DB_CHAT_DATABASE", "chat")
dbUser = APP_CONFIG.get("DB_CHAT_USER")
dbPassword = APP_CONFIG.get("DB_CHAT_PASSWORD_SECRET")
dbPort = int(APP_CONFIG.get("DB_CHAT_PORT", 5432))
dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data")
dbDatabase = "poweron_chatbot"
dbUser = APP_CONFIG.get("DB_USER")
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
# Create database connector directly
self.db = DatabaseConnector(
@ -346,7 +367,9 @@ class ChatObjects:
permissions = self.rbac.getUserPermissions(
self.currentUser,
AccessRuleContext.DATA,
tableName
tableName,
mandateId=self.mandateId,
featureInstanceId=self.featureInstanceId
)
if operation == "create":
@ -587,10 +610,12 @@ class ChatObjects:
If pagination is None: List[Dict[str, Any]]
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,
ChatWorkflow,
self.currentUser
self.currentUser,
mandateId=self.mandateId,
featureInstanceId=self.featureInstanceId
)
# 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]:
"""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,
ChatWorkflow,
self.currentUser,
recordFilter={"id": workflowId}
recordFilter={"id": workflowId},
mandateId=self.mandateId,
featureInstanceId=self.featureInstanceId
)
if not workflows:
@ -654,7 +681,7 @@ class ChatObjects:
logs=logs,
messages=messages,
stats=stats,
mandateId=workflow.get("mandateId", self.currentUser.mandateId)
mandateId=workflow.get("mandateId", self.mandateId)
)
except Exception as e:
logger.error(f"Error validating workflow data: {str(e)}")
@ -673,6 +700,12 @@ class ChatObjects:
if "lastActivity" not in workflowData:
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
simpleFields, objectFields = self._separateObjectFields(ChatWorkflow, workflowData)
@ -695,7 +728,7 @@ class ChatObjects:
logs=[],
messages=[],
stats=[],
mandateId=created.get("mandateId", self.currentUser.mandateId),
mandateId=created.get("mandateId", self.mandateId),
workflowMode=created["workflowMode"],
maxSteps=created.get("maxSteps", 1)
)
@ -993,6 +1026,12 @@ class ChatObjects:
if "actionNumber" not in messageData:
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
simpleFields, objectFields = self._separateObjectFields(ChatMessage, messageData)
@ -1088,7 +1127,7 @@ class ChatObjects:
logger.error(f"Error creating workflow message: {str(e)}")
return None
def updateMessage(self, messageId: str, messageData: Dict[str, Any]) -> Dict[str, Any]:
def updateMessage(self, messageId: str, messageData: Dict[str, Any]) -> Optional[ChatMessage]:
"""Updates a workflow message if user has access to the workflow."""
try:
@ -1174,8 +1213,10 @@ class ChatObjects:
logger.error(f"Error updating message documents: {str(e)}")
if not updatedMessage:
logger.warning(f"Failed to update message {messageId}")
return updatedMessage
return None
# Convert to ChatMessage model
return ChatMessage(**updatedMessage)
except Exception as e:
logger.error(f"Error updating message {messageId}: {str(e)}", exc_info=True)
raise ValueError(f"Error updating message {messageId}: {str(e)}")
@ -1285,6 +1326,12 @@ class ChatObjects:
def createDocument(self, documentData: Dict[str, Any]) -> ChatDocument:
"""Creates a document for a message in normalized table."""
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
document = ChatDocument(**documentData)
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:
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
if "status" not in logData and "type" in logData:
if logData["type"] == "error":
@ -1490,6 +1543,12 @@ class ChatObjects:
if "workflowId" not 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
stat = ChatStat(**statData)
@ -1612,7 +1671,7 @@ class ChatObjects:
if not 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
userIds = set()
@ -1716,7 +1775,7 @@ class ChatObjects:
totalPages=totalPages
)
def getAutomationDefinition(self, automationId: str) -> Optional[Dict[str, Any]]:
def getAutomationDefinition(self, automationId: str) -> Optional[AutomationDefinition]:
"""Returns an automation definition by ID if user has access, with computed status."""
try:
# Use RBAC filtering
@ -1736,21 +1795,25 @@ class ChatObjects:
automation["executionLogs"] = []
# Enrich with user and mandate names
self._enrichAutomationWithUserAndMandate(automation)
return automation
# Clean metadata fields and return Pydantic model
cleanedRecord = {k: v for k, v in automation.items() if not k.startswith("_")}
return AutomationDefinition(**cleanedRecord)
except Exception as e:
logger.error(f"Error getting automation definition: {str(e)}")
return None
def createAutomationDefinition(self, automationData: Dict[str, Any]) -> Dict[str, Any]:
def createAutomationDefinition(self, automationData: Dict[str, Any]) -> AutomationDefinition:
"""Creates a new automation definition, then triggers sync."""
try:
# Ensure ID is present
if "id" not in automationData or not automationData["id"]:
automationData["id"] = str(uuid.uuid4())
# Ensure mandateId is set
# Ensure mandateId and featureInstanceId are set for proper data isolation
if "mandateId" not in automationData:
automationData["mandateId"] = self.mandateId
if "featureInstanceId" not in automationData:
automationData["featureInstanceId"] = self.featureInstanceId
# Ensure database connector has correct userId context
# 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)
asyncio.create_task(self._notifyAutomationChanged())
return createdAutomation
# Clean metadata fields and return Pydantic model
cleanedRecord = {k: v for k, v in createdAutomation.items() if not k.startswith("_")}
return AutomationDefinition(**cleanedRecord)
except Exception as e:
logger.error(f"Error creating automation definition: {str(e)}")
raise
def updateAutomationDefinition(self, automationId: str, automationData: Dict[str, Any]) -> Dict[str, Any]:
def updateAutomationDefinition(self, automationId: str, automationData: Dict[str, Any]) -> AutomationDefinition:
"""Updates an automation definition, then triggers sync."""
try:
# Check access
@ -1808,7 +1873,9 @@ class ChatObjects:
# Trigger automation change callback (async, don't wait)
asyncio.create_task(self._notifyAutomationChanged())
return updatedAutomation
# Clean metadata fields and return Pydantic model
cleanedRecord = {k: v for k, v in updatedAutomation.items() if not k.startswith("_")}
return AutomationDefinition(**cleanedRecord)
except Exception as e:
logger.error(f"Error updating automation definition: {str(e)}")
raise
@ -1870,19 +1937,30 @@ class ChatObjects:
logger.error(f"Error notifying automation change: {str(e)}")
def getInterface(currentUser: Optional[User] = None) -> 'ChatObjects':
def getInterface(currentUser: Optional[User] = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None) -> 'ChatObjects':
"""
Returns a ChatObjects instance for the current user.
Handles initialization of database and records.
Args:
currentUser: The authenticated user
mandateId: The mandate ID from RequestContext (X-Mandate-Id header). Required.
featureInstanceId: The feature instance ID from RequestContext (X-Feature-Instance-Id header).
"""
if not currentUser:
raise ValueError("Invalid user context: user is required")
# Create context key
contextKey = f"{currentUser.mandateId}_{currentUser.id}"
effectiveMandateId = str(mandateId) if mandateId else None
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
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]

View file

@ -4,16 +4,136 @@
Simple chatbot feature - basic implementation.
User input is processed by AI to create list of needed queries.
Those queries get streamed back.
This module also handles feature initialization and RBAC catalog registration.
"""
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 uuid
import asyncio
import re
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.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum, ProcessingModeEnum
from modules.datamodels.datamodelDocref import DocumentReferenceList, DocumentItemReference
@ -62,6 +182,7 @@ def _extractJsonFromResponse(content: str) -> Optional[dict]:
async def chatProcess(
currentUser: User,
mandateId: str,
userInput: UserInputRequest,
workflowId: Optional[str] = None
) -> ChatWorkflow:
@ -76,6 +197,7 @@ async def chatProcess(
Args:
currentUser: Current user
mandateId: Mandate context (from RequestContext / X-Mandate-Id header)
userInput: User input request
workflowId: Optional workflow ID to continue existing conversation
@ -83,8 +205,8 @@ async def chatProcess(
ChatWorkflow instance
"""
try:
# Get services
services = getServices(currentUser, None)
# Get services with mandate context
services = getServices(currentUser, None, mandateId=mandateId)
interfaceDbChat = services.interfaceDbChat
# Get event manager and create queue if needed
@ -120,7 +242,7 @@ async def chatProcess(
# Create new workflow
workflowData = {
"id": str(uuid.uuid4()),
"mandateId": currentUser.mandateId,
"mandateId": mandateId,
"status": "running",
"name": conversation_name,
"currentRound": 1,
@ -333,7 +455,6 @@ async def _emit_log_and_event(
# Emit event directly for streaming (using correct signature)
if created_log and event_manager:
try:
from modules.datamodels.datamodelChat import ChatLog
# Convert to dict if it's a Pydantic model
if hasattr(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
if not document_id:
try:
from modules.shared.databaseUtils import getRecordsetWithRBAC
from modules.interfaces.interfaceRbac import getRecordsetWithRBAC
documents = getRecordsetWithRBAC(
services.interfaceDbChat.db,
ChatDocument,
services.currentUser,
recordFilter={"fileId": file_id}
services.user,
recordFilter={"fileId": file_id},
mandateId=services.mandateId
)
if documents:
workflow_message_ids = {msg.id for msg in workflow.messages} if workflow.messages else set()
@ -1141,7 +1263,6 @@ async def _processChatbotMessage(
)
# 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
empty_count = len(sql_queries)

View file

@ -15,23 +15,22 @@ from fastapi.responses import StreamingResponse
from modules.shared.timeUtils import parseTimestamp
# Import auth modules
from modules.auth import limiter, getCurrentUser
from modules.auth import limiter, getRequestContext, RequestContext
# Import interfaces
import modules.interfaces.interfaceDbChatObjects as interfaceDbChatObjects
from . import interfaceFeatureChatbot as interfaceDbChat
from modules.interfaces.interfaceRbac import getRecordsetWithRBAC
# Import models
from modules.datamodels.datamodelChat import ChatWorkflow, UserInputRequest, WorkflowModeEnum
from modules.datamodels.datamodelUam import User
from .datamodelFeatureChatbot import ChatWorkflow, UserInputRequest, WorkflowModeEnum
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse
# Import chatbot feature
from modules.features.chatbot import chatProcess
from modules.features.chatbot.eventManager import get_event_manager
from . import chatProcess
from .eventManager import get_event_manager
# Import workflow control functions
from modules.features.workflow import chatStop
from modules.workflows.automation import chatStop
# Configure logger
logger = logging.getLogger(__name__)
@ -43,8 +42,8 @@ router = APIRouter(
responses={404: {"description": "Not found"}}
)
def getServiceChat(currentUser: User):
return interfaceDbChatObjects.getInterface(currentUser)
def _getServiceChat(context: RequestContext):
return interfaceDbChat.getInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None)
# Chatbot streaming endpoint (SSE)
@router.post("/start/stream")
@ -53,7 +52,7 @@ async def stream_chatbot_start(
request: Request,
workflowId: Optional[str] = Query(None, description="Optional ID of the workflow to continue (can also be in request body)"),
userInput: UserInputRequest = Body(...),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> StreamingResponse:
"""
Starts a new chatbot workflow or continues an existing one with SSE streaming.
@ -71,7 +70,7 @@ async def stream_chatbot_start(
final_workflow_id = workflowId or userInput.workflowId
# Start background processing (this will create the workflow and event queue)
workflow = await chatProcess(currentUser, userInput, final_workflow_id)
workflow = await chatProcess(context.user, str(context.mandateId), userInput, final_workflow_id)
# Get event queue for the workflow
queue = event_manager.get_queue(workflow.id)
@ -83,7 +82,7 @@ async def stream_chatbot_start(
"""Async generator for SSE events - pure event-driven streaming (no polling)."""
try:
# Get interface for initial data and status checks
interfaceDbChat = getServiceChat(currentUser)
interfaceDbChat = _getServiceChat(context)
# Get current workflow to check if resuming and get current round
current_workflow = interfaceDbChat.getWorkflow(workflow.id)
@ -239,11 +238,11 @@ async def stream_chatbot_start(
async def stop_chatbot(
request: Request,
workflowId: str = Path(..., description="ID of the workflow to stop"),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> ChatWorkflow:
"""Stops a running chatbot workflow."""
try:
workflow = await chatStop(currentUser, workflowId)
workflow = await chatStop(context.user, workflowId)
# Emit stopped event to active streams
event_manager = get_event_manager()
@ -272,18 +271,18 @@ async def stop_chatbot(
async def delete_chatbot(
request: Request,
workflowId: str = Path(..., description="ID of the workflow to delete"),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""Deletes a chatbot workflow and its associated data."""
try:
# Get service center
interfaceDbChat = getServiceChat(currentUser)
interfaceDbChat = _getServiceChat(context)
# Check workflow access and permission using RBAC
workflows = getRecordsetWithRBAC(
interfaceDbChat.db,
ChatWorkflow,
currentUser,
context.user,
recordFilter={"id": workflowId}
)
if not workflows:
@ -337,7 +336,7 @@ async def get_chatbot_threads(
request: Request,
workflowId: Optional[str] = Query(None, description="Optional workflow ID to get details and chat data for a specific thread"),
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object (only used when workflowId is not provided)"),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> Union[PaginatedResponse[ChatWorkflow], Dict[str, Any]]:
"""
List all chatbot workflows (threads) for the current user, or get details and chat data for a specific thread.
@ -346,7 +345,7 @@ async def get_chatbot_threads(
- If workflowId is not provided: Returns a paginated list of all workflows
"""
try:
interfaceDbChat = getServiceChat(currentUser)
interfaceDbChat = _getServiceChat(context)
# If workflowId is provided, return single workflow with chat data
if workflowId:
@ -433,7 +432,6 @@ async def get_chatbot_threads(
normalized_workflows.append(normalized_wf)
# Create paginated response
from modules.datamodels.datamodelPagination import PaginationMetadata
metadata = PaginationMetadata(
currentPage=paginationParams.page if paginationParams else 1,
pageSize=paginationParams.pageSize if paginationParams else len(workflows),
@ -456,4 +454,3 @@ async def get_chatbot_threads(
status_code=500,
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):
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})
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})
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})
@ -22,6 +23,7 @@ registerModelLabels(
{
"id": {"en": "ID", "fr": "ID"},
"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"},
"enabled": {"en": "Enabled", "fr": "Activé"},
"namesToParse": {"en": "Names to Parse", "fr": "Noms à analyser"},
@ -33,6 +35,7 @@ registerModelLabels(
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})
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})
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})
@ -43,6 +46,7 @@ registerModelLabels(
{
"id": {"en": "ID", "fr": "ID"},
"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"},
"originalText": {"en": "Original Text", "fr": "Texte original"},
"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 modules.datamodels.datamodelUam import User
from modules.datamodels.datamodelNeutralizer import DataNeutralizerAttributes, DataNeutraliserConfig
from .datamodelFeatureNeutralizer import DataNeutralizerAttributes, DataNeutraliserConfig
from modules.services import getInterface as getServices
logger = logging.getLogger(__name__)
@ -15,9 +15,10 @@ logger = logging.getLogger(__name__)
class NeutralizationPlayground:
"""Feature/UI wrapper around NeutralizationService for playground & routes."""
def __init__(self, currentUser: User):
def __init__(self, currentUser: User, mandateId: str):
self.currentUser = currentUser
self.services = getServices(currentUser, None)
self.mandateId = mandateId
self.services = getServices(currentUser, None, mandateId=mandateId)
def processText(self, text: str) -> Dict[str, Any]:
return self.services.neutralization.processText(text)
@ -81,7 +82,7 @@ class NeutralizationPlayground:
'total_attributes': len(allAttributes),
'unique_files': len(uniqueFiles),
'pattern_counts': patternCounts,
'mandate_id': self.currentUser.mandateId if self.currentUser else None,
'mandate_id': self.mandateId,
}
except Exception as e:
logger.error(f"Error getting stats: {str(e)}")
@ -181,7 +182,6 @@ class SharepointProcessor:
async def _getSharepointConnection(self, sharepointPath: str = None):
try:
from modules.datamodels.datamodelUam import UserConnection
connections = self.services.interfaceDbApp.db.getRecordset(
UserConnection,
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 auth module
from modules.auth import limiter, getCurrentUser
from modules.auth import limiter, getRequestContext, RequestContext
# Import interfaces
from modules.datamodels.datamodelUam import User
from modules.datamodels.datamodelNeutralizer import DataNeutraliserConfig, DataNeutralizerAttributes
from modules.features.neutralizePlayground.mainNeutralizePlayground import NeutralizationPlayground
from .datamodelFeatureNeutralizer import DataNeutraliserConfig, DataNeutralizerAttributes
from .mainNeutralizePlayground import NeutralizationPlayground
# Configure logger
logger = logging.getLogger(__name__)
@ -32,18 +31,18 @@ router = APIRouter(
@limiter.limit("30/minute")
async def get_neutralization_config(
request: Request,
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> DataNeutraliserConfig:
"""Get data neutralization configuration"""
try:
service = NeutralizationPlayground(currentUser)
service = NeutralizationPlayground(context.user, str(context.mandateId))
config = service.getConfig()
if not config:
# Return default config instead of 404
return DataNeutraliserConfig(
mandateId=currentUser.mandateId,
userId=currentUser.id,
mandateId=context.mandateId,
userId=context.user.id,
enabled=True,
namesToParse="",
sharepointSourcePath="",
@ -66,11 +65,11 @@ async def get_neutralization_config(
async def save_neutralization_config(
request: Request,
config_data: Dict[str, Any] = Body(...),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> DataNeutraliserConfig:
"""Save or update data neutralization configuration"""
try:
service = NeutralizationPlayground(currentUser)
service = NeutralizationPlayground(context.user, str(context.mandateId))
config = service.saveConfig(config_data)
return config
@ -87,7 +86,7 @@ async def save_neutralization_config(
async def neutralize_text(
request: Request,
text_data: Dict[str, Any] = Body(...),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""Neutralize text content"""
try:
@ -100,7 +99,7 @@ async def neutralize_text(
detail="Text content is required"
)
service = NeutralizationPlayground(currentUser)
service = NeutralizationPlayground(context.user, str(context.mandateId))
result = service.neutralizeText(text, file_id)
return result
@ -119,7 +118,7 @@ async def neutralize_text(
async def resolve_text(
request: Request,
text_data: Dict[str, str] = Body(...),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, str]:
"""Resolve UIDs in neutralized text back to original text"""
try:
@ -131,7 +130,7 @@ async def resolve_text(
detail="Text content is required"
)
service = NeutralizationPlayground(currentUser)
service = NeutralizationPlayground(context.user, str(context.mandateId))
resolved_text = service.resolveText(text)
return {"resolved_text": resolved_text}
@ -150,11 +149,11 @@ async def resolve_text(
async def get_neutralization_attributes(
request: Request,
fileId: Optional[str] = Query(None, description="Filter by file ID"),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> List[DataNeutralizerAttributes]:
"""Get neutralization attributes, optionally filtered by file ID"""
try:
service = NeutralizationPlayground(currentUser)
service = NeutralizationPlayground(context.user, str(context.mandateId))
attributes = service.getAttributes(fileId)
return attributes
@ -171,7 +170,7 @@ async def get_neutralization_attributes(
async def process_sharepoint_files(
request: Request,
paths_data: Dict[str, str] = Body(...),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""Process files from SharePoint source path and store neutralized files in target path"""
try:
@ -184,7 +183,7 @@ async def process_sharepoint_files(
detail="Both source and target paths are required"
)
service = NeutralizationPlayground(currentUser)
service = NeutralizationPlayground(context.user, str(context.mandateId))
result = await service.processSharepointFiles(source_path, target_path)
return result
@ -203,7 +202,7 @@ async def process_sharepoint_files(
async def batch_process_files(
request: Request,
files_data: List[Dict[str, Any]] = Body(...),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""Process multiple files for neutralization"""
try:
@ -213,7 +212,7 @@ async def batch_process_files(
detail="Files data is required"
)
service = NeutralizationPlayground(currentUser)
service = NeutralizationPlayground(context.user, str(context.mandateId))
result = service.batchNeutralizeFiles(files_data)
return result
@ -231,11 +230,11 @@ async def batch_process_files(
@limiter.limit("30/minute")
async def get_neutralization_stats(
request: Request,
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""Get neutralization processing statistics"""
try:
service = NeutralizationPlayground(currentUser)
service = NeutralizationPlayground(context.user, str(context.mandateId))
stats = service.getProcessingStats()
return stats
@ -252,11 +251,11 @@ async def get_neutralization_stats(
async def cleanup_file_attributes(
request: Request,
fileId: str = Path(..., description="File ID to cleanup attributes for"),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, str]:
"""Clean up neutralization attributes for a specific file"""
try:
service = NeutralizationPlayground(currentUser)
service = NeutralizationPlayground(context.user, str(context.mandateId))
success = service.cleanupFileAttributes(fileId)
if success:

View file

@ -13,14 +13,15 @@ import re
import json
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
from modules.services.serviceNeutralization.subProcessCommon import CommonUtils, NeutralizationResult, NeutralizationAttribute
from modules.services.serviceNeutralization.subProcessText import TextProcessor, PlainText
from modules.services.serviceNeutralization.subProcessList import ListProcessor, TableData
from modules.services.serviceNeutralization.subProcessBinary import BinaryProcessor
from modules.services.serviceNeutralization.subPatterns import HeaderPatterns, DataPatterns, TextTablePatterns
from .subProcessCommon import CommonUtils, NeutralizationResult, NeutralizationAttribute
from .subProcessText import TextProcessor, PlainText
from .subProcessList import ListProcessor, TableData
from .subProcessBinary import BinaryProcessor
from .subPatterns import HeaderPatterns, DataPatterns, TextTablePatterns
logger = logging.getLogger(__name__)
@ -35,9 +36,18 @@ class NeutralizationService:
NamesToParse: List of names to parse and replace (case-insensitive)
"""
self.services = serviceCenter
self.interfaceDbApp = serviceCenter.interfaceDbApp
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
self.NamesToParse = NamesToParse or []
self.textProcessor = TextProcessor(NamesToParse)
@ -47,15 +57,15 @@ class NeutralizationService:
def getConfig(self) -> Optional[DataNeutraliserConfig]:
"""Get the neutralization configuration for the current user's mandate"""
if not self.interfaceDbApp:
if not self.interfaceNeutralizer:
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"""
if not self.interfaceDbApp:
if not self.interfaceNeutralizer:
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
@ -125,44 +135,37 @@ class NeutralizationService:
return result
def resolveText(self, text: str) -> str:
if not self.interfaceDbApp:
if not self.interfaceNeutralizer:
return text
try:
placeholder_pattern = r'\[([a-z]+)\.([a-f0-9-]{36})\]'
matches = re.findall(placeholder_pattern, text)
resolved_text = text
for placeholder_type, uid in matches:
attributes = self.interfaceDbApp.db.getRecordset(
DataNeutralizerAttributes,
recordFilter={
"mandateId": self.interfaceDbApp.mandateId,
"id": uid
}
)
if attributes:
attribute = attributes[0]
placeholder = f"[{placeholder_type}.{uid}]"
resolved_text = resolved_text.replace(placeholder, attribute["originalText"])
return resolved_text
placeholderPattern = r'\[([a-z]+)\.([a-f0-9-]{36})\]'
matches = re.findall(placeholderPattern, text)
resolvedText = text
for placeholderType, uid in matches:
attribute = self.interfaceNeutralizer.getAttributeById(uid)
if attribute:
placeholder = f"[{placeholderType}.{uid}]"
resolvedText = resolvedText.replace(placeholder, attribute["originalText"])
return resolvedText
except Exception:
return text
def getAttributes(self) -> List[DataNeutralizerAttributes]:
"""Get all neutralization attributes for the current user's mandate"""
if not self.interfaceDbApp:
if not self.interfaceNeutralizer:
return []
try:
# Use the interface method which properly converts dicts to objects
return self.interfaceDbApp.getNeutralizationAttributes()
return self.interfaceNeutralizer.getNeutralizationAttributes()
except Exception as e:
logger.error(f"Error getting neutralization attributes: {str(e)}")
return []
def deleteNeutralizationAttributes(self, fileId: str) -> bool:
"""Delete neutralization attributes for a specific file"""
if not self.interfaceDbApp:
if not self.interfaceNeutralizer:
return False
return self.interfaceDbApp.deleteNeutralizationAttributes(fileId)
return self.interfaceNeutralizer.deleteNeutralizationAttributes(fileId)
def _reloadNamesFromConfig(self) -> None:
"""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 uuid
from typing import Dict, List, Tuple, Any
from modules.services.serviceNeutralization.subPatterns import DataPatterns, findPatternsInText
from .subPatterns import DataPatterns, findPatternsInText
class StringParser:
"""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 dataclasses import dataclass
from io import StringIO
from modules.services.serviceNeutralization.subParseString import StringParser
from modules.services.serviceNeutralization.subPatterns import getPatternForHeader, HeaderPatterns
from .subParseString import StringParser
from .subPatterns import getPatternForHeader, HeaderPatterns
@dataclass
class TableData:
@ -158,7 +158,7 @@ class ListProcessor:
processedAttrs[attrName] = self.string_parser.mapping[attrValue]
else:
# 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)
if matches:
patternName = matches[0][0]
@ -193,7 +193,7 @@ class ListProcessor:
# Skip if already a placeholder
if not self.string_parser._isPlaceholder(text):
# Check if text matches any patterns
from modules.services.serviceNeutralization.subPatterns import findPatternsInText, DataPatterns
from .subPatterns import findPatternsInText, DataPatterns
patternMatches = findPatternsInText(text, DataPatterns.patterns)
if patternMatches:

View file

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

View file

@ -123,6 +123,12 @@ class Dokument(BaseModel):
frontend_readonly=True,
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(
description="Document label",
frontend_type="text",
@ -207,6 +213,12 @@ class Land(BaseModel):
frontend_readonly=True,
frontend_required=False,
)
featureInstanceId: str = Field(
description="ID of the feature instance",
frontend_type="text",
frontend_readonly=True,
frontend_required=False,
)
label: str = Field(
description="Country name (e.g. 'Schweiz')",
frontend_type="text",
@ -251,6 +263,12 @@ class Kanton(BaseModel):
frontend_readonly=True,
frontend_required=False,
)
featureInstanceId: str = Field(
description="ID of the feature instance",
frontend_type="text",
frontend_readonly=True,
frontend_required=False,
)
label: str = Field(
description="Canton name (e.g. 'Zürich')",
frontend_type="text",
@ -302,6 +320,12 @@ class Gemeinde(BaseModel):
frontend_readonly=True,
frontend_required=False,
)
featureInstanceId: str = Field(
description="ID of the feature instance",
frontend_type="text",
frontend_readonly=True,
frontend_required=False,
)
label: str = Field(
description="Municipality name (e.g. 'Zürich')",
frontend_type="text",
@ -359,6 +383,12 @@ class Parzelle(BaseModel):
frontend_readonly=True,
frontend_required=False,
)
featureInstanceId: str = Field(
description="ID of the feature instance",
frontend_type="text",
frontend_readonly=True,
frontend_required=False,
)
# Grunddaten
label: str = Field(
@ -579,6 +609,12 @@ class Projekt(BaseModel):
frontend_readonly=True,
frontend_required=False,
)
featureInstanceId: str = Field(
description="ID of the feature instance",
frontend_type="text",
frontend_readonly=True,
frontend_required=False,
)
label: str = Field(
description="Project designation",
frontend_type="text",
@ -643,6 +679,7 @@ registerModelLabels(
"label": {"en": "Label", "fr": "Libellé", "de": "Bezeichnung"},
"statusProzess": {"en": "Process Status", "fr": "Statut du processus", "de": "Prozessstatus"},
"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"},
"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"},
},
)
@ -662,6 +700,8 @@ registerModelLabels(
{
"id": {"en": "ID", "fr": "ID", "de": "ID"},
"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
from typing import Dict, Any, List, Optional, Union
from modules.datamodels.datamodelRealEstate import (
from .datamodelFeatureRealEstate import (
Projekt,
Parzelle,
Dokument,
@ -39,11 +39,23 @@ class RealEstateObjects:
Handles CRUD operations on Real Estate entities.
"""
def __init__(self, currentUser: Optional[User] = None):
"""Initializes the Real Estate Interface."""
# Feature code for RBAC objectKey construction
# 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.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
# Initialize database
@ -51,17 +63,17 @@ class RealEstateObjects:
# Set user context if provided
if currentUser:
self.setUserContext(currentUser)
self.setUserContext(currentUser, mandateId=mandateId, featureInstanceId=featureInstanceId)
def _initializeDatabase(self):
"""Initialize PostgreSQL database connection."""
try:
# Get database configuration from environment
dbHost = APP_CONFIG.get("DB_REALESTATE_HOST", "localhost")
dbDatabase = APP_CONFIG.get("DB_REALESTATE_DATABASE", "poweron_realestate")
dbUser = APP_CONFIG.get("DB_REALESTATE_USER")
dbPassword = APP_CONFIG.get("DB_REALESTATE_PASSWORD_SECRET")
dbPort = int(APP_CONFIG.get("DB_REALESTATE_PORT", 5432))
dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data")
dbDatabase = "poweron_realestate"
dbUser = APP_CONFIG.get("DB_USER")
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
# Initialize database connector
self.db = DatabaseConnector(
@ -101,14 +113,27 @@ class RealEstateObjects:
logger.warning(f"Error ensuring supporting tables exist: {e}")
# Don't raise - tables will be created on-demand anyway
def setUserContext(self, currentUser: User):
"""Sets the user context for the interface."""
def setUserContext(self, currentUser: User, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None):
"""Sets the user context for the interface.
Args:
currentUser: The authenticated user
mandateId: The mandate ID from RequestContext (X-Mandate-Id header)
featureInstanceId: The feature instance ID from RequestContext (X-Feature-Instance-Id header)
"""
self.currentUser = currentUser
self.userId = currentUser.id
self.mandateId = currentUser.mandateId
# Use mandateId from parameter (Request-Context), not from user object
self.mandateId = mandateId
self.featureInstanceId = featureInstanceId
if not self.userId or not self.mandateId:
raise ValueError("Invalid user context: id and mandateId are required")
if not self.userId:
raise ValueError("Invalid user context: id is required")
# 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
if not self.currentUser:
@ -129,9 +154,11 @@ class RealEstateObjects:
if not self.checkRbacPermission(Projekt, "create"):
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:
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
self.db.recordCreate(Projekt, projekt.model_dump(mode='json'))
@ -144,7 +171,8 @@ class RealEstateObjects:
self.db,
Projekt,
self.currentUser,
recordFilter={"id": projektId}
recordFilter={"id": projektId},
featureCode=self.FEATURE_CODE
)
if not records:
@ -158,7 +186,8 @@ class RealEstateObjects:
self.db,
Projekt,
self.currentUser,
recordFilter=recordFilter or {}
recordFilter=recordFilter or {},
featureCode=self.FEATURE_CODE
)
return [Projekt(**r) for r in records]
@ -215,8 +244,11 @@ class RealEstateObjects:
if not self.checkRbacPermission(Parzelle, "create"):
raise PermissionError(f"User {self.userId} cannot create plots")
# Ensure mandateId and featureInstanceId are set for proper data isolation
if not parzelle.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
self.db.recordCreate(Parzelle, parzelle.model_dump(mode='json'))
@ -229,7 +261,8 @@ class RealEstateObjects:
self.db,
Parzelle,
self.currentUser,
recordFilter={"id": parzelleId}
recordFilter={"id": parzelleId},
featureCode=self.FEATURE_CODE
)
if not records:
@ -239,14 +272,8 @@ class RealEstateObjects:
def getParzellen(self, recordFilter: Optional[Dict[str, Any]] = None) -> List[Parzelle]:
"""Get all plots matching the filter."""
original_gemeinde_value = None
# Resolve location names to IDs if needed
if recordFilter:
# Save original value before resolution for fallback search
if "kontextGemeinde" in recordFilter:
original_gemeinde_value = recordFilter["kontextGemeinde"]
recordFilter = self._resolveLocationFilters(recordFilter)
records = getRecordsetWithRBAC(
@ -256,23 +283,6 @@ class RealEstateObjects:
recordFilter=recordFilter or {}
)
# Fallback: If no records found and we resolved a Gemeinde name,
# try searching with the original name for backwards compatibility
# (handles case where data has string names instead of UUIDs)
if not records and original_gemeinde_value and recordFilter and "kontextGemeinde" in recordFilter:
if recordFilter["kontextGemeinde"] != original_gemeinde_value:
logger.info(f"No results with resolved UUID, trying with original name '{original_gemeinde_value}'")
fallback_filter = recordFilter.copy()
fallback_filter["kontextGemeinde"] = original_gemeinde_value
records = getRecordsetWithRBAC(
self.db,
Parzelle,
self.currentUser,
recordFilter=fallback_filter
)
if records:
logger.info(f"Found {len(records)} records using original name (legacy data format)")
return [Parzelle(**r) for r in records]
def _resolveLocationFilters(self, recordFilter: Dict[str, Any]) -> Dict[str, Any]:
@ -445,8 +455,11 @@ class RealEstateObjects:
if not self.checkRbacPermission(Dokument, "create"):
raise PermissionError(f"User {self.userId} cannot create documents")
# Ensure mandateId and featureInstanceId are set for proper data isolation
if not dokument.mandateId:
dokument.mandateId = self.mandateId
if not dokument.featureInstanceId:
dokument.featureInstanceId = self.featureInstanceId
self.db.recordCreate(Dokument, dokument.model_dump())
@ -458,7 +471,8 @@ class RealEstateObjects:
self.db,
Dokument,
self.currentUser,
recordFilter={"id": dokumentId}
recordFilter={"id": dokumentId},
featureCode=self.FEATURE_CODE
)
if not records:
@ -472,7 +486,8 @@ class RealEstateObjects:
self.db,
Dokument,
self.currentUser,
recordFilter=recordFilter or {}
recordFilter=recordFilter or {},
featureCode=self.FEATURE_CODE
)
return [Dokument(**r) for r in records]
@ -511,8 +526,11 @@ class RealEstateObjects:
if not self.checkRbacPermission(Gemeinde, "create"):
raise PermissionError(f"User {self.userId} cannot create municipalities")
# Ensure mandateId and featureInstanceId are set for proper data isolation
if not gemeinde.mandateId:
gemeinde.mandateId = self.mandateId
if not gemeinde.featureInstanceId:
gemeinde.featureInstanceId = self.featureInstanceId
self.db.recordCreate(Gemeinde, gemeinde.model_dump())
@ -524,7 +542,8 @@ class RealEstateObjects:
self.db,
Gemeinde,
self.currentUser,
recordFilter={"id": gemeindeId}
recordFilter={"id": gemeindeId},
featureCode=self.FEATURE_CODE
)
if not records:
@ -538,7 +557,8 @@ class RealEstateObjects:
self.db,
Gemeinde,
self.currentUser,
recordFilter=recordFilter or {}
recordFilter=recordFilter or {},
featureCode=self.FEATURE_CODE
)
return [Gemeinde(**r) for r in records]
@ -577,8 +597,11 @@ class RealEstateObjects:
if not self.checkRbacPermission(Kanton, "create"):
raise PermissionError(f"User {self.userId} cannot create cantons")
# Ensure mandateId and featureInstanceId are set for proper data isolation
if not kanton.mandateId:
kanton.mandateId = self.mandateId
if not kanton.featureInstanceId:
kanton.featureInstanceId = self.featureInstanceId
self.db.recordCreate(Kanton, kanton.model_dump())
@ -590,7 +613,8 @@ class RealEstateObjects:
self.db,
Kanton,
self.currentUser,
recordFilter={"id": kantonId}
recordFilter={"id": kantonId},
featureCode=self.FEATURE_CODE
)
if not records:
@ -604,7 +628,8 @@ class RealEstateObjects:
self.db,
Kanton,
self.currentUser,
recordFilter=recordFilter or {}
recordFilter=recordFilter or {},
featureCode=self.FEATURE_CODE
)
return [Kanton(**r) for r in records]
@ -643,8 +668,11 @@ class RealEstateObjects:
if not self.checkRbacPermission(Land, "create"):
raise PermissionError(f"User {self.userId} cannot create countries")
# Ensure mandateId and featureInstanceId are set for proper data isolation
if not land.mandateId:
land.mandateId = self.mandateId
if not land.featureInstanceId:
land.featureInstanceId = self.featureInstanceId
self.db.recordCreate(Land, land.model_dump())
@ -656,7 +684,8 @@ class RealEstateObjects:
self.db,
Land,
self.currentUser,
recordFilter={"id": landId}
recordFilter={"id": landId},
featureCode=self.FEATURE_CODE
)
if not records:
@ -670,7 +699,8 @@ class RealEstateObjects:
self.db,
Land,
self.currentUser,
recordFilter=recordFilter or {}
recordFilter=recordFilter or {},
featureCode=self.FEATURE_CODE
)
return [Land(**r) for r in records]
@ -727,7 +757,9 @@ class RealEstateObjects:
permissions = self.rbac.getUserPermissions(
self.currentUser,
AccessRuleContext.DATA,
tableName
tableName,
mandateId=self.mandateId,
featureInstanceId=self.featureInstanceId
)
if operation == "create":
@ -799,15 +831,27 @@ class RealEstateObjects:
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.
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:
_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]

View file

@ -2,16 +2,154 @@
Real Estate feature main logic.
Handles database operations with AI-powered natural language processing.
Stateless implementation without session management.
This module also handles feature initialization and RBAC catalog registration.
"""
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
from typing import Optional, Dict, Any, List
from fastapi import HTTPException, status
from shapely.geometry import Polygon
from shapely.ops import unary_union
from modules.datamodels.datamodelUam import User
from modules.datamodels.datamodelRealEstate import (
from .datamodelFeatureRealEstate import (
Projekt,
Parzelle,
StatusProzess,
@ -23,7 +161,7 @@ from modules.datamodels.datamodelRealEstate import (
Land,
)
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
logger = logging.getLogger(__name__)
@ -346,6 +484,7 @@ async def fetch_parcel_polygon_from_swisstopo(
async def executeDirectQuery(
currentUser: User,
mandateId: str,
queryText: str,
parameters: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
@ -354,6 +493,7 @@ async def executeDirectQuery(
Args:
currentUser: Current authenticated user
mandateId: Mandate context (from RequestContext / X-Mandate-Id header)
queryText: SQL query text
parameters: Optional parameters for parameterized queries
@ -364,16 +504,15 @@ async def executeDirectQuery(
- No session or query history is saved
- Query is executed directly and result is returned
- For production, validate and sanitize queries before execution
- TODO: Implement actual database query execution via interface
"""
try:
logger.info(f"Executing direct query for user {currentUser.id} (mandate: {currentUser.mandateId})")
logger.info(f"Executing direct query for user {currentUser.id} (mandate: {mandateId})")
logger.debug(f"Query text: {queryText}")
if parameters:
logger.debug(f"Query parameters: {parameters}")
# Execute query via Real Estate interface (stateless)
realEstateInterface = getRealEstateInterface(currentUser)
realEstateInterface = getRealEstateInterface(currentUser, mandateId=mandateId)
result = realEstateInterface.executeQuery(queryText, parameters)
logger.info(
@ -529,6 +668,7 @@ def _formatEntitySummary(entity_type: str, items: List[Dict[str, Any]], filters:
async def processNaturalLanguageCommand(
currentUser: User,
mandateId: str,
userInput: str,
) -> Dict[str, Any]:
"""
@ -539,6 +679,7 @@ async def processNaturalLanguageCommand(
Args:
currentUser: Current authenticated user
mandateId: Mandate context (from RequestContext / X-Mandate-Id header)
userInput: Natural language command from user
Returns:
@ -552,11 +693,11 @@ async def processNaturalLanguageCommand(
- "SELECT * FROM Projekt WHERE plz = '8000'"
"""
try:
logger.info(f"Processing natural language command for user {currentUser.id} (mandate: {currentUser.mandateId})")
logger.info(f"Processing natural language command for user {currentUser.id} (mandate: {mandateId})")
logger.debug(f"User input: {userInput}")
# Initialize services for AI access
services = getServices(currentUser, workflow=None)
services = getServices(currentUser, workflow=None, mandateId=mandateId)
aiService = services.ai
# Step 1: Analyze user intent with AI
@ -567,6 +708,7 @@ async def processNaturalLanguageCommand(
# Step 2: Execute CRUD operation based on intent
result = await executeIntentBasedOperation(
currentUser=currentUser,
mandateId=mandateId,
intent=intentAnalysis["intent"],
entity=intentAnalysis.get("entity"),
parameters=intentAnalysis.get("parameters", {}),
@ -839,6 +981,7 @@ IMPORTANT EXTRACTION RULES:
async def executeIntentBasedOperation(
currentUser: User,
mandateId: str,
intent: str,
entity: Optional[str],
parameters: Dict[str, Any],
@ -848,6 +991,7 @@ async def executeIntentBasedOperation(
Args:
currentUser: Current authenticated user
mandateId: Mandate context (from RequestContext / X-Mandate-Id header)
intent: Intent from AI analysis (CREATE, READ, UPDATE, DELETE, QUERY)
entity: Entity type from AI analysis
parameters: Extracted parameters from AI analysis
@ -856,8 +1000,8 @@ async def executeIntentBasedOperation(
Operation result
Note:
- TODO: Implement actual interface calls once datamodels are ready
- Currently returns test responses showing what would be executed
- Supports CREATE, READ, UPDATE, DELETE, QUERY intents
- Entity types: Projekt, Parzelle, Gemeinde, Kanton, Land, Dokument
"""
try:
logger.info(f"Executing intent-based operation: intent={intent}, entity={entity}")
@ -872,6 +1016,7 @@ async def executeIntentBasedOperation(
result = await executeDirectQuery(
currentUser=currentUser,
mandateId=mandateId,
queryText=queryText,
parameters=parameters.get("queryParameters"),
)
@ -879,12 +1024,12 @@ async def executeIntentBasedOperation(
elif intent == "CREATE":
# Create new entity
realEstateInterface = getRealEstateInterface(currentUser)
realEstateInterface = getRealEstateInterface(currentUser, mandateId=mandateId)
if entity == "Projekt":
# Create Projekt from parameters
projekt = Projekt(
mandateId=currentUser.mandateId,
mandateId=mandateId,
label=parameters.get("label", ""),
statusProzess=StatusProzess(parameters.get("statusProzess", "EINGANG")) if parameters.get("statusProzess") else None,
)
@ -898,11 +1043,11 @@ async def executeIntentBasedOperation(
elif entity == "Parzelle":
# Create Parzelle from parameters
# 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
parzelle_data = {
"mandateId": currentUser.mandateId,
"mandateId": mandateId,
"label": parameters.get("label", ""),
}
@ -983,9 +1128,9 @@ async def executeIntentBasedOperation(
}
elif entity == "Gemeinde":
# Create Gemeinde from parameters
from modules.datamodels.datamodelRealEstate import Gemeinde
from modules.features.realestate.datamodelFeatureRealEstate import Gemeinde
gemeinde = Gemeinde(
mandateId=currentUser.mandateId,
mandateId=mandateId,
label=parameters.get("label", ""),
id_kanton=parameters.get("id_kanton"),
plz=parameters.get("plz"),
@ -998,9 +1143,9 @@ async def executeIntentBasedOperation(
}
elif entity == "Kanton":
# Create Kanton from parameters
from modules.datamodels.datamodelRealEstate import Kanton
from modules.features.realestate.datamodelFeatureRealEstate import Kanton
kanton = Kanton(
mandateId=currentUser.mandateId,
mandateId=mandateId,
label=parameters.get("label", ""),
id_land=parameters.get("id_land"),
abk=parameters.get("abk"),
@ -1013,9 +1158,9 @@ async def executeIntentBasedOperation(
}
elif entity == "Land":
# Create Land from parameters
from modules.datamodels.datamodelRealEstate import Land
from modules.features.realestate.datamodelFeatureRealEstate import Land
land = Land(
mandateId=currentUser.mandateId,
mandateId=mandateId,
label=parameters.get("label", ""),
abk=parameters.get("abk"),
)
@ -1027,9 +1172,9 @@ async def executeIntentBasedOperation(
}
elif entity == "Dokument":
# Create Dokument from parameters
from modules.datamodels.datamodelRealEstate import Dokument
from modules.features.realestate.datamodelFeatureRealEstate import Dokument
dokument = Dokument(
mandateId=currentUser.mandateId,
mandateId=mandateId,
label=parameters.get("label", ""),
dokumentReferenz=parameters.get("dokumentReferenz", ""),
versionsbezeichnung=parameters.get("versionsbezeichnung"),
@ -1188,7 +1333,7 @@ async def executeIntentBasedOperation(
"count": len(parzellen)
}
elif entity == "Gemeinde":
from modules.datamodels.datamodelRealEstate import Gemeinde
from modules.features.realestate.datamodelFeatureRealEstate import Gemeinde
gemeindeId = parameters.get("id")
if gemeindeId:
gemeinde = realEstateInterface.getGemeinde(gemeindeId)
@ -1209,7 +1354,7 @@ async def executeIntentBasedOperation(
"count": len(gemeinden)
}
elif entity == "Kanton":
from modules.datamodels.datamodelRealEstate import Kanton
from modules.features.realestate.datamodelFeatureRealEstate import Kanton
kantonId = parameters.get("id")
if kantonId:
kanton = realEstateInterface.getKanton(kantonId)
@ -1230,7 +1375,7 @@ async def executeIntentBasedOperation(
"count": len(kantone)
}
elif entity == "Land":
from modules.datamodels.datamodelRealEstate import Land
from modules.features.realestate.datamodelFeatureRealEstate import Land
landId = parameters.get("id")
if landId:
land = realEstateInterface.getLand(landId)
@ -1251,7 +1396,7 @@ async def executeIntentBasedOperation(
"count": len(laender)
}
elif entity == "Dokument":
from modules.datamodels.datamodelRealEstate import Dokument
from modules.features.realestate.datamodelFeatureRealEstate import Dokument
dokumentId = parameters.get("id")
if dokumentId:
dokument = realEstateInterface.getDokument(dokumentId)
@ -1315,7 +1460,7 @@ async def executeIntentBasedOperation(
"result": updated.model_dump()
}
elif entity == "Gemeinde":
from modules.datamodels.datamodelRealEstate import Gemeinde
from modules.features.realestate.datamodelFeatureRealEstate import Gemeinde
gemeindeId = parameters.get("id")
if not gemeindeId:
raise ValueError("UPDATE operation requires entity ID")
@ -1332,7 +1477,7 @@ async def executeIntentBasedOperation(
"result": updated.model_dump()
}
elif entity == "Kanton":
from modules.datamodels.datamodelRealEstate import Kanton
from modules.features.realestate.datamodelFeatureRealEstate import Kanton
kantonId = parameters.get("id")
if not kantonId:
raise ValueError("UPDATE operation requires entity ID")
@ -1349,7 +1494,7 @@ async def executeIntentBasedOperation(
"result": updated.model_dump()
}
elif entity == "Land":
from modules.datamodels.datamodelRealEstate import Land
from modules.features.realestate.datamodelFeatureRealEstate import Land
landId = parameters.get("id")
if not landId:
raise ValueError("UPDATE operation requires entity ID")
@ -1366,7 +1511,7 @@ async def executeIntentBasedOperation(
"result": updated.model_dump()
}
elif entity == "Dokument":
from modules.datamodels.datamodelRealEstate import Dokument
from modules.features.realestate.datamodelFeatureRealEstate import Dokument
dokumentId = parameters.get("id")
if not dokumentId:
raise ValueError("UPDATE operation requires entity ID")
@ -1412,7 +1557,7 @@ async def executeIntentBasedOperation(
"success": success
}
elif entity == "Gemeinde":
from modules.datamodels.datamodelRealEstate import Gemeinde
from modules.features.realestate.datamodelFeatureRealEstate import Gemeinde
gemeindeId = parameters.get("id")
if not gemeindeId:
raise ValueError("DELETE operation requires entity ID")
@ -1424,7 +1569,7 @@ async def executeIntentBasedOperation(
"success": success
}
elif entity == "Kanton":
from modules.datamodels.datamodelRealEstate import Kanton
from modules.features.realestate.datamodelFeatureRealEstate import Kanton
kantonId = parameters.get("id")
if not kantonId:
raise ValueError("DELETE operation requires entity ID")
@ -1436,7 +1581,7 @@ async def executeIntentBasedOperation(
"success": success
}
elif entity == "Land":
from modules.datamodels.datamodelRealEstate import Land
from modules.features.realestate.datamodelFeatureRealEstate import Land
landId = parameters.get("id")
if not landId:
raise ValueError("DELETE operation requires entity ID")
@ -1448,7 +1593,7 @@ async def executeIntentBasedOperation(
"success": success
}
elif entity == "Dokument":
from modules.datamodels.datamodelRealEstate import Dokument
from modules.features.realestate.datamodelFeatureRealEstate import Dokument
dokumentId = parameters.get("id")
if not dokumentId:
raise ValueError("DELETE operation requires entity ID")
@ -1474,6 +1619,7 @@ async def executeIntentBasedOperation(
async def create_project_with_parcel_data(
currentUser: User,
mandateId: str,
projekt_label: str,
parzellen_data: List[Dict[str, Any]],
status_prozess: Optional[str] = None,
@ -1483,6 +1629,7 @@ async def create_project_with_parcel_data(
Args:
currentUser: Current authenticated user
mandateId: Mandate context (from RequestContext / X-Mandate-Id header)
projekt_label: Label for the Projekt
parzellen_data: List of dictionaries containing parcel information from request
status_prozess: Optional project status (defaults to "Eingang")
@ -1496,8 +1643,8 @@ async def create_project_with_parcel_data(
try:
logger.info(f"Creating project '{projekt_label}' with {len(parzellen_data)} parcel(s) for user {currentUser.id}")
# Get interface
realEstateInterface = getRealEstateInterface(currentUser)
# Get interface with mandate context
realEstateInterface = getRealEstateInterface(currentUser, mandateId=mandateId)
# Validate required fields
if not projekt_label:
@ -1587,7 +1734,7 @@ async def create_project_with_parcel_data(
# Check if Parzelle with this label already exists
existing_parzellen = realEstateInterface.getParzellen(
recordFilter={"label": parcel_label, "mandateId": currentUser.mandateId}
recordFilter={"label": parcel_label, "mandateId": mandateId}
)
if existing_parzellen and len(existing_parzellen) > 0:
@ -1630,7 +1777,7 @@ async def create_project_with_parcel_data(
if not laender:
logger.info("Creating Land 'Schweiz'")
land = Land(
mandateId=currentUser.mandateId,
mandateId=mandateId,
label="Schweiz",
abk="CH"
)
@ -1648,7 +1795,7 @@ async def create_project_with_parcel_data(
logger.info(f"Kanton '{canton_abk}' not found, creating it")
kanton_label = canton_names.get(canton_abk, canton_abk) # Use mapping or fallback to abk
kanton = Kanton(
mandateId=currentUser.mandateId,
mandateId=mandateId,
label=kanton_label,
abk=canton_abk,
id_land=land.id
@ -1668,7 +1815,7 @@ async def create_project_with_parcel_data(
if not gemeinden:
logger.info(f"Gemeinde '{municipality_name}' in Kanton '{canton_abk}' not found, creating it")
gemeinde = Gemeinde(
mandateId=currentUser.mandateId,
mandateId=mandateId,
label=municipality_name,
id_kanton=kanton.id,
plz=parzelle_data.get("plz") # Use PLZ directly from Swiss Topo API
@ -1837,7 +1984,7 @@ async def create_project_with_parcel_data(
# Build Parzelle data
parzelle_create_data = {
"mandateId": currentUser.mandateId,
"mandateId": mandateId,
"label": parcel_label, # Use the label we determined earlier for uniqueness check
"parzellenAliasTags": alias_tags,
"eigentuemerschaft": None,
@ -1979,7 +2126,7 @@ async def create_project_with_parcel_data(
project_perimeter = created_parzellen[0].perimeter if created_parzellen else None
projekt_create_data = {
"mandateId": currentUser.mandateId,
"mandateId": mandateId,
"label": projekt_label,
"statusProzess": status_prozess_enum,
"perimeter": project_perimeter, # Use first parcel perimeter as project perimeter

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

View file

@ -44,6 +44,15 @@ class TrusteeOrganisation(BaseModel):
"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:
# _createdAt, _modifiedAt, _createdBy, _modifiedBy
@ -56,6 +65,7 @@ registerModelLabels(
"label": {"en": "Label", "fr": "Libellé", "de": "Bezeichnung"},
"enabled": {"en": "Enabled", "fr": "Activé", "de": "Aktiviert"},
"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
}
)
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
@ -97,6 +116,7 @@ registerModelLabels(
"id": {"en": "ID", "fr": "ID", "de": "ID"},
"desc": {"en": "Description", "fr": "Description", "de": "Beschreibung"},
"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_readonly": False,
"frontend_required": True,
"frontend_options": "TrusteeOrganisation"
"frontend_options": "/api/trustee/{instanceId}/organisations/options"
}
)
roleId: str = Field(
@ -127,7 +147,7 @@ class TrusteeAccess(BaseModel):
"frontend_type": "select",
"frontend_readonly": False,
"frontend_required": True,
"frontend_options": "TrusteeRole"
"frontend_options": "/api/trustee/{instanceId}/roles/options"
}
)
userId: str = Field(
@ -136,7 +156,7 @@ class TrusteeAccess(BaseModel):
"frontend_type": "select",
"frontend_readonly": False,
"frontend_required": True,
"frontend_options": "User"
"frontend_options": "/api/users/options"
}
)
contractId: Optional[str] = Field(
@ -146,7 +166,7 @@ class TrusteeAccess(BaseModel):
"frontend_type": "select",
"frontend_readonly": False,
"frontend_required": False,
"frontend_options": "TrusteeContract",
"frontend_options": "/api/trustee/{instanceId}/contracts/options",
"frontend_depends_on": "organisationId"
}
)
@ -159,6 +179,15 @@ class TrusteeAccess(BaseModel):
"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
@ -172,6 +201,7 @@ registerModelLabels(
"userId": {"en": "User", "fr": "Utilisateur", "de": "Benutzer"},
"contractId": {"en": "Contract (optional)", "fr": "Contrat (optionnel)", "de": "Vertrag (optional)"},
"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_readonly": False, # Editable at creation, then readonly
"frontend_required": True,
"frontend_options": "TrusteeOrganisation"
"frontend_options": "/api/trustee/{instanceId}/organisations/options"
}
)
label: str = Field(
@ -222,6 +252,15 @@ class TrusteeContract(BaseModel):
"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
@ -234,12 +273,21 @@ registerModelLabels(
"label": {"en": "Label", "fr": "Libellé", "de": "Bezeichnung"},
"enabled": {"en": "Enabled", "fr": "Activé", "de": "Aktiviert"},
"mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"},
"featureInstanceId": {"en": "Feature Instance", "fr": "Instance de fonctionnalité", "de": "Feature-Instanz"},
},
)
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(
default_factory=lambda: str(uuid.uuid4()),
description="Unique document ID",
@ -249,30 +297,11 @@ class TrusteeDocument(BaseModel):
"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"
}
)
documentData: Optional[bytes] = Field(
fileId: Optional[str] = Field(
default=None,
description="The file content (binary)",
description="Reference to central Files table (Files.id)",
json_schema_extra={
"frontend_type": "file",
"frontend_type": "file_reference",
"frontend_readonly": False,
"frontend_required": False
}
@ -292,23 +321,47 @@ class TrusteeDocument(BaseModel):
"frontend_type": "select",
"frontend_readonly": False,
"frontend_required": True,
"frontend_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"}},
]
"frontend_options": "/api/trustee/mime-types/options"
}
)
mandateId: Optional[str] = Field(
sourceType: Optional[str] = Field(
default=None,
description="Mandate ID",
description="Source type (e.g., 'sharepoint', 'upload', 'email')",
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"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
@ -317,18 +370,24 @@ registerModelLabels(
{"en": "Document", "fr": "Document", "de": "Dokument"},
{
"id": {"en": "ID", "fr": "ID", "de": "ID"},
"organisationId": {"en": "Organisation", "fr": "Organisation", "de": "Organisation"},
"contractId": {"en": "Contract", "fr": "Contrat", "de": "Vertrag"},
"documentData": {"en": "Document Data", "fr": "Données du document", "de": "Dokumentdaten"},
"fileId": {"en": "File Reference", "fr": "Référence du fichier", "de": "Datei-Referenz"},
"documentName": {"en": "Document Name", "fr": "Nom du document", "de": "Dokumentname"},
"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"},
"featureInstanceId": {"en": "Feature Instance", "fr": "Instance de fonctionnalité", "de": "Feature-Instanz"},
},
)
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(
default_factory=lambda: str(uuid.uuid4()),
description="Unique position ID",
@ -338,25 +397,6 @@ class TrusteePosition(BaseModel):
"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(
default=None,
description="Value date (ISO format: YYYY-MM-DD)",
@ -470,11 +510,22 @@ class TrusteePosition(BaseModel):
)
mandateId: Optional[str] = Field(
default=None,
description="Mandate ID",
description="Mandate ID (auto-set from context)",
json_schema_extra={
"frontend_type": "text",
"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
@ -485,8 +536,6 @@ registerModelLabels(
{"en": "Position", "fr": "Position", "de": "Position"},
{
"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"},
"transactionDateTime": {"en": "Transaction Date/Time", "fr": "Date/Heure de transaction", "de": "Transaktionszeitpunkt"},
"company": {"en": "Company", "fr": "Entreprise", "de": "Firma"},
@ -499,12 +548,18 @@ registerModelLabels(
"vatPercentage": {"en": "VAT Percentage", "fr": "Pourcentage TVA", "de": "MwSt-Prozentsatz"},
"vatAmount": {"en": "VAT Amount", "fr": "Montant TVA", "de": "MwSt-Betrag"},
"mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"},
"featureInstanceId": {"en": "Feature Instance", "fr": "Instance de fonctionnalité", "de": "Feature-Instanz"},
},
)
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(
default_factory=lambda: str(uuid.uuid4()),
description="Unique link ID",
@ -514,33 +569,13 @@ class TrusteePositionDocument(BaseModel):
"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(
description="Reference to TrusteeDocument.id",
json_schema_extra={
"frontend_type": "select",
"frontend_readonly": False,
"frontend_required": True,
"frontend_options": "TrusteeDocument",
"frontend_depends_on": "contractId"
"frontend_options": "/api/trustee/{instanceId}/documents/options"
}
)
positionId: str = Field(
@ -549,17 +584,27 @@ class TrusteePositionDocument(BaseModel):
"frontend_type": "select",
"frontend_readonly": False,
"frontend_required": True,
"frontend_options": "TrusteePosition",
"frontend_depends_on": "contractId"
"frontend_options": "/api/trustee/{instanceId}/positions/options"
}
)
mandateId: Optional[str] = Field(
default=None,
description="Mandate ID",
description="Mandate ID (auto-set from context)",
json_schema_extra={
"frontend_type": "text",
"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
@ -570,10 +615,9 @@ registerModelLabels(
{"en": "Position-Document Link", "fr": "Lien Position-Document", "de": "Position-Dokument-Verknüpfung"},
{
"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"},
"positionId": {"en": "Position", "fr": "Position", "de": "Position"},
"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.
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
@ -32,11 +36,15 @@ from modules.datamodels.datamodelRbac import (
)
from modules.datamodels.datamodelUam import AccessLevel
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.datamodelMembership import (
UserMandate,
UserMandateRole,
FeatureAccess,
FeatureAccessRole,
)
from modules.datamodels.datamodelFeatures import Feature, FeatureInstance
from modules.datamodels.datamodelInvitation import Invitation
logger = logging.getLogger(__name__)
@ -61,7 +69,7 @@ class AppObjects:
# Initialize variables
self.currentUser = currentUser # Store User object directly
self.userId = currentUser.id if currentUser else None
self.mandateId = currentUser.mandateId if currentUser else None
self.mandateId = None # mandateId comes from setUserContext, not from User
# Initialize database
self._initializeDatabase()
@ -73,25 +81,40 @@ class AppObjects:
if currentUser:
self.setUserContext(currentUser)
def setUserContext(self, currentUser: User):
"""Sets the user context for the interface."""
def setUserContext(self, currentUser: User, mandateId: Optional[str] = None):
"""
Sets the user context for the interface.
Multi-Tenant Design:
- mandateId wird explizit übergeben (aus Request-Context / X-Mandate-Id Header)
- isSysAdmin User brauchen kein mandateId für System-Operationen
Args:
currentUser: User object
mandateId: Explicit mandate context (from request header). Required for non-sysadmin.
"""
if not currentUser:
logger.info("Initializing interface without user context")
return
self.currentUser = currentUser # Store User object directly
self.userId = currentUser.id
self.mandateId = currentUser.mandateId
# mandateId comes from parameter only
self.mandateId = mandateId
if not self.userId or not self.mandateId:
raise ValueError("Invalid user context: id and mandateId are required")
# Validate: userId is always required
if not self.userId:
raise ValueError("Invalid user context: id is required")
# 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
self.userLanguage = currentUser.language # Default user language
# Initialize RBAC interface
if not currentUser:
raise ValueError("User context is required for RBAC")
# Pass self.db as dbApp since this interface uses DbApp database
self.rbac = RbacClass(self.db, dbApp=self.db)
@ -110,11 +133,11 @@ class AppObjects:
"""Initializes the database connection directly."""
try:
# Get configuration values with defaults
dbHost = APP_CONFIG.get("DB_APP_HOST", "_no_config_default_data")
dbDatabase = APP_CONFIG.get("DB_APP_DATABASE", "app")
dbUser = APP_CONFIG.get("DB_APP_USER")
dbPassword = APP_CONFIG.get("DB_APP_PASSWORD_SECRET")
dbPort = int(APP_CONFIG.get("DB_APP_PORT", 5432))
dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data")
dbDatabase = "poweron_app"
dbUser = APP_CONFIG.get("DB_USER")
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
# Create database connector directly
self.db = DatabaseConnector(
@ -204,7 +227,8 @@ class AppObjects:
permissions = self.rbac.getUserPermissions(
self.currentUser,
AccessRuleContext.DATA,
tableName
tableName,
mandateId=self.mandateId
)
if operation == "create":
@ -574,26 +598,51 @@ class AppObjects:
logger.error(f"Error getting user by ID: {str(e)}")
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]:
"""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.
SECURITY NOTE: Uses _getUserForAuthentication() which bypasses RBAC.
This is intentional because users are mandate-independent.
"""
# Get full user record directly (bypasses RBAC - see _getUserForAuthentication docstring)
userRecord = self._getUserForAuthentication(username)
# Get user by username
user = self.getUserByUsername(username)
if not user:
if not userRecord:
raise ValueError("User not found")
# Check if the user is enabled
if not user.enabled:
if not userRecord.get("enabled", True):
raise ValueError("User is disabled")
# 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")
# 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)
if userRecord.get("resetToken"):
@ -605,7 +654,12 @@ class AppObjects:
if not self._verifyPassword(password, userRecord["hashedPassword"]):
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(
self,
@ -615,13 +669,17 @@ class AppObjects:
fullName: str = None,
language: str = "en",
enabled: bool = True,
roleLabels: List[str] = None,
authenticationAuthority: AuthAuthority = AuthAuthority.LOCAL,
externalId: str = None,
externalUsername: str = None,
externalEmail: str = None,
isSysAdmin: bool = False,
) -> User:
"""Create a new user with optional external connection"""
"""
Create a new user.
Note: Role assignment is done via createUserMandate(), not via User fields.
"""
try:
# Ensure username is a string
username = str(username).strip()
@ -638,28 +696,17 @@ class AppObjects:
if not password.strip():
raise ValueError("Password cannot be empty")
# Ensure mandateId is set - use self.mandateId or default mandate
mandateId = self.mandateId
if not mandateId:
mandateId = self._getDefaultMandateId()
logger.warning(f"Using default mandate ID {mandateId} for new user {username}")
# Default roleLabels to ["user"] if not provided
if roleLabels is None or not roleLabels:
roleLabels = ["user"]
# Create user data using UserInDB model
# Note: mandateId and roleLabels are REMOVED - use UserMandate + UserMandateRole
userData = UserInDB(
username=username,
email=email,
fullName=fullName,
language=language,
mandateId=mandateId,
enabled=enabled,
roleLabels=roleLabels,
isSysAdmin=isSysAdmin,
authenticationAuthority=authenticationAuthority,
hashedPassword=self._getPasswordHash(password) if password else None,
connections=[],
)
# Create user record
@ -695,8 +742,16 @@ class AppObjects:
logger.error(f"Unexpected error creating user: {str(e)}")
raise ValueError(f"Failed to create user: {str(e)}")
def updateUser(self, userId: str, updateData: Union[Dict[str, Any], User]) -> User:
"""Update a user's information"""
def updateUser(self, userId: str, updateData: Union[Dict[str, Any], User], allowSysAdminChange: bool = False) -> User:
"""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:
# Get user
user = self.getUser(userId)
@ -711,26 +766,20 @@ class AppObjects:
# Remove id field from updateDict if present - we'll use userId from parameter
updateDict.pop("id", None)
# Ensure mandateId is set - if missing or None, use default mandate
if "mandateId" not in updateDict or not updateDict.get("mandateId"):
if not user.mandateId:
# User has no mandateId, set to default
defaultMandateId = self._getDefaultMandateId()
updateDict["mandateId"] = defaultMandateId
logger.warning(f"Setting default mandate ID {defaultMandateId} for user {userId}")
else:
# Keep existing mandateId if update doesn't provide one
updateDict["mandateId"] = user.mandateId
# SECURITY: Protect sensitive fields from being overwritten by profile updates.
# These fields should only be changed explicitly by admins, not through
# profile forms where they might be sent as default values (e.g., isSysAdmin=False).
protectedFields = ["isSysAdmin"]
if not allowSysAdminChange:
for field in protectedFields:
updateDict.pop(field, None)
# Update user data using model
updatedData = user.model_dump()
updatedData.update(updateDict)
# Ensure ID matches userId parameter
updatedData["id"] = userId
# Ensure mandateId is set in final data
if not updatedData.get("mandateId"):
updatedData["mandateId"] = self._getDefaultMandateId()
updatedUser = User(**updatedData)
# Update user record
@ -1184,10 +1233,10 @@ class AppObjects:
The created UserConnection object
"""
try:
# Get the user
user = self.getUser(userId)
if not user:
raise ValueError(f"User not found: {userId}")
# Note: User verification is skipped here because:
# 1. The caller (route) already has an authenticated currentUser
# 2. Users should always be able to create connections for themselves
# 3. getUser() uses RBAC filtering which may fail for users without UserInDB view permissions
# Create new connection with all required fields
connection = UserConnection(
@ -1305,13 +1354,13 @@ class AppObjects:
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."""
if not self.checkRbacPermission(Mandate, "create"):
raise PermissionError("No permission to create mandates")
# Create mandate data using model
mandateData = Mandate(name=name, language=language)
mandateData = Mandate(name=name, description=description, enabled=enabled)
# Create mandate record
createdRecord = self.db.recordCreate(Mandate, mandateData)
@ -1382,6 +1431,325 @@ class AppObjects:
logger.error(f"Error deleting mandate: {str(e)}")
raise ValueError(f"Failed to delete mandate: {str(e)}")
# ============================================
# User-Mandate Membership Methods (Multi-Tenant)
# ============================================
def getUserMandate(self, userId: str, mandateId: str) -> Optional[UserMandate]:
"""
Get UserMandate record for a user in a specific mandate.
Args:
userId: User ID
mandateId: Mandate ID
Returns:
UserMandate object or None
"""
try:
records = self.db.getRecordset(
UserMandate,
recordFilter={"userId": userId, "mandateId": mandateId}
)
if not records:
return None
cleanedRecord = {k: v for k, v in records[0].items() if not k.startswith("_")}
return UserMandate(**cleanedRecord)
except Exception as e:
logger.error(f"Error getting UserMandate: {e}")
return None
def getUserMandates(self, userId: str) -> List[UserMandate]:
"""
Get all mandates a user is member of.
Args:
userId: User ID
Returns:
List of UserMandate objects
"""
try:
records = self.db.getRecordset(
UserMandate,
recordFilter={"userId": userId, "enabled": True}
)
result = []
for record in records:
cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_")}
result.append(UserMandate(**cleanedRecord))
return result
except Exception as e:
logger.error(f"Error getting UserMandates: {e}")
return []
def createUserMandate(self, userId: str, mandateId: str, roleIds: List[str] = None) -> UserMandate:
"""
Create a UserMandate record (add user to mandate).
Args:
userId: User ID
mandateId: Mandate ID
roleIds: Optional list of role IDs to assign
Returns:
Created UserMandate object
"""
try:
# Check if already exists
existing = self.getUserMandate(userId, mandateId)
if existing:
raise ValueError(f"User {userId} is already member of mandate {mandateId}")
# Create UserMandate
userMandate = UserMandate(
userId=userId,
mandateId=mandateId,
enabled=True
)
createdRecord = self.db.recordCreate(UserMandate, userMandate.model_dump())
# Assign roles via junction table
if roleIds and createdRecord:
userMandateId = createdRecord.get("id")
for roleId in roleIds:
userMandateRole = UserMandateRole(
userMandateId=userMandateId,
roleId=roleId
)
self.db.recordCreate(UserMandateRole, userMandateRole.model_dump())
cleanedRecord = {k: v for k, v in createdRecord.items() if not k.startswith("_")}
return UserMandate(**cleanedRecord)
except Exception as e:
logger.error(f"Error creating UserMandate: {e}")
raise ValueError(f"Failed to create UserMandate: {e}")
def deleteUserMandate(self, userId: str, mandateId: str) -> bool:
"""
Delete a UserMandate record (remove user from mandate).
CASCADE will delete UserMandateRole entries.
Args:
userId: User ID
mandateId: Mandate ID
Returns:
True if deleted, False if not found
"""
try:
existing = self.getUserMandate(userId, mandateId)
if not existing:
return False
return self.db.recordDelete(UserMandate, existing.id)
except Exception as e:
logger.error(f"Error deleting UserMandate: {e}")
raise ValueError(f"Failed to delete UserMandate: {e}")
def getRoleIdsForUserMandate(self, userMandateId: str) -> List[str]:
"""
Get all role IDs assigned to a UserMandate.
Args:
userMandateId: UserMandate ID
Returns:
List of role IDs
"""
try:
records = self.db.getRecordset(
UserMandateRole,
recordFilter={"userMandateId": userMandateId}
)
return [r.get("roleId") for r in records if r.get("roleId")]
except Exception as e:
logger.error(f"Error getting role IDs for UserMandate: {e}")
return []
def addRoleToUserMandate(self, userMandateId: str, roleId: str) -> UserMandateRole:
"""
Add a role to a UserMandate.
Args:
userMandateId: UserMandate ID
roleId: Role ID to add
Returns:
Created UserMandateRole object
"""
try:
# Check if already exists
existing = self.db.getRecordset(
UserMandateRole,
recordFilter={"userMandateId": userMandateId, "roleId": roleId}
)
if existing:
cleanedRecord = {k: v for k, v in existing[0].items() if not k.startswith("_")}
return UserMandateRole(**cleanedRecord)
userMandateRole = UserMandateRole(
userMandateId=userMandateId,
roleId=roleId
)
createdRecord = self.db.recordCreate(UserMandateRole, userMandateRole.model_dump())
cleanedRecord = {k: v for k, v in createdRecord.items() if not k.startswith("_")}
return UserMandateRole(**cleanedRecord)
except Exception as e:
logger.error(f"Error adding role to UserMandate: {e}")
raise ValueError(f"Failed to add role: {e}")
def removeRoleFromUserMandate(self, userMandateId: str, roleId: str) -> bool:
"""
Remove a role from a UserMandate.
If no roles remain, the UserMandate is deleted (Application-Level Cleanup).
Args:
userMandateId: UserMandate ID
roleId: Role ID to remove
Returns:
True if removed
"""
try:
# Find and delete the junction record
records = self.db.getRecordset(
UserMandateRole,
recordFilter={"userMandateId": userMandateId, "roleId": roleId}
)
if not records:
return False
self.db.recordDelete(UserMandateRole, records[0].get("id"))
# Application-Level Cleanup: Delete UserMandate if no roles remain
remainingRoles = self.db.getRecordset(
UserMandateRole,
recordFilter={"userMandateId": userMandateId}
)
if not remainingRoles:
self.db.recordDelete(UserMandate, userMandateId)
logger.info(f"Deleted empty UserMandate {userMandateId}")
return True
except Exception as e:
logger.error(f"Error removing role from UserMandate: {e}")
raise ValueError(f"Failed to remove role: {e}")
# ============================================
# Feature Access Methods (Multi-Tenant)
# ============================================
def getFeatureAccess(self, userId: str, featureInstanceId: str) -> Optional[FeatureAccess]:
"""
Get FeatureAccess record for a user to a specific feature instance.
Args:
userId: User ID
featureInstanceId: FeatureInstance ID
Returns:
FeatureAccess object or None
"""
try:
records = self.db.getRecordset(
FeatureAccess,
recordFilter={"userId": userId, "featureInstanceId": featureInstanceId}
)
if not records:
return None
cleanedRecord = {k: v for k, v in records[0].items() if not k.startswith("_")}
return FeatureAccess(**cleanedRecord)
except Exception as e:
logger.error(f"Error getting FeatureAccess: {e}")
return None
def getFeatureAccessesForUser(self, userId: str) -> List[FeatureAccess]:
"""
Get all feature accesses for a user.
Args:
userId: User ID
Returns:
List of FeatureAccess objects
"""
try:
records = self.db.getRecordset(
FeatureAccess,
recordFilter={"userId": userId, "enabled": True}
)
result = []
for record in records:
cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_")}
result.append(FeatureAccess(**cleanedRecord))
return result
except Exception as e:
logger.error(f"Error getting FeatureAccesses: {e}")
return []
def createFeatureAccess(self, userId: str, featureInstanceId: str, roleIds: List[str] = None) -> FeatureAccess:
"""
Create a FeatureAccess record (grant user access to feature instance).
Args:
userId: User ID
featureInstanceId: FeatureInstance ID
roleIds: Optional list of role IDs to assign
Returns:
Created FeatureAccess object
"""
try:
# Check if already exists
existing = self.getFeatureAccess(userId, featureInstanceId)
if existing:
raise ValueError(f"User {userId} already has access to feature instance {featureInstanceId}")
# Create FeatureAccess
featureAccess = FeatureAccess(
userId=userId,
featureInstanceId=featureInstanceId,
enabled=True
)
createdRecord = self.db.recordCreate(FeatureAccess, featureAccess.model_dump())
# Assign roles via junction table
if roleIds and createdRecord:
featureAccessId = createdRecord.get("id")
for roleId in roleIds:
featureAccessRole = FeatureAccessRole(
featureAccessId=featureAccessId,
roleId=roleId
)
self.db.recordCreate(FeatureAccessRole, featureAccessRole.model_dump())
cleanedRecord = {k: v for k, v in createdRecord.items() if not k.startswith("_")}
return FeatureAccess(**cleanedRecord)
except Exception as e:
logger.error(f"Error creating FeatureAccess: {e}")
raise ValueError(f"Failed to create FeatureAccess: {e}")
def getRoleIdsForFeatureAccess(self, featureAccessId: str) -> List[str]:
"""
Get all role IDs assigned to a FeatureAccess.
Args:
featureAccessId: FeatureAccess ID
Returns:
List of role IDs
"""
try:
records = self.db.getRecordset(
FeatureAccessRole,
recordFilter={"featureAccessId": featureAccessId}
)
return [r.get("roleId") for r in records if r.get("roleId")]
except Exception as e:
logger.error(f"Error getting role IDs for FeatureAccess: {e}")
return []
# Token methods
def saveAccessToken(self, token: Token, replace_existing: bool = True) -> None:
@ -1713,115 +2081,6 @@ class AppObjects:
logger.error(f"Error during logout: {str(e)}")
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
def createAccessRule(self, accessRule: AccessRule) -> AccessRule:
@ -1902,6 +2161,7 @@ class AppObjects:
def getAccessRules(
self,
roleLabel: Optional[str] = None,
roleId: Optional[str] = None,
context: Optional[AccessRuleContext] = None,
item: Optional[str] = None,
pagination: Optional[PaginationParams] = None
@ -1910,7 +2170,8 @@ class AppObjects:
Get access rules with optional filters and pagination.
Args:
roleLabel: Optional role label filter
roleLabel: Optional role label filter (deprecated, use roleId)
roleId: Optional role ID filter
context: Optional context filter
item: Optional item filter
pagination: Optional pagination parameters. If None, returns all items.
@ -1921,7 +2182,9 @@ class AppObjects:
"""
try:
recordFilter = {}
if roleLabel:
if roleId:
recordFilter["roleId"] = roleId
elif roleLabel:
recordFilter["roleLabel"] = roleLabel
if context:
recordFilter["context"] = context.value
@ -2134,6 +2397,29 @@ class AppObjects:
else:
return PaginatedResult(items=[], totalItems=0, totalPages=0)
def countRoleAssignments(self) -> Dict[str, int]:
"""
Count the number of user assignments per role from UserMandateRole table.
Returns:
Dictionary mapping roleId to count of user assignments
"""
try:
# Get all UserMandateRole records
assignments = self.db.getRecordset(UserMandateRole)
# Count assignments per roleId
roleCounts: Dict[str, int] = {}
for assignment in assignments:
roleId = str(assignment.get("roleId", ""))
if roleId:
roleCounts[roleId] = roleCounts.get(roleId, 0) + 1
return roleCounts
except Exception as e:
logger.error(f"Error counting role assignments: {str(e)}")
return {}
def updateRole(self, roleId: str, role: Role) -> Role:
"""
Update an existing role.
@ -2185,14 +2471,13 @@ class AppObjects:
if role.isSystemRole:
raise ValueError(f"Cannot delete system role '{role.roleLabel}'")
# Check if role is assigned to any users
allUsers = self.getUsersByMandate(None) # Get all users across all mandates
for user in allUsers:
if role.roleLabel in (user.roleLabels or []):
raise ValueError(f"Cannot delete role '{role.roleLabel}' - it is assigned to users")
# Check if role is assigned to any users via UserMandateRole
roleAssignments = self.db.getRecordset(UserMandateRole, recordFilter={"roleId": roleId})
if roleAssignments:
raise ValueError(f"Cannot delete role '{role.roleLabel}' - it is assigned to users")
# Check if role is used in any access rules
accessRules = self.getAccessRules(roleLabel=role.roleLabel)
accessRules = self.getAccessRules(roleId=roleId)
if accessRules:
raise ValueError(f"Cannot delete role '{role.roleLabel}' - it is used in access rules")
@ -2207,20 +2492,34 @@ class AppObjects:
# Public Methods
def getInterface(currentUser: User) -> AppObjects:
def getInterface(currentUser: User, mandateId: Optional[str] = None) -> AppObjects:
"""
Returns a AppObjects instance for the current user.
Handles initialization of database and records.
Multi-Tenant Design:
- mandateId wird explizit übergeben (aus Request-Context / X-Mandate-Id Header)
Args:
currentUser: User object
mandateId: Explicit mandate context (from request header). Required for non-sysadmin.
Returns:
AppObjects instance for the user context
"""
if not currentUser:
raise ValueError("Invalid user context: user is required")
# Create context key
contextKey = f"{currentUser.mandateId}_{currentUser.id}"
effectiveMandateId = mandateId
# Create context key (user + mandate combination)
contextKey = f"{effectiveMandateId}_{currentUser.id}"
# Create new instance if not exists
if contextKey not in _gatewayInterfaces:
_gatewayInterfaces[contextKey] = AppObjects(currentUser)
instance = AppObjects(currentUser)
instance.setUserContext(currentUser, mandateId=effectiveMandateId)
_gatewayInterfaces[contextKey] = instance
return _gatewayInterfaces[contextKey]

File diff suppressed because it is too large Load diff

View file

@ -76,14 +76,23 @@ class ComponentObjects:
# Initialize standard records if needed
self._initRecords()
def setUserContext(self, currentUser: User):
"""Sets the user context for the interface."""
def setUserContext(self, currentUser: User, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None):
"""Sets the user context for the interface.
Args:
currentUser: The authenticated user
mandateId: The mandate ID from RequestContext (X-Mandate-Id header)
featureInstanceId: The feature instance ID from RequestContext (X-Feature-Instance-Id header)
"""
if not currentUser:
logger.info("Initializing interface without user context")
return
self.currentUser = currentUser # Store User object directly
self.userId = currentUser.id
# Use mandateId from parameter (Request-Context), not from user object
self.mandateId = mandateId
self.featureInstanceId = featureInstanceId
if not self.userId:
raise ValueError("Invalid user context: id is required")
@ -116,11 +125,11 @@ class ComponentObjects:
"""Initializes the database connection directly."""
try:
# Get configuration values with defaults
dbHost = APP_CONFIG.get("DB_MANAGEMENT_HOST", "_no_config_default_data")
dbDatabase = APP_CONFIG.get("DB_MANAGEMENT_DATABASE", "management")
dbUser = APP_CONFIG.get("DB_MANAGEMENT_USER")
dbPassword = APP_CONFIG.get("DB_MANAGEMENT_PASSWORD_SECRET")
dbPort = int(APP_CONFIG.get("DB_MANAGEMENT_PORT"))
dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data")
dbDatabase = "poweron_management"
dbUser = APP_CONFIG.get("DB_USER")
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
# Create database connector directly
self.db = DatabaseConnector(
@ -206,7 +215,7 @@ class ComponentObjects:
# Get the root interface to access the initial mandate ID
from modules.security.rootAccess import getRootUser
from modules.interfaces.interfaceDbAppObjects import getInterface
from modules.interfaces.interfaceDbApp import getInterface
rootUser = getRootUser()
rootInterface = getInterface(rootUser)
@ -310,7 +319,9 @@ class ComponentObjects:
permissions = self.rbac.getUserPermissions(
self.currentUser,
AccessRuleContext.DATA,
tableName
tableName,
mandateId=self.mandateId,
featureInstanceId=self.featureInstanceId
)
if operation == "create":
@ -979,12 +990,15 @@ class ComponentObjects:
fileSize = len(content)
fileHash = hashlib.sha256(content).hexdigest()
# Ensure mandateId is valid
mandateId = self.currentUser.mandateId or "default"
# Use mandateId and featureInstanceId from context for proper data isolation
# Convert None to empty string to satisfy Pydantic validation
mandateId = self.mandateId or ""
featureInstanceId = self.featureInstanceId or ""
# Create FileItem instance
fileItem = FileItem(
mandateId=mandateId,
featureInstanceId=featureInstanceId,
fileName=uniqueName,
mimeType=mimeType,
fileSize=fileSize,
@ -1320,9 +1334,11 @@ class ComponentObjects:
if "userId" not in settingsData:
settingsData["userId"] = self.userId
# Ensure mandateId is set
# Ensure mandateId and featureInstanceId are set from context
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
existingSettings = self.getVoiceSettings(settingsData["userId"])
@ -1406,7 +1422,7 @@ class ComponentObjects:
# Create default settings
defaultSettings = {
"userId": targetUserId,
"mandateId": self.currentUser.mandateId if self.currentUser else "default",
"mandateId": self.mandateId,
"sttLanguage": "de-DE",
"ttsLanguage": "de-DE",
"ttsVoice": "de-DE-KatjaNeural",
@ -1494,9 +1510,11 @@ class ComponentObjects:
if not all(c.isalpha() or c == "_" for c in subscriptionId):
raise ValueError("subscriptionId must contain only letters and underscores")
# Set mandateId if not provided
# Set mandateId and featureInstanceId from context for proper data isolation
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)
if not createdRecord or not createdRecord.get("id"):
@ -1598,6 +1616,12 @@ class ComponentObjects:
if "userId" not in registrationData:
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)
if not createdRecord or not createdRecord.get("id"):
raise ValueError("Failed to create registration record")
@ -1672,6 +1696,13 @@ class ComponentObjects:
def createDelivery(self, delivery: MessagingDelivery) -> Dict[str, Any]:
"""Creates a new delivery record."""
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)
if not createdRecord or not createdRecord.get("id"):
raise ValueError("Failed to create delivery record")
@ -1741,12 +1772,20 @@ class ComponentObjects:
return MessagingDelivery(**filteredDeliveries[0]) if filteredDeliveries else None
def getInterface(currentUser: Optional[User] = None) -> 'ComponentObjects':
def getInterface(currentUser: Optional[User] = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None) -> 'ComponentObjects':
"""
Returns a ComponentObjects instance.
If currentUser is provided, initializes with user context.
Otherwise, returns an instance with only database access.
Args:
currentUser: The authenticated user
mandateId: The mandate ID from RequestContext (X-Mandate-Id header). Required.
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
if "default" not in _instancesManagement:
_instancesManagement["default"] = ComponentObjects()
@ -1754,7 +1793,7 @@ def getInterface(currentUser: Optional[User] = None) -> 'ComponentObjects':
interface = _instancesManagement["default"]
if currentUser:
interface.setUserContext(currentUser)
interface.setUserContext(currentUser, mandateId=effectiveMandateId, featureInstanceId=effectiveFeatureInstanceId)
else:
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.
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
@ -17,6 +33,72 @@ from modules.security.rootAccess import getRootDbAppConnector
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(
connector, # DatabaseConnector instance
modelClass: Type[BaseModel],
@ -24,41 +106,72 @@ def getRecordsetWithRBAC(
recordFilter: Dict[str, Any] = None,
orderBy: str = None,
limit: int = None,
mandateId: Optional[str] = None,
featureInstanceId: Optional[str] = None,
enrichPermissions: bool = False,
featureCode: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""
Get records with RBAC filtering applied at database level.
This function wraps connector.getRecordset() with RBAC logic.
Multi-Tenant Design:
- mandateId wird explizit übergeben (aus Request-Context / X-Mandate-Id Header)
Args:
connector: DatabaseConnector instance
modelClass: Pydantic model class for the table
currentUser: User object with roleLabels
currentUser: User object
recordFilter: Additional record filters
orderBy: Field to order by (defaults to "id")
limit: Maximum number of records to return
mandateId: Explicit mandate context (from request header). Required for GROUP access.
featureInstanceId: Explicit feature instance context
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:
List of filtered records
List of filtered records (with _permissions if enrichPermissions=True)
"""
table = modelClass.__name__
# Build full objectKey for RBAC lookup
objectKey = buildDataObjectKey(table, featureCode)
effectiveMandateId = mandateId
try:
if not connector._ensureTableExists(modelClass):
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
dbApp = getRootDbAppConnector()
rbacInstance = RbacClass(connector, dbApp=dbApp)
permissions = rbacInstance.getUserPermissions(
currentUser,
AccessRuleContext.DATA,
table
objectKey, # Use full objectKey (e.g., "data.uam.UserInDB", "data.chat.ChatWorkflow")
mandateId=effectiveMandateId,
featureInstanceId=featureInstanceId
)
# Check view permission first
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 []
# Build WHERE clause with RBAC filtering
@ -66,7 +179,13 @@ def getRecordsetWithRBAC(
whereValues = []
# Add RBAC WHERE clause based on read permission
rbacWhereClause = buildRbacWhereClause(permissions, currentUser, table, connector)
rbacWhereClause = buildRbacWhereClause(
permissions,
currentUser,
table,
connector,
mandateId=effectiveMandateId
)
if rbacWhereClause:
whereConditions.append(rbacWhereClause["condition"])
whereValues.extend(rbacWhereClause["values"])
@ -145,6 +264,12 @@ def getRecordsetWithRBAC(
f"Could not parse JSONB field {fieldName}, keeping as string: {record[fieldName]}"
)
# Enrich records with row-level permissions if requested
if enrichPermissions:
records = _enrichRecordsWithPermissions(
records, permissions, currentUser
)
return records
except Exception as e:
logger.error(f"Error loading records with RBAC from table {table}: {e}")
@ -155,17 +280,21 @@ def buildRbacWhereClause(
permissions: UserPermissions,
currentUser: User,
table: str,
connector # DatabaseConnector instance for connection access
connector, # DatabaseConnector instance for connection access
mandateId: Optional[str] = None
) -> Optional[Dict[str, Any]]:
"""
Build RBAC WHERE clause based on permissions and access level.
Moved from connector to interfaces.
Multi-Tenant Design:
- mandateId wird explizit übergeben (aus Request-Context / X-Mandate-Id Header)
Args:
permissions: UserPermissions object
currentUser: User object
table: Table name
connector: DatabaseConnector instance (needed for GROUP queries)
mandateId: Explicit mandate context (from request header). Required for GROUP access.
Returns:
Dictionary with "condition" and "values" keys, or None if no filtering needed
@ -199,29 +328,70 @@ def buildRbacWhereClause(
"values": [currentUser.id]
}
# Group records - filter by mandateId
# Group records - filter by mandateId or ownership based on namespace
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")
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":
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:
with connector.connection.cursor() as cursor:
# Get all user IDs that are members of the current mandate
cursor.execute(
'SELECT "id" FROM "UserInDB" WHERE "mandateId" = %s',
(currentUser.mandateId,)
'SELECT "userId" FROM "UserMandate" WHERE "mandateId" = %s AND "enabled" = true',
(effectiveMandateId,)
)
users = cursor.fetchall()
userIds = [u["id"] for u in users]
userMandates = cursor.fetchall()
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:
return {"condition": "1 = 0", "values": []}
placeholders = ",".join(["%s"] * len(userIds))
@ -232,12 +402,102 @@ def buildRbacWhereClause(
except Exception as e:
logger.error(f"Error building GROUP filter for UserConnection: {e}")
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:
return {
"condition": '"mandateId" = %s',
"values": [currentUser.mandateId]
"condition": '("mandateId" = %s OR "mandateId" IS NULL)',
"values": [effectiveMandateId]
}
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._google_speech_connector: Optional[ConnectorGoogleSpeech] = None
def setUserContext(self, currentUser: User):
"""Set the user context for the interface."""
def setUserContext(self, currentUser: User, mandateId: Optional[str] = None):
"""Set the user context for the interface.
Args:
currentUser: The authenticated user
mandateId: The mandate ID from RequestContext (X-Mandate-Id header)
"""
if not currentUser:
logger.info("Initializing voice interface without user context")
return
self.currentUser = currentUser
self.userId = currentUser.id
# Use mandateId from parameter (Request-Context), not from user object
self.mandateId = mandateId
if not self.userId:
raise ValueError("Invalid user context: id is required")
logger.debug(f"Voice interface user context set: userId={self.userId}")
logger.debug(f"Voice interface user context set: userId={self.userId}, mandateId={self.mandateId}")
def _getGoogleSpeechConnector(self) -> ConnectorGoogleSpeech:
"""Get or create Google Cloud Speech connector instance."""
@ -308,11 +315,11 @@ class VoiceObjects:
try:
logger.info(f"Creating voice settings: {settingsData}")
# Ensure mandateId is set from user context if not provided
# Ensure mandateId is set from context if not provided
if "mandateId" not in settingsData or not settingsData["mandateId"]:
if not self.currentUser or not self.currentUser.mandateId:
raise ValueError("mandateId is required but not provided and user context has no mandateId")
settingsData["mandateId"] = self.currentUser.mandateId
if not self.mandateId:
raise ValueError("mandateId is required but not provided and context has no mandateId")
settingsData["mandateId"] = self.mandateId
# Add timestamps
currentTime = getUtcTimestamp()
@ -376,7 +383,7 @@ class VoiceObjects:
# Create default settings if none exist
defaultSettings = {
"userId": userId,
"mandateId": self.currentUser.mandateId,
"mandateId": self.mandateId,
"sttLanguage": "de-DE",
"ttsLanguage": "de-DE",
"ttsVoice": "de-DE-Wavenet-A",
@ -524,21 +531,22 @@ class VoiceObjects:
}
def getVoiceInterface(currentUser: User = None) -> VoiceObjects:
def getVoiceInterface(currentUser: User = None, mandateId: Optional[str] = None) -> VoiceObjects:
"""
Factory function to get or create Voice interface instance.
Args:
currentUser: User object for context (optional)
mandateId: The mandate ID from RequestContext (X-Mandate-Id header). Required.
Returns:
VoiceObjects instance
"""
# For now, create a new instance each time
# In the future, this could be enhanced with singleton pattern per user
effectiveMandateId = str(mandateId) if mandateId else None
voiceInterface = VoiceObjects()
if currentUser:
voiceInterface.setUserContext(currentUser)
voiceInterface.setUserContext(currentUser, mandateId=effectiveMandateId)
return voiceInterface

View file

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

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.
Provides endpoints for managing roles and role assignments to users.
MULTI-TENANT: These are SYSTEM-LEVEL operations requiring isSysAdmin=true.
Roles are global system resources, not mandate-specific.
Role assignments are managed via UserMandateRole (not User.roleLabels).
"""
from fastapi import APIRouter, HTTPException, Depends, Query, Body, Path, Request
from typing import List, Dict, Any, Optional
from typing import List, Dict, Any, Optional, Set
import logging
from modules.auth import getCurrentUser, limiter
from modules.auth import limiter, requireSysAdmin
from modules.datamodels.datamodelUam import User, UserInDB
from modules.datamodels.datamodelRbac import Role
from modules.interfaces.interfaceDbAppObjects import getInterface
from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole
from modules.interfaces.interfaceDbApp import getInterface, getRootInterface
# Configure logger
logger = logging.getLogger(__name__)
def _getUserRoleLabels(interface, userId: str) -> List[str]:
"""
Get role labels for a user from UserMandateRole (across all mandates).
Args:
interface: Database interface
userId: User ID
Returns:
List of role labels
"""
roleLabels: Set[str] = set()
# Get all UserMandate records for this user
userMandates = interface.db.getRecordset(UserMandate, recordFilter={"userId": userId})
for um in userMandates:
userMandateId = um.get("id")
if not userMandateId:
continue
# Get all UserMandateRole records for this membership
userMandateRoles = interface.db.getRecordset(
UserMandateRole,
recordFilter={"userMandateId": str(userMandateId)}
)
for umr in userMandateRoles:
roleId = umr.get("roleId")
if roleId:
# Get role by ID to get roleLabel
role = interface.getRole(str(roleId))
if role:
roleLabels.add(role.roleLabel)
return list(roleLabels)
def _hasRoleLabel(interface, userId: str, roleLabel: str) -> bool:
"""
Check if user has a specific role label (across all mandates).
"""
return roleLabel in _getUserRoleLabels(interface, userId)
router = APIRouter(
prefix="/api/admin/rbac/roles",
tags=["Admin RBAC Roles"],
@ -24,51 +74,27 @@ router = APIRouter(
)
def _ensureAdminAccess(currentUser: User) -> None:
"""Ensure current user has admin access to RBAC roles management."""
interface = getInterface(currentUser)
# Check if user has admin or sysadmin role
roleLabels = currentUser.roleLabels or []
if "sysadmin" not in roleLabels and "admin" not in roleLabels:
raise HTTPException(
status_code=403,
detail="Admin or sysadmin role required to manage RBAC roles"
)
# Additional RBAC check: verify user has permission to update UserInDB
# This is already covered by admin/sysadmin role check above, but we can add explicit RBAC check if needed
# For now, admin/sysadmin role check is sufficient
@router.get("/", response_model=List[Dict[str, Any]])
@limiter.limit("60/minute")
async def listRoles(
async def list_roles(
request: Request,
currentUser: User = Depends(getCurrentUser)
currentUser: User = Depends(requireSysAdmin)
) -> List[Dict[str, Any]]:
"""
Get list of all available roles with metadata.
MULTI-TENANT: SysAdmin-only (roles are system resources).
Returns:
- List of role dictionaries with role label, description, and user count
"""
try:
_ensureAdminAccess(currentUser)
interface = getInterface(currentUser)
interface = getRootInterface()
# Get all roles from database
dbRoles = interface.getAllRoles()
# Get all users to count role assignments
allUsers = interface.getUsers()
# Count users per role
roleCounts: Dict[str, int] = {}
for user in allUsers:
for roleLabel in (user.roleLabels or []):
roleCounts[roleLabel] = roleCounts.get(roleLabel, 0) + 1
# Count role assignments from UserMandateRole table
roleCounts = interface.countRoleAssignments()
# Convert Role objects to dictionaries and add user counts
result = []
@ -77,22 +103,10 @@ async def listRoles(
"id": role.id,
"roleLabel": role.roleLabel,
"description": role.description,
"userCount": roleCounts.get(role.roleLabel, 0),
"userCount": roleCounts.get(str(role.id), 0),
"isSystemRole": role.isSystemRole
})
# Add any roles found in user assignments that don't exist in database
dbRoleLabels = {role.roleLabel for role in dbRoles}
for roleLabel, count in roleCounts.items():
if roleLabel not in dbRoleLabels:
result.append({
"id": None,
"roleLabel": roleLabel,
"description": {"en": f"Custom role: {roleLabel}", "fr": f"Rôle personnalisé : {roleLabel}"},
"userCount": count,
"isSystemRole": False
})
return result
except HTTPException:
@ -107,21 +121,19 @@ async def listRoles(
@router.get("/options", response_model=List[Dict[str, Any]])
@limiter.limit("60/minute")
async def getRoleOptions(
async def get_role_options(
request: Request,
currentUser: User = Depends(getCurrentUser)
currentUser: User = Depends(requireSysAdmin)
) -> List[Dict[str, Any]]:
"""
Get role options for select dropdowns.
Returns roles in format suitable for frontend select components.
MULTI-TENANT: SysAdmin-only.
Returns:
- List of role option dictionaries with value and label
"""
try:
_ensureAdminAccess(currentUser)
interface = getInterface(currentUser)
interface = getRootInterface()
# Get all roles from database
dbRoles = interface.getAllRoles()
@ -150,13 +162,14 @@ async def getRoleOptions(
@router.post("/", response_model=Dict[str, Any])
@limiter.limit("30/minute")
async def createRole(
async def create_role(
request: Request,
role: Role = Body(...),
currentUser: User = Depends(getCurrentUser)
currentUser: User = Depends(requireSysAdmin)
) -> Dict[str, Any]:
"""
Create a new role.
MULTI-TENANT: SysAdmin-only (roles are system resources).
Request Body:
- role: Role object to create
@ -165,9 +178,7 @@ async def createRole(
- Created role dictionary
"""
try:
_ensureAdminAccess(currentUser)
interface = getInterface(currentUser)
interface = getRootInterface()
createdRole = interface.createRole(role)
@ -195,13 +206,14 @@ async def createRole(
@router.get("/{roleId}", response_model=Dict[str, Any])
@limiter.limit("60/minute")
async def getRole(
async def get_role(
request: Request,
roleId: str = Path(..., description="Role ID"),
currentUser: User = Depends(getCurrentUser)
currentUser: User = Depends(requireSysAdmin)
) -> Dict[str, Any]:
"""
Get a role by ID.
MULTI-TENANT: SysAdmin-only.
Path Parameters:
- roleId: Role ID
@ -210,9 +222,7 @@ async def getRole(
- Role dictionary
"""
try:
_ensureAdminAccess(currentUser)
interface = getInterface(currentUser)
interface = getRootInterface()
role = interface.getRole(roleId)
if not role:
@ -240,14 +250,15 @@ async def getRole(
@router.put("/{roleId}", response_model=Dict[str, Any])
@limiter.limit("30/minute")
async def updateRole(
async def update_role(
request: Request,
roleId: str = Path(..., description="Role ID"),
role: Role = Body(...),
currentUser: User = Depends(getCurrentUser)
currentUser: User = Depends(requireSysAdmin)
) -> Dict[str, Any]:
"""
Update an existing role.
MULTI-TENANT: SysAdmin-only.
Path Parameters:
- roleId: Role ID
@ -259,9 +270,7 @@ async def updateRole(
- Updated role dictionary
"""
try:
_ensureAdminAccess(currentUser)
interface = getInterface(currentUser)
interface = getRootInterface()
updatedRole = interface.updateRole(roleId, role)
@ -289,13 +298,14 @@ async def updateRole(
@router.delete("/{roleId}", response_model=Dict[str, str])
@limiter.limit("30/minute")
async def deleteRole(
async def delete_role(
request: Request,
roleId: str = Path(..., description="Role ID"),
currentUser: User = Depends(getCurrentUser)
currentUser: User = Depends(requireSysAdmin)
) -> Dict[str, str]:
"""
Delete a role.
MULTI-TENANT: SysAdmin-only.
Path Parameters:
- roleId: Role ID
@ -304,9 +314,7 @@ async def deleteRole(
- Success message
"""
try:
_ensureAdminAccess(currentUser)
interface = getInterface(currentUser)
interface = getRootInterface()
success = interface.deleteRole(roleId)
if not success:
@ -334,51 +342,60 @@ async def deleteRole(
@router.get("/users", response_model=List[Dict[str, Any]])
@limiter.limit("60/minute")
async def listUsersWithRoles(
async def list_users_with_roles(
request: Request,
roleLabel: Optional[str] = Query(None, description="Filter by role label"),
mandateId: Optional[str] = Query(None, description="Filter by mandate ID"),
currentUser: User = Depends(getCurrentUser)
mandateId: Optional[str] = Query(None, description="Filter by mandate ID (via UserMandate)"),
currentUser: User = Depends(requireSysAdmin)
) -> List[Dict[str, Any]]:
"""
Get list of users with their role assignments.
MULTI-TENANT: SysAdmin-only, can see all users across mandates.
Query Parameters:
- roleLabel: Optional filter by role label
- mandateId: Optional filter by mandate ID
- mandateId: Optional filter by mandate ID (via UserMandate table)
Returns:
- List of user dictionaries with role assignments
"""
try:
_ensureAdminAccess(currentUser)
interface = getRootInterface()
interface = getInterface(currentUser)
# Get all users (SysAdmin sees all)
# 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:
# Filter by mandate (if user has permission)
users = interface.getUsers()
users = [u for u in users if u.mandateId == mandateId]
else:
users = interface.getUsers()
userMandates = interface.db.getRecordset(UserMandate, recordFilter={"mandateId": mandateId})
mandateUserIds = {str(um["userId"]) for um in userMandates}
users = [u for u in users if str(u.id) in mandateUserIds]
# Filter by role if specified
# Filter by role if specified (via UserMandateRole)
if roleLabel:
users = [u for u in users if roleLabel in (u.roleLabels or [])]
users = [u for u in users if _hasRoleLabel(interface, str(u.id), roleLabel)]
# Format response
result = []
for user in users:
userRoleLabels = _getUserRoleLabels(interface, str(user.id))
result.append({
"id": user.id,
"username": user.username,
"email": user.email,
"fullName": user.fullName,
"mandateId": user.mandateId,
"isSysAdmin": user.isSysAdmin,
"enabled": user.enabled,
"roleLabels": user.roleLabels or [],
"roleCount": len(user.roleLabels or [])
"roleLabels": userRoleLabels,
"roleCount": len(userRoleLabels)
})
return result
@ -395,13 +412,14 @@ async def listUsersWithRoles(
@router.get("/users/{userId}", response_model=Dict[str, Any])
@limiter.limit("60/minute")
async def getUserRoles(
async def get_user_roles(
request: Request,
userId: str = Path(..., description="User ID"),
currentUser: User = Depends(getCurrentUser)
currentUser: User = Depends(requireSysAdmin)
) -> Dict[str, Any]:
"""
Get role assignments for a specific user.
MULTI-TENANT: SysAdmin-only.
Path Parameters:
- userId: User ID
@ -410,9 +428,7 @@ async def getUserRoles(
- User dictionary with role assignments
"""
try:
_ensureAdminAccess(currentUser)
interface = getInterface(currentUser)
interface = getRootInterface()
# Get user
user = interface.getUser(userId)
@ -422,15 +438,16 @@ async def getUserRoles(
detail=f"User {userId} not found"
)
userRoleLabels = _getUserRoleLabels(interface, str(user.id))
return {
"id": user.id,
"username": user.username,
"email": user.email,
"fullName": user.fullName,
"mandateId": user.mandateId,
"isSysAdmin": user.isSysAdmin,
"enabled": user.enabled,
"roleLabels": user.roleLabels or [],
"roleCount": len(user.roleLabels or [])
"roleLabels": userRoleLabels,
"roleCount": len(userRoleLabels)
}
except HTTPException:
@ -445,28 +462,27 @@ async def getUserRoles(
@router.put("/users/{userId}/roles", response_model=Dict[str, Any])
@limiter.limit("30/minute")
async def updateUserRoles(
async def update_user_roles(
request: Request,
userId: str = Path(..., description="User ID"),
roleLabels: List[str] = Body(..., description="List of role labels to assign"),
currentUser: User = Depends(getCurrentUser)
newRoleLabels: List[str] = Body(..., description="List of role labels to assign"),
currentUser: User = Depends(requireSysAdmin)
) -> Dict[str, Any]:
"""
Update role assignments for a specific user.
MULTI-TENANT: SysAdmin-only. Updates roles in user's first mandate.
Path Parameters:
- userId: User ID
Request Body:
- roleLabels: List of role labels to assign (e.g., ["admin", "user"])
- newRoleLabels: List of role labels to assign (e.g., ["admin", "user"])
Returns:
- Updated user dictionary with role assignments
"""
try:
_ensureAdminAccess(currentUser)
interface = getInterface(currentUser)
interface = getRootInterface()
# Get user
user = interface.getUser(userId)
@ -478,28 +494,57 @@ async def updateUserRoles(
# Validate role labels (basic validation - check against standard roles)
standardRoles = ["sysadmin", "admin", "user", "viewer"]
for roleLabel in roleLabels:
for roleLabel in newRoleLabels:
if roleLabel not in standardRoles:
logger.warning(f"Non-standard role label assigned: {roleLabel}")
# Update user roles
userData = {
"roleLabels": roleLabels
}
# Get user's first mandate (for role assignment)
userMandates = interface.db.getRecordset(UserMandate, recordFilter={"userId": userId})
if not userMandates:
raise HTTPException(
status_code=400,
detail=f"User {userId} has no mandate memberships. Add to mandate first."
)
updatedUser = interface.updateUser(userId, userData)
userMandateId = str(userMandates[0].get("id"))
logger.info(f"Updated roles for user {userId}: {roleLabels}")
# Get current roles for this mandate
existingRoles = interface.db.getRecordset(
UserMandateRole,
recordFilter={"userMandateId": userMandateId}
)
existingRoleIds = {str(r.get("roleId")) for r in existingRoles}
# Convert roleLabels to roleIds
newRoleIds = set()
for roleLabel in newRoleLabels:
role = interface.getRoleByLabel(roleLabel)
if role:
newRoleIds.add(str(role.id))
# Remove roles that are no longer needed
for existingRole in existingRoles:
if str(existingRole.get("roleId")) not in newRoleIds:
interface.db.recordDelete(UserMandateRole, str(existingRole.get("id")))
# Add new roles
for roleId in newRoleIds:
if roleId not in existingRoleIds:
newRole = UserMandateRole(userMandateId=userMandateId, roleId=roleId)
interface.db.recordCreate(UserMandateRole, newRole.model_dump())
logger.info(f"Updated roles for user {userId}: {newRoleLabels} by SysAdmin {currentUser.id}")
userRoleLabels = _getUserRoleLabels(interface, userId)
return {
"id": updatedUser.id,
"username": updatedUser.username,
"email": updatedUser.email,
"fullName": updatedUser.fullName,
"mandateId": updatedUser.mandateId,
"enabled": updatedUser.enabled,
"roleLabels": updatedUser.roleLabels or [],
"roleCount": len(updatedUser.roleLabels or [])
"id": user.id,
"username": user.username,
"email": user.email,
"fullName": user.fullName,
"isSysAdmin": user.isSysAdmin,
"enabled": user.enabled,
"roleLabels": userRoleLabels,
"roleCount": len(userRoleLabels)
}
except HTTPException:
@ -514,14 +559,15 @@ async def updateUserRoles(
@router.post("/users/{userId}/roles/{roleLabel}", response_model=Dict[str, Any])
@limiter.limit("30/minute")
async def addUserRole(
async def add_user_role(
request: Request,
userId: str = Path(..., description="User ID"),
roleLabel: str = Path(..., description="Role label to add"),
currentUser: User = Depends(getCurrentUser)
currentUser: User = Depends(requireSysAdmin)
) -> Dict[str, Any]:
"""
Add a role to a user (if not already assigned).
MULTI-TENANT: SysAdmin-only. Adds role to user's first mandate.
Path Parameters:
- userId: User ID
@ -531,9 +577,7 @@ async def addUserRole(
- Updated user dictionary with role assignments
"""
try:
_ensureAdminAccess(currentUser)
interface = getInterface(currentUser)
interface = getRootInterface()
# Get user
user = interface.getUser(userId)
@ -543,33 +587,46 @@ async def addUserRole(
detail=f"User {userId} not found"
)
# Get current roles
currentRoles = list(user.roleLabels or [])
# Get role by label
role = interface.getRoleByLabel(roleLabel)
if not role:
raise HTTPException(
status_code=404,
detail=f"Role '{roleLabel}' not found"
)
# Add role if not already present
if roleLabel not in currentRoles:
currentRoles.append(roleLabel)
# Update user roles
userData = {
"roleLabels": currentRoles
}
updatedUser = interface.updateUser(userId, userData)
logger.info(f"Added role {roleLabel} to user {userId}")
else:
updatedUser = user
# Get user's first mandate
userMandates = interface.db.getRecordset(UserMandate, recordFilter={"userId": userId})
if not userMandates:
raise HTTPException(
status_code=400,
detail=f"User {userId} has no mandate memberships. Add to mandate first."
)
userMandateId = str(userMandates[0].get("id"))
# Check if role is already assigned
existingAssignment = interface.db.getRecordset(
UserMandateRole,
recordFilter={"userMandateId": userMandateId, "roleId": str(role.id)}
)
if not existingAssignment:
# Add the role
newRole = UserMandateRole(userMandateId=userMandateId, roleId=str(role.id))
interface.db.recordCreate(UserMandateRole, newRole.model_dump())
logger.info(f"Added role {roleLabel} to user {userId} by SysAdmin {currentUser.id}")
userRoleLabels = _getUserRoleLabels(interface, userId)
return {
"id": updatedUser.id,
"username": updatedUser.username,
"email": updatedUser.email,
"fullName": updatedUser.fullName,
"mandateId": updatedUser.mandateId,
"enabled": updatedUser.enabled,
"roleLabels": updatedUser.roleLabels or [],
"roleCount": len(updatedUser.roleLabels or [])
"id": user.id,
"username": user.username,
"email": user.email,
"fullName": user.fullName,
"isSysAdmin": user.isSysAdmin,
"enabled": user.enabled,
"roleLabels": userRoleLabels,
"roleCount": len(userRoleLabels)
}
except HTTPException:
@ -584,14 +641,15 @@ async def addUserRole(
@router.delete("/users/{userId}/roles/{roleLabel}", response_model=Dict[str, Any])
@limiter.limit("30/minute")
async def removeUserRole(
async def remove_user_role(
request: Request,
userId: str = Path(..., description="User ID"),
roleLabel: str = Path(..., description="Role label to remove"),
currentUser: User = Depends(getCurrentUser)
currentUser: User = Depends(requireSysAdmin)
) -> Dict[str, Any]:
"""
Remove a role from a user.
MULTI-TENANT: SysAdmin-only. Removes role from all user's mandates.
Path Parameters:
- userId: User ID
@ -601,9 +659,7 @@ async def removeUserRole(
- Updated user dictionary with role assignments
"""
try:
_ensureAdminAccess(currentUser)
interface = getInterface(currentUser)
interface = getRootInterface()
# Get user
user = interface.getUser(userId)
@ -613,38 +669,44 @@ async def removeUserRole(
detail=f"User {userId} not found"
)
# Get current roles
currentRoles = list(user.roleLabels or [])
# Get role by label
role = interface.getRoleByLabel(roleLabel)
if not role:
raise HTTPException(
status_code=404,
detail=f"Role '{roleLabel}' not found"
)
# Remove role if present
if roleLabel in currentRoles:
currentRoles.remove(roleLabel)
# Ensure user has at least one role (default to "user")
if not currentRoles:
currentRoles = ["user"]
logger.warning(f"User {userId} had all roles removed, defaulting to 'user' role")
# Update user roles
userData = {
"roleLabels": currentRoles
}
updatedUser = interface.updateUser(userId, userData)
logger.info(f"Removed role {roleLabel} from user {userId}")
else:
updatedUser = user
# Remove role from all user's mandates
userMandates = interface.db.getRecordset(UserMandate, recordFilter={"userId": userId})
roleRemoved = False
for um in userMandates:
userMandateId = str(um.get("id"))
# Find and delete the role assignment
assignments = interface.db.getRecordset(
UserMandateRole,
recordFilter={"userMandateId": userMandateId, "roleId": str(role.id)}
)
for assignment in assignments:
interface.db.recordDelete(UserMandateRole, str(assignment.get("id")))
roleRemoved = True
if roleRemoved:
logger.info(f"Removed role {roleLabel} from user {userId} by SysAdmin {currentUser.id}")
userRoleLabels = _getUserRoleLabels(interface, userId)
return {
"id": updatedUser.id,
"username": updatedUser.username,
"email": updatedUser.email,
"fullName": updatedUser.fullName,
"mandateId": updatedUser.mandateId,
"enabled": updatedUser.enabled,
"roleLabels": updatedUser.roleLabels or [],
"roleCount": len(updatedUser.roleLabels or [])
"id": user.id,
"username": user.username,
"email": user.email,
"fullName": user.fullName,
"isSysAdmin": user.isSysAdmin,
"enabled": user.enabled,
"roleLabels": userRoleLabels,
"roleCount": len(userRoleLabels)
}
except HTTPException:
@ -659,52 +721,72 @@ async def removeUserRole(
@router.get("/roles/{roleLabel}/users", response_model=List[Dict[str, Any]])
@limiter.limit("60/minute")
async def getUsersWithRole(
async def get_users_with_role(
request: Request,
roleLabel: str = Path(..., description="Role label"),
mandateId: Optional[str] = Query(None, description="Filter by mandate ID"),
currentUser: User = Depends(getCurrentUser)
mandateId: Optional[str] = Query(None, description="Filter by mandate ID (via UserMandate)"),
currentUser: User = Depends(requireSysAdmin)
) -> List[Dict[str, Any]]:
"""
Get all users with a specific role.
MULTI-TENANT: SysAdmin-only.
Path Parameters:
- roleLabel: Role label
Query Parameters:
- mandateId: Optional filter by mandate ID
- mandateId: Optional filter by mandate ID (via UserMandate table)
Returns:
- List of users with the specified role
"""
try:
_ensureAdminAccess(currentUser)
interface = getRootInterface()
interface = getInterface(currentUser)
# Get role by label
role = interface.getRoleByLabel(roleLabel)
if not role:
raise HTTPException(
status_code=404,
detail=f"Role '{roleLabel}' not found"
)
# Get all users
users = interface.getUsers()
# Get all UserMandateRole assignments for this role
roleAssignments = interface.db.getRecordset(
UserMandateRole,
recordFilter={"roleId": str(role.id)}
)
# Filter by role
users = [u for u in users if roleLabel in (u.roleLabels or [])]
# Get unique userMandateIds
userMandateIds = {str(ra.get("userMandateId")) for ra in roleAssignments}
# Filter by mandate if specified
if mandateId:
users = [u for u in users if u.mandateId == mandateId]
# Get userIds from UserMandate records
userIds: Set[str] = set()
for userMandateId in userMandateIds:
umRecords = interface.db.getRecordset(UserMandate, recordFilter={"id": userMandateId})
if umRecords:
um = umRecords[0]
# Filter by mandate if specified
if mandateId and str(um.get("mandateId")) != mandateId:
continue
userIds.add(str(um.get("userId")))
# Format response
# Get users and format response
result = []
for user in users:
result.append({
"id": user.id,
"username": user.username,
"email": user.email,
"fullName": user.fullName,
"mandateId": user.mandateId,
"enabled": user.enabled,
"roleLabels": user.roleLabels or [],
"roleCount": len(user.roleLabels or [])
})
for userId in userIds:
user = interface.getUser(userId)
if user:
userRoleLabels = _getUserRoleLabels(interface, userId)
result.append({
"id": user.id,
"username": user.username,
"email": user.email,
"fullName": user.fullName,
"isSysAdmin": user.isSysAdmin,
"enabled": user.enabled,
"roleLabels": userRoleLabels,
"roleCount": len(userRoleLabels)
})
return result

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

View file

@ -20,10 +20,11 @@ import math
from modules.datamodels.datamodelUam import User, UserConnection, AuthAuthority, ConnectionStatus
from modules.datamodels.datamodelSecurity import Token
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.interfaces.interfaceDbAppObjects import getInterface
from modules.interfaces.interfaceDbApp import getInterface
from modules.shared.timeUtils import getUtcTimestamp, parseTimestamp
from modules.interfaces.interfaceDbComponentObjects import ComponentObjects
from modules.interfaces.interfaceDbManagement import ComponentObjects
# Configure logger
logger = logging.getLogger(__name__)
@ -92,6 +93,52 @@ router = APIRouter(
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])
@limiter.limit("30/minute")
async def get_connections(
@ -136,7 +183,6 @@ async def get_connections(
# Perform silent token refresh for expired OAuth connections
try:
from modules.auth import token_refresh_service
refresh_result = await token_refresh_service.refresh_expired_tokens(currentUser.id)
if refresh_result.get("refreshed", 0) > 0:
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')}"
)
# Get fresh copy of user from database
user = interface.getUser(currentUser.id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
# Note: currentUser is already authenticated via JWT - no need to re-verify from database
# The getCurrentUser dependency already validated the user exists
# Always create a new connection with PENDING status
connection = interface.addUserConnection(

View file

@ -10,7 +10,7 @@ import json
from modules.auth import limiter, getCurrentUser
# Import interfaces
import modules.interfaces.interfaceDbComponentObjects as interfaceDbComponentObjects
import modules.interfaces.interfaceDbManagement as interfaceDbManagement
from modules.datamodels.datamodelFiles import FileItem, FilePreview
from modules.shared.attributeUtils import getModelAttributeDefinitions
from modules.datamodels.datamodelUam import User
@ -69,7 +69,7 @@ async def get_files(
detail=f"Invalid pagination parameter: {str(e)}"
)
managementInterface = interfaceDbComponentObjects.getInterface(currentUser)
managementInterface = interfaceDbManagement.getInterface(currentUser)
result = managementInterface.getAllFiles(pagination=paginationParams)
# If pagination was requested, result is PaginatedResult
@ -112,17 +112,17 @@ async def upload_file(
file.fileName = file.filename
"""Upload a file"""
try:
managementInterface = interfaceDbComponentObjects.getInterface(currentUser)
managementInterface = interfaceDbManagement.getInterface(currentUser)
# Read file
fileContent = await file.read()
# 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:
raise HTTPException(
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
@ -136,15 +136,13 @@ async def upload_file(
else: # new_file
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
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
return JSONResponse({
"message": message,
@ -155,7 +153,7 @@ async def upload_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)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@ -177,7 +175,7 @@ async def get_file(
) -> FileItem:
"""Get a file"""
try:
managementInterface = interfaceDbComponentObjects.getInterface(currentUser)
managementInterface = interfaceDbManagement.getInterface(currentUser)
# Get file via LucyDOM interface from the database
fileData = managementInterface.getFile(fileId)
@ -189,19 +187,19 @@ async def get_file(
return fileData
except interfaceDbComponentObjects.FileNotFoundError as e:
except interfaceDbManagement.FileNotFoundError as e:
logger.warning(f"File not found: {str(e)}")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e)
)
except interfaceDbComponentObjects.FilePermissionError as e:
except interfaceDbManagement.FilePermissionError as e:
logger.warning(f"No permission for file: {str(e)}")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=str(e)
)
except interfaceDbComponentObjects.FileError as e:
except interfaceDbManagement.FileError as e:
logger.error(f"Error retrieving file: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@ -224,7 +222,7 @@ async def update_file(
) -> FileItem:
"""Update file info"""
try:
managementInterface = interfaceDbComponentObjects.getInterface(currentUser)
managementInterface = interfaceDbManagement.getInterface(currentUser)
# Get the file from the database
file = managementInterface.getFile(fileId)
@ -270,7 +268,7 @@ async def delete_file(
currentUser: User = Depends(getCurrentUser)
) -> Dict[str, Any]:
"""Delete a file"""
managementInterface = interfaceDbComponentObjects.getInterface(currentUser)
managementInterface = interfaceDbManagement.getInterface(currentUser)
# Check if the file exists
existingFile = managementInterface.getFile(fileId)
@ -297,7 +295,7 @@ async def get_file_stats(
) -> Dict[str, Any]:
"""Returns statistics about the stored files"""
try:
managementInterface = interfaceDbComponentObjects.getInterface(currentUser)
managementInterface = interfaceDbManagement.getInterface(currentUser)
# Get all files - metadata only
allFiles = managementInterface.getAllFiles()
@ -336,7 +334,7 @@ async def download_file(
) -> Response:
"""Download a file"""
try:
managementInterface = interfaceDbComponentObjects.getInterface(currentUser)
managementInterface = interfaceDbManagement.getInterface(currentUser)
# Get file data
fileData = managementInterface.getFile(fileId)
@ -384,7 +382,7 @@ async def preview_file(
) -> FilePreview:
"""Preview a file's content"""
try:
managementInterface = interfaceDbComponentObjects.getInterface(currentUser)
managementInterface = interfaceDbManagement.getInterface(currentUser)
# Get file preview using the correct method
preview = managementInterface.getFileContent(fileId)

View file

@ -3,6 +3,10 @@
"""
Mandate routes for the backend API.
Implements the endpoints for mandate management.
MULTI-TENANT:
- Mandate CRUD is SysAdmin-only (mandates are system resources)
- User management within mandates is Mandate-Admin (add/remove users)
"""
from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Response, Query
@ -10,18 +14,53 @@ from typing import List, Dict, Any, Optional
from fastapi import status
import logging
import json
from pydantic import BaseModel, Field
# Import auth module
from modules.auth import limiter, getCurrentUser
from modules.auth import limiter, requireSysAdmin, getRequestContext, RequestContext
# Import interfaces
import modules.interfaces.interfaceDbAppObjects as interfaceDbAppObjects
import modules.interfaces.interfaceDbApp as interfaceDbApp
from modules.shared.attributeUtils import getModelAttributeDefinitions
from modules.shared.auditLogger import audit_logger
# Import the model classes
from modules.datamodels.datamodelUam import Mandate, User
from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole
from modules.datamodels.datamodelRbac import Role
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
# =============================================================================
# Request/Response Models for User Management
# =============================================================================
class UserMandateCreate(BaseModel):
"""Request model for adding a user to a mandate"""
targetUserId: str = Field(..., description="User ID to add to the mandate")
roleIds: List[str] = Field(..., description="Role IDs to assign to the user")
class UserMandateResponse(BaseModel):
"""Response model for user mandate membership"""
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
logger = logging.getLogger(__name__)
@ -40,10 +79,11 @@ router = APIRouter(
async def get_mandates(
request: Request,
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
currentUser: User = Depends(getCurrentUser)
currentUser: User = Depends(requireSysAdmin)
) -> PaginatedResponse[Mandate]:
"""
Get mandates with optional pagination, sorting, and filtering.
MULTI-TENANT: SysAdmin-only (mandates are system resources).
Query Parameters:
- pagination: JSON-encoded PaginationParams object, or None for no pagination
@ -67,7 +107,7 @@ async def get_mandates(
detail=f"Invalid pagination parameter: {str(e)}"
)
appInterface = interfaceDbAppObjects.getInterface(currentUser)
appInterface = interfaceDbApp.getRootInterface()
result = appInterface.getAllMandates(pagination=paginationParams)
# If pagination was requested, result is PaginatedResult
@ -103,11 +143,14 @@ async def get_mandates(
async def get_mandate(
request: Request,
mandateId: str = Path(..., description="ID of the mandate"),
currentUser: User = Depends(getCurrentUser)
currentUser: User = Depends(requireSysAdmin)
) -> Mandate:
"""Get a specific mandate by ID"""
"""
Get a specific mandate by ID.
MULTI-TENANT: SysAdmin-only.
"""
try:
appInterface = interfaceDbAppObjects.getInterface(currentUser)
appInterface = interfaceDbApp.getRootInterface()
mandate = appInterface.getMandate(mandateId)
if not mandate:
@ -131,9 +174,12 @@ async def get_mandate(
async def create_mandate(
request: Request,
mandateData: dict = Body(..., description="Mandate data with at least 'name' field"),
currentUser: User = Depends(getCurrentUser)
currentUser: User = Depends(requireSysAdmin)
) -> Mandate:
"""Create a new mandate"""
"""
Create a new mandate.
MULTI-TENANT: SysAdmin-only.
"""
try:
logger.debug(f"Creating mandate with data: {mandateData}")
@ -146,14 +192,16 @@ async def create_mandate(
)
# 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
newMandate = appInterface.createMandate(
name=name,
language=language
description=description,
enabled=enabled
)
if not newMandate:
@ -161,6 +209,8 @@ async def create_mandate(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create mandate"
)
logger.info(f"Mandate {newMandate.id} created by SysAdmin {currentUser.id}")
return newMandate
except HTTPException:
@ -178,13 +228,16 @@ async def update_mandate(
request: Request,
mandateId: str = Path(..., description="ID of the mandate to update"),
mandateData: dict = Body(..., description="Mandate update data"),
currentUser: User = Depends(getCurrentUser)
currentUser: User = Depends(requireSysAdmin)
) -> Mandate:
"""Update an existing mandate"""
"""
Update an existing mandate.
MULTI-TENANT: SysAdmin-only.
"""
try:
logger.debug(f"Updating mandate {mandateId} with data: {mandateData}")
appInterface = interfaceDbAppObjects.getInterface(currentUser)
appInterface = interfaceDbApp.getRootInterface()
# Check if mandate exists
existingMandate = appInterface.getMandate(mandateId)
@ -202,6 +255,8 @@ async def update_mandate(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update mandate"
)
logger.info(f"Mandate {mandateId} updated by SysAdmin {currentUser.id}")
return updatedMandate
except HTTPException:
@ -218,11 +273,14 @@ async def update_mandate(
async def delete_mandate(
request: Request,
mandateId: str = Path(..., description="ID of the mandate to delete"),
currentUser: User = Depends(getCurrentUser)
currentUser: User = Depends(requireSysAdmin)
) -> Dict[str, Any]:
"""Delete a mandate"""
"""
Delete a mandate.
MULTI-TENANT: SysAdmin-only.
"""
try:
appInterface = interfaceDbAppObjects.getInterface(currentUser)
appInterface = interfaceDbApp.getRootInterface()
# Check if mandate exists
existingMandate = appInterface.getMandate(mandateId)
@ -231,6 +289,12 @@ async def delete_mandate(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Mandate {mandateId} not found"
)
# MULTI-TENANT: Delete all UserMandate entries for this mandate first
userMandates = appInterface.db.getRecordset(UserMandate, recordFilter={"mandateId": mandateId})
for um in userMandates:
appInterface.db.deleteRecord(UserMandate, um["id"])
logger.info(f"Deleted {len(userMandates)} UserMandate entries for mandate {mandateId}")
# Delete mandate
try:
@ -240,6 +304,8 @@ async def delete_mandate(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
logger.info(f"Mandate {mandateId} deleted by SysAdmin {currentUser.id}")
return {"message": f"Mandate {mandateId} deleted successfully"}
except HTTPException:
@ -250,3 +316,557 @@ async def delete_mandate(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to delete mandate: {str(e)}"
)
# =============================================================================
# User Management within Mandates (Mandate-Admin)
# =============================================================================
@router.get("/{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
# Import interfaces
import modules.interfaces.interfaceDbComponentObjects as interfaceDbComponentObjects
import modules.interfaces.interfaceDbManagement as interfaceDbManagement
from modules.datamodels.datamodelUtils import Prompt
from modules.datamodels.datamodelUam import User
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)}"
)
managementInterface = interfaceDbComponentObjects.getInterface(currentUser)
managementInterface = interfaceDbManagement.getInterface(currentUser)
result = managementInterface.getAllPrompts(pagination=paginationParams)
# If pagination was requested, result is PaginatedResult
@ -89,7 +89,7 @@ async def create_prompt(
currentUser: User = Depends(getCurrentUser)
) -> Prompt:
"""Create a new prompt"""
managementInterface = interfaceDbComponentObjects.getInterface(currentUser)
managementInterface = interfaceDbManagement.getInterface(currentUser)
# Create prompt
newPrompt = managementInterface.createPrompt(prompt)
@ -104,7 +104,7 @@ async def get_prompt(
currentUser: User = Depends(getCurrentUser)
) -> Prompt:
"""Get a specific prompt"""
managementInterface = interfaceDbComponentObjects.getInterface(currentUser)
managementInterface = interfaceDbManagement.getInterface(currentUser)
# Get prompt
prompt = managementInterface.getPrompt(promptId)
@ -125,7 +125,7 @@ async def update_prompt(
currentUser: User = Depends(getCurrentUser)
) -> Prompt:
"""Update an existing prompt"""
managementInterface = interfaceDbComponentObjects.getInterface(currentUser)
managementInterface = interfaceDbManagement.getInterface(currentUser)
# Check if the prompt exists
existingPrompt = managementInterface.getPrompt(promptId)
@ -160,7 +160,7 @@ async def delete_prompt(
currentUser: User = Depends(getCurrentUser)
) -> Dict[str, Any]:
"""Delete a prompt"""
managementInterface = interfaceDbComponentObjects.getInterface(currentUser)
managementInterface = interfaceDbManagement.getInterface(currentUser)
# Check if the prompt exists
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.
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 typing import List, Dict, Any, Optional
from fastapi import status
from pydantic import BaseModel
import logging
import json
# Import interfaces and models
import modules.interfaces.interfaceDbAppObjects as interfaceDbAppObjects
from modules.auth import getCurrentUser, limiter
import modules.interfaces.interfaceDbApp as interfaceDbApp
from modules.auth import limiter, getRequestContext, RequestContext
# 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
# Configure logger
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(
prefix="/api/users",
tags=["Manage Users"],
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])
@limiter.limit("30/minute")
async def get_users(
request: Request,
mandateId: Optional[str] = Query(None, description="Mandate ID to filter users"),
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> PaginatedResponse[User]:
"""
Get users with optional pagination, sorting, and filtering.
MULTI-TENANT: mandateId from X-Mandate-Id header determines scope.
SysAdmin without mandateId sees all users.
Query Parameters:
- mandateId: Optional mandate ID to filter users
- pagination: JSON-encoded PaginationParams object, or None for no pagination
Examples:
- GET /api/users/ (no pagination - returns all users)
- GET /api/users/ (no pagination - returns all users in mandate)
- GET /api/users/?pagination={"page":1,"pageSize":10,"sort":[]}
"""
try:
@ -62,30 +221,82 @@ async def get_users(
detail=f"Invalid pagination parameter: {str(e)}"
)
appInterface = interfaceDbAppObjects.getInterface(currentUser)
# If mandateId is provided, use it, otherwise use the current user's mandate
targetMandateId = mandateId or currentUser.mandateId
# Get users with optional pagination
result = appInterface.getUsersByMandate(targetMandateId, pagination=paginationParams)
appInterface = interfaceDbApp.getInterface(context.user)
# If pagination was requested, result is PaginatedResult
# If no pagination, result is List[User]
if paginationParams:
return PaginatedResponse(
items=result.items,
pagination=PaginationMetadata(
currentPage=paginationParams.page,
pageSize=paginationParams.pageSize,
totalItems=result.totalItems,
totalPages=result.totalPages,
sort=paginationParams.sort,
filters=paginationParams.filters
# MULTI-TENANT: Use mandateId from context (header)
# SysAdmin without mandateId can see all users
if context.mandateId:
# Get users for specific mandate using getUsersByMandate
result = appInterface.getUsersByMandate(str(context.mandateId), paginationParams)
# getUsersByMandate returns PaginatedResult if pagination was provided
if paginationParams and hasattr(result, 'items'):
return PaginatedResponse(
items=result.items,
pagination=PaginationMetadata(
currentPage=result.currentPage,
pageSize=result.pageSize,
totalItems=result.totalItems,
totalPages=result.totalPages,
sort=paginationParams.sort,
filters=paginationParams.filters
)
)
else:
# No pagination - result is a list
users = result if isinstance(result, list) else result.items if hasattr(result, 'items') else []
return PaginatedResponse(
items=users,
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:
return PaginatedResponse(
items=result,
pagination=None
# Non-SysAdmin without mandateId - should not happen (getRequestContext enforces)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="X-Mandate-Id header is required"
)
except HTTPException:
raise
@ -101,11 +312,14 @@ async def get_users(
async def get_user(
request: Request,
userId: str = Path(..., description="ID of the user"),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> User:
"""Get a specific user by ID"""
"""
Get a specific user by ID.
MULTI-TENANT: User must be in the same mandate (via UserMandate) or caller is SysAdmin.
"""
try:
appInterface = interfaceDbAppObjects.getInterface(currentUser)
appInterface = interfaceDbApp.getInterface(context.user)
# Get user without filtering by enabled status
user = appInterface.getUser(userId)
@ -114,6 +328,19 @@ async def get_user(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User with ID {userId} not found"
)
# MULTI-TENANT: Verify user is in the same mandate (unless SysAdmin)
if context.mandateId and not context.isSysAdmin:
from modules.datamodels.datamodelMembership import UserMandate
userMandate = appInterface.db.getRecordset(UserMandate, recordFilter={
"userId": userId,
"mandateId": str(context.mandateId)
})
if not userMandate:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User not in your mandate"
)
return user
except HTTPException:
@ -125,30 +352,55 @@ async def get_user(
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)
@limiter.limit("10/minute")
async def create_user(
request: Request,
user_data: User = Body(...),
password: Optional[str] = Body(None, embed=True),
currentUser: User = Depends(getCurrentUser)
userData: CreateUserRequest = Body(...),
context: RequestContext = Depends(getRequestContext)
) -> User:
"""Create a new user"""
appInterface = interfaceDbAppObjects.getInterface(currentUser)
"""
Create a new user.
MULTI-TENANT: User is created and automatically added to the current mandate.
"""
appInterface = interfaceDbApp.getInterface(context.user)
# Extract fields from User model and call createUser with individual parameters
from modules.datamodels.datamodelUam import AuthAuthority
# Extract fields from request model and call createUser with individual parameters
newUser = appInterface.createUser(
username=user_data.username,
password=password,
email=user_data.email,
fullName=user_data.fullName,
language=user_data.language,
enabled=user_data.enabled,
roleLabels=user_data.roleLabels if user_data.roleLabels else ["user"],
authenticationAuthority=user_data.authenticationAuthority
username=userData.username,
password=userData.password,
email=userData.email,
fullName=userData.fullName,
language=userData.language,
enabled=userData.enabled,
authenticationAuthority=AuthAuthority.LOCAL,
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
@router.put("/{userId}", response_model=User)
@ -157,10 +409,13 @@ async def update_user(
request: Request,
userId: str = Path(..., description="ID of the user to update"),
userData: User = Body(...),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> User:
"""Update an existing user"""
appInterface = interfaceDbAppObjects.getInterface(currentUser)
"""
Update an existing user.
MULTI-TENANT: Can only update users in the same mandate (unless SysAdmin).
"""
appInterface = interfaceDbApp.getInterface(context.user)
# Check if the user exists
existingUser = appInterface.getUser(userId)
@ -170,6 +425,19 @@ async def update_user(
detail=f"User with ID {userId} not found"
)
# MULTI-TENANT: Verify user is in the same mandate (unless SysAdmin)
if context.mandateId and not context.isSysAdmin:
from modules.datamodels.datamodelMembership import UserMandate
userMandate = appInterface.db.getRecordset(UserMandate, recordFilter={
"userId": userId,
"mandateId": str(context.mandateId)
})
if not userMandate:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot update user outside your mandate"
)
# Update user
updatedUser = appInterface.updateUser(userId, userData)
@ -187,28 +455,44 @@ async def reset_user_password(
request: Request,
userId: str = Path(..., description="ID of the user to reset password for"),
newPassword: str = Body(..., embed=True),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""Reset user password (Admin only)"""
"""
Reset user password (Admin only).
MULTI-TENANT: Can only reset passwords for users in the same mandate (unless SysAdmin).
"""
try:
# Check if current user is admin
if "admin" not in (currentUser.roleLabels or []) and "sysadmin" not in (currentUser.roleLabels or []):
if not context.isSysAdmin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only administrators can reset passwords"
)
# Get user interface
appInterface = interfaceDbAppObjects.getInterface(currentUser)
appInterface = interfaceDbApp.getInterface(context.user)
# Get target user
target_user = appInterface.getUserById(userId)
target_user = appInterface.getUser(userId)
if not target_user:
raise HTTPException(
status_code=status.HTTP_404_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
if len(newPassword) < 8:
raise HTTPException(
@ -226,12 +510,11 @@ async def reset_user_password(
# SECURITY: Automatically revoke all tokens for the user after password reset
try:
from modules.datamodels.datamodelUam import AuthAuthority
revoked_count = appInterface.revokeTokensByUser(
userId=userId,
authority=None, # Revoke all authorities
mandateId=None, # Revoke across all mandates
revokedBy=currentUser.id,
revokedBy=context.user.id,
reason="password_reset"
)
logger.info(f"Revoked {revoked_count} tokens for user {userId} after password reset")
@ -243,10 +526,12 @@ async def reset_user_password(
try:
from modules.shared.auditLogger import audit_logger
audit_logger.logSecurityEvent(
userId=str(currentUser.id),
mandateId=str(currentUser.mandateId),
userId=str(context.user.id),
mandateId=str(context.mandateId) if context.mandateId else "system",
action="password_reset",
details=f"Reset password for user {userId}"
details=f"Reset password for user {userId}",
ipAddress=request.client.host if request.client else None,
success=True
)
except Exception:
pass
@ -271,15 +556,18 @@ async def change_password(
request: Request,
currentPassword: str = Body(..., embed=True),
newPassword: str = Body(..., embed=True),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""Change current user's password"""
"""
Change current user's password.
MULTI-TENANT: User changes their own password (no mandate restriction).
"""
try:
# Get user interface
appInterface = interfaceDbAppObjects.getInterface(currentUser)
appInterface = interfaceDbApp.getInterface(context.user)
# Verify current password
if not appInterface.verifyPassword(currentPassword, currentUser.passwordHash):
if not appInterface.verifyPassword(currentPassword, context.user.passwordHash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Current password is incorrect"
@ -293,7 +581,7 @@ async def change_password(
)
# Change password
success = appInterface.resetUserPassword(str(currentUser.id), newPassword)
success = appInterface.resetUserPassword(str(context.user.id), newPassword)
if not success:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@ -302,27 +590,28 @@ async def change_password(
# SECURITY: Automatically revoke all tokens for the user after password change
try:
from modules.datamodels.datamodelUam import AuthAuthority
revoked_count = appInterface.revokeTokensByUser(
userId=str(currentUser.id),
userId=str(context.user.id),
authority=None, # Revoke all authorities
mandateId=None, # Revoke across all mandates
revokedBy=currentUser.id,
revokedBy=context.user.id,
reason="password_change"
)
logger.info(f"Revoked {revoked_count} tokens for user {currentUser.id} after password change")
logger.info(f"Revoked {revoked_count} tokens for user {context.user.id} after password change")
except Exception as e:
logger.error(f"Failed to revoke tokens after password change for user {currentUser.id}: {str(e)}")
logger.error(f"Failed to revoke tokens after password change for user {context.user.id}: {str(e)}")
# Don't fail the password change if token revocation fails
# Log password change
try:
from modules.shared.auditLogger import audit_logger
audit_logger.logSecurityEvent(
userId=str(currentUser.id),
mandateId=str(currentUser.mandateId),
userId=str(context.user.id),
mandateId=str(context.mandateId) if context.mandateId else "system",
action="password_change",
details="User changed their own password"
details="User changed their own password",
ipAddress=request.client.host if request.client else None,
success=True
)
except Exception:
pass
@ -342,13 +631,15 @@ async def change_password(
@router.post("/{userId}/send-password-link")
@limiter.limit("10/minute")
async def sendPasswordLink(
async def send_password_link(
request: Request,
userId: str = Path(..., description="ID of the user to send password setup link"),
frontendUrl: str = Body(..., embed=True),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""Send password setup/reset link to a user (admin function).
"""
Send password setup/reset link to a user (admin function).
MULTI-TENANT: Can only send to users in the same mandate (unless SysAdmin).
This allows admins to send a magic link to users to set or reset their password.
Used when creating users without password or to help users who forgot their password.
@ -359,10 +650,9 @@ async def sendPasswordLink(
"""
try:
from modules.shared.configuration import APP_CONFIG
from modules.interfaces.interfaceDbAppObjects import getRootInterface
# Get user interface
appInterface = interfaceDbAppObjects.getInterface(currentUser)
appInterface = interfaceDbApp.getInterface(context.user)
# Get target user
targetUser = appInterface.getUser(userId)
@ -372,6 +662,19 @@ async def sendPasswordLink(
detail="User not found"
)
# MULTI-TENANT: Verify user is in the same mandate (unless SysAdmin)
if context.mandateId and not context.isSysAdmin:
from modules.datamodels.datamodelMembership import UserMandate
userMandate = appInterface.db.getRecordset(UserMandate, recordFilter={
"userId": userId,
"mandateId": str(context.mandateId)
})
if not userMandate:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot send password link to user outside your mandate"
)
# Check if user has an email
if not targetUser.email:
raise HTTPException(
@ -440,15 +743,15 @@ Falls Sie diese Anforderung nicht erwartet haben, kontaktieren Sie bitte Ihren A
try:
from modules.shared.auditLogger import audit_logger
audit_logger.logSecurityEvent(
userId=str(currentUser.id),
mandateId=str(currentUser.mandateId),
userId=str(context.user.id),
mandateId=str(context.mandateId) if context.mandateId else "system",
action="send_password_link",
details=f"Sent password setup link to user {userId} ({targetUser.email})"
)
except Exception:
pass
logger.info(f"Password setup link sent to {targetUser.email} for user {targetUser.username} by admin {currentUser.username}")
logger.info(f"Password setup link sent to {targetUser.email} for user {targetUser.username} by admin {context.user.username}")
return {
"message": f"Password setup link sent to {targetUser.email}",
@ -470,10 +773,13 @@ Falls Sie diese Anforderung nicht erwartet haben, kontaktieren Sie bitte Ihren A
async def delete_user(
request: Request,
userId: str = Path(..., description="ID of the user to delete"),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""Delete a user"""
appInterface = interfaceDbAppObjects.getInterface(currentUser)
"""
Delete a user.
MULTI-TENANT: Can only delete users in the same mandate (unless SysAdmin).
"""
appInterface = interfaceDbApp.getInterface(context.user)
# Check if the user exists
existingUser = appInterface.getUser(userId)
@ -483,6 +789,25 @@ async def delete_user(
detail=f"User with ID {userId} not found"
)
# MULTI-TENANT: Verify user is in the same mandate (unless SysAdmin)
if context.mandateId and not context.isSysAdmin:
from modules.datamodels.datamodelMembership import UserMandate
userMandate = appInterface.db.getRecordset(UserMandate, recordFilter={
"userId": userId,
"mandateId": str(context.mandateId)
})
if not userMandate:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot delete user outside your mandate"
)
# Delete UserMandate entries for this user first
from modules.datamodels.datamodelMembership import UserMandate
userMandates = appInterface.db.getRecordset(UserMandate, recordFilter={"userId": userId})
for um in userMandates:
appInterface.db.deleteRecord(UserMandate, um["id"])
success = appInterface.deleteUser(userId)
if not success:
raise HTTPException(

View file

@ -13,12 +13,12 @@ from fastapi import APIRouter, HTTPException, Depends, Body, Path, Query, Respon
# Import auth modules
from modules.auth import limiter, getCurrentUser
# Import interfaces
import modules.interfaces.interfaceDbChatObjects as interfaceDbChatObjects
from modules.interfaces.interfaceDbChatObjects import getInterface
# Import interfaces from feature containers
import modules.interfaces.interfaceDbChat as interfaceDbChat
from modules.interfaces.interfaceDbChat import getInterface
from modules.interfaces.interfaceRbac import getRecordsetWithRBAC
# Import models
# Import models from feature containers
from modules.datamodels.datamodelChat import (
ChatWorkflow,
ChatMessage,
@ -45,7 +45,7 @@ router = APIRouter(
)
def getServiceChat(currentUser: User):
return interfaceDbChatObjects.getInterface(currentUser)
return interfaceDbChat.getInterface(currentUser)
# Consolidated endpoint for getting all workflows
@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 auth module
from modules.auth import limiter, getCurrentUser
from modules.auth import limiter, getCurrentUser, getRequestContext, RequestContext
from modules.datamodels.datamodelRbac import Role
# Import interfaces
import modules.interfaces.interfaceDbComponentObjects as interfaceDbComponentObjects
import modules.interfaces.interfaceDbManagement as interfaceDbManagement
from modules.datamodels.datamodelMessaging import (
MessagingSubscription,
MessagingSubscriptionRegistration,
@ -37,7 +38,7 @@ router = APIRouter(
@router.get("/subscriptions", response_model=PaginatedResponse[MessagingSubscription])
@limiter.limit("60/minute")
async def getSubscriptions(
async def get_subscriptions(
request: Request,
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
currentUser: User = Depends(getCurrentUser)
@ -54,7 +55,7 @@ async def getSubscriptions(
detail=f"Invalid pagination parameter: {str(e)}"
)
managementInterface = interfaceDbComponentObjects.getInterface(currentUser)
managementInterface = interfaceDbManagement.getInterface(currentUser)
result = managementInterface.getAllSubscriptions(pagination=paginationParams)
if paginationParams:
@ -78,13 +79,13 @@ async def getSubscriptions(
@router.post("/subscriptions", response_model=MessagingSubscription)
@limiter.limit("60/minute")
async def createSubscription(
async def create_subscription(
request: Request,
subscription: MessagingSubscription,
currentUser: User = Depends(getCurrentUser)
) -> MessagingSubscription:
"""Create a new subscription"""
managementInterface = interfaceDbComponentObjects.getInterface(currentUser)
managementInterface = interfaceDbManagement.getInterface(currentUser)
subscriptionData = subscription.model_dump(exclude={"id"})
newSubscription = managementInterface.createSubscription(subscriptionData)
@ -94,13 +95,13 @@ async def createSubscription(
@router.get("/subscriptions/{subscriptionId}", response_model=MessagingSubscription)
@limiter.limit("60/minute")
async def getSubscription(
async def get_subscription(
request: Request,
subscriptionId: str = Path(..., description="ID of the subscription"),
currentUser: User = Depends(getCurrentUser)
) -> MessagingSubscription:
"""Get a specific subscription"""
managementInterface = interfaceDbComponentObjects.getInterface(currentUser)
managementInterface = interfaceDbManagement.getInterface(currentUser)
subscription = managementInterface.getSubscription(subscriptionId)
if not subscription:
@ -114,14 +115,14 @@ async def getSubscription(
@router.put("/subscriptions/{subscriptionId}", response_model=MessagingSubscription)
@limiter.limit("60/minute")
async def updateSubscription(
async def update_subscription(
request: Request,
subscriptionId: str = Path(..., description="ID of the subscription to update"),
subscriptionData: MessagingSubscription = Body(...),
currentUser: User = Depends(getCurrentUser)
) -> MessagingSubscription:
"""Update an existing subscription"""
managementInterface = interfaceDbComponentObjects.getInterface(currentUser)
managementInterface = interfaceDbManagement.getInterface(currentUser)
existingSubscription = managementInterface.getSubscription(subscriptionId)
if not existingSubscription:
@ -144,13 +145,13 @@ async def updateSubscription(
@router.delete("/subscriptions/{subscriptionId}", response_model=Dict[str, Any])
@limiter.limit("60/minute")
async def deleteSubscription(
async def delete_subscription(
request: Request,
subscriptionId: str = Path(..., description="ID of the subscription to delete"),
currentUser: User = Depends(getCurrentUser)
) -> Dict[str, Any]:
"""Delete a subscription"""
managementInterface = interfaceDbComponentObjects.getInterface(currentUser)
managementInterface = interfaceDbManagement.getInterface(currentUser)
existingSubscription = managementInterface.getSubscription(subscriptionId)
if not existingSubscription:
@ -173,7 +174,7 @@ async def deleteSubscription(
@router.get("/subscriptions/{subscriptionId}/registrations", response_model=PaginatedResponse[MessagingSubscriptionRegistration])
@limiter.limit("60/minute")
async def getSubscriptionRegistrations(
async def get_subscription_registrations(
request: Request,
subscriptionId: str = Path(..., description="ID of the subscription"),
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
@ -191,7 +192,7 @@ async def getSubscriptionRegistrations(
detail=f"Invalid pagination parameter: {str(e)}"
)
managementInterface = interfaceDbComponentObjects.getInterface(currentUser)
managementInterface = interfaceDbManagement.getInterface(currentUser)
result = managementInterface.getAllRegistrations(
subscriptionId=subscriptionId,
pagination=paginationParams
@ -218,7 +219,7 @@ async def getSubscriptionRegistrations(
@router.post("/subscriptions/{subscriptionId}/subscribe", response_model=MessagingSubscriptionRegistration)
@limiter.limit("60/minute")
async def subscribeUser(
async def subscribe_user(
request: Request,
subscriptionId: str = Path(..., description="ID of the subscription"),
channel: MessagingChannel = Body(..., embed=True),
@ -226,7 +227,7 @@ async def subscribeUser(
currentUser: User = Depends(getCurrentUser)
) -> MessagingSubscriptionRegistration:
"""Subscribe user to a subscription with a specific channel"""
managementInterface = interfaceDbComponentObjects.getInterface(currentUser)
managementInterface = interfaceDbManagement.getInterface(currentUser)
registration = managementInterface.subscribeUser(
subscriptionId=subscriptionId,
@ -240,14 +241,14 @@ async def subscribeUser(
@router.delete("/subscriptions/{subscriptionId}/unsubscribe", response_model=Dict[str, Any])
@limiter.limit("60/minute")
async def unsubscribeUser(
async def unsubscribe_user(
request: Request,
subscriptionId: str = Path(..., description="ID of the subscription"),
channel: MessagingChannel = Body(..., embed=True),
currentUser: User = Depends(getCurrentUser)
) -> Dict[str, Any]:
"""Unsubscribe user from a subscription for a specific channel"""
managementInterface = interfaceDbComponentObjects.getInterface(currentUser)
managementInterface = interfaceDbManagement.getInterface(currentUser)
success = managementInterface.unsubscribeUser(
subscriptionId=subscriptionId,
@ -266,7 +267,7 @@ async def unsubscribeUser(
@router.get("/registrations", response_model=PaginatedResponse[MessagingSubscriptionRegistration])
@limiter.limit("60/minute")
async def getMyRegistrations(
async def get_my_registrations(
request: Request,
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
currentUser: User = Depends(getCurrentUser)
@ -283,7 +284,7 @@ async def getMyRegistrations(
detail=f"Invalid pagination parameter: {str(e)}"
)
managementInterface = interfaceDbComponentObjects.getInterface(currentUser)
managementInterface = interfaceDbManagement.getInterface(currentUser)
result = managementInterface.getAllRegistrations(
userId=currentUser.id,
pagination=paginationParams
@ -310,14 +311,14 @@ async def getMyRegistrations(
@router.put("/registrations/{registrationId}", response_model=MessagingSubscriptionRegistration)
@limiter.limit("60/minute")
async def updateRegistration(
async def update_registration(
request: Request,
registrationId: str = Path(..., description="ID of the registration to update"),
registrationData: MessagingSubscriptionRegistration = Body(...),
currentUser: User = Depends(getCurrentUser)
) -> MessagingSubscriptionRegistration:
"""Update a registration"""
managementInterface = interfaceDbComponentObjects.getInterface(currentUser)
managementInterface = interfaceDbManagement.getInterface(currentUser)
existingRegistration = managementInterface.getRegistration(registrationId)
if not existingRegistration:
@ -340,13 +341,13 @@ async def updateRegistration(
@router.delete("/registrations/{registrationId}", response_model=Dict[str, Any])
@limiter.limit("60/minute")
async def deleteRegistration(
async def delete_registration(
request: Request,
registrationId: str = Path(..., description="ID of the registration to delete"),
currentUser: User = Depends(getCurrentUser)
) -> Dict[str, Any]:
"""Delete a registration"""
managementInterface = interfaceDbComponentObjects.getInterface(currentUser)
managementInterface = interfaceDbManagement.getInterface(currentUser)
existingRegistration = managementInterface.getRegistration(registrationId)
if not existingRegistration:
@ -375,20 +376,27 @@ def _getTriggerKey(request: Request) -> str:
@router.post("/trigger/{subscriptionId}", response_model=MessagingSubscriptionExecutionResult)
@limiter.limit("60/minute", key_func=_getTriggerKey)
async def triggerSubscription(
async def trigger_subscription(
request: Request,
subscriptionId: str = Path(..., description="ID of the subscription to trigger"),
eventParameters: Dict[str, Any] = Body(...),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> MessagingSubscriptionExecutionResult:
"""Trigger a subscription with event parameters"""
# RBAC-Check: Nur Admin/Mandate-Admin kann triggern
# TODO: Add proper RBAC check here
"""
Trigger a subscription with event parameters.
Requires Mandate-Admin role or SysAdmin.
"""
# RBAC-Check: Admin or Mandate-Admin can trigger
if not _hasTriggerPermission(context):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin or Mandate-Admin role required to trigger subscriptions"
)
# Get messaging service from request app state
# We need to access services through the request
from modules.services import getInterface as getServicesInterface
services = getServicesInterface(currentUser, None)
services = getServicesInterface(context.user, None, mandateId=str(context.mandateId))
# Konvertiere Dict zu Pydantic Model
eventParams = MessagingEventParameters(triggerData=eventParameters)
@ -397,11 +405,42 @@ async def triggerSubscription(
return executionResult
def _hasTriggerPermission(context: RequestContext) -> bool:
"""
Check if user has permission to trigger subscriptions.
Requires admin or mandate-admin role.
"""
if context.isSysAdmin:
return True
if not context.roleIds:
return False
try:
from modules.interfaces.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
@router.get("/deliveries", response_model=PaginatedResponse[MessagingDelivery])
@limiter.limit("60/minute")
async def getDeliveries(
async def get_deliveries(
request: Request,
subscriptionId: Optional[str] = Query(None, description="Filter by subscription ID"),
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
@ -419,7 +458,7 @@ async def getDeliveries(
detail=f"Invalid pagination parameter: {str(e)}"
)
managementInterface = interfaceDbComponentObjects.getInterface(currentUser)
managementInterface = interfaceDbManagement.getInterface(currentUser)
result = managementInterface.getDeliveries(
subscriptionId=subscriptionId,
userId=currentUser.id, # Users can only see their own deliveries
@ -447,13 +486,13 @@ async def getDeliveries(
@router.get("/deliveries/{deliveryId}", response_model=MessagingDelivery)
@limiter.limit("60/minute")
async def getDelivery(
async def get_delivery(
request: Request,
deliveryId: str = Path(..., description="ID of the delivery"),
currentUser: User = Depends(getCurrentUser)
) -> MessagingDelivery:
"""Get a specific delivery"""
managementInterface = interfaceDbComponentObjects.getInterface(currentUser)
managementInterface = interfaceDbManagement.getInterface(currentUser)
delivery = managementInterface.getDelivery(deliveryId)
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
# All rights reserved.
"""
Security Administration routes.
MULTI-TENANT: These are SYSTEM-LEVEL operations requiring isSysAdmin=true.
No mandate context - SysAdmin manages infrastructure, not data.
"""
from fastapi import APIRouter, HTTPException, Depends, status, Request, Body
from fastapi.responses import FileResponse, JSONResponse
from typing import Optional, Dict, Any, List
import os
import logging
from modules.auth import getCurrentUser, limiter
from modules.interfaces.interfaceDbAppObjects import getInterface, getRootInterface
from modules.auth import getCurrentUser, limiter, requireSysAdmin
from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelUam import User, UserInDB, AuthAuthority
from modules.datamodels.datamodelSecurity import Token
from modules.shared.configuration import APP_CONFIG
@ -26,13 +32,63 @@ router = APIRouter(
}
)
def _ensure_admin_scope(current_user: User, target_mandate_id: Optional[str] = None) -> None:
roleLabels = current_user.roleLabels or []
if "admin" not in roleLabels and "sysadmin" not in roleLabels:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin privileges required")
if "admin" in roleLabels and "sysadmin" not in roleLabels:
if target_mandate_id and str(target_mandate_id) != str(current_user.mandateId):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden for target mandate")
def _getPoweronDatabases() -> List[str]:
"""Load databases from PostgreSQL host matching poweron_%."""
dbHost = APP_CONFIG.get("DB_HOST")
dbUser = APP_CONFIG.get("DB_USER")
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
# Connect to 'postgres' system database to query all databases
connector = DatabaseConnector(
dbHost=dbHost,
dbDatabase="postgres",
dbUser=dbUser,
dbPassword=dbPassword,
dbPort=dbPort,
userId=None
)
try:
with connector.connection.cursor() as cursor:
cursor.execute(
"""
SELECT datname
FROM pg_database
WHERE datname LIKE 'poweron_%'
AND datistemplate = false
ORDER BY datname
"""
)
rows = cursor.fetchall()
return [row["datname"] for row in rows if row.get("datname")]
finally:
connector.close()
def _getDatabaseConnector(databaseName: str, userId: str = None) -> DatabaseConnector:
"""
Create a generic DatabaseConnector for any poweron_* database.
Fully dynamic - no interface mapping needed.
"""
if not databaseName.startswith("poweron_"):
raise ValueError(f"Invalid database name: {databaseName}")
dbHost = APP_CONFIG.get("DB_HOST")
dbUser = APP_CONFIG.get("DB_USER")
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
connector = DatabaseConnector(
dbHost=dbHost,
dbDatabase=databaseName,
dbUser=dbUser,
dbPassword=dbPassword,
dbPort=dbPort,
userId=userId
)
return connector
# ----------------------
@ -43,17 +99,19 @@ def _ensure_admin_scope(current_user: User, target_mandate_id: Optional[str] = N
@limiter.limit("30/minute")
async def list_tokens(
request: Request,
currentUser: User = Depends(getCurrentUser),
currentUser: User = Depends(requireSysAdmin),
userId: Optional[str] = None,
authority: Optional[str] = None,
sessionId: Optional[str] = None,
statusFilter: Optional[str] = None,
connectionId: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""
List all tokens in the system.
MULTI-TENANT: SysAdmin-only, no mandate filter (system-level view).
"""
try:
appInterface = getRootInterface()
target_mandate = currentUser.mandateId
_ensure_admin_scope(currentUser, target_mandate)
recordFilter: Dict[str, Any] = {}
if userId:
@ -66,9 +124,7 @@ async def list_tokens(
recordFilter["connectionId"] = connectionId
if statusFilter:
recordFilter["status"] = statusFilter
roleLabels = currentUser.roleLabels or []
if "admin" in roleLabels and "sysadmin" not in roleLabels:
recordFilter["mandateId"] = str(currentUser.mandateId)
# MULTI-TENANT: SysAdmin sees ALL tokens (no mandate filter)
tokens = appInterface.db.getRecordset(Token, recordFilter=recordFilter)
return tokens
@ -83,27 +139,26 @@ async def list_tokens(
@limiter.limit("30/minute")
async def revoke_tokens_by_user(
request: Request,
currentUser: User = Depends(getCurrentUser),
currentUser: User = Depends(requireSysAdmin),
payload: Dict[str, Any] = Body(...)
) -> Dict[str, Any]:
"""
Revoke all tokens for a user.
MULTI-TENANT: SysAdmin-only, can revoke across all mandates.
"""
try:
userId = payload.get("userId")
authority = payload.get("authority")
reason = payload.get("reason", "admin revoke")
reason = payload.get("reason", "sysadmin revoke")
if not userId:
raise HTTPException(status_code=400, detail="userId is required")
appInterface = getRootInterface()
# Tenant scope check
target_user = appInterface.db.getRecordset(User, recordFilter={"id": userId})
target_mandate = target_user[0].get("mandateId") if target_user else None
_ensure_admin_scope(currentUser, target_mandate)
roleLabels = currentUser.roleLabels or []
# MULTI-TENANT: SysAdmin can revoke any user's tokens (no mandate restriction)
count = appInterface.revokeTokensByUser(
userId=userId,
authority=AuthAuthority(authority) if authority else None,
mandateId=None if "sysadmin" in roleLabels else str(currentUser.mandateId),
mandateId=None, # SysAdmin: no mandate filter
revokedBy=currentUser.id,
reason=reason
)
@ -119,22 +174,23 @@ async def revoke_tokens_by_user(
@limiter.limit("30/minute")
async def revoke_tokens_by_session(
request: Request,
currentUser: User = Depends(getCurrentUser),
currentUser: User = Depends(requireSysAdmin),
payload: Dict[str, Any] = Body(...)
) -> Dict[str, Any]:
"""
Revoke all tokens for a specific session.
MULTI-TENANT: SysAdmin-only.
"""
try:
userId = payload.get("userId")
sessionId = payload.get("sessionId")
authority = payload.get("authority", "local")
reason = payload.get("reason", "admin session revoke")
reason = payload.get("reason", "sysadmin session revoke")
if not userId or not sessionId:
raise HTTPException(status_code=400, detail="userId and sessionId are required")
appInterface = getRootInterface()
target_user = appInterface.db.getRecordset(User, recordFilter={"id": userId})
target_mandate = target_user[0].get("mandateId") if target_user else None
_ensure_admin_scope(currentUser, target_mandate)
# MULTI-TENANT: SysAdmin can revoke any session (no mandate check)
count = appInterface.revokeTokensBySessionId(
sessionId=sessionId,
userId=userId,
@ -154,22 +210,20 @@ async def revoke_tokens_by_session(
@limiter.limit("30/minute")
async def revoke_token_by_id(
request: Request,
currentUser: User = Depends(getCurrentUser),
currentUser: User = Depends(requireSysAdmin),
payload: Dict[str, Any] = Body(...)
) -> Dict[str, Any]:
"""
Revoke a specific token by ID.
MULTI-TENANT: SysAdmin-only.
"""
try:
tokenId = payload.get("tokenId")
reason = payload.get("reason", "admin revoke")
reason = payload.get("reason", "sysadmin revoke")
if not tokenId:
raise HTTPException(status_code=400, detail="tokenId is required")
appInterface = getRootInterface()
# Load token to check tenant scope for admins
tokens = appInterface.db.getRecordset(Token, recordFilter={"id": tokenId})
if not tokens:
return {"revoked": 0}
target_mandate = tokens[0].get("mandateId")
_ensure_admin_scope(currentUser, target_mandate)
# MULTI-TENANT: SysAdmin can revoke any token (no mandate check)
ok = appInterface.revokeTokenById(tokenId, revokedBy=currentUser.id, reason=reason)
return {"revoked": 1 if ok else 0}
except HTTPException:
@ -183,29 +237,34 @@ async def revoke_token_by_id(
@limiter.limit("10/minute")
async def revoke_tokens_by_mandate(
request: Request,
currentUser: User = Depends(getCurrentUser),
currentUser: User = Depends(requireSysAdmin),
payload: Dict[str, Any] = Body(...)
) -> Dict[str, Any]:
"""
Revoke all tokens for users in a mandate.
MULTI-TENANT: SysAdmin-only, can revoke tokens for any mandate.
"""
try:
mandateId = payload.get("mandateId")
authority = payload.get("authority", "local")
reason = payload.get("reason", "admin mandate revoke")
reason = payload.get("reason", "sysadmin mandate revoke")
if not mandateId:
raise HTTPException(status_code=400, detail="mandateId is required")
_ensure_admin_scope(currentUser, mandateId)
# Revoke for all users in mandate
# MULTI-TENANT: SysAdmin can revoke tokens for any mandate
appInterface = getRootInterface()
# IMPORTANT: user rows are stored as UserInDB in the database
users = appInterface.db.getRecordset(UserInDB, recordFilter={"mandateId": mandateId})
# Get all UserMandate entries for this mandate to find users
# Note: In new model, users are linked via UserMandate, not User.mandateId
from modules.datamodels.datamodelMembership import UserMandate
userMandates = appInterface.db.getRecordset(UserMandate, recordFilter={"mandateId": mandateId})
total = 0
for u in users:
# Revoke regardless of token.mandateId to also catch legacy tokens without mandateId
for um in userMandates:
total += appInterface.revokeTokensByUser(
userId=u["id"],
authority=AuthAuthority(authority),
mandateId=None,
userId=um["userId"],
authority=AuthAuthority(authority) if authority else None,
mandateId=None, # Revoke all tokens for user
revokedBy=currentUser.id,
reason=reason
)
@ -225,10 +284,13 @@ async def revoke_tokens_by_mandate(
@limiter.limit("60/minute")
async def download_log(
request: Request,
currentUser: User = Depends(getCurrentUser),
currentUser: User = Depends(requireSysAdmin),
log_name: str = "poweron"
):
_ensure_admin_scope(currentUser)
"""
Download server logs.
MULTI-TENANT: SysAdmin-only (infrastructure management).
"""
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# base_dir -> gateway
if log_name == "poweron":
@ -251,33 +313,18 @@ async def download_log(
@limiter.limit("10/minute")
async def list_databases(
request: Request,
currentUser: User = Depends(getCurrentUser)
currentUser: User = Depends(requireSysAdmin)
) -> Dict[str, Any]:
_ensure_admin_scope(currentUser)
# Get database names from configuration for each interface
databases = []
# App database (interfaceDbAppObjects.py)
app_db = APP_CONFIG.get("DB_APP_DATABASE")
if app_db:
databases.append(app_db)
# Chat database (interfaceDbChatObjects.py)
chat_db = APP_CONFIG.get("DB_CHAT_DATABASE")
if chat_db:
databases.append(chat_db)
# Management database (interfaceDbComponentObjects.py)
management_db = APP_CONFIG.get("DB_MANAGEMENT_DATABASE")
if management_db:
databases.append(management_db)
# Fallback to default if no databases configured
if not databases:
databases = ["poweron"]
return {"databases": databases}
"""
List all poweron_* databases.
MULTI-TENANT: SysAdmin-only (infrastructure management).
"""
try:
databases = _getPoweronDatabases()
return {"databases": databases}
except Exception as e:
logger.error(f"Failed to load databases from host: {e}")
raise HTTPException(status_code=500, detail="Failed to load databases from host")
@router.get("/databases/{database_name}/tables")
@ -285,48 +332,28 @@ async def list_databases(
async def get_database_tables(
request: Request,
database_name: str,
currentUser: User = Depends(getCurrentUser)
currentUser: User = Depends(requireSysAdmin)
) -> Dict[str, Any]:
_ensure_admin_scope(currentUser)
# Get all configured database names
configured_dbs = []
app_db = APP_CONFIG.get("DB_APP_DATABASE")
if app_db:
configured_dbs.append(app_db)
chat_db = APP_CONFIG.get("DB_CHAT_DATABASE")
if chat_db:
configured_dbs.append(chat_db)
management_db = APP_CONFIG.get("DB_MANAGEMENT_DATABASE")
if management_db:
configured_dbs.append(management_db)
if not configured_dbs:
configured_dbs = ["poweron"]
if database_name not in configured_dbs:
raise HTTPException(status_code=400, detail=f"Invalid database name. Available databases: {configured_dbs}")
"""
List tables in a database.
MULTI-TENANT: SysAdmin-only (infrastructure management).
"""
if not database_name.startswith("poweron_"):
raise HTTPException(status_code=400, detail="Invalid database name format")
connector = None
try:
# Use the appropriate interface based on database name
if database_name == app_db:
appInterface = getRootInterface()
tables = appInterface.db.getTables()
elif database_name == chat_db:
from modules.interfaces.interfaceDbChatObjects import getInterface as getChatInterface
chatInterface = getChatInterface(currentUser)
tables = chatInterface.db.getTables()
elif database_name == management_db:
from modules.interfaces.interfaceDbComponentObjects import getInterface as getComponentInterface
componentInterface = getComponentInterface(currentUser)
tables = componentInterface.db.getTables()
else:
raise HTTPException(status_code=400, detail="Database not found")
connector = _getDatabaseConnector(database_name, currentUser.id)
tables = connector.getTables()
return {"tables": tables}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Error getting database tables: {str(e)}")
raise HTTPException(status_code=500, detail="Failed to get database tables")
raise HTTPException(status_code=500, detail=f"Failed to get database tables: {str(e)}")
finally:
if connector:
connector.close()
@router.post("/databases/{database_name}/tables/{table_name}/drop")
@ -335,43 +362,20 @@ async def drop_table(
request: Request,
database_name: str,
table_name: str,
currentUser: User = Depends(getCurrentUser),
currentUser: User = Depends(requireSysAdmin),
payload: Dict[str, Any] = Body(...)
) -> Dict[str, Any]:
_ensure_admin_scope(currentUser)
# Get all configured database names
configured_dbs = []
app_db = APP_CONFIG.get("DB_APP_DATABASE")
if app_db:
configured_dbs.append(app_db)
chat_db = APP_CONFIG.get("DB_CHAT_DATABASE")
if chat_db:
configured_dbs.append(chat_db)
management_db = APP_CONFIG.get("DB_MANAGEMENT_DATABASE")
if management_db:
configured_dbs.append(management_db)
if not configured_dbs:
configured_dbs = ["poweron"]
if database_name not in configured_dbs:
raise HTTPException(status_code=400, detail=f"Invalid database name. Available databases: {configured_dbs}")
"""
Drop a table from a database.
MULTI-TENANT: SysAdmin-only (infrastructure management).
"""
if not database_name.startswith("poweron_"):
raise HTTPException(status_code=400, detail="Invalid database name format")
connector = None
try:
# Use the appropriate interface based on database name
if database_name == app_db:
interface = getRootInterface()
elif database_name == chat_db:
from modules.interfaces.interfaceDbChatObjects import getInterface as getChatInterface
interface = getChatInterface(currentUser)
elif database_name == management_db:
from modules.interfaces.interfaceDbComponentObjects import getInterface as getComponentInterface
interface = getComponentInterface(currentUser)
else:
raise HTTPException(status_code=400, detail="Database not found")
conn = interface.db.connection
connector = _getDatabaseConnector(database_name, currentUser.id)
conn = connector.connection
with conn.cursor() as cursor:
# Check if table exists
cursor.execute("""
@ -388,57 +392,50 @@ async def drop_table(
return {"message": f"Table '{table_name}' dropped successfully from database '{database_name}'"}
except HTTPException:
raise
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Error dropping table: {str(e)}")
if 'interface' in locals() and interface and interface.db and interface.db.connection:
interface.db.connection.rollback()
if connector and connector.connection:
connector.connection.rollback()
raise HTTPException(status_code=500, detail="Failed to drop table")
finally:
if connector:
connector.close()
@router.post("/databases/drop")
@limiter.limit("5/minute")
async def drop_database(
request: Request,
currentUser: User = Depends(getCurrentUser),
currentUser: User = Depends(requireSysAdmin),
payload: Dict[str, Any] = Body(...)
) -> Dict[str, Any]:
_ensure_admin_scope(currentUser)
db_name = payload.get("database")
"""
Drop all tables in a database.
MULTI-TENANT: SysAdmin-only (infrastructure management).
"""
dbName = payload.get("database")
# Get all configured database names
configured_dbs = []
app_db = APP_CONFIG.get("DB_APP_DATABASE")
if app_db:
configured_dbs.append(app_db)
chat_db = APP_CONFIG.get("DB_CHAT_DATABASE")
if chat_db:
configured_dbs.append(chat_db)
management_db = APP_CONFIG.get("DB_MANAGEMENT_DATABASE")
if management_db:
configured_dbs.append(management_db)
if not dbName or not dbName.startswith("poweron_"):
raise HTTPException(status_code=400, detail="Invalid database name")
if not configured_dbs:
configured_dbs = ["poweron"]
if not db_name or db_name not in configured_dbs:
raise HTTPException(status_code=400, detail=f"Invalid database name. Available databases: {configured_dbs}")
# Validate database exists
try:
# Use the appropriate interface based on database name
if db_name == app_db:
interface = getRootInterface()
elif db_name == chat_db:
from modules.interfaces.interfaceDbChatObjects import getInterface as getChatInterface
interface = getChatInterface(currentUser)
elif db_name == management_db:
from modules.interfaces.interfaceDbComponentObjects import getInterface as getComponentInterface
interface = getComponentInterface(currentUser)
else:
raise HTTPException(status_code=400, detail="Database not found")
conn = interface.db.connection
configuredDbs = _getPoweronDatabases()
except Exception as e:
logger.warning(f"Failed to load databases from host: {e}")
configuredDbs = []
if configuredDbs and dbName not in configuredDbs:
raise HTTPException(status_code=400, detail=f"Database not found. Available: {configuredDbs}")
connector = None
try:
connector = _getDatabaseConnector(dbName, currentUser.id)
conn = connector.connection
with conn.cursor() as cursor:
# Drop all user tables (public schema) except system table
# Drop all user tables (public schema)
cursor.execute("""
SELECT table_name FROM information_schema.tables
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
@ -449,12 +446,17 @@ async def drop_database(
cursor.execute(f'DROP TABLE IF EXISTS "{tbl}" CASCADE')
dropped.append(tbl)
conn.commit()
logger.warning(f"Admin drop_database executed by {currentUser.id}: dropped tables from '{db_name}': {dropped}")
logger.warning(f"Admin drop_database executed by {currentUser.id}: dropped tables from '{dbName}': {dropped}")
return {"droppedTables": dropped}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Error dropping database tables: {str(e)}")
if 'interface' in locals() and interface and interface.db and interface.db.connection:
interface.db.connection.rollback()
if connector and connector.connection:
connector.connection.rollback()
raise HTTPException(status_code=500, detail="Failed to drop database tables")
finally:
if connector:
connector.close()

View file

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

View file

@ -16,14 +16,64 @@ from jose import jwt
# Import auth modules
from modules.auth import getCurrentUser, limiter, SECRET_KEY, ALGORITHM
from modules.auth import createAccessToken, createRefreshToken, setAccessTokenCookie, setRefreshTokenCookie, clearAccessTokenCookie, clearRefreshTokenCookie
from modules.interfaces.interfaceDbAppObjects import getInterface, getRootInterface
from modules.datamodels.datamodelUam import User, UserInDB, AuthAuthority
from modules.interfaces.interfaceDbApp import getInterface, getRootInterface
from modules.datamodels.datamodelUam import User, UserInDB, AuthAuthority, Mandate
from modules.datamodels.datamodelSecurity import Token
from modules.shared.configuration import APP_CONFIG
from modules.shared.timeUtils import getUtcTimestamp
# Configure logger
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
router = APIRouter(
prefix="/api/local",
@ -57,19 +107,9 @@ async def login(
# Get gateway interface with root privileges for authentication
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
# Note: authenticateLocalUser uses _getUserForAuthentication which bypasses RBAC
# This is correct because users are mandate-independent (Multi-Tenant Design)
user = rootInterface.authenticateLocalUser(
username=formData.username,
password=formData.password
@ -83,11 +123,13 @@ async def login(
)
# Create token data
# MULTI-TENANT: Token does NOT contain mandateId anymore
# Mandate context is determined per request via X-Mandate-Id header
token_data = {
"sub": user.username,
"mandateId": str(user.mandateId),
"userId": str(user.id),
"authenticationAuthority": AuthAuthority.LOCAL
# NO mandateId in token - stateless multi-tenant design
}
# Create session id and include in token claims for session-scoped logout
@ -116,7 +158,8 @@ async def login(
# Get jti from already decoded payload
jti = payload.get("jti")
# Create token
# Create token record in database
# MULTI-TENANT: Token model no longer has mandateId field
token = Token(
id=jti,
userId=user.id,
@ -124,21 +167,25 @@ async def login(
tokenAccess=access_token,
tokenType="bearer",
expiresAt=expires_at.timestamp(),
sessionId=session_id,
mandateId=str(user.mandateId)
sessionId=session_id
# NO mandateId - Token is not mandate-bound
)
# Save access token
userInterface.saveAccessToken(token)
# Log successful login
# MULTI-TENANT: Login is a system-level function, no mandate context
try:
from modules.shared.auditLogger import audit_logger
audit_logger.logUserAccess(
userId=str(user.id),
mandateId=str(user.mandateId),
mandateId="system",
action="login",
successInfo="local_auth_success"
successInfo="local_auth_success",
ipAddress=request.client.host if request.client else None,
userAgent=request.headers.get("user-agent"),
success=True
)
except Exception:
# Don't fail if audit logging fails
@ -167,10 +214,13 @@ async def login(
try:
from modules.shared.auditLogger import audit_logger
audit_logger.logUserAccess(
userId="unknown",
mandateId="unknown",
action="login",
successInfo=f"failed: {error_msg}"
userId=formData.username or "unknown",
mandateId="system",
action="login_failed",
successInfo=f"failed: {error_msg}",
ipAddress=request.client.host if request.client else None,
userAgent=request.headers.get("user-agent"),
success=False
)
except Exception:
# 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
appInterface = getRootInterface()
# Get default mandate ID
from modules.datamodels.datamodelUam import Mandate
defaultMandateId = appInterface.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
appInterface.mandateId = defaultMandateId
# Note: User registration does NOT require mandateId context
# Users are mandate-independent (Multi-Tenant Design)
# Mandate assignment happens via createUserMandate() after registration
# Frontend URL is required - no fallback
baseUrl = frontendUrl.rstrip("/")
@ -236,7 +278,6 @@ async def register_user(
fullName=userData.fullName,
language=userData.language,
enabled=True, # Users are enabled by default (can login after setting password)
roleLabels=["user"], # Default role for new registrations
authenticationAuthority=AuthAuthority.LOCAL
)
@ -252,15 +293,11 @@ async def register_user(
# Send registration email with magic link
try:
from modules.services import Services
services = Services(user)
magicLink = f"{baseUrl}/reset?token={token}"
expiryHours = int(APP_CONFIG.get("Auth_RESET_TOKEN_EXPIRY_HOURS", "24"))
emailSubject = "PowerOn Registrierung - Passwort setzen"
emailBody = f"""
Hallo {user.fullName or user.username},
emailBody = f"""Hallo {user.fullName or user.username},
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.
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,
subject=emailSubject,
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)}")
# 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 {
"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")
# Create new token data
# MULTI-TENANT: Token does NOT contain mandateId anymore
token_data = {
"sub": current_user.username,
"mandateId": str(current_user.mandateId),
"userId": str(current_user.id),
"authenticationAuthority": current_user.authenticationAuthority
# NO mandateId in token
}
# Create new access token + set cookie
@ -427,13 +510,17 @@ async def logout(request: Request, response: Response, currentUser: User = Depen
revoked = 1
# Log successful logout
# MULTI-TENANT: Logout is a system-level function, no mandate context
try:
from modules.shared.auditLogger import audit_logger
audit_logger.logUserAccess(
userId=str(currentUser.id),
mandateId=str(currentUser.mandateId),
mandateId="system",
action="logout",
successInfo=f"revoked_tokens: {revoked}"
successInfo=f"revoked_tokens: {revoked}",
ipAddress=request.client.host if request.client else None,
userAgent=request.headers.get("user-agent"),
success=True
)
except Exception:
# Don't fail if audit logging fails
@ -492,7 +579,7 @@ async def check_username_availability(
@router.post("/password-reset-request")
@limiter.limit("5/minute")
async def passwordResetRequest(
async def password_reset_request(
request: Request,
username: str = Body(..., embed=True),
frontendUrl: str = Body(..., embed=True)
@ -515,7 +602,6 @@ async def passwordResetRequest(
user = rootInterface.findUserByUsernameLocalAuth(username)
if user and user.email:
from modules.services import Services
expiryHours = int(APP_CONFIG.get("Auth_RESET_TOKEN_EXPIRY_HOURS", "24"))
try:
@ -525,16 +611,12 @@ async def passwordResetRequest(
# Set reset token (clears password)
rootInterface.setResetToken(user.id, token, expires)
# Get services for email sending
services = Services(user)
# Generate magic link using provided frontend URL
magicLink = f"{baseUrl}/reset?token={token}"
# Send email
# Send email using dedicated auth email function
emailSubject = "PowerOn - Passwort zurücksetzen"
emailBody = f"""
Hallo {user.fullName or user.username},
emailBody = f"""Hallo {user.fullName or user.username},
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.
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,
subject=emailSubject,
message=emailBody,
userId=str(user.id)
)
logger.info(f"Password reset email sent to {user.email} for user {user.username}")
if emailSent:
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:
logger.error(f"Failed to send reset email for user {username}: {str(userErr)}")
else:
@ -575,7 +659,7 @@ Falls Sie diese Anforderung nicht gestellt haben, können Sie diese E-Mail ignor
@router.post("/password-reset")
@limiter.limit("10/minute")
async def passwordReset(
async def password_reset(
request: Request,
token: str = Body(..., embed=True),
password: str = Body(..., embed=True)

View file

@ -13,11 +13,12 @@ import msal
import httpx
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.datamodelSecurity import Token
from modules.auth import getCurrentUser, limiter
from modules.auth import createAccessToken, setAccessTokenCookie, createRefreshToken, setRefreshTokenCookie
from modules.auth.tokenManager import TokenManager
from modules.shared.timeUtils import createExpirationTimestamp, getUtcTimestamp, parseTimestamp
# Configure logger
@ -348,11 +349,12 @@ async def auth_callback(code: str, state: str, request: Request, response: Respo
appInterface.saveAccessToken(token)
# Create JWT token data
# MULTI-TENANT: Token does NOT contain mandateId anymore
jwt_token_data = {
"sub": user.username,
"mandateId": str(user.mandateId),
"userId": str(user.id),
"authenticationAuthority": AuthAuthority.MSFT.value
# NO mandateId in token - stateless multi-tenant design
}
# Create JWT access token
@ -363,11 +365,11 @@ async def auth_callback(code: str, state: str, request: Request, response: Respo
# Decode token to get jti for database record
from jose import jwt
from modules.auth import SECRET_KEY, ALGORITHM
payload = jwt.decode(jwt_token, SECRET_KEY, algorithms=[ALGORITHM])
jti = payload.get("jti")
# Create JWT token with matching id
# MULTI-TENANT: Token model no longer has mandateId field
jwt_token_obj = Token(
id=jti,
userId=user.id,
@ -375,8 +377,8 @@ async def auth_callback(code: str, state: str, request: Request, response: Respo
tokenAccess=jwt_token,
tokenType="bearer",
expiresAt=jwt_expires_at.timestamp(),
createdAt=getUtcTimestamp(),
mandateId=str(user.mandateId)
createdAt=getUtcTimestamp()
# NO mandateId - Token is not mandate-bound
)
# Save JWT access token
@ -625,13 +627,17 @@ async def logout(
appInterface.logout()
# Log successful logout
# MULTI-TENANT: Logout is a system-level function, no mandate context
try:
from modules.shared.auditLogger import audit_logger
audit_logger.logUserAccess(
userId=str(currentUser.id),
mandateId=str(currentUser.mandateId),
mandateId="system",
action="logout",
successInfo="microsoft_auth_logout"
successInfo="microsoft_auth_logout",
ipAddress=request.client.host if request.client else None,
userAgent=request.headers.get("user-agent"),
success=True
)
except Exception:
# 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}")
# Get a fresh token via TokenManager convenience method
from modules.auth import TokenManager
current_token = TokenManager().getFreshToken(msft_connection.id)
if not current_token:
@ -732,7 +737,6 @@ async def refresh_token(
# Always attempt refresh (as per your requirement)
from modules.auth import TokenManager
token_manager = TokenManager()
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.datamodels.datamodelUam import User, UserConnection
from modules.interfaces.interfaceDbAppObjects import getInterface
from modules.interfaces.interfaceDbApp import getInterface
from modules.services import getInterface as getServices
logger = logging.getLogger(__name__)
@ -146,3 +146,108 @@ async def list_sharepoint_folders(
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]
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:
await websocket.send_text(json.dumps(message))
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.
"""
RBAC interface: Core RBAC logic and permission resolution.
Moved from interfaces to security module to maintain proper architectural layering.
Connectors can import from security, but not from interfaces.
Multi-Tenant Design:
- AccessRules referenzieren roleId (FK), nicht roleLabel
- Rollen werden über UserMandate + UserMandateRole geladen
- Priorisierung: Instance > Mandate > Global
- Stateless Design: Kein Cache, direkt aus DB
"""
import logging
from typing import List, Optional, Dict, Any, TYPE_CHECKING
from modules.datamodels.datamodelRbac import AccessRule, AccessRuleContext
from modules.datamodels.datamodelUam import User, UserPermissions, AccessLevel
from typing import List, Optional, TYPE_CHECKING
from modules.datamodels.datamodelRbac import AccessRule, AccessRuleContext, Role
from modules.datamodels.datamodelUam import User, UserPermissions, AccessLevel, Mandate
from modules.datamodels.datamodelMembership import (
UserMandate,
UserMandateRole,
FeatureAccess,
FeatureAccessRole
)
if TYPE_CHECKING:
from modules.connectors.connectorDbPostgre import DatabaseConnector
@ -20,6 +30,11 @@ logger = logging.getLogger(__name__)
class RbacClass:
"""
RBAC interface for permission resolution and rule validation.
Multi-Tenant Design:
- Lädt Rollen über UserMandate + UserMandateRole
- AccessRules werden über roleId gefunden
- isSysAdmin für System-Level Operationen (ohne Mandant)
"""
def __init__(self, db: "DatabaseConnector", dbApp: "DatabaseConnector"):
@ -34,14 +49,27 @@ class RbacClass:
self.db = db
self.dbApp = dbApp
def getUserPermissions(self, user: User, context: AccessRuleContext, item: str) -> UserPermissions:
def getUserPermissions(
self,
user: User,
context: AccessRuleContext,
item: str,
mandateId: Optional[str] = None,
featureInstanceId: Optional[str] = None
) -> UserPermissions:
"""
Get combined permissions for a user across all their roles.
Multi-Tenant Design:
- Lädt Rollen aus UserMandate + UserMandateRole wenn mandateId gegeben
- isSysAdmin gibt vollen Zugriff auf System-Level (kein mandateId)
Args:
user: User object with roleLabels
user: User object
context: Access rule context (DATA, UI, RESOURCE)
item: Item identifier (table name, UI path, resource path)
mandateId: Optional mandate context for role lookup
featureInstanceId: Optional feature instance context
Returns:
UserPermissions object with combined permissions
@ -54,29 +82,51 @@ class RbacClass:
delete=AccessLevel.NONE
)
if not hasattr(user, 'roleLabels') or not user.roleLabels:
# SysAdmin auf System-Level (kein Mandant) hat vollen Zugriff
if hasattr(user, 'isSysAdmin') and user.isSysAdmin and not mandateId:
return UserPermissions(
view=True,
read=AccessLevel.ALL,
create=AccessLevel.ALL,
update=AccessLevel.ALL,
delete=AccessLevel.ALL
)
# Lade Role-IDs für den User via UserMandate + UserMandateRole
roleIds = self._getRoleIdsForUser(user, mandateId, featureInstanceId)
if not roleIds:
return permissions
# Step 1: For each role, find the most specific matching rule (most specific wins within role)
rolePermissions = {}
for roleLabel in user.roleLabels:
# Get all rules for this role and context
allRules = self._getRulesForRole(roleLabel, context)
# Find most specific rule for this item (longest matching prefix)
mostSpecificRule = self.findMostSpecificRule(allRules, item)
if mostSpecificRule:
rolePermissions[roleLabel] = mostSpecificRule
# Lade alle relevanten Regeln für alle Rollen
allRulesWithPriority = self._getRulesForRoleIds(roleIds, context, mandateId, featureInstanceId)
# Step 2: Combine permissions across roles using opening (union) logic
for roleLabel, rule in rolePermissions.items():
# Für jede Rolle die spezifischste Regel finden
rolePermissions = {}
for priority, rule in allRulesWithPriority:
# Find most specific rule for this item
if self._ruleMatchesItem(rule, item):
roleId = rule.roleId
# Speichere mit Priorität (höhere Priorität überschreibt)
if roleId not in rolePermissions or priority > rolePermissions[roleId][0]:
rolePermissions[roleId] = (priority, rule)
# Find highest priority among matching rules
highestPriority = max((p for p, _ in rolePermissions.values()), default=0)
# Combine permissions ONLY from rules with highest priority
# 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
# View: union logic - if ANY role has view=true, then view=true
if rule.view:
permissions.view = True
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):
permissions.read = rule.read
if rule.create and self._isMorePermissive(rule.create, permissions.create):
@ -88,6 +138,301 @@ class RbacClass:
return permissions
def _getRoleIdsForUser(
self,
user: User,
mandateId: Optional[str],
featureInstanceId: Optional[str]
) -> List[str]:
"""
Get all role IDs for a user in the given context.
Uses UserMandate + UserMandateRole for the new multi-tenant model.
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]:
"""
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
# Find longest matching prefix
itemParts = item.split(".")
bestMatch = None
bestMatchLength = -1
@ -176,39 +520,3 @@ class RbacClass:
AccessLevel.ALL: 3
}
return hierarchy.get(level1, 0) > hierarchy.get(level2, 0)
def _getRulesForRole(self, roleLabel: str, context: AccessRuleContext) -> List[AccessRule]:
"""
Get all access rules for a specific role and context.
Always queries from DbApp database, not the current database.
Args:
roleLabel: Role label to get rules for
context: Context type
Returns:
List of AccessRule objects
"""
try:
# Always use DbApp database for AccessRule queries
rules = self.dbApp.getRecordset(
AccessRule,
recordFilter={
"roleLabel": roleLabel,
"context": context.value
}
)
# Convert dict records to AccessRule objects
accessRules = []
for record in rules:
try:
accessRule = AccessRule(**record)
accessRules.append(accessRule)
except Exception as e:
logger.error(f"Error converting rule record to AccessRule: {e}, record={record}")
return accessRules
except Exception as e:
logger.error(f"Error getting rules for role {roleLabel} and context {context.value}: {e}", exc_info=True)
return []

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.
Provides secure access to root user and DbApp database connector.
Bei leerer Datenbank wird automatisch Bootstrap ausgeführt.
"""
import logging
@ -14,6 +16,7 @@ logger = logging.getLogger(__name__)
_rootDbAppConnector = None
_rootUser = None
_bootstrapExecuted = False
def getRootDbAppConnector() -> DatabaseConnector:
"""
@ -24,29 +27,62 @@ def getRootDbAppConnector() -> DatabaseConnector:
if _rootDbAppConnector is None:
_rootDbAppConnector = DatabaseConnector(
dbHost=APP_CONFIG.get("DB_APP_HOST"),
dbDatabase=APP_CONFIG.get("DB_APP_DATABASE", "app"),
dbUser=APP_CONFIG.get("DB_APP_USER"),
dbPassword=APP_CONFIG.get("DB_APP_PASSWORD_SECRET"),
dbPort=int(APP_CONFIG.get("DB_APP_PORT", 5432)),
dbHost=APP_CONFIG.get("DB_HOST"),
dbDatabase="poweron_app",
dbUser=APP_CONFIG.get("DB_USER"),
dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET"),
dbPort=int(APP_CONFIG.get("DB_PORT", 5432)),
userId=None # No user context for root connector
)
_rootDbAppConnector.initDbSystem()
return _rootDbAppConnector
def _ensureBootstrap():
"""
Führt Bootstrap aus, falls noch nicht geschehen.
Wird automatisch aufgerufen, wenn getRootUser() keinen User findet.
"""
global _bootstrapExecuted
if _bootstrapExecuted:
return
logger.info("Running bootstrap to initialize database")
# Import here to avoid circular imports
from modules.interfaces.interfaceBootstrap import initBootstrap
dbApp = getRootDbAppConnector()
initBootstrap(dbApp)
_bootstrapExecuted = True
logger.info("Bootstrap completed")
def getRootUser() -> User:
"""
Returns the root user (initial user from database).
Used for system-level operations that require root privileges.
Falls kein User existiert, wird Bootstrap automatisch ausgeführt.
"""
global _rootUser
if _rootUser is None:
dbApp = getRootDbAppConnector()
initialUserId = dbApp.getInitialId(UserInDB)
# Wenn kein User existiert, Bootstrap ausführen
if not initialUserId:
raise ValueError("No initial user ID found in database")
logger.info("No initial user found, running bootstrap")
_ensureBootstrap()
# Nochmal versuchen nach Bootstrap
initialUserId = dbApp.getInitialId(UserInDB)
if not initialUserId:
raise ValueError("No initial user ID found in database after bootstrap")
users = dbApp.getRecordset(UserInDB, recordFilter={"id": initialUserId})
if not users:
@ -56,4 +92,3 @@ def getRootUser() -> User:
_rootUser = User(**user_data)
return _rootUser

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