implemented multimandate

This commit is contained in:
ValueOn AG 2026-01-17 02:17:58 +01:00
parent 89202eb040
commit 5c0ab3f893
53 changed files with 7558 additions and 2458 deletions

24
app.py
View file

@ -405,7 +405,7 @@ app.include_router(userRouter)
from modules.routes.routeDataFiles import router as fileRouter
app.include_router(fileRouter)
from modules.routes.routeDataNeutralization import router as neutralizationRouter
from modules.routes.routeFeatureNeutralization import router as neutralizationRouter
app.include_router(neutralizationRouter)
from modules.routes.routeDataPrompts import router as promptRouter
@ -417,10 +417,10 @@ app.include_router(connectionsRouter)
from modules.routes.routeWorkflows import router as workflowRouter
app.include_router(workflowRouter)
from modules.routes.routeChatPlayground import router as chatPlaygroundRouter
from modules.routes.routeFeatureChatDynamic import router as chatPlaygroundRouter
app.include_router(chatPlaygroundRouter)
from modules.routes.routeRealEstate import router as realEstateRouter
from modules.routes.routeFeatureRealEstate import router as realEstateRouter
app.include_router(realEstateRouter)
from modules.routes.routeSecurityLocal import router as localRouter
@ -444,7 +444,7 @@ app.include_router(sharepointRouter)
from modules.routes.routeDataAutomation import router as automationRouter
app.include_router(automationRouter)
from modules.routes.routeAdminAutomationEvents import router as adminAutomationEventsRouter
from modules.routes.routeFeatureAutomation import router as adminAutomationEventsRouter
app.include_router(adminAutomationEventsRouter)
from modules.routes.routeRbac import router as rbacRouter
@ -456,9 +456,21 @@ app.include_router(optionsRouter)
from modules.routes.routeMessaging import router as messagingRouter
app.include_router(messagingRouter)
from modules.routes.routeChatbot import router as chatbotRouter
from modules.routes.routeFeatureChatbot import router as chatbotRouter
app.include_router(chatbotRouter)
from modules.routes.routeDataTrustee import router as trusteeRouter
from modules.routes.routeFeatureTrustee import router as trusteeRouter
app.include_router(trusteeRouter)
# Phase 8: New Feature Routes
from modules.routes.routeFeatures import router as featuresRouter
app.include_router(featuresRouter)
from modules.routes.routeInvitations import router as invitationsRouter
app.include_router(invitationsRouter)
from modules.routes.routeRbacExport import router as rbacExportRouter
app.include_router(rbacExportRouter)
from modules.routes.routeGdpr import router as gdprRouter
app.include_router(gdprRouter)

View file

@ -8,33 +8,11 @@ APP_KEY_SYSVAR = D:/Athi/Local/Web/poweron/local/key.txt
APP_INIT_PASS_ADMIN_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEeFFtRGtQeVUtcjlrU3dab1ZxUm9WSks0MlJVYUtERFlqUElHemZrOGNENk1tcmJNX3Vxc01UMDhlNU40VzZZRVBpUGNmT3podzZrOGhOeEJIUEt4eVlSWG5UYXA3d09DVXlLT21Kb1JYSUU9
APP_INIT_PASS_EVENT_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERzZjNm56WGVBdjJTeG5Udjd6OGQwUVotYXUzQjJ1YVNyVXVBa3NZVml3ODU0MVNkZjhWWmJwNUFkc19BcHlHMTU1Q3BRcHU0cDBoZkFlR2l6UEZQU3d2U3MtMDh5UDZteGFoQ0EyMUE1ckE9
# PostgreSQL Storage (new)
DB_APP_HOST=localhost
DB_APP_DATABASE=poweron_app
DB_APP_USER=poweron_dev
DB_APP_PASSWORD_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEcUIxNEFfQ2xnS0RrSC1KNnUxTlVvTGZoMHgzaEI4Z3NlVzVROTVLak5Ubi1vaEZubFZaMTFKMGd6MXAxekN2d2NvMy1hRjg2UVhybktlcFA5anZ1WjFlQmZhcXdwaGhWdzRDc3ExeUhzWTg9
DB_APP_PORT=5432
# PostgreSQL Storage (new)
DB_CHAT_HOST=localhost
DB_CHAT_DATABASE=poweron_chat
DB_CHAT_USER=poweron_dev
DB_CHAT_PASSWORD_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERFNzNVhoalpCR0QxYXAwdEpXWXVVOTdZdWtqWW5FNXFGcFl2amNYLWYwYl9STXltRlFxLWNzVWlMVnNYdXk0RklnRExFT0FaQjg2aGswNnhhSGhCN29KN2VEb2FlUV9NTlV3b0tLelplSVU9
DB_CHAT_PORT=5432
# PostgreSQL Storage (new)
DB_MANAGEMENT_HOST=localhost
DB_MANAGEMENT_DATABASE=poweron_management
DB_MANAGEMENT_USER=poweron_dev
DB_MANAGEMENT_PASSWORD_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEUldqSTVpUnFqdGhITDYzT3RScGlMYVdTMmZhOXdudDRCc3dhdllOd3l6MS1vWHY2MjVsTUF1Sk9saEJOSk9ONUlBZjQwb2c2T1gtWWJhcXFzVVVXd01xc0U0b0lJX0JyVDRxaDhNS01JcWs9
DB_MANAGEMENT_PORT=5432
# PostgreSQL Storage (new)
DB_REALESTATE_HOST=localhost
DB_REALESTATE_DATABASE=poweron_realestate
DB_REALESTATE_USER=poweron_dev
DB_REALESTATE_PASSWORD_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEcUIxNEFfQ2xnS0RrSC1KNnUxTlVvTGZoMHgzaEI4Z3NlVzVROTVLak5Ubi1vaEZubFZaMTFKMGd6MXAxekN2d2NvMy1hRjg2UVhybktlcFA5anZ1WjFlQmZhcXdwaGhWdzRDc3ExeUhzWTg9
DB_REALESTATE_PORT=5432
# PostgreSQL DB Host
DB_HOST=localhost
DB_USER=poweron_dev
DB_PASSWORD_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEcUIxNEFfQ2xnS0RrSC1KNnUxTlVvTGZoMHgzaEI4Z3NlVzVROTVLak5Ubi1vaEZubFZaMTFKMGd6MXAxekN2d2NvMy1hRjg2UVhybktlcFA5anZ1WjFlQmZhcXdwaGhWdzRDc3ExeUhzWTg9
DB_PORT=5432
# Security Configuration
APP_JWT_KEY_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERjlrSktmZHVuQnJ1VVJDdndLaUcxZGJsT2ZlUFRlcFdOZ001RnlzM2FhLWhRV2tjWWFhaWQwQ3hkcUFvbThMcndxSjFpYTdfRV9OZGhTcksxbXFTZWg5MDZvOHpCVXBHcDJYaHlJM0tyNWRZckZsVHpQcmxTZHJoZUs1M3lfU2ljRnJaTmNSQ0w0X085OXI0QW80M2xfQnJqZmZ6VEh3TUltX0xzeE42SGtZPQ==

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

@ -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.interfaceDbAppObjects import getInterface, getRootInterface
from modules.datamodels.datamodelUam import User, AuthAuthority, AccessLevel
from modules.datamodels.datamodelSecurity import Token
from modules.datamodels.datamodelRbac import AccessRule
# Get Config Data
SECRET_KEY = APP_CONFIG.get("APP_JWT_KEY_SECRET")
@ -98,15 +105,16 @@ def _getUserBase(token: str = Depends(cookieAuth)) -> User:
if username is None:
raise credentialsException
# Extract mandate ID and user ID from token
mandateId: str = payload.get("mandateId")
# Extract user ID from token
# MULTI-TENANT: mandateId is NO LONGER in the token - it comes from X-Mandate-Id header
userId: str = payload.get("userId")
authority: str = payload.get("authenticationAuthority")
tokenId: Optional[str] = payload.get("jti")
sessionId: Optional[str] = payload.get("sid") or payload.get("sessionId")
if not mandateId or not userId:
logger.error(f"Missing context in token: mandateId={mandateId}, userId={userId}")
# Only userId is required in token now (no mandateId)
if not userId:
logger.error(f"Missing userId in token")
raise credentialsException
except JWTError:
@ -129,9 +137,10 @@ def _getUserBase(token: str = Depends(cookieAuth)) -> User:
logger.warning(f"User {username} is disabled")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is disabled")
# Ensure the user has the correct context
if str(user.mandateId) != str(mandateId) or str(user.id) != str(userId):
logger.error(f"User context mismatch: token(mandateId={mandateId}, userId={userId}) vs user(mandateId={user.mandateId}, id={user.id})")
# Ensure the user ID in token matches the user in database
# MULTI-TENANT: mandateId is NO LONGER checked here - it comes from headers
if str(user.id) != str(userId):
logger.error(f"User ID mismatch: token(userId={userId}) vs user(id={user.id})")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User context has changed. Please log in again.",
@ -166,17 +175,18 @@ def _getUserBase(token: str = Depends(cookieAuth)) -> User:
db_token = db_tokens[0]
token_authority = str(db_token.get("authority", "")).lower()
if token_authority == str(AuthAuthority.LOCAL.value):
# Must be active and match user/session/mandate
# Must be active and match user/session
# MULTI-TENANT: mandateId is NOT checked here - tokens are no longer mandate-bound
active_token = appInterface.findActiveTokenById(
tokenId=tokenId,
userId=user.id,
authority=AuthAuthority.LOCAL,
sessionId=sessionId,
mandateId=str(mandateId) if mandateId else None,
mandateId=None, # Token is no longer mandate-bound
)
if not active_token:
logger.info(
f"Local JWT db record not active/valid: jti={tokenId}, userId={user.id}, mandateId={mandateId}, sessionId={sessionId}"
f"Local JWT db record not active/valid: jti={tokenId}, userId={user.id}, sessionId={sessionId}"
)
raise credentialsException
else:
@ -203,3 +213,171 @@ def getCurrentUser(currentUser: User = Depends(_getUserBase)) -> User:
return currentUser
# =============================================================================
# MULTI-TENANT: Request Context System
# =============================================================================
class RequestContext:
"""
Request context for multi-tenant operations.
Contains user, mandate context, feature instance context, and loaded role IDs.
This context is per-request (not persisted) - follows stateless design.
IMPORTANT: SysAdmin also needs explicit membership for mandate context!
isSysAdmin flag does NOT give implicit access to mandate data.
"""
def __init__(self, user: User):
self.user: User = user
self.mandateId: Optional[str] = None
self.featureInstanceId: Optional[str] = None
self.roleIds: List[str] = []
# Request-scoped cache: rules loaded only once per request
self._cachedRules: Optional[List[tuple]] = None
def getRules(self) -> List[tuple]:
"""
Loads rules once per request (not across requests).
Returns list of (priority, AccessRule) tuples.
"""
if self._cachedRules is None:
if not self.mandateId:
# No mandate context = no rules
self._cachedRules = []
else:
try:
rootUser = getRootUser()
appInterface = getInterface(rootUser)
self._cachedRules = appInterface.rbac.getRulesForUserBulk(
self.user.id,
self.mandateId,
self.featureInstanceId
)
except Exception as e:
logger.error(f"Error loading RBAC rules: {e}")
self._cachedRules = []
return self._cachedRules
@property
def isSysAdmin(self) -> bool:
"""Convenience property to check if user is a system admin."""
return getattr(self.user, 'isSysAdmin', False)
def getRequestContext(
request: Request,
mandateId: Optional[str] = Header(None, alias="X-Mandate-Id"),
featureInstanceId: Optional[str] = Header(None, alias="X-Instance-Id"),
currentUser: User = Depends(getCurrentUser)
) -> RequestContext:
"""
Determines request context from headers.
Checks authorization and loads role IDs.
IMPORTANT: Even SysAdmin needs explicit membership for mandate context!
SysAdmin flag does NOT give implicit access to mandate data.
Args:
request: FastAPI Request object
mandateId: Mandate ID from X-Mandate-Id header
featureInstanceId: Feature instance ID from X-Instance-Id header
currentUser: Current authenticated user
Returns:
RequestContext with user, mandate, roles
Raises:
HTTPException 403: If user is not member of mandate or has no feature access
"""
ctx = RequestContext(user=currentUser)
# Get root interface for membership checks
rootInterface = getRootInterface()
if mandateId:
# Check mandate membership - ALSO for SysAdmin!
# SysAdmin must be explicitly added to the mandate
membership = rootInterface.getUserMandate(currentUser.id, mandateId)
if not membership:
# No implicit access for SysAdmin - Fail-Fast!
logger.warning(f"User {currentUser.id} is not member of mandate {mandateId}")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not member of mandate"
)
if not membership.enabled:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Mandate membership is disabled"
)
ctx.mandateId = mandateId
# Load roles via Junction Table
ctx.roleIds = rootInterface.getRoleIdsForUserMandate(membership.id)
if featureInstanceId:
# Check feature access - ALSO for SysAdmin!
access = rootInterface.getFeatureAccess(currentUser.id, featureInstanceId)
if not access:
logger.warning(f"User {currentUser.id} has no access to feature instance {featureInstanceId}")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="No access to feature instance"
)
if not access.enabled:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Feature access is disabled"
)
ctx.featureInstanceId = featureInstanceId
# Add instance roles
instanceRoleIds = rootInterface.getRoleIdsForFeatureAccess(access.id)
ctx.roleIds.extend(instanceRoleIds)
return ctx
def requireSysAdmin(currentUser: User = Depends(getCurrentUser)) -> User:
"""
SysAdmin check for system-level operations.
Use this dependency for endpoints that require SysAdmin privileges.
SysAdmin has access to system-level operations, but NOT to mandate data.
Args:
currentUser: Current authenticated user
Returns:
User if they are a SysAdmin
Raises:
HTTPException 403: If user is not a SysAdmin
"""
if not getattr(currentUser, 'isSysAdmin', False):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="SysAdmin privileges required"
)
# Audit for all SysAdmin actions
try:
from modules.shared.auditLogger import audit_logger
audit_logger.logSecurityEvent(
userId=str(currentUser.id),
mandateId="system",
action="sysadmin_action",
details="System-level operation"
)
except Exception:
# Don't fail if audit logging fails
pass
return currentUser

View file

@ -638,14 +638,12 @@ class DatabaseConnector:
# Only set _createdBy if userId is valid (not None or empty string)
if self.userId:
record["_createdBy"] = self.userId
else:
logger.warning(f"Attempting to create record with empty userId - _createdBy will not be set")
# No warning - empty userId is normal during bootstrap
# Also ensure _createdBy is set even if _createdAt exists but _createdBy is missing/empty
elif "_createdBy" not in record or not record.get("_createdBy"):
if self.userId:
record["_createdBy"] = self.userId
else:
logger.warning(f"Attempting to set _createdBy with empty userId for record {recordId}")
# No warning - empty userId is normal during bootstrap
# Always update modification metadata
record["_modifiedAt"] = currentTime
if self.userId:

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
class Feature(BaseModel):
"""
Feature-Definition (global, z.B. 'trustee', 'chatbot').
Features sind die verfügbaren Funktionalitäten der Plattform.
"""
code: str = Field(
description="Unique feature code (Primary Key), z.B. 'trustee', 'chatbot'",
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True}
)
label: dict = Field(
default_factory=dict,
description="Feature label in multiple languages (I18n)",
json_schema_extra={"frontend_type": "multilingual", "frontend_readonly": False, "frontend_required": True}
)
icon: str = Field(
default="",
description="Icon identifier for the feature",
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False}
)
registerModelLabels(
"Feature",
{"en": "Feature", "de": "Feature", "fr": "Fonctionnalité"},
{
"code": {"en": "Code", "de": "Code", "fr": "Code"},
"label": {"en": "Label", "de": "Bezeichnung", "fr": "Libellé"},
"icon": {"en": "Icon", "de": "Symbol", "fr": "Icône"},
},
)
class FeatureInstance(BaseModel):
"""
Instanz eines Features in einem Mandanten.
Ein Mandant kann mehrere Instanzen desselben Features haben.
"""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the feature instance",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
featureCode: str = Field(
description="FK → Feature.code",
json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": True}
)
mandateId: str = Field(
description="FK → Mandate.id (CASCADE DELETE)",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
)
label: str = Field(
default="",
description="Instance label, z.B. 'Buchhaltung 2025'",
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True}
)
enabled: bool = Field(
default=True,
description="Whether this feature instance is enabled",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
)
registerModelLabels(
"FeatureInstance",
{"en": "Feature Instance", "de": "Feature-Instanz", "fr": "Instance de fonctionnalité"},
{
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"featureCode": {"en": "Feature", "de": "Feature", "fr": "Fonctionnalité"},
"mandateId": {"en": "Mandate", "de": "Mandant", "fr": "Mandat"},
"label": {"en": "Label", "de": "Bezeichnung", "fr": "Libellé"},
"enabled": {"en": "Enabled", "de": "Aktiviert", "fr": "Activé"},
},
)

View file

@ -0,0 +1,120 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Invitation model for self-service onboarding.
Token-basierte Einladungen für neue User zu Mandanten/Features.
"""
import uuid
import secrets
from typing import Optional, List
from pydantic import BaseModel, Field
from modules.shared.attributeUtils import registerModelLabels
from modules.shared.timeUtils import getUtcTimestamp
class Invitation(BaseModel):
"""
Einladungs-Token für neue User.
Ermöglicht Self-Service Onboarding zu Mandanten und Feature-Instanzen.
"""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the invitation",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
token: str = Field(
default_factory=lambda: secrets.token_urlsafe(32),
description="Secure invitation token",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
# Ziel der Einladung
mandateId: str = Field(
description="FK → Mandate.id - Target mandate for the invitation",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
)
featureInstanceId: Optional[str] = Field(
default=None,
description="Optional FK → FeatureInstance.id - Direct access to specific feature",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
roleIds: List[str] = Field(
default_factory=list,
description="List of Role IDs to assign to the invited user",
json_schema_extra={"frontend_type": "multiselect", "frontend_readonly": False, "frontend_required": True}
)
# Einladungs-Details
email: Optional[str] = Field(
default=None,
description="Target email address (optional, for tracking)",
json_schema_extra={"frontend_type": "email", "frontend_readonly": False, "frontend_required": False}
)
createdBy: str = Field(
description="User ID of the person who created the invitation",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
)
createdAt: float = Field(
default_factory=getUtcTimestamp,
description="When the invitation was created (UTC timestamp)",
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}
)
expiresAt: float = Field(
description="When the invitation expires (UTC timestamp)",
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": True}
)
# Status
usedBy: Optional[str] = Field(
default=None,
description="User ID of the person who used the invitation",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
usedAt: Optional[float] = Field(
default=None,
description="When the invitation was used (UTC timestamp)",
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}
)
revokedAt: Optional[float] = Field(
default=None,
description="When the invitation was revoked (UTC timestamp)",
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}
)
# Einschränkungen
maxUses: int = Field(
default=1,
ge=1,
le=100,
description="Maximum number of times this invitation can be used",
json_schema_extra={"frontend_type": "number", "frontend_readonly": False, "frontend_required": False}
)
currentUses: int = Field(
default=0,
ge=0,
description="Current number of times this invitation has been used",
json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False}
)
registerModelLabels(
"Invitation",
{"en": "Invitation", "de": "Einladung", "fr": "Invitation"},
{
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"token": {"en": "Token", "de": "Token", "fr": "Jeton"},
"mandateId": {"en": "Mandate", "de": "Mandant", "fr": "Mandat"},
"featureInstanceId": {"en": "Feature Instance", "de": "Feature-Instanz", "fr": "Instance"},
"roleIds": {"en": "Roles", "de": "Rollen", "fr": "Rôles"},
"email": {"en": "Email", "de": "E-Mail", "fr": "Email"},
"createdBy": {"en": "Created By", "de": "Erstellt von", "fr": "Créé par"},
"createdAt": {"en": "Created At", "de": "Erstellt am", "fr": "Créé le"},
"expiresAt": {"en": "Expires At", "de": "Gültig bis", "fr": "Expire le"},
"usedBy": {"en": "Used By", "de": "Verwendet von", "fr": "Utilisé par"},
"usedAt": {"en": "Used At", "de": "Verwendet am", "fr": "Utilisé le"},
"revokedAt": {"en": "Revoked At", "de": "Widerrufen am", "fr": "Révoqué le"},
"maxUses": {"en": "Max Uses", "de": "Max. Verwendungen", "fr": "Utilisations max"},
"currentUses": {"en": "Current Uses", "de": "Aktuelle Verwendungen", "fr": "Utilisations actuelles"},
},
)

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_required": False}
)
userId: str = Field(
description="FK → User.id (CASCADE DELETE)",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
)
mandateId: str = Field(
description="FK → Mandate.id (CASCADE DELETE)",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
)
enabled: bool = Field(
default=True,
description="Whether this membership is enabled",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
)
# Rollen werden via Junction Table UserMandateRole verknüpft
registerModelLabels(
"UserMandate",
{"en": "User Mandate", "de": "Benutzer-Mandant", "fr": "Mandat utilisateur"},
{
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"userId": {"en": "User", "de": "Benutzer", "fr": "Utilisateur"},
"mandateId": {"en": "Mandate", "de": "Mandant", "fr": "Mandat"},
"enabled": {"en": "Enabled", "de": "Aktiviert", "fr": "Activé"},
},
)
class FeatureAccess(BaseModel):
"""
User-Zugriff auf eine Feature-Instanz.
Definiert welche User auf welche Feature-Instanzen zugreifen können.
"""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the feature access",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
userId: str = Field(
description="FK → User.id (CASCADE DELETE)",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
)
featureInstanceId: str = Field(
description="FK → FeatureInstance.id (CASCADE DELETE)",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
)
enabled: bool = Field(
default=True,
description="Whether this feature access is enabled",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
)
# Rollen werden via Junction Table FeatureAccessRole verknüpft
registerModelLabels(
"FeatureAccess",
{"en": "Feature Access", "de": "Feature-Zugang", "fr": "Accès fonctionnalité"},
{
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"userId": {"en": "User", "de": "Benutzer", "fr": "Utilisateur"},
"featureInstanceId": {"en": "Feature Instance", "de": "Feature-Instanz", "fr": "Instance"},
"enabled": {"en": "Enabled", "de": "Aktiviert", "fr": "Activé"},
},
)
class UserMandateRole(BaseModel):
"""
Junction Table: UserMandate zu Role.
Ermöglicht CASCADE DELETE auf Datenbankebene.
"""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the junction record",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
userMandateId: str = Field(
description="FK → UserMandate.id (CASCADE DELETE)",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
)
roleId: str = Field(
description="FK → Role.id (CASCADE DELETE)",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
)
registerModelLabels(
"UserMandateRole",
{"en": "User Mandate Role", "de": "Benutzer-Mandant-Rolle", "fr": "Rôle mandat utilisateur"},
{
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"userMandateId": {"en": "User Mandate", "de": "Benutzer-Mandant", "fr": "Mandat utilisateur"},
"roleId": {"en": "Role", "de": "Rolle", "fr": "Rôle"},
},
)
class FeatureAccessRole(BaseModel):
"""
Junction Table: FeatureAccess zu Role.
Ermöglicht CASCADE DELETE auf Datenbankebene.
"""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the junction record",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
featureAccessId: str = Field(
description="FK → FeatureAccess.id (CASCADE DELETE)",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
)
roleId: str = Field(
description="FK → Role.id (CASCADE DELETE)",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
)
registerModelLabels(
"FeatureAccessRole",
{"en": "Feature Access Role", "de": "Feature-Zugang-Rolle", "fr": "Rôle accès fonctionnalité"},
{
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"featureAccessId": {"en": "Feature Access", "de": "Feature-Zugang", "fr": "Accès fonctionnalité"},
"roleId": {"en": "Role", "de": "Rolle", "fr": "Rôle"},
},
)

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,7 +26,17 @@ class AccessRuleContext(str, Enum):
class Role(BaseModel):
"""Data model for RBAC roles"""
"""
Data model for RBAC roles.
Kernkonzept: Eine Rolle existiert immer in einem spezifischen Kontext.
Der Kontext ist IMMUTABLE nach Erstellung.
Kontext-Typen:
- mandateId=None, featureInstanceId=None GLOBAL (Template-Rolle)
- mandateId=X, featureInstanceId=None MANDATE-Rolle
- mandateId=X, featureInstanceId=Y INSTANCE-Rolle
"""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the role",
@ -33,106 +50,163 @@ class Role(BaseModel):
description="Role description in multiple languages",
json_schema_extra={"frontend_type": "multilingual", "frontend_readonly": False, "frontend_required": True}
)
# KONTEXT - IMMUTABLE nach Create (nur Create/Delete, kein Update!)
mandateId: Optional[str] = Field(
default=None,
description="FK → Mandate.id (CASCADE DELETE). Null = Global/Template role.",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
featureInstanceId: Optional[str] = Field(
default=None,
description="FK → FeatureInstance.id (CASCADE DELETE). Null = Mandate-level or Global role.",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
featureCode: Optional[str] = Field(
default=None,
description="Feature code (z.B. 'trustee') - für Template-Rollen",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
isSystemRole: bool = Field(
False,
default=False,
description="Whether this is a system role that cannot be deleted",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": False}
)
registerModelLabels(
"Role",
{"en": "Role", "fr": "Rôle"},
{"en": "Role", "de": "Rolle", "fr": "Rôle"},
{
"id": {"en": "ID", "fr": "ID"},
"roleLabel": {"en": "Role Label", "fr": "Label du rôle"},
"description": {"en": "Description", "fr": "Description"},
"isSystemRole": {"en": "System Role", "fr": "Rôle système"},
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"roleLabel": {"en": "Role Label", "de": "Rollen-Label", "fr": "Label du rôle"},
"description": {"en": "Description", "de": "Beschreibung", "fr": "Description"},
"mandateId": {"en": "Mandate", "de": "Mandant", "fr": "Mandat"},
"featureInstanceId": {"en": "Feature Instance", "de": "Feature-Instanz", "fr": "Instance"},
"featureCode": {"en": "Feature Code", "de": "Feature-Code", "fr": "Code fonctionnalité"},
"isSystemRole": {"en": "System Role", "de": "System-Rolle", "fr": "Rôle système"},
},
)
class AccessRule(BaseModel):
"""Data model for access control rules"""
"""
Data model for access control rules.
WICHTIG: roleId referenziert die Role via FK (nicht mehr roleLabel!)
Die Felder 'context' und 'roleId' sind IMMUTABLE nach Erstellung.
"""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the access rule",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
roleLabel: str = Field(
description="Role label this rule applies to",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_options": "user.role"}
roleId: str = Field(
description="FK → Role.id (CASCADE DELETE!)",
json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": True}
)
context: AccessRuleContext = Field(
description="Context type: DATA (database), UI (interface), RESOURCE (system resources)",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_options": [
{"value": "DATA", "label": {"en": "Data", "fr": "Données"}},
{"value": "UI", "label": {"en": "UI", "fr": "Interface"}},
{"value": "RESOURCE", "label": {"en": "Resource", "fr": "Ressource"}}
description="Context type: DATA (database), UI (interface), RESOURCE (system resources). IMMUTABLE!",
json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": True, "frontend_options": [
{"value": "DATA", "label": {"en": "Data", "de": "Daten", "fr": "Données"}},
{"value": "UI", "label": {"en": "UI", "de": "Oberfläche", "fr": "Interface"}},
{"value": "RESOURCE", "label": {"en": "Resource", "de": "Ressource", "fr": "Ressource"}}
]}
)
item: Optional[str] = Field(
None,
default=None,
description="Item identifier (null = all items in context). Format: DATA: '<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,7 +58,12 @@ class UserPermissions(BaseModel):
description="Delete permission level"
)
class Mandate(BaseModel):
"""
Mandate (Mandant/Tenant) model.
Ein Mandant ist ein isolierter Bereich für Daten und Berechtigungen.
"""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the mandate",
@ -61,37 +73,24 @@ class Mandate(BaseModel):
description="Name of the mandate",
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True}
)
language: str = Field(
default="en",
description="Default language of the mandate",
json_schema_extra={
"frontend_type": "select",
"frontend_readonly": False,
"frontend_required": True,
"frontend_options": [
{"value": "de", "label": {"en": "Deutsch", "fr": "Allemand"}},
{"value": "en", "label": {"en": "English", "fr": "Anglais"}},
{"value": "fr", "label": {"en": "Français", "fr": "Français"}},
{"value": "it", "label": {"en": "Italiano", "fr": "Italien"}},
]
}
)
enabled: bool = Field(
default=True,
description="Indicates whether the mandate is enabled",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
)
registerModelLabels(
"Mandate",
{"en": "Mandate", "fr": "Mandat"},
{"en": "Mandate", "de": "Mandant", "fr": "Mandat"},
{
"id": {"en": "ID", "fr": "ID"},
"name": {"en": "Name", "fr": "Nom"},
"language": {"en": "Language", "fr": "Langue"},
"enabled": {"en": "Enabled", "fr": "Activé"},
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"name": {"en": "Name", "de": "Name", "fr": "Nom"},
"enabled": {"en": "Enabled", "de": "Aktiviert", "fr": "Activé"},
},
)
class UserConnection(BaseModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the connection", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
userId: str = Field(description="ID of the user this connection belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
@ -109,70 +108,123 @@ class UserConnection(BaseModel):
{"value": "none", "label": {"en": "None", "fr": "Aucun"}},
]})
tokenExpiresAt: Optional[float] = Field(None, description="When the current token expires (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False})
registerModelLabels(
"UserConnection",
{"en": "User Connection", "fr": "Connexion utilisateur"},
{"en": "User Connection", "de": "Benutzerverbindung", "fr": "Connexion utilisateur"},
{
"id": {"en": "ID", "fr": "ID"},
"userId": {"en": "User ID", "fr": "ID utilisateur"},
"authority": {"en": "Authority", "fr": "Autorité"},
"externalId": {"en": "External ID", "fr": "ID externe"},
"externalUsername": {"en": "External Username", "fr": "Nom d'utilisateur externe"},
"externalEmail": {"en": "External Email", "fr": "Email externe"},
"status": {"en": "Status", "fr": "Statut"},
"connectedAt": {"en": "Connected At", "fr": "Connecté le"},
"lastChecked": {"en": "Last Checked", "fr": "Dernière vérification"},
"expiresAt": {"en": "Expires At", "fr": "Expire le"},
"tokenStatus": {"en": "Connection Status", "fr": "Statut de connexion"},
"tokenExpiresAt": {"en": "Expires At", "fr": "Expire le"},
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"userId": {"en": "User ID", "de": "Benutzer-ID", "fr": "ID utilisateur"},
"authority": {"en": "Authority", "de": "Autorität", "fr": "Autorité"},
"externalId": {"en": "External ID", "de": "Externe ID", "fr": "ID externe"},
"externalUsername": {"en": "External Username", "de": "Externer Benutzername", "fr": "Nom d'utilisateur externe"},
"externalEmail": {"en": "External Email", "de": "Externe E-Mail", "fr": "Email externe"},
"status": {"en": "Status", "de": "Status", "fr": "Statut"},
"connectedAt": {"en": "Connected At", "de": "Verbunden am", "fr": "Connecté le"},
"lastChecked": {"en": "Last Checked", "de": "Zuletzt geprüft", "fr": "Dernière vérification"},
"expiresAt": {"en": "Expires At", "de": "Läuft ab am", "fr": "Expire le"},
"tokenStatus": {"en": "Connection Status", "de": "Verbindungsstatus", "fr": "Statut de connexion"},
"tokenExpiresAt": {"en": "Expires At", "de": "Läuft ab am", "fr": "Expire le"},
},
)
class User(BaseModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the user", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
username: str = Field(description="Username for login", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True})
email: Optional[EmailStr] = Field(None, description="Email address of the user", json_schema_extra={"frontend_type": "email", "frontend_readonly": False, "frontend_required": True})
fullName: Optional[str] = Field(None, description="Full name of the user", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False})
language: str = Field(default="en", description="Preferred language of the user", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_options": [
{"value": "de", "label": {"en": "Deutsch", "fr": "Allemand"}},
{"value": "en", "label": {"en": "English", "fr": "Anglais"}},
{"value": "fr", "label": {"en": "Français", "fr": "Français"}},
{"value": "it", "label": {"en": "Italiano", "fr": "Italien"}},
]})
enabled: bool = Field(default=True, description="Indicates whether the user is enabled", json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False})
roleLabels: List[str] = Field(
default_factory=list,
description="List of role labels assigned to this user. All roles are opening roles (union) - if one role enables something, it is enabled.",
json_schema_extra={"frontend_type": "multiselect", "frontend_readonly": False, "frontend_required": True, "frontend_options": "user.role"}
"""
User model.
Multi-Tenant Design:
- User gehört NICHT direkt zu einem Mandanten
- Zugehörigkeit wird über UserMandate gesteuert (siehe datamodelMembership.py)
- Rollen werden über UserMandateRole gesteuert
- isSysAdmin = System-Zugriff, KEIN Daten-Zugriff
"""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the user",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
authenticationAuthority: AuthAuthority = Field(default=AuthAuthority.LOCAL, description="Primary authentication authority", json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": False, "frontend_options": "auth.authority"})
mandateId: Optional[str] = Field(None, description="ID of the mandate this user belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
username: str = Field(
description="Username for login",
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True}
)
email: Optional[EmailStr] = Field(
default=None,
description="Email address of the user",
json_schema_extra={"frontend_type": "email", "frontend_readonly": False, "frontend_required": True}
)
fullName: Optional[str] = Field(
default=None,
description="Full name of the user",
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False}
)
language: str = Field(
default="en",
description="Preferred language of the user",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_options": [
{"value": "de", "label": {"en": "Deutsch", "fr": "Allemand"}},
{"value": "en", "label": {"en": "English", "fr": "Anglais"}},
{"value": "fr", "label": {"en": "Français", "fr": "Français"}},
{"value": "it", "label": {"en": "Italiano", "fr": "Italien"}},
]}
)
enabled: bool = Field(
default=True,
description="Indicates whether the user is enabled",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
)
isSysAdmin: bool = Field(
default=False,
description="Global SysAdmin flag. SysAdmin = System-Zugriff, KEIN Daten-Zugriff!",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
)
@field_validator('isSysAdmin', mode='before')
@classmethod
def _coerceIsSysAdmin(cls, v):
"""Konvertiert None zu False (für bestehende DB-Einträge ohne isSysAdmin Feld)."""
if v is None:
return False
return v
authenticationAuthority: AuthAuthority = Field(
default=AuthAuthority.LOCAL,
description="Primary authentication authority",
json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": False, "frontend_options": "auth.authority"}
)
registerModelLabels(
"User",
{"en": "User", "fr": "Utilisateur"},
{"en": "User", "de": "Benutzer", "fr": "Utilisateur"},
{
"id": {"en": "ID", "fr": "ID"},
"username": {"en": "Username", "fr": "Nom d'utilisateur"},
"email": {"en": "Email", "fr": "Email"},
"fullName": {"en": "Full Name", "fr": "Nom complet"},
"language": {"en": "Language", "fr": "Langue"},
"enabled": {"en": "Enabled", "fr": "Activé"},
"roleLabels": {"en": "Role Labels", "fr": "Labels de rôle"},
"authenticationAuthority": {"en": "Auth Authority", "fr": "Autorité d'authentification"},
"mandateId": {"en": "Mandate ID", "fr": "ID de mandat"},
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"username": {"en": "Username", "de": "Benutzername", "fr": "Nom d'utilisateur"},
"email": {"en": "Email", "de": "E-Mail", "fr": "Email"},
"fullName": {"en": "Full Name", "de": "Vollständiger Name", "fr": "Nom complet"},
"language": {"en": "Language", "de": "Sprache", "fr": "Langue"},
"enabled": {"en": "Enabled", "de": "Aktiviert", "fr": "Activé"},
"isSysAdmin": {"en": "System Admin", "de": "System-Admin", "fr": "Admin système"},
"authenticationAuthority": {"en": "Auth Authority", "de": "Authentifizierung", "fr": "Autorité d'authentification"},
},
)
class UserInDB(User):
"""User model with password hash for database storage."""
hashedPassword: Optional[str] = Field(None, description="Hash of the user password")
resetToken: Optional[str] = Field(None, description="Password reset token (UUID)")
resetTokenExpires: Optional[float] = Field(None, description="Reset token expiration (UTC timestamp in seconds)")
registerModelLabels(
"UserInDB",
{"en": "User Access", "fr": "Accès de l'utilisateur"},
{"en": "User Access", "de": "Benutzerzugang", "fr": "Accès de l'utilisateur"},
{
"hashedPassword": {"en": "Password hash", "fr": "Hachage de mot de passe"},
"resetToken": {"en": "Reset Token", "fr": "Jeton de réinitialisation"},
"resetTokenExpires": {"en": "Reset Token Expires", "fr": "Expiration du jeton"},
"hashedPassword": {"en": "Password hash", "de": "Passwort-Hash", "fr": "Hachage de mot de passe"},
"resetToken": {"en": "Reset Token", "de": "Reset-Token", "fr": "Jeton de réinitialisation"},
"resetTokenExpires": {"en": "Reset Token Expires", "de": "Token läuft ab", "fr": "Expiration du jeton"},
},
)

View file

@ -62,6 +62,7 @@ def _extractJsonFromResponse(content: str) -> Optional[dict]:
async def chatProcess(
currentUser: User,
mandateId: str,
userInput: UserInputRequest,
workflowId: Optional[str] = None
) -> ChatWorkflow:
@ -76,6 +77,7 @@ async def chatProcess(
Args:
currentUser: Current user
mandateId: Mandate context (from RequestContext / X-Mandate-Id header)
userInput: User input request
workflowId: Optional workflow ID to continue existing conversation
@ -83,8 +85,8 @@ async def chatProcess(
ChatWorkflow instance
"""
try:
# Get services
services = getServices(currentUser, None)
# Get services with mandate context
services = getServices(currentUser, None, mandateId=mandateId)
interfaceDbChat = services.interfaceDbChat
# Get event manager and create queue if needed
@ -120,7 +122,7 @@ async def chatProcess(
# Create new workflow
workflowData = {
"id": str(uuid.uuid4()),
"mandateId": currentUser.mandateId,
"mandateId": mandateId,
"status": "running",
"name": conversation_name,
"currentRound": 1,
@ -687,12 +689,13 @@ async def _convert_file_ids_to_document_references(
# Search database if not found in messages
if not document_id:
try:
from modules.shared.databaseUtils import getRecordsetWithRBAC
from modules.interfaces.interfaceRbac import getRecordsetWithRBAC
documents = getRecordsetWithRBAC(
services.interfaceDbChat.db,
ChatDocument,
services.currentUser,
recordFilter={"fileId": file_id}
services.user,
recordFilter={"fileId": file_id},
mandateId=services.mandateId
)
if documents:
workflow_message_ids = {msg.id for msg in workflow.messages} if workflow.messages else set()

View file

@ -126,7 +126,7 @@ def getOptions(optionsName: str, services, currentUser: Optional[User] = None) -
return []
try:
users = services.interfaceDbApp.getUsersByMandate(currentUser.mandateId)
users = services.interfaceDbApp.getUsersByMandate(services.mandateId)
# Handle both list and PaginatedResult
if hasattr(users, 'items'):

View file

@ -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)}")

View file

@ -346,6 +346,7 @@ async def fetch_parcel_polygon_from_swisstopo(
async def executeDirectQuery(
currentUser: User,
mandateId: str,
queryText: str,
parameters: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
@ -354,6 +355,7 @@ async def executeDirectQuery(
Args:
currentUser: Current authenticated user
mandateId: Mandate context (from RequestContext / X-Mandate-Id header)
queryText: SQL query text
parameters: Optional parameters for parameterized queries
@ -364,16 +366,15 @@ async def executeDirectQuery(
- No session or query history is saved
- Query is executed directly and result is returned
- For production, validate and sanitize queries before execution
- TODO: Implement actual database query execution via interface
"""
try:
logger.info(f"Executing direct query for user {currentUser.id} (mandate: {currentUser.mandateId})")
logger.info(f"Executing direct query for user {currentUser.id} (mandate: {mandateId})")
logger.debug(f"Query text: {queryText}")
if parameters:
logger.debug(f"Query parameters: {parameters}")
# Execute query via Real Estate interface (stateless)
realEstateInterface = getRealEstateInterface(currentUser)
realEstateInterface = getRealEstateInterface(currentUser, mandateId=mandateId)
result = realEstateInterface.executeQuery(queryText, parameters)
logger.info(
@ -529,6 +530,7 @@ def _formatEntitySummary(entity_type: str, items: List[Dict[str, Any]], filters:
async def processNaturalLanguageCommand(
currentUser: User,
mandateId: str,
userInput: str,
) -> Dict[str, Any]:
"""
@ -539,6 +541,7 @@ async def processNaturalLanguageCommand(
Args:
currentUser: Current authenticated user
mandateId: Mandate context (from RequestContext / X-Mandate-Id header)
userInput: Natural language command from user
Returns:
@ -552,11 +555,11 @@ async def processNaturalLanguageCommand(
- "SELECT * FROM Projekt WHERE plz = '8000'"
"""
try:
logger.info(f"Processing natural language command for user {currentUser.id} (mandate: {currentUser.mandateId})")
logger.info(f"Processing natural language command for user {currentUser.id} (mandate: {mandateId})")
logger.debug(f"User input: {userInput}")
# Initialize services for AI access
services = getServices(currentUser, workflow=None)
services = getServices(currentUser, workflow=None, mandateId=mandateId)
aiService = services.ai
# Step 1: Analyze user intent with AI
@ -567,6 +570,7 @@ async def processNaturalLanguageCommand(
# Step 2: Execute CRUD operation based on intent
result = await executeIntentBasedOperation(
currentUser=currentUser,
mandateId=mandateId,
intent=intentAnalysis["intent"],
entity=intentAnalysis.get("entity"),
parameters=intentAnalysis.get("parameters", {}),
@ -839,6 +843,7 @@ IMPORTANT EXTRACTION RULES:
async def executeIntentBasedOperation(
currentUser: User,
mandateId: str,
intent: str,
entity: Optional[str],
parameters: Dict[str, Any],
@ -848,6 +853,7 @@ async def executeIntentBasedOperation(
Args:
currentUser: Current authenticated user
mandateId: Mandate context (from RequestContext / X-Mandate-Id header)
intent: Intent from AI analysis (CREATE, READ, UPDATE, DELETE, QUERY)
entity: Entity type from AI analysis
parameters: Extracted parameters from AI analysis
@ -856,8 +862,8 @@ async def executeIntentBasedOperation(
Operation result
Note:
- TODO: Implement actual interface calls once datamodels are ready
- Currently returns test responses showing what would be executed
- Supports CREATE, READ, UPDATE, DELETE, QUERY intents
- Entity types: Projekt, Parzelle, Gemeinde, Kanton, Land, Dokument
"""
try:
logger.info(f"Executing intent-based operation: intent={intent}, entity={entity}")
@ -872,6 +878,7 @@ async def executeIntentBasedOperation(
result = await executeDirectQuery(
currentUser=currentUser,
mandateId=mandateId,
queryText=queryText,
parameters=parameters.get("queryParameters"),
)
@ -879,12 +886,12 @@ async def executeIntentBasedOperation(
elif intent == "CREATE":
# Create new entity
realEstateInterface = getRealEstateInterface(currentUser)
realEstateInterface = getRealEstateInterface(currentUser, mandateId=mandateId)
if entity == "Projekt":
# Create Projekt from parameters
projekt = Projekt(
mandateId=currentUser.mandateId,
mandateId=mandateId,
label=parameters.get("label", ""),
statusProzess=StatusProzess(parameters.get("statusProzess", "EINGANG")) if parameters.get("statusProzess") else None,
)
@ -902,7 +909,7 @@ async def executeIntentBasedOperation(
# Build parzelle data with all extracted parameters
parzelle_data = {
"mandateId": currentUser.mandateId,
"mandateId": mandateId,
"label": parameters.get("label", ""),
}
@ -985,7 +992,7 @@ async def executeIntentBasedOperation(
# Create Gemeinde from parameters
from modules.datamodels.datamodelRealEstate import Gemeinde
gemeinde = Gemeinde(
mandateId=currentUser.mandateId,
mandateId=mandateId,
label=parameters.get("label", ""),
id_kanton=parameters.get("id_kanton"),
plz=parameters.get("plz"),
@ -1000,7 +1007,7 @@ async def executeIntentBasedOperation(
# Create Kanton from parameters
from modules.datamodels.datamodelRealEstate import Kanton
kanton = Kanton(
mandateId=currentUser.mandateId,
mandateId=mandateId,
label=parameters.get("label", ""),
id_land=parameters.get("id_land"),
abk=parameters.get("abk"),
@ -1015,7 +1022,7 @@ async def executeIntentBasedOperation(
# Create Land from parameters
from modules.datamodels.datamodelRealEstate import Land
land = Land(
mandateId=currentUser.mandateId,
mandateId=mandateId,
label=parameters.get("label", ""),
abk=parameters.get("abk"),
)
@ -1029,7 +1036,7 @@ async def executeIntentBasedOperation(
# Create Dokument from parameters
from modules.datamodels.datamodelRealEstate import Dokument
dokument = Dokument(
mandateId=currentUser.mandateId,
mandateId=mandateId,
label=parameters.get("label", ""),
dokumentReferenz=parameters.get("dokumentReferenz", ""),
versionsbezeichnung=parameters.get("versionsbezeichnung"),
@ -1474,6 +1481,7 @@ async def executeIntentBasedOperation(
async def create_project_with_parcel_data(
currentUser: User,
mandateId: str,
projekt_label: str,
parzellen_data: List[Dict[str, Any]],
status_prozess: Optional[str] = None,
@ -1483,6 +1491,7 @@ async def create_project_with_parcel_data(
Args:
currentUser: Current authenticated user
mandateId: Mandate context (from RequestContext / X-Mandate-Id header)
projekt_label: Label for the Projekt
parzellen_data: List of dictionaries containing parcel information from request
status_prozess: Optional project status (defaults to "Eingang")
@ -1496,8 +1505,8 @@ async def create_project_with_parcel_data(
try:
logger.info(f"Creating project '{projekt_label}' with {len(parzellen_data)} parcel(s) for user {currentUser.id}")
# Get interface
realEstateInterface = getRealEstateInterface(currentUser)
# Get interface with mandate context
realEstateInterface = getRealEstateInterface(currentUser, mandateId=mandateId)
# Validate required fields
if not projekt_label:
@ -1587,7 +1596,7 @@ async def create_project_with_parcel_data(
# Check if Parzelle with this label already exists
existing_parzellen = realEstateInterface.getParzellen(
recordFilter={"label": parcel_label, "mandateId": currentUser.mandateId}
recordFilter={"label": parcel_label, "mandateId": mandateId}
)
if existing_parzellen and len(existing_parzellen) > 0:
@ -1630,7 +1639,7 @@ async def create_project_with_parcel_data(
if not laender:
logger.info("Creating Land 'Schweiz'")
land = Land(
mandateId=currentUser.mandateId,
mandateId=mandateId,
label="Schweiz",
abk="CH"
)
@ -1648,7 +1657,7 @@ async def create_project_with_parcel_data(
logger.info(f"Kanton '{canton_abk}' not found, creating it")
kanton_label = canton_names.get(canton_abk, canton_abk) # Use mapping or fallback to abk
kanton = Kanton(
mandateId=currentUser.mandateId,
mandateId=mandateId,
label=kanton_label,
abk=canton_abk,
id_land=land.id
@ -1668,7 +1677,7 @@ async def create_project_with_parcel_data(
if not gemeinden:
logger.info(f"Gemeinde '{municipality_name}' in Kanton '{canton_abk}' not found, creating it")
gemeinde = Gemeinde(
mandateId=currentUser.mandateId,
mandateId=mandateId,
label=municipality_name,
id_kanton=kanton.id,
plz=parzelle_data.get("plz") # Use PLZ directly from Swiss Topo API
@ -1837,7 +1846,7 @@ async def create_project_with_parcel_data(
# Build Parzelle data
parzelle_create_data = {
"mandateId": currentUser.mandateId,
"mandateId": mandateId,
"label": parcel_label, # Use the label we determined earlier for uniqueness check
"parzellenAliasTags": alias_tags,
"eigentuemerschaft": None,
@ -1979,7 +1988,7 @@ async def create_project_with_parcel_data(
project_perimeter = created_parzellen[0].perimeter if created_parzellen else None
projekt_create_data = {
"mandateId": currentUser.mandateId,
"mandateId": mandateId,
"label": projekt_label,
"statusProzess": status_prozess_enum,
"perimeter": project_perimeter, # Use first parcel perimeter as project perimeter

View file

@ -83,8 +83,8 @@ async def executeAutomation(automationId: str, services) -> ChatWorkflow:
executionLog["messages"].append(f"Started execution at {executionStartTime}")
# 2. Replace placeholders in template to generate plan
template = automation.get("template", "")
placeholders = automation.get("placeholders", {})
template = automation.template or ""
placeholders = automation.placeholders or {}
planJson = replacePlaceholders(template, placeholders)
try:
plan = json.loads(planJson)
@ -102,7 +102,7 @@ async def executeAutomation(automationId: str, services) -> ChatWorkflow:
executionLog["messages"].append("Template placeholders replaced successfully")
# 3. Get user who created automation
creatorUserId = automation.get("_createdBy")
creatorUserId = getattr(automation, "_createdBy", None)
# CRITICAL: Automation MUST run as creator user only, or fail
if not creatorUserId:
@ -147,13 +147,13 @@ async def executeAutomation(automationId: str, services) -> ChatWorkflow:
logger.info(f"Started workflow {workflow.id} with plan containing {len(plan.get('tasks', []))} tasks (plan embedded in userInput)")
# Set workflow name with "automated" prefix
automationLabel = automation.get("label", "Unknown Automation")
automationLabel = automation.label or "Unknown Automation"
workflowName = f"automated: {automationLabel}"
workflow = services.interfaceDbChat.updateWorkflow(workflow.id, {"name": workflowName})
logger.info(f"Set workflow {workflow.id} name to: {workflowName}")
# Update automation with execution log
executionLogs = automation.get("executionLogs", [])
executionLogs = list(automation.executionLogs or [])
executionLogs.append(executionLog)
# Keep only last 50 executions
if len(executionLogs) > 50:
@ -174,7 +174,7 @@ async def executeAutomation(automationId: str, services) -> ChatWorkflow:
try:
automation = services.interfaceDbChat.getAutomationDefinition(automationId)
if automation:
executionLogs = automation.get("executionLogs", [])
executionLogs = list(automation.executionLogs or [])
executionLogs.append(executionLog)
if len(executionLogs) > 50:
executionLogs = executionLogs[-50:]
@ -204,10 +204,10 @@ async def syncAutomationEvents(services, eventUser) -> Dict[str, Any]:
registeredEvents = {}
for automation in filtered:
automationId = automation.get("id")
isActive = automation.get("active", False)
currentEventId = automation.get("eventId")
schedule = automation.get("schedule")
automationId = automation.id
isActive = automation.active if hasattr(automation, 'active') else False
currentEventId = automation.eventId if hasattr(automation, 'eventId') else None
schedule = automation.schedule if hasattr(automation, 'schedule') else None
if not schedule:
logger.warning(f"Automation {automationId} has no schedule, skipping")
@ -288,12 +288,12 @@ def createAutomationEventHandler(automationId: str, eventUser):
# Load automation using event user context
automation = eventServices.interfaceDbChat.getAutomationDefinition(automationId)
if not automation or not automation.get("active"):
if not automation or not getattr(automation, "active", False):
logger.warning(f"Automation {automationId} not found or not active, skipping execution")
return
# Get creator user
creatorUserId = automation.get("_createdBy")
creatorUserId = getattr(automation, "_createdBy", None)
if not creatorUserId:
logger.error(f"Automation {automationId} has no creator user")
return

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
@ -37,6 +41,14 @@ from modules.datamodels.datamodelNeutralizer import (
DataNeutralizerAttributes,
)
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResult
from modules.datamodels.datamodelMembership import (
UserMandate,
UserMandateRole,
FeatureAccess,
FeatureAccessRole,
)
from modules.datamodels.datamodelFeatures import Feature, FeatureInstance
from modules.datamodels.datamodelInvitation import Invitation
logger = logging.getLogger(__name__)
@ -61,7 +73,7 @@ class AppObjects:
# Initialize variables
self.currentUser = currentUser # Store User object directly
self.userId = currentUser.id if currentUser else None
self.mandateId = currentUser.mandateId if currentUser else None
self.mandateId = None # mandateId comes from setUserContext, not from User
# Initialize database
self._initializeDatabase()
@ -73,25 +85,42 @@ class AppObjects:
if currentUser:
self.setUserContext(currentUser)
def setUserContext(self, currentUser: User):
"""Sets the user context for the interface."""
def setUserContext(self, currentUser: User, mandateId: Optional[str] = None):
"""
Sets the user context for the interface.
Multi-Tenant Design:
- mandateId wird explizit übergeben (aus Request-Context / X-Mandate-Id Header)
- isSysAdmin User brauchen kein mandateId für System-Operationen
Args:
currentUser: User object
mandateId: Explicit mandate context (from request header). Required for non-sysadmin.
"""
if not currentUser:
logger.info("Initializing interface without user context")
return
self.currentUser = currentUser # Store User object directly
self.userId = currentUser.id
self.mandateId = currentUser.mandateId
# mandateId comes from parameter only
self.mandateId = mandateId
if not self.userId or not self.mandateId:
raise ValueError("Invalid user context: id and mandateId are required")
# Validate: userId is always required
if not self.userId:
raise ValueError("Invalid user context: id is required")
# mandateId is optional for isSysAdmin users doing system-level operations
isSysAdmin = getattr(currentUser, 'isSysAdmin', False)
if not self.mandateId and not isSysAdmin:
# Non-sysadmin users MUST have a mandateId for tenant-scoped operations
logger.warning(f"User {self.userId} has no mandateId context")
# Add language settings
self.userLanguage = currentUser.language # Default user language
# Initialize RBAC interface
if not currentUser:
raise ValueError("User context is required for RBAC")
# Pass self.db as dbApp since this interface uses DbApp database
self.rbac = RbacClass(self.db, dbApp=self.db)
@ -110,11 +139,11 @@ class AppObjects:
"""Initializes the database connection directly."""
try:
# Get configuration values with defaults
dbHost = APP_CONFIG.get("DB_APP_HOST", "_no_config_default_data")
dbDatabase = APP_CONFIG.get("DB_APP_DATABASE", "app")
dbUser = APP_CONFIG.get("DB_APP_USER")
dbPassword = APP_CONFIG.get("DB_APP_PASSWORD_SECRET")
dbPort = int(APP_CONFIG.get("DB_APP_PORT", 5432))
dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data")
dbDatabase = "poweron_app"
dbUser = APP_CONFIG.get("DB_USER")
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
# Create database connector directly
self.db = DatabaseConnector(
@ -615,13 +644,17 @@ class AppObjects:
fullName: str = None,
language: str = "en",
enabled: bool = True,
roleLabels: List[str] = None,
authenticationAuthority: AuthAuthority = AuthAuthority.LOCAL,
externalId: str = None,
externalUsername: str = None,
externalEmail: str = None,
isSysAdmin: bool = False,
) -> User:
"""Create a new user with optional external connection"""
"""
Create a new user.
Note: Role assignment is done via createUserMandate(), not via User fields.
"""
try:
# Ensure username is a string
username = str(username).strip()
@ -638,28 +671,17 @@ class AppObjects:
if not password.strip():
raise ValueError("Password cannot be empty")
# Ensure mandateId is set - use self.mandateId or default mandate
mandateId = self.mandateId
if not mandateId:
mandateId = self._getDefaultMandateId()
logger.warning(f"Using default mandate ID {mandateId} for new user {username}")
# Default roleLabels to ["user"] if not provided
if roleLabels is None or not roleLabels:
roleLabels = ["user"]
# Create user data using UserInDB model
# Note: mandateId and roleLabels are REMOVED - use UserMandate + UserMandateRole
userData = UserInDB(
username=username,
email=email,
fullName=fullName,
language=language,
mandateId=mandateId,
enabled=enabled,
roleLabels=roleLabels,
isSysAdmin=isSysAdmin,
authenticationAuthority=authenticationAuthority,
hashedPassword=self._getPasswordHash(password) if password else None,
connections=[],
)
# Create user record
@ -712,25 +734,11 @@ class AppObjects:
# Remove id field from updateDict if present - we'll use userId from parameter
updateDict.pop("id", None)
# Ensure mandateId is set - if missing or None, use default mandate
if "mandateId" not in updateDict or not updateDict.get("mandateId"):
if not user.mandateId:
# User has no mandateId, set to default
defaultMandateId = self._getDefaultMandateId()
updateDict["mandateId"] = defaultMandateId
logger.warning(f"Setting default mandate ID {defaultMandateId} for user {userId}")
else:
# Keep existing mandateId if update doesn't provide one
updateDict["mandateId"] = user.mandateId
# Update user data using model
updatedData = user.model_dump()
updatedData.update(updateDict)
# Ensure ID matches userId parameter
updatedData["id"] = userId
# Ensure mandateId is set in final data
if not updatedData.get("mandateId"):
updatedData["mandateId"] = self._getDefaultMandateId()
updatedUser = User(**updatedData)
# Update user record
@ -1382,6 +1390,325 @@ class AppObjects:
logger.error(f"Error deleting mandate: {str(e)}")
raise ValueError(f"Failed to delete mandate: {str(e)}")
# ============================================
# User-Mandate Membership Methods (Multi-Tenant)
# ============================================
def getUserMandate(self, userId: str, mandateId: str) -> Optional[UserMandate]:
"""
Get UserMandate record for a user in a specific mandate.
Args:
userId: User ID
mandateId: Mandate ID
Returns:
UserMandate object or None
"""
try:
records = self.db.getRecordset(
UserMandate,
recordFilter={"userId": userId, "mandateId": mandateId}
)
if not records:
return None
cleanedRecord = {k: v for k, v in records[0].items() if not k.startswith("_")}
return UserMandate(**cleanedRecord)
except Exception as e:
logger.error(f"Error getting UserMandate: {e}")
return None
def getUserMandates(self, userId: str) -> List[UserMandate]:
"""
Get all mandates a user is member of.
Args:
userId: User ID
Returns:
List of UserMandate objects
"""
try:
records = self.db.getRecordset(
UserMandate,
recordFilter={"userId": userId, "enabled": True}
)
result = []
for record in records:
cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_")}
result.append(UserMandate(**cleanedRecord))
return result
except Exception as e:
logger.error(f"Error getting UserMandates: {e}")
return []
def createUserMandate(self, userId: str, mandateId: str, roleIds: List[str] = None) -> UserMandate:
"""
Create a UserMandate record (add user to mandate).
Args:
userId: User ID
mandateId: Mandate ID
roleIds: Optional list of role IDs to assign
Returns:
Created UserMandate object
"""
try:
# Check if already exists
existing = self.getUserMandate(userId, mandateId)
if existing:
raise ValueError(f"User {userId} is already member of mandate {mandateId}")
# Create UserMandate
userMandate = UserMandate(
userId=userId,
mandateId=mandateId,
enabled=True
)
createdRecord = self.db.recordCreate(UserMandate, userMandate.model_dump())
# Assign roles via junction table
if roleIds and createdRecord:
userMandateId = createdRecord.get("id")
for roleId in roleIds:
userMandateRole = UserMandateRole(
userMandateId=userMandateId,
roleId=roleId
)
self.db.recordCreate(UserMandateRole, userMandateRole.model_dump())
cleanedRecord = {k: v for k, v in createdRecord.items() if not k.startswith("_")}
return UserMandate(**cleanedRecord)
except Exception as e:
logger.error(f"Error creating UserMandate: {e}")
raise ValueError(f"Failed to create UserMandate: {e}")
def deleteUserMandate(self, userId: str, mandateId: str) -> bool:
"""
Delete a UserMandate record (remove user from mandate).
CASCADE will delete UserMandateRole entries.
Args:
userId: User ID
mandateId: Mandate ID
Returns:
True if deleted, False if not found
"""
try:
existing = self.getUserMandate(userId, mandateId)
if not existing:
return False
return self.db.recordDelete(UserMandate, existing.id)
except Exception as e:
logger.error(f"Error deleting UserMandate: {e}")
raise ValueError(f"Failed to delete UserMandate: {e}")
def getRoleIdsForUserMandate(self, userMandateId: str) -> List[str]:
"""
Get all role IDs assigned to a UserMandate.
Args:
userMandateId: UserMandate ID
Returns:
List of role IDs
"""
try:
records = self.db.getRecordset(
UserMandateRole,
recordFilter={"userMandateId": userMandateId}
)
return [r.get("roleId") for r in records if r.get("roleId")]
except Exception as e:
logger.error(f"Error getting role IDs for UserMandate: {e}")
return []
def addRoleToUserMandate(self, userMandateId: str, roleId: str) -> UserMandateRole:
"""
Add a role to a UserMandate.
Args:
userMandateId: UserMandate ID
roleId: Role ID to add
Returns:
Created UserMandateRole object
"""
try:
# Check if already exists
existing = self.db.getRecordset(
UserMandateRole,
recordFilter={"userMandateId": userMandateId, "roleId": roleId}
)
if existing:
cleanedRecord = {k: v for k, v in existing[0].items() if not k.startswith("_")}
return UserMandateRole(**cleanedRecord)
userMandateRole = UserMandateRole(
userMandateId=userMandateId,
roleId=roleId
)
createdRecord = self.db.recordCreate(UserMandateRole, userMandateRole.model_dump())
cleanedRecord = {k: v for k, v in createdRecord.items() if not k.startswith("_")}
return UserMandateRole(**cleanedRecord)
except Exception as e:
logger.error(f"Error adding role to UserMandate: {e}")
raise ValueError(f"Failed to add role: {e}")
def removeRoleFromUserMandate(self, userMandateId: str, roleId: str) -> bool:
"""
Remove a role from a UserMandate.
If no roles remain, the UserMandate is deleted (Application-Level Cleanup).
Args:
userMandateId: UserMandate ID
roleId: Role ID to remove
Returns:
True if removed
"""
try:
# Find and delete the junction record
records = self.db.getRecordset(
UserMandateRole,
recordFilter={"userMandateId": userMandateId, "roleId": roleId}
)
if not records:
return False
self.db.recordDelete(UserMandateRole, records[0].get("id"))
# Application-Level Cleanup: Delete UserMandate if no roles remain
remainingRoles = self.db.getRecordset(
UserMandateRole,
recordFilter={"userMandateId": userMandateId}
)
if not remainingRoles:
self.db.recordDelete(UserMandate, userMandateId)
logger.info(f"Deleted empty UserMandate {userMandateId}")
return True
except Exception as e:
logger.error(f"Error removing role from UserMandate: {e}")
raise ValueError(f"Failed to remove role: {e}")
# ============================================
# Feature Access Methods (Multi-Tenant)
# ============================================
def getFeatureAccess(self, userId: str, featureInstanceId: str) -> Optional[FeatureAccess]:
"""
Get FeatureAccess record for a user to a specific feature instance.
Args:
userId: User ID
featureInstanceId: FeatureInstance ID
Returns:
FeatureAccess object or None
"""
try:
records = self.db.getRecordset(
FeatureAccess,
recordFilter={"userId": userId, "featureInstanceId": featureInstanceId}
)
if not records:
return None
cleanedRecord = {k: v for k, v in records[0].items() if not k.startswith("_")}
return FeatureAccess(**cleanedRecord)
except Exception as e:
logger.error(f"Error getting FeatureAccess: {e}")
return None
def getFeatureAccessesForUser(self, userId: str) -> List[FeatureAccess]:
"""
Get all feature accesses for a user.
Args:
userId: User ID
Returns:
List of FeatureAccess objects
"""
try:
records = self.db.getRecordset(
FeatureAccess,
recordFilter={"userId": userId, "enabled": True}
)
result = []
for record in records:
cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_")}
result.append(FeatureAccess(**cleanedRecord))
return result
except Exception as e:
logger.error(f"Error getting FeatureAccesses: {e}")
return []
def createFeatureAccess(self, userId: str, featureInstanceId: str, roleIds: List[str] = None) -> FeatureAccess:
"""
Create a FeatureAccess record (grant user access to feature instance).
Args:
userId: User ID
featureInstanceId: FeatureInstance ID
roleIds: Optional list of role IDs to assign
Returns:
Created FeatureAccess object
"""
try:
# Check if already exists
existing = self.getFeatureAccess(userId, featureInstanceId)
if existing:
raise ValueError(f"User {userId} already has access to feature instance {featureInstanceId}")
# Create FeatureAccess
featureAccess = FeatureAccess(
userId=userId,
featureInstanceId=featureInstanceId,
enabled=True
)
createdRecord = self.db.recordCreate(FeatureAccess, featureAccess.model_dump())
# Assign roles via junction table
if roleIds and createdRecord:
featureAccessId = createdRecord.get("id")
for roleId in roleIds:
featureAccessRole = FeatureAccessRole(
featureAccessId=featureAccessId,
roleId=roleId
)
self.db.recordCreate(FeatureAccessRole, featureAccessRole.model_dump())
cleanedRecord = {k: v for k, v in createdRecord.items() if not k.startswith("_")}
return FeatureAccess(**cleanedRecord)
except Exception as e:
logger.error(f"Error creating FeatureAccess: {e}")
raise ValueError(f"Failed to create FeatureAccess: {e}")
def getRoleIdsForFeatureAccess(self, featureAccessId: str) -> List[str]:
"""
Get all role IDs assigned to a FeatureAccess.
Args:
featureAccessId: FeatureAccess ID
Returns:
List of role IDs
"""
try:
records = self.db.getRecordset(
FeatureAccessRole,
recordFilter={"featureAccessId": featureAccessId}
)
return [r.get("roleId") for r in records if r.get("roleId")]
except Exception as e:
logger.error(f"Error getting role IDs for FeatureAccess: {e}")
return []
# Token methods
def saveAccessToken(self, token: Token, replace_existing: bool = True) -> None:
@ -1902,6 +2229,7 @@ class AppObjects:
def getAccessRules(
self,
roleLabel: Optional[str] = None,
roleId: Optional[str] = None,
context: Optional[AccessRuleContext] = None,
item: Optional[str] = None,
pagination: Optional[PaginationParams] = None
@ -1910,7 +2238,8 @@ class AppObjects:
Get access rules with optional filters and pagination.
Args:
roleLabel: Optional role label filter
roleLabel: Optional role label filter (deprecated, use roleId)
roleId: Optional role ID filter
context: Optional context filter
item: Optional item filter
pagination: Optional pagination parameters. If None, returns all items.
@ -1921,7 +2250,9 @@ class AppObjects:
"""
try:
recordFilter = {}
if roleLabel:
if roleId:
recordFilter["roleId"] = roleId
elif roleLabel:
recordFilter["roleLabel"] = roleLabel
if context:
recordFilter["context"] = context.value
@ -2134,6 +2465,29 @@ class AppObjects:
else:
return PaginatedResult(items=[], totalItems=0, totalPages=0)
def countRoleAssignments(self) -> Dict[str, int]:
"""
Count the number of user assignments per role from UserMandateRole table.
Returns:
Dictionary mapping roleId to count of user assignments
"""
try:
# Get all UserMandateRole records
assignments = self.db.getRecordset(UserMandateRole)
# Count assignments per roleId
roleCounts: Dict[str, int] = {}
for assignment in assignments:
roleId = str(assignment.get("roleId", ""))
if roleId:
roleCounts[roleId] = roleCounts.get(roleId, 0) + 1
return roleCounts
except Exception as e:
logger.error(f"Error counting role assignments: {str(e)}")
return {}
def updateRole(self, roleId: str, role: Role) -> Role:
"""
Update an existing role.
@ -2185,14 +2539,13 @@ class AppObjects:
if role.isSystemRole:
raise ValueError(f"Cannot delete system role '{role.roleLabel}'")
# Check if role is assigned to any users
allUsers = self.getUsersByMandate(None) # Get all users across all mandates
for user in allUsers:
if role.roleLabel in (user.roleLabels or []):
raise ValueError(f"Cannot delete role '{role.roleLabel}' - it is assigned to users")
# Check if role is assigned to any users via UserMandateRole
roleAssignments = self.db.getRecordset(UserMandateRole, recordFilter={"roleId": roleId})
if roleAssignments:
raise ValueError(f"Cannot delete role '{role.roleLabel}' - it is assigned to users")
# Check if role is used in any access rules
accessRules = self.getAccessRules(roleLabel=role.roleLabel)
accessRules = self.getAccessRules(roleId=roleId)
if accessRules:
raise ValueError(f"Cannot delete role '{role.roleLabel}' - it is used in access rules")
@ -2207,20 +2560,34 @@ class AppObjects:
# Public Methods
def getInterface(currentUser: User) -> AppObjects:
def getInterface(currentUser: User, mandateId: Optional[str] = None) -> AppObjects:
"""
Returns a AppObjects instance for the current user.
Handles initialization of database and records.
Multi-Tenant Design:
- mandateId wird explizit übergeben (aus Request-Context / X-Mandate-Id Header)
Args:
currentUser: User object
mandateId: Explicit mandate context (from request header). Required for non-sysadmin.
Returns:
AppObjects instance for the user context
"""
if not currentUser:
raise ValueError("Invalid user context: user is required")
# Create context key
contextKey = f"{currentUser.mandateId}_{currentUser.id}"
effectiveMandateId = mandateId
# Create context key (user + mandate combination)
contextKey = f"{effectiveMandateId}_{currentUser.id}"
# Create new instance if not exists
if contextKey not in _gatewayInterfaces:
_gatewayInterfaces[contextKey] = AppObjects(currentUser)
instance = AppObjects(currentUser)
instance.setUserContext(currentUser, mandateId=effectiveMandateId)
_gatewayInterfaces[contextKey] = instance
return _gatewayInterfaces[contextKey]

View file

@ -178,12 +178,18 @@ class ChatObjects:
Uses the JSON connector for data access with added language support.
"""
def __init__(self, currentUser: Optional[User] = None):
"""Initializes the Chat Interface."""
def __init__(self, currentUser: Optional[User] = None, mandateId: Optional[str] = None):
"""Initializes the Chat Interface.
Args:
currentUser: The authenticated user
mandateId: The mandate ID from RequestContext (X-Mandate-Id header)
"""
# Initialize variables
self.currentUser = currentUser # Store User object directly
self.userId = currentUser.id if currentUser else None
self.mandateId = currentUser.mandateId if currentUser else None
# Use mandateId from parameter (Request-Context), not from user object
self.mandateId = mandateId
self.rbac = None # RBAC interface
# Initialize services
@ -194,7 +200,7 @@ class ChatObjects:
# Set user context if provided
if currentUser:
self.setUserContext(currentUser)
self.setUserContext(currentUser, mandateId=mandateId)
# ===== Generic Utility Methods =====
@ -257,14 +263,24 @@ class ChatObjects:
def _initializeServices(self):
pass
def setUserContext(self, currentUser: User):
"""Sets the user context for the interface."""
def setUserContext(self, currentUser: User, mandateId: Optional[str] = None):
"""Sets the user context for the interface.
Args:
currentUser: The authenticated user
mandateId: The mandate ID from RequestContext (X-Mandate-Id header)
"""
self.currentUser = currentUser # Store User object directly
self.userId = currentUser.id
self.mandateId = currentUser.mandateId
# Use mandateId from parameter (Request-Context), not from user object
self.mandateId = mandateId
if not self.userId or not self.mandateId:
raise ValueError("Invalid user context: id and mandateId are required")
if not self.userId:
raise ValueError("Invalid user context: id is required")
# mandateId can be None for sysadmins performing cross-mandate operations
if not self.mandateId and not getattr(currentUser, 'isSysAdmin', False):
raise ValueError("Invalid user context: mandateId is required for non-sysadmin users")
# Add language settings
self.userLanguage = currentUser.language # Default user language
@ -293,11 +309,11 @@ class ChatObjects:
"""Initializes the database connection directly."""
try:
# Get configuration values with defaults
dbHost = APP_CONFIG.get("DB_CHAT_HOST", "_no_config_default_data")
dbDatabase = APP_CONFIG.get("DB_CHAT_DATABASE", "chat")
dbUser = APP_CONFIG.get("DB_CHAT_USER")
dbPassword = APP_CONFIG.get("DB_CHAT_PASSWORD_SECRET")
dbPort = int(APP_CONFIG.get("DB_CHAT_PORT", 5432))
dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data")
dbDatabase = "poweron_chat"
dbUser = APP_CONFIG.get("DB_USER")
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
# Create database connector directly
self.db = DatabaseConnector(
@ -654,7 +670,7 @@ class ChatObjects:
logs=logs,
messages=messages,
stats=stats,
mandateId=workflow.get("mandateId", self.currentUser.mandateId)
mandateId=workflow.get("mandateId", self.mandateId)
)
except Exception as e:
logger.error(f"Error validating workflow data: {str(e)}")
@ -695,7 +711,7 @@ class ChatObjects:
logs=[],
messages=[],
stats=[],
mandateId=created.get("mandateId", self.currentUser.mandateId),
mandateId=created.get("mandateId", self.mandateId),
workflowMode=created["workflowMode"],
maxSteps=created.get("maxSteps", 1)
)
@ -1088,7 +1104,7 @@ class ChatObjects:
logger.error(f"Error creating workflow message: {str(e)}")
return None
def updateMessage(self, messageId: str, messageData: Dict[str, Any]) -> Dict[str, Any]:
def updateMessage(self, messageId: str, messageData: Dict[str, Any]) -> Optional[ChatMessage]:
"""Updates a workflow message if user has access to the workflow."""
try:
@ -1174,8 +1190,10 @@ class ChatObjects:
logger.error(f"Error updating message documents: {str(e)}")
if not updatedMessage:
logger.warning(f"Failed to update message {messageId}")
return updatedMessage
return None
# Convert to ChatMessage model
return ChatMessage(**updatedMessage)
except Exception as e:
logger.error(f"Error updating message {messageId}: {str(e)}", exc_info=True)
raise ValueError(f"Error updating message {messageId}: {str(e)}")
@ -1716,7 +1734,7 @@ class ChatObjects:
totalPages=totalPages
)
def getAutomationDefinition(self, automationId: str) -> Optional[Dict[str, Any]]:
def getAutomationDefinition(self, automationId: str) -> Optional[AutomationDefinition]:
"""Returns an automation definition by ID if user has access, with computed status."""
try:
# Use RBAC filtering
@ -1736,12 +1754,14 @@ class ChatObjects:
automation["executionLogs"] = []
# Enrich with user and mandate names
self._enrichAutomationWithUserAndMandate(automation)
return automation
# Clean metadata fields and return Pydantic model
cleanedRecord = {k: v for k, v in automation.items() if not k.startswith("_")}
return AutomationDefinition(**cleanedRecord)
except Exception as e:
logger.error(f"Error getting automation definition: {str(e)}")
return None
def createAutomationDefinition(self, automationData: Dict[str, Any]) -> Dict[str, Any]:
def createAutomationDefinition(self, automationData: Dict[str, Any]) -> AutomationDefinition:
"""Creates a new automation definition, then triggers sync."""
try:
# Ensure ID is present
@ -1777,12 +1797,14 @@ class ChatObjects:
# Trigger automation change callback (async, don't wait)
asyncio.create_task(self._notifyAutomationChanged())
return createdAutomation
# Clean metadata fields and return Pydantic model
cleanedRecord = {k: v for k, v in createdAutomation.items() if not k.startswith("_")}
return AutomationDefinition(**cleanedRecord)
except Exception as e:
logger.error(f"Error creating automation definition: {str(e)}")
raise
def updateAutomationDefinition(self, automationId: str, automationData: Dict[str, Any]) -> Dict[str, Any]:
def updateAutomationDefinition(self, automationId: str, automationData: Dict[str, Any]) -> AutomationDefinition:
"""Updates an automation definition, then triggers sync."""
try:
# Check access
@ -1808,7 +1830,9 @@ class ChatObjects:
# Trigger automation change callback (async, don't wait)
asyncio.create_task(self._notifyAutomationChanged())
return updatedAutomation
# Clean metadata fields and return Pydantic model
cleanedRecord = {k: v for k, v in updatedAutomation.items() if not k.startswith("_")}
return AutomationDefinition(**cleanedRecord)
except Exception as e:
logger.error(f"Error updating automation definition: {str(e)}")
raise
@ -1870,19 +1894,28 @@ class ChatObjects:
logger.error(f"Error notifying automation change: {str(e)}")
def getInterface(currentUser: Optional[User] = None) -> 'ChatObjects':
def getInterface(currentUser: Optional[User] = None, mandateId: Optional[str] = None) -> 'ChatObjects':
"""
Returns a ChatObjects instance for the current user.
Handles initialization of database and records.
Args:
currentUser: The authenticated user
mandateId: The mandate ID from RequestContext (X-Mandate-Id header). Required.
"""
if not currentUser:
raise ValueError("Invalid user context: user is required")
effectiveMandateId = str(mandateId) if mandateId else None
# Create context key
contextKey = f"{currentUser.mandateId}_{currentUser.id}"
contextKey = f"{effectiveMandateId}_{currentUser.id}"
# Create new instance if not exists
if contextKey not in _chatInterfaces:
_chatInterfaces[contextKey] = ChatObjects(currentUser)
_chatInterfaces[contextKey] = ChatObjects(currentUser, mandateId=effectiveMandateId)
else:
# Update user context if needed
_chatInterfaces[contextKey].setUserContext(currentUser, mandateId=effectiveMandateId)
return _chatInterfaces[contextKey]

View file

@ -76,14 +76,21 @@ class ComponentObjects:
# Initialize standard records if needed
self._initRecords()
def setUserContext(self, currentUser: User):
"""Sets the user context for the interface."""
def setUserContext(self, currentUser: User, mandateId: Optional[str] = None):
"""Sets the user context for the interface.
Args:
currentUser: The authenticated user
mandateId: The mandate ID from RequestContext (X-Mandate-Id header)
"""
if not currentUser:
logger.info("Initializing interface without user context")
return
self.currentUser = currentUser # Store User object directly
self.userId = currentUser.id
# Use mandateId from parameter (Request-Context), not from user object
self.mandateId = mandateId
if not self.userId:
raise ValueError("Invalid user context: id is required")
@ -116,11 +123,11 @@ class ComponentObjects:
"""Initializes the database connection directly."""
try:
# Get configuration values with defaults
dbHost = APP_CONFIG.get("DB_MANAGEMENT_HOST", "_no_config_default_data")
dbDatabase = APP_CONFIG.get("DB_MANAGEMENT_DATABASE", "management")
dbUser = APP_CONFIG.get("DB_MANAGEMENT_USER")
dbPassword = APP_CONFIG.get("DB_MANAGEMENT_PASSWORD_SECRET")
dbPort = int(APP_CONFIG.get("DB_MANAGEMENT_PORT"))
dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data")
dbDatabase = "poweron_management"
dbUser = APP_CONFIG.get("DB_USER")
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
# Create database connector directly
self.db = DatabaseConnector(
@ -979,8 +986,8 @@ class ComponentObjects:
fileSize = len(content)
fileHash = hashlib.sha256(content).hexdigest()
# Ensure mandateId is valid
mandateId = self.currentUser.mandateId or "default"
# Use mandateId from context
mandateId = self.mandateId
# Create FileItem instance
fileItem = FileItem(
@ -1320,9 +1327,9 @@ class ComponentObjects:
if "userId" not in settingsData:
settingsData["userId"] = self.userId
# Ensure mandateId is set
# Ensure mandateId is set from context
if "mandateId" not in settingsData:
settingsData["mandateId"] = self.currentUser.mandateId if self.currentUser else "default"
settingsData["mandateId"] = self.mandateId
# Check if settings already exist for this user
existingSettings = self.getVoiceSettings(settingsData["userId"])
@ -1406,7 +1413,7 @@ class ComponentObjects:
# Create default settings
defaultSettings = {
"userId": targetUserId,
"mandateId": self.currentUser.mandateId if self.currentUser else "default",
"mandateId": self.mandateId,
"sttLanguage": "de-DE",
"ttsLanguage": "de-DE",
"ttsVoice": "de-DE-KatjaNeural",
@ -1494,9 +1501,9 @@ class ComponentObjects:
if not all(c.isalpha() or c == "_" for c in subscriptionId):
raise ValueError("subscriptionId must contain only letters and underscores")
# Set mandateId if not provided
# Set mandateId from context
if "mandateId" not in subscriptionData:
subscriptionData["mandateId"] = self.currentUser.mandateId if self.currentUser else "default"
subscriptionData["mandateId"] = self.mandateId
createdRecord = self.db.recordCreate(MessagingSubscription, subscriptionData)
if not createdRecord or not createdRecord.get("id"):
@ -1741,12 +1748,18 @@ class ComponentObjects:
return MessagingDelivery(**filteredDeliveries[0]) if filteredDeliveries else None
def getInterface(currentUser: Optional[User] = None) -> 'ComponentObjects':
def getInterface(currentUser: Optional[User] = None, mandateId: Optional[str] = None) -> 'ComponentObjects':
"""
Returns a ComponentObjects instance.
If currentUser is provided, initializes with user context.
Otherwise, returns an instance with only database access.
Args:
currentUser: The authenticated user
mandateId: The mandate ID from RequestContext (X-Mandate-Id header). Required.
"""
effectiveMandateId = str(mandateId) if mandateId else None
# Create new instance if not exists
if "default" not in _instancesManagement:
_instancesManagement["default"] = ComponentObjects()
@ -1754,7 +1767,7 @@ def getInterface(currentUser: Optional[User] = None) -> 'ComponentObjects':
interface = _instancesManagement["default"]
if currentUser:
interface.setUserContext(currentUser)
interface.setUserContext(currentUser, mandateId=effectiveMandateId)
else:
logger.info("Returning interface without user context")

View file

@ -39,11 +39,17 @@ class RealEstateObjects:
Handles CRUD operations on Real Estate entities.
"""
def __init__(self, currentUser: Optional[User] = None):
"""Initializes the Real Estate Interface."""
def __init__(self, currentUser: Optional[User] = None, mandateId: Optional[str] = None):
"""Initializes the Real Estate Interface.
Args:
currentUser: The authenticated user
mandateId: The mandate ID from RequestContext (X-Mandate-Id header)
"""
self.currentUser = currentUser
self.userId = currentUser.id if currentUser else None
self.mandateId = currentUser.mandateId if currentUser else None
# Use mandateId from parameter (Request-Context), not from user object
self.mandateId = mandateId
self.rbac = None # RBAC interface
# Initialize database
@ -51,17 +57,17 @@ class RealEstateObjects:
# Set user context if provided
if currentUser:
self.setUserContext(currentUser)
self.setUserContext(currentUser, mandateId=mandateId)
def _initializeDatabase(self):
"""Initialize PostgreSQL database connection."""
try:
# Get database configuration from environment
dbHost = APP_CONFIG.get("DB_REALESTATE_HOST", "localhost")
dbDatabase = APP_CONFIG.get("DB_REALESTATE_DATABASE", "poweron_realestate")
dbUser = APP_CONFIG.get("DB_REALESTATE_USER")
dbPassword = APP_CONFIG.get("DB_REALESTATE_PASSWORD_SECRET")
dbPort = int(APP_CONFIG.get("DB_REALESTATE_PORT", 5432))
dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data")
dbDatabase = "poweron_realestate"
dbUser = APP_CONFIG.get("DB_USER")
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
# Initialize database connector
self.db = DatabaseConnector(
@ -101,14 +107,24 @@ class RealEstateObjects:
logger.warning(f"Error ensuring supporting tables exist: {e}")
# Don't raise - tables will be created on-demand anyway
def setUserContext(self, currentUser: User):
"""Sets the user context for the interface."""
def setUserContext(self, currentUser: User, mandateId: Optional[str] = None):
"""Sets the user context for the interface.
Args:
currentUser: The authenticated user
mandateId: The mandate ID from RequestContext (X-Mandate-Id header)
"""
self.currentUser = currentUser
self.userId = currentUser.id
self.mandateId = currentUser.mandateId
# Use mandateId from parameter (Request-Context), not from user object
self.mandateId = mandateId
if not self.userId or not self.mandateId:
raise ValueError("Invalid user context: id and mandateId are required")
if not self.userId:
raise ValueError("Invalid user context: id is required")
# mandateId can be None for sysadmins performing cross-mandate operations
if not self.mandateId and not getattr(currentUser, 'isSysAdmin', False):
raise ValueError("Invalid user context: mandateId is required for non-sysadmin users")
# Initialize RBAC interface
if not self.currentUser:
@ -239,14 +255,8 @@ class RealEstateObjects:
def getParzellen(self, recordFilter: Optional[Dict[str, Any]] = None) -> List[Parzelle]:
"""Get all plots matching the filter."""
original_gemeinde_value = None
# Resolve location names to IDs if needed
if recordFilter:
# Save original value before resolution for fallback search
if "kontextGemeinde" in recordFilter:
original_gemeinde_value = recordFilter["kontextGemeinde"]
recordFilter = self._resolveLocationFilters(recordFilter)
records = getRecordsetWithRBAC(
@ -256,23 +266,6 @@ class RealEstateObjects:
recordFilter=recordFilter or {}
)
# Fallback: If no records found and we resolved a Gemeinde name,
# try searching with the original name for backwards compatibility
# (handles case where data has string names instead of UUIDs)
if not records and original_gemeinde_value and recordFilter and "kontextGemeinde" in recordFilter:
if recordFilter["kontextGemeinde"] != original_gemeinde_value:
logger.info(f"No results with resolved UUID, trying with original name '{original_gemeinde_value}'")
fallback_filter = recordFilter.copy()
fallback_filter["kontextGemeinde"] = original_gemeinde_value
records = getRecordsetWithRBAC(
self.db,
Parzelle,
self.currentUser,
recordFilter=fallback_filter
)
if records:
logger.info(f"Found {len(records)} records using original name (legacy data format)")
return [Parzelle(**r) for r in records]
def _resolveLocationFilters(self, recordFilter: Dict[str, Any]) -> Dict[str, Any]:
@ -799,15 +792,24 @@ class RealEstateObjects:
raise
def getInterface(currentUser: User) -> RealEstateObjects:
def getInterface(currentUser: User, mandateId: Optional[str] = None) -> RealEstateObjects:
"""
Factory function to get or create a Real Estate interface instance for a user.
Uses singleton pattern per user.
Args:
currentUser: The authenticated user
mandateId: The mandate ID from RequestContext (X-Mandate-Id header). Required.
"""
userKey = f"{currentUser.id}_{currentUser.mandateId}"
effectiveMandateId = str(mandateId) if mandateId else None
userKey = f"{currentUser.id}_{effectiveMandateId}"
if userKey not in _realEstateInterfaces:
_realEstateInterfaces[userKey] = RealEstateObjects(currentUser)
_realEstateInterfaces[userKey] = RealEstateObjects(currentUser, mandateId=effectiveMandateId)
else:
# Update user context if needed
_realEstateInterfaces[userKey].setUserContext(currentUser, mandateId=effectiveMandateId)
return _realEstateInterfaces[userKey]

View file

@ -7,7 +7,8 @@ Manages trustee organisations, roles, access, contracts, documents, and position
import logging
import math
from typing import Dict, Any, List, Optional
import uuid
from typing import Dict, Any, List, Optional, Union
from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.shared.configuration import APP_CONFIG
@ -32,20 +33,27 @@ logger = logging.getLogger(__name__)
_trusteeInterfaces = {}
def getInterface(currentUser: User) -> "TrusteeObjects":
"""Get or create a TrusteeObjects instance for the given user context."""
def getInterface(currentUser: User, mandateId: Optional[Union[str, uuid.UUID]] = None) -> "TrusteeObjects":
"""Get or create a TrusteeObjects instance for the given user context.
Args:
currentUser: The authenticated user
mandateId: The mandate ID from RequestContext (X-Mandate-Id header). Required.
"""
global _trusteeInterfaces
if not currentUser or not currentUser.id:
raise ValueError("Valid user context required")
cacheKey = f"{currentUser.id}_{currentUser.mandateId}"
effectiveMandateId = str(mandateId) if mandateId else None
cacheKey = f"{currentUser.id}_{effectiveMandateId}"
if cacheKey not in _trusteeInterfaces:
_trusteeInterfaces[cacheKey] = TrusteeObjects(currentUser)
_trusteeInterfaces[cacheKey] = TrusteeObjects(currentUser, mandateId=effectiveMandateId)
else:
# Update user context if needed
_trusteeInterfaces[cacheKey].setUserContext(currentUser)
_trusteeInterfaces[cacheKey].setUserContext(currentUser, mandateId=effectiveMandateId)
return _trusteeInterfaces[cacheKey]
@ -56,11 +64,17 @@ class TrusteeObjects:
Manages trustee organisations, roles, access, contracts, documents, and positions.
"""
def __init__(self, currentUser: Optional[User] = None):
"""Initializes the Trustee Interface."""
def __init__(self, currentUser: Optional[User] = None, mandateId: Optional[str] = None):
"""Initializes the Trustee Interface.
Args:
currentUser: The authenticated user
mandateId: The mandate ID from RequestContext (X-Mandate-Id header)
"""
self.currentUser = currentUser
self.userId = currentUser.id if currentUser else None
self.mandateId = currentUser.mandateId if currentUser else None
# Use mandateId from parameter (Request-Context), not from user object
self.mandateId = mandateId
self.rbac = None
# Initialize database
@ -68,20 +82,30 @@ class TrusteeObjects:
# Set user context if provided
if currentUser:
self.setUserContext(currentUser)
self.setUserContext(currentUser, mandateId=mandateId)
def setUserContext(self, currentUser: User):
"""Sets the user context for the interface."""
def setUserContext(self, currentUser: User, mandateId: Optional[str] = None):
"""Sets the user context for the interface.
Args:
currentUser: The authenticated user
mandateId: The mandate ID from RequestContext (X-Mandate-Id header)
"""
if not currentUser:
logger.info("Initializing interface without user context")
return
self.currentUser = currentUser
self.userId = currentUser.id
self.mandateId = currentUser.mandateId
# Use mandateId from parameter (Request-Context), not from user object
self.mandateId = mandateId
if not self.userId or not self.mandateId:
raise ValueError("Invalid user context: id and mandateId are required")
if not self.userId:
raise ValueError("Invalid user context: id is required")
# mandateId can be None for sysadmins performing cross-mandate operations
if not self.mandateId and not getattr(currentUser, 'isSysAdmin', False):
raise ValueError("Invalid user context: mandateId is required for non-sysadmin users")
self.userLanguage = currentUser.language
@ -104,11 +128,11 @@ class TrusteeObjects:
def _initializeDatabase(self):
"""Initializes the database connection directly."""
try:
dbHost = APP_CONFIG.get("DB_TRUSTEE_HOST", APP_CONFIG.get("DB_CHAT_HOST", "_no_config_default_data"))
dbDatabase = APP_CONFIG.get("DB_TRUSTEE_DATABASE", "trustee")
dbUser = APP_CONFIG.get("DB_TRUSTEE_USER", APP_CONFIG.get("DB_CHAT_USER"))
dbPassword = APP_CONFIG.get("DB_TRUSTEE_PASSWORD_SECRET", APP_CONFIG.get("DB_CHAT_PASSWORD_SECRET"))
dbPort = int(APP_CONFIG.get("DB_TRUSTEE_PORT", APP_CONFIG.get("DB_CHAT_PORT", 5432)))
dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data")
dbDatabase = "poweron_trustee"
dbUser = APP_CONFIG.get("DB_USER")
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
self.db = DatabaseConnector(
dbHost=dbHost,
@ -174,7 +198,7 @@ class TrusteeObjects:
# ===== Organisation CRUD =====
def createOrganisation(self, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
def createOrganisation(self, data: Dict[str, Any]) -> Optional[TrusteeOrganisation]:
"""Create a new organisation."""
if not self.checkRbacPermission(TrusteeOrganisation, "create"):
logger.warning(f"User {self.userId} lacks permission to create organisation")
@ -196,13 +220,15 @@ class TrusteeObjects:
createdRecord = self.db.recordCreate(TrusteeOrganisation, data)
if createdRecord and createdRecord.get("id"):
return createdRecord
return TrusteeOrganisation(**{k: v for k, v in createdRecord.items() if not k.startswith("_")})
return None
def getOrganisation(self, orgId: str) -> Optional[Dict[str, Any]]:
def getOrganisation(self, orgId: str) -> Optional[TrusteeOrganisation]:
"""Get a single organisation by ID."""
records = self.db.getRecordset(TrusteeOrganisation, recordFilter={"id": orgId})
return records[0] if records else None
if not records:
return None
return TrusteeOrganisation(**{k: v for k, v in records[0].items() if not k.startswith("_")})
def getAllOrganisations(self, params: Optional[PaginationParams] = None) -> PaginatedResult:
"""Get all organisations with RBAC filtering.
@ -214,7 +240,7 @@ class TrusteeObjects:
- New organisations wouldn't be visible without an access record
"""
# Debug: Log user info and permissions
logger.debug(f"getAllOrganisations called for user {self.userId}, roles: {self.currentUser.roleLabels if self.currentUser else 'None'}, mandateId: {self.mandateId}")
logger.debug(f"getAllOrganisations called for user {self.userId}, mandateId: {self.mandateId}")
# System RBAC filtering (filters by mandate for GROUP access level)
records = getRecordsetWithRBAC(
@ -247,7 +273,7 @@ class TrusteeObjects:
totalPages=totalPages
)
def updateOrganisation(self, orgId: str, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
def updateOrganisation(self, orgId: str, data: Dict[str, Any]) -> Optional[TrusteeOrganisation]:
"""Update an organisation."""
if not self.checkRbacPermission(TrusteeOrganisation, "update"):
logger.warning(f"User {self.userId} lacks permission to update organisation")
@ -260,7 +286,9 @@ class TrusteeObjects:
data["id"] = orgId
updatedRecord = self.db.recordModify(TrusteeOrganisation, orgId, data)
return updatedRecord
if not updatedRecord:
return None
return TrusteeOrganisation(**{k: v for k, v in updatedRecord.items() if not k.startswith("_")})
def deleteOrganisation(self, orgId: str) -> bool:
"""Delete an organisation."""
@ -272,7 +300,7 @@ class TrusteeObjects:
# ===== Role CRUD =====
def createRole(self, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
def createRole(self, data: Dict[str, Any]) -> Optional[TrusteeRole]:
"""Create a new role (sysadmin only)."""
if not self.checkRbacPermission(TrusteeRole, "create"):
logger.warning(f"User {self.userId} lacks permission to create role")
@ -287,13 +315,15 @@ class TrusteeObjects:
createdRecord = self.db.recordCreate(TrusteeRole, data)
if createdRecord and createdRecord.get("id"):
return createdRecord
return TrusteeRole(**{k: v for k, v in createdRecord.items() if not k.startswith("_")})
return None
def getRole(self, roleId: str) -> Optional[Dict[str, Any]]:
def getRole(self, roleId: str) -> Optional[TrusteeRole]:
"""Get a single role by ID."""
records = self.db.getRecordset(TrusteeRole, recordFilter={"id": roleId})
return records[0] if records else None
if not records:
return None
return TrusteeRole(**{k: v for k, v in records[0].items() if not k.startswith("_")})
def getAllRoles(self, params: Optional[PaginationParams] = None) -> PaginatedResult:
"""Get all roles with RBAC filtering.
@ -338,7 +368,7 @@ class TrusteeObjects:
totalPages=totalPages
)
def updateRole(self, roleId: str, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
def updateRole(self, roleId: str, data: Dict[str, Any]) -> Optional[TrusteeRole]:
"""Update a role (sysadmin only)."""
if not self.checkRbacPermission(TrusteeRole, "update"):
logger.warning(f"User {self.userId} lacks permission to update role")
@ -346,7 +376,9 @@ class TrusteeObjects:
data["id"] = roleId
updatedRecord = self.db.recordModify(TrusteeRole, roleId, data)
return updatedRecord
if not updatedRecord:
return None
return TrusteeRole(**{k: v for k, v in updatedRecord.items() if not k.startswith("_")})
def deleteRole(self, roleId: str) -> bool:
"""Delete a role (sysadmin only, not if in use)."""
@ -364,7 +396,7 @@ class TrusteeObjects:
# ===== Access CRUD =====
def createAccess(self, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
def createAccess(self, data: Dict[str, Any]) -> Optional[TrusteeAccess]:
"""Create a new access record. Requires admin role for the organisation or ALL access level."""
# Check system RBAC first
if not self.checkRbacPermission(TrusteeAccess, "create"):
@ -389,13 +421,15 @@ class TrusteeObjects:
createdRecord = self.db.recordCreate(TrusteeAccess, data)
if createdRecord and createdRecord.get("id"):
return createdRecord
return TrusteeAccess(**{k: v for k, v in createdRecord.items() if not k.startswith("_")})
return None
def getAccess(self, accessId: str) -> Optional[Dict[str, Any]]:
def getAccess(self, accessId: str) -> Optional[TrusteeAccess]:
"""Get a single access record by ID."""
records = self.db.getRecordset(TrusteeAccess, recordFilter={"id": accessId})
return records[0] if records else None
if not records:
return None
return TrusteeAccess(**{k: v for k, v in records[0].items() if not k.startswith("_")})
def getAllAccess(self, params: Optional[PaginationParams] = None) -> PaginatedResult:
"""Get all access records with RBAC filtering + feature-level filtering.
@ -451,7 +485,7 @@ class TrusteeObjects:
totalPages=totalPages
)
def getAccessByOrganisation(self, organisationId: str) -> List[Dict[str, Any]]:
def getAccessByOrganisation(self, organisationId: str) -> List[TrusteeAccess]:
"""Get all access records for a specific organisation.
Requires admin role for the organisation.
@ -461,15 +495,16 @@ class TrusteeObjects:
logger.warning(f"User {self.userId} lacks admin role for organisation {organisationId}")
return []
return getRecordsetWithRBAC(
records = getRecordsetWithRBAC(
connector=self.db,
modelClass=TrusteeAccess,
currentUser=self.currentUser,
recordFilter={"organisationId": organisationId},
orderBy="id"
)
return [TrusteeAccess(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in records]
def getAccessByUser(self, userId: str) -> List[Dict[str, Any]]:
def getAccessByUser(self, userId: str) -> List[TrusteeAccess]:
"""Get all access records for a specific user.
Users with ALL access level see all access records.
@ -486,7 +521,7 @@ class TrusteeObjects:
# Users with ALL access level (from system RBAC) see all records
accessLevel = self.getRbacAccessLevel(TrusteeAccess, "read")
if accessLevel == AccessLevel.ALL:
return records
return [TrusteeAccess(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in records]
# Filter to only organisations where current user has admin role
userAccess = self.getAllUserAccess(self.userId)
@ -495,9 +530,10 @@ class TrusteeObjects:
if access.get("roleId") == "admin":
adminOrgs.add(access.get("organisationId"))
return [r for r in records if r.get("organisationId") in adminOrgs]
filtered = [r for r in records if r.get("organisationId") in adminOrgs]
return [TrusteeAccess(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in filtered]
def updateAccess(self, accessId: str, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
def updateAccess(self, accessId: str, data: Dict[str, Any]) -> Optional[TrusteeAccess]:
"""Update an access record. Requires admin role for the organisation or ALL access level."""
# Check system RBAC first
if not self.checkRbacPermission(TrusteeAccess, "update"):
@ -524,7 +560,9 @@ class TrusteeObjects:
data["id"] = accessId
updatedRecord = self.db.recordModify(TrusteeAccess, accessId, data)
return updatedRecord
if not updatedRecord:
return None
return TrusteeAccess(**{k: v for k, v in updatedRecord.items() if not k.startswith("_")})
def deleteAccess(self, accessId: str) -> bool:
"""Delete an access record. Requires admin role for the organisation or ALL access level."""
@ -555,7 +593,7 @@ class TrusteeObjects:
# ===== Contract CRUD =====
def createContract(self, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
def createContract(self, data: Dict[str, Any]) -> Optional[TrusteeContract]:
"""Create a new contract."""
organisationId = data.get("organisationId")
@ -572,13 +610,15 @@ class TrusteeObjects:
createdRecord = self.db.recordCreate(TrusteeContract, data)
if createdRecord and createdRecord.get("id"):
return createdRecord
return TrusteeContract(**{k: v for k, v in createdRecord.items() if not k.startswith("_")})
return None
def getContract(self, contractId: str) -> Optional[Dict[str, Any]]:
def getContract(self, contractId: str) -> Optional[TrusteeContract]:
"""Get a single contract by ID."""
records = self.db.getRecordset(TrusteeContract, recordFilter={"id": contractId})
return records[0] if records else None
if not records:
return None
return TrusteeContract(**{k: v for k, v in records[0].items() if not k.startswith("_")})
def getAllContracts(self, params: Optional[PaginationParams] = None) -> PaginatedResult:
"""Get all contracts with RBAC filtering + feature-level access filtering."""
@ -614,7 +654,7 @@ class TrusteeObjects:
totalPages=totalPages
)
def getContractsByOrganisation(self, organisationId: str) -> List[Dict[str, Any]]:
def getContractsByOrganisation(self, organisationId: str) -> List[TrusteeContract]:
"""Get all contracts for a specific organisation."""
# Step 1: System RBAC filtering
records = getRecordsetWithRBAC(
@ -626,9 +666,10 @@ class TrusteeObjects:
)
# Step 2: Feature-level filtering based on trustee.access
return self.filterRecordsByTrusteeAccess(records, TrusteeContract)
filtered = self.filterRecordsByTrusteeAccess(records, TrusteeContract)
return [TrusteeContract(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in filtered]
def updateContract(self, contractId: str, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
def updateContract(self, contractId: str, data: Dict[str, Any]) -> Optional[TrusteeContract]:
"""Update a contract (organisationId is immutable)."""
# Get existing contract to check organisation
existingRecords = self.db.getRecordset(TrusteeContract, recordFilter={"id": contractId})
@ -652,7 +693,9 @@ class TrusteeObjects:
data["id"] = contractId
updatedRecord = self.db.recordModify(TrusteeContract, contractId, data)
return updatedRecord
if not updatedRecord:
return None
return TrusteeContract(**{k: v for k, v in updatedRecord.items() if not k.startswith("_")})
def deleteContract(self, contractId: str) -> bool:
"""Delete a contract."""
@ -675,7 +718,7 @@ class TrusteeObjects:
# ===== Document CRUD =====
def createDocument(self, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
def createDocument(self, data: Dict[str, Any]) -> Optional[TrusteeDocument]:
"""Create a new document."""
organisationId = data.get("organisationId")
contractId = data.get("contractId")
@ -693,20 +736,19 @@ class TrusteeObjects:
createdRecord = self.db.recordCreate(TrusteeDocument, data)
if createdRecord and createdRecord.get("id"):
# Remove binary data from response
createdRecord.pop("documentData", None)
return createdRecord
# Remove binary data and metadata from Pydantic model
cleanedRecord = {k: v for k, v in createdRecord.items() if not k.startswith("_") and k != "documentData"}
return TrusteeDocument(**cleanedRecord)
return None
def getDocument(self, documentId: str) -> Optional[Dict[str, Any]]:
def getDocument(self, documentId: str) -> Optional[TrusteeDocument]:
"""Get a single document by ID (metadata only)."""
records = self.db.getRecordset(TrusteeDocument, recordFilter={"id": documentId})
if records:
record = records[0]
# Remove binary data from response
record.pop("documentData", None)
return record
return None
if not records:
return None
# Remove binary data and metadata from Pydantic model
cleanedRecord = {k: v for k, v in records[0].items() if not k.startswith("_") and k != "documentData"}
return TrusteeDocument(**cleanedRecord)
def getDocumentData(self, documentId: str) -> Optional[bytes]:
"""Get document binary data."""
@ -755,7 +797,7 @@ class TrusteeObjects:
totalPages=totalPages
)
def getDocumentsByContract(self, contractId: str) -> List[Dict[str, Any]]:
def getDocumentsByContract(self, contractId: str) -> List[TrusteeDocument]:
"""Get all documents for a specific contract."""
# Step 1: System RBAC filtering
records = getRecordsetWithRBAC(
@ -767,13 +809,15 @@ class TrusteeObjects:
)
# Step 2: Feature-level filtering based on trustee.access
records = self.filterRecordsByTrusteeAccess(records, TrusteeDocument)
filtered = self.filterRecordsByTrusteeAccess(records, TrusteeDocument)
for record in records:
record.pop("documentData", None)
return records
result = []
for record in filtered:
cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_") and k != "documentData"}
result.append(TrusteeDocument(**cleanedRecord))
return result
def updateDocument(self, documentId: str, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
def updateDocument(self, documentId: str, data: Dict[str, Any]) -> Optional[TrusteeDocument]:
"""Update a document."""
# Get existing document to check organisation and creator
existingRecords = self.db.getRecordset(TrusteeDocument, recordFilter={"id": documentId})
@ -795,9 +839,10 @@ class TrusteeObjects:
data["id"] = documentId
updatedRecord = self.db.recordModify(TrusteeDocument, documentId, data)
if updatedRecord:
updatedRecord.pop("documentData", None)
return updatedRecord
if not updatedRecord:
return None
cleanedRecord = {k: v for k, v in updatedRecord.items() if not k.startswith("_") and k != "documentData"}
return TrusteeDocument(**cleanedRecord)
def deleteDocument(self, documentId: str) -> bool:
"""Delete a document."""
@ -823,7 +868,7 @@ class TrusteeObjects:
# ===== Position CRUD =====
def createPosition(self, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
def createPosition(self, data: Dict[str, Any]) -> Optional[TrusteePosition]:
"""Create a new position."""
organisationId = data.get("organisationId")
contractId = data.get("contractId")
@ -847,13 +892,15 @@ class TrusteeObjects:
createdRecord = self.db.recordCreate(TrusteePosition, data)
if createdRecord and createdRecord.get("id"):
return createdRecord
return TrusteePosition(**{k: v for k, v in createdRecord.items() if not k.startswith("_")})
return None
def getPosition(self, positionId: str) -> Optional[Dict[str, Any]]:
def getPosition(self, positionId: str) -> Optional[TrusteePosition]:
"""Get a single position by ID."""
records = self.db.getRecordset(TrusteePosition, recordFilter={"id": positionId})
return records[0] if records else None
if not records:
return None
return TrusteePosition(**{k: v for k, v in records[0].items() if not k.startswith("_")})
def getAllPositions(self, params: Optional[PaginationParams] = None) -> PaginatedResult:
"""Get all positions with RBAC filtering + feature-level access filtering."""
@ -890,7 +937,7 @@ class TrusteeObjects:
totalPages=totalPages
)
def getPositionsByContract(self, contractId: str) -> List[Dict[str, Any]]:
def getPositionsByContract(self, contractId: str) -> List[TrusteePosition]:
"""Get all positions for a specific contract."""
# Step 1: System RBAC filtering
records = getRecordsetWithRBAC(
@ -902,9 +949,10 @@ class TrusteeObjects:
)
# Step 2: Feature-level filtering based on trustee.access
return self.filterRecordsByTrusteeAccess(records, TrusteePosition)
filtered = self.filterRecordsByTrusteeAccess(records, TrusteePosition)
return [TrusteePosition(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in filtered]
def getPositionsByOrganisation(self, organisationId: str) -> List[Dict[str, Any]]:
def getPositionsByOrganisation(self, organisationId: str) -> List[TrusteePosition]:
"""Get all positions for a specific organisation."""
# Step 1: System RBAC filtering
records = getRecordsetWithRBAC(
@ -916,9 +964,10 @@ class TrusteeObjects:
)
# Step 2: Feature-level filtering based on trustee.access
return self.filterRecordsByTrusteeAccess(records, TrusteePosition)
filtered = self.filterRecordsByTrusteeAccess(records, TrusteePosition)
return [TrusteePosition(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in filtered]
def updatePosition(self, positionId: str, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
def updatePosition(self, positionId: str, data: Dict[str, Any]) -> Optional[TrusteePosition]:
"""Update a position."""
# Get existing position to check organisation and creator
existingRecords = self.db.getRecordset(TrusteePosition, recordFilter={"id": positionId})
@ -940,7 +989,9 @@ class TrusteeObjects:
data["id"] = positionId
updatedRecord = self.db.recordModify(TrusteePosition, positionId, data)
return updatedRecord
if not updatedRecord:
return None
return TrusteePosition(**{k: v for k, v in updatedRecord.items() if not k.startswith("_")})
def deletePosition(self, positionId: str) -> bool:
"""Delete a position."""
@ -966,7 +1017,7 @@ class TrusteeObjects:
# ===== Position-Document Link CRUD =====
def createPositionDocument(self, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
def createPositionDocument(self, data: Dict[str, Any]) -> Optional[TrusteePositionDocument]:
"""Create a new position-document link."""
organisationId = data.get("organisationId")
contractId = data.get("contractId")
@ -984,13 +1035,15 @@ class TrusteeObjects:
createdRecord = self.db.recordCreate(TrusteePositionDocument, data)
if createdRecord and createdRecord.get("id"):
return createdRecord
return TrusteePositionDocument(**{k: v for k, v in createdRecord.items() if not k.startswith("_")})
return None
def getPositionDocument(self, linkId: str) -> Optional[Dict[str, Any]]:
def getPositionDocument(self, linkId: str) -> Optional[TrusteePositionDocument]:
"""Get a single position-document link by ID."""
records = self.db.getRecordset(TrusteePositionDocument, recordFilter={"id": linkId})
return records[0] if records else None
if not records:
return None
return TrusteePositionDocument(**{k: v for k, v in records[0].items() if not k.startswith("_")})
def getAllPositionDocuments(self, params: Optional[PaginationParams] = None) -> PaginatedResult:
"""Get all position-document links with RBAC filtering + feature-level access filtering."""
@ -1027,7 +1080,7 @@ class TrusteeObjects:
totalPages=totalPages
)
def getDocumentsForPosition(self, positionId: str) -> List[Dict[str, Any]]:
def getDocumentsForPosition(self, positionId: str) -> List[TrusteePositionDocument]:
"""Get all documents linked to a position."""
# Step 1: System RBAC filtering
links = getRecordsetWithRBAC(
@ -1039,9 +1092,10 @@ class TrusteeObjects:
)
# Step 2: Feature-level filtering based on trustee.access
return self.filterRecordsByTrusteeAccess(links, TrusteePositionDocument)
filtered = self.filterRecordsByTrusteeAccess(links, TrusteePositionDocument)
return [TrusteePositionDocument(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in filtered]
def getPositionsForDocument(self, documentId: str) -> List[Dict[str, Any]]:
def getPositionsForDocument(self, documentId: str) -> List[TrusteePositionDocument]:
"""Get all positions linked to a document."""
# Step 1: System RBAC filtering
links = getRecordsetWithRBAC(
@ -1053,7 +1107,8 @@ class TrusteeObjects:
)
# Step 2: Feature-level filtering based on trustee.access
return self.filterRecordsByTrusteeAccess(links, TrusteePositionDocument)
filtered = self.filterRecordsByTrusteeAccess(links, TrusteePositionDocument)
return [TrusteePositionDocument(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in filtered]
def deletePositionDocument(self, linkId: str) -> bool:
"""Delete a position-document link."""

View file

@ -0,0 +1,478 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Feature Instance Management Interface.
Multi-Tenant Design:
- Feature-Instanzen gehören zu Mandanten
- Template-Rollen werden bei Erstellung kopiert
- Synchronisation von Templates ist explizit (nicht automatisch)
"""
import logging
import uuid
from typing import List, Dict, Any, Optional
from modules.datamodels.datamodelFeatures import Feature, FeatureInstance
from modules.datamodels.datamodelRbac import Role, AccessRule
from modules.connectors.connectorDbPostgre import DatabaseConnector
logger = logging.getLogger(__name__)
class FeatureInterface:
"""
Interface for Feature and FeatureInstance management.
Responsibilities:
- CRUD operations for Features and FeatureInstances
- Template role copying on instance creation
- Template synchronization for existing instances
"""
def __init__(self, db: DatabaseConnector):
"""
Initialize Feature interface.
Args:
db: DatabaseConnector instance (DbApp database)
"""
self.db = db
# ============================================
# Feature Methods (Global Feature Definitions)
# ============================================
def getFeature(self, featureCode: str) -> Optional[Feature]:
"""
Get a feature by code.
Args:
featureCode: Feature code (e.g., "trustee", "chatbot")
Returns:
Feature object or None
"""
try:
records = self.db.getRecordset(Feature, recordFilter={"code": featureCode})
if not records:
return None
cleanedRecord = {k: v for k, v in records[0].items() if not k.startswith("_")}
return Feature(**cleanedRecord)
except Exception as e:
logger.error(f"Error getting feature {featureCode}: {e}")
return None
def getAllFeatures(self) -> List[Feature]:
"""
Get all available features.
Returns:
List of Feature objects
"""
try:
records = self.db.getRecordset(Feature)
result = []
for record in records:
cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_")}
result.append(Feature(**cleanedRecord))
return result
except Exception as e:
logger.error(f"Error getting all features: {e}")
return []
def createFeature(self, code: str, label: Dict[str, str], icon: str = "mdi-puzzle") -> Feature:
"""
Create a new feature definition.
Args:
code: Unique feature code (e.g., "trustee")
label: I18n labels (e.g., {"en": "Trustee", "de": "Treuhand"})
icon: Icon identifier
Returns:
Created Feature object
"""
try:
feature = Feature(code=code, label=label, icon=icon)
createdRecord = self.db.recordCreate(Feature, feature.model_dump())
cleanedRecord = {k: v for k, v in createdRecord.items() if not k.startswith("_")}
return Feature(**cleanedRecord)
except Exception as e:
logger.error(f"Error creating feature {code}: {e}")
raise ValueError(f"Failed to create feature: {e}")
# ============================================
# Feature Instance Methods
# ============================================
def getFeatureInstance(self, instanceId: str) -> Optional[FeatureInstance]:
"""
Get a feature instance by ID.
Args:
instanceId: FeatureInstance ID
Returns:
FeatureInstance object or None
"""
try:
records = self.db.getRecordset(FeatureInstance, recordFilter={"id": instanceId})
if not records:
return None
cleanedRecord = {k: v for k, v in records[0].items() if not k.startswith("_")}
return FeatureInstance(**cleanedRecord)
except Exception as e:
logger.error(f"Error getting feature instance {instanceId}: {e}")
return None
def getFeatureInstancesForMandate(self, mandateId: str, featureCode: Optional[str] = None) -> List[FeatureInstance]:
"""
Get all feature instances for a mandate.
Args:
mandateId: Mandate ID
featureCode: Optional filter by feature code
Returns:
List of FeatureInstance objects
"""
try:
recordFilter = {"mandateId": mandateId}
if featureCode:
recordFilter["featureCode"] = featureCode
records = self.db.getRecordset(FeatureInstance, recordFilter=recordFilter)
result = []
for record in records:
cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_")}
result.append(FeatureInstance(**cleanedRecord))
return result
except Exception as e:
logger.error(f"Error getting feature instances for mandate {mandateId}: {e}")
return []
def createFeatureInstance(
self,
featureCode: str,
mandateId: str,
label: str,
copyTemplateRoles: bool = True
) -> FeatureInstance:
"""
Create a new feature instance for a mandate.
Optionally copies global template roles for this feature.
WICHTIG: Templates werden NUR bei Erstellung kopiert.
Spätere Template-Änderungen werden NICHT automatisch propagiert.
Für manuelle Nachsynchronisation siehe syncRolesFromTemplate().
Args:
featureCode: Feature code (e.g., "trustee")
mandateId: Mandate ID
label: Instance label (e.g., "Buchhaltung 2025")
copyTemplateRoles: Whether to copy template roles
Returns:
Created FeatureInstance object
"""
try:
# Create instance
instance = FeatureInstance(
featureCode=featureCode,
mandateId=mandateId,
label=label,
enabled=True
)
createdInstance = self.db.recordCreate(FeatureInstance, instance.model_dump())
if not createdInstance:
raise ValueError("Failed to create feature instance record")
instanceId = createdInstance.get("id")
# Copy template roles if requested
if copyTemplateRoles:
self._copyTemplateRoles(featureCode, mandateId, instanceId)
cleanedRecord = {k: v for k, v in createdInstance.items() if not k.startswith("_")}
return FeatureInstance(**cleanedRecord)
except Exception as e:
logger.error(f"Error creating feature instance: {e}")
raise ValueError(f"Failed to create feature instance: {e}")
def _copyTemplateRoles(self, featureCode: str, mandateId: str, instanceId: str) -> int:
"""
Copy global template roles for a feature to a new instance.
Args:
featureCode: Feature code
mandateId: Mandate ID
instanceId: New FeatureInstance ID
Returns:
Number of roles copied
"""
try:
# Find global template roles for this feature (mandateId=None)
globalRoles = self.db.getRecordset(
Role,
recordFilter={"featureCode": featureCode, "mandateId": None}
)
if not globalRoles:
logger.debug(f"No template roles found for feature {featureCode}")
return 0
templateRoleIds = [r.get("id") for r in globalRoles]
# BULK: Load all template AccessRules in one query
allTemplateRules = []
for roleId in templateRoleIds:
rules = self.db.getRecordset(AccessRule, recordFilter={"roleId": roleId})
allTemplateRules.extend([(roleId, r) for r in rules])
# Index for fast lookup: roleId -> rules
rulesByRoleId = {}
for roleId, rule in allTemplateRules:
if roleId not in rulesByRoleId:
rulesByRoleId[roleId] = []
rulesByRoleId[roleId].append(rule)
# Copy roles and their AccessRules
copiedCount = 0
for templateRole in globalRoles:
newRoleId = str(uuid.uuid4())
# Create new role for this instance
newRole = Role(
id=newRoleId,
roleLabel=templateRole.get("roleLabel"),
description=templateRole.get("description", {}),
featureCode=featureCode,
mandateId=mandateId,
featureInstanceId=instanceId,
isSystemRole=False
)
self.db.recordCreate(Role, newRole.model_dump())
# Copy AccessRules for this role
templateRulesForRole = rulesByRoleId.get(templateRole.get("id"), [])
for rule in templateRulesForRole:
newRule = AccessRule(
id=str(uuid.uuid4()),
roleId=newRoleId,
context=rule.get("context"),
item=rule.get("item"),
view=rule.get("view", False),
read=rule.get("read"),
create=rule.get("create"),
update=rule.get("update"),
delete=rule.get("delete")
)
self.db.recordCreate(AccessRule, newRule.model_dump())
copiedCount += 1
logger.info(f"Copied {copiedCount} template roles for instance {instanceId}")
return copiedCount
except Exception as e:
logger.error(f"Error copying template roles: {e}")
return 0
def syncRolesFromTemplate(self, featureInstanceId: str, addOnly: bool = True) -> Dict[str, int]:
"""
Synchronize roles of a feature instance with current templates.
WICHTIG: Templates werden NUR bei Erstellung einer neuen FeatureInstance kopiert.
Diese Sync-Funktion ist für manuelle Nachsynchronisation gedacht, nicht für
automatische Propagation von Template-Änderungen.
Args:
featureInstanceId: ID of the instance to sync
addOnly: If True, only add missing roles. If False, also remove extras.
Returns:
Dict with added/removed/unchanged counts
"""
try:
instance = self.getFeatureInstance(featureInstanceId)
if not instance:
raise ValueError(f"FeatureInstance {featureInstanceId} not found")
featureCode = instance.featureCode
mandateId = instance.mandateId
# Get current template roles
templateRoles = self.db.getRecordset(
Role,
recordFilter={"featureCode": featureCode, "mandateId": None}
)
templateLabels = {r.get("roleLabel") for r in templateRoles}
# Get current instance roles
instanceRoles = self.db.getRecordset(
Role,
recordFilter={"featureInstanceId": featureInstanceId}
)
instanceLabels = {r.get("roleLabel") for r in instanceRoles}
result = {"added": 0, "removed": 0, "unchanged": 0}
# Add missing roles
for templateRole in templateRoles:
if templateRole.get("roleLabel") not in instanceLabels:
# Copy this role
newRoleId = str(uuid.uuid4())
newRole = Role(
id=newRoleId,
roleLabel=templateRole.get("roleLabel"),
description=templateRole.get("description", {}),
featureCode=featureCode,
mandateId=mandateId,
featureInstanceId=featureInstanceId,
isSystemRole=False
)
self.db.recordCreate(Role, newRole.model_dump())
# Copy AccessRules
templateRules = self.db.getRecordset(
AccessRule,
recordFilter={"roleId": templateRole.get("id")}
)
for rule in templateRules:
newRule = AccessRule(
id=str(uuid.uuid4()),
roleId=newRoleId,
context=rule.get("context"),
item=rule.get("item"),
view=rule.get("view", False),
read=rule.get("read"),
create=rule.get("create"),
update=rule.get("update"),
delete=rule.get("delete")
)
self.db.recordCreate(AccessRule, newRule.model_dump())
result["added"] += 1
else:
result["unchanged"] += 1
# Remove extra roles (optional)
if not addOnly:
from modules.datamodels.datamodelMembership import FeatureAccessRole
for instanceRole in instanceRoles:
if instanceRole.get("roleLabel") not in templateLabels:
# Check if role is still in use
usages = self.db.getRecordset(
FeatureAccessRole,
recordFilter={"roleId": instanceRole.get("id")}
)
if not usages:
self.db.recordDelete(Role, instanceRole.get("id"))
result["removed"] += 1
logger.info(f"Synced roles for instance {featureInstanceId}: {result}")
return result
except Exception as e:
logger.error(f"Error syncing roles from template: {e}")
raise ValueError(f"Failed to sync roles: {e}")
def deleteFeatureInstance(self, instanceId: str) -> bool:
"""
Delete a feature instance.
CASCADE will delete associated roles and access records.
Args:
instanceId: FeatureInstance ID
Returns:
True if deleted
"""
try:
instance = self.getFeatureInstance(instanceId)
if not instance:
return False
return self.db.recordDelete(FeatureInstance, instanceId)
except Exception as e:
logger.error(f"Error deleting feature instance {instanceId}: {e}")
raise ValueError(f"Failed to delete feature instance: {e}")
# ============================================
# Template Role Methods (Global)
# ============================================
def getTemplateRoles(self, featureCode: Optional[str] = None) -> List[Role]:
"""
Get global template roles (mandateId=None).
Args:
featureCode: Optional filter by feature code
Returns:
List of Role objects
"""
try:
recordFilter = {"mandateId": None}
if featureCode:
recordFilter["featureCode"] = featureCode
records = self.db.getRecordset(Role, recordFilter=recordFilter)
result = []
for record in records:
cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_")}
result.append(Role(**cleanedRecord))
return result
except Exception as e:
logger.error(f"Error getting template roles: {e}")
return []
def createTemplateRole(
self,
roleLabel: str,
featureCode: str,
description: Dict[str, str] = None
) -> Role:
"""
Create a global template role for a feature.
Args:
roleLabel: Role label (e.g., "admin", "viewer")
featureCode: Feature code this role belongs to
description: I18n descriptions
Returns:
Created Role object
"""
try:
role = Role(
roleLabel=roleLabel,
description=description or {},
featureCode=featureCode,
mandateId=None, # Global template
featureInstanceId=None,
isSystemRole=False
)
createdRecord = self.db.recordCreate(Role, role.model_dump())
cleanedRecord = {k: v for k, v in createdRecord.items() if not k.startswith("_")}
return Role(**cleanedRecord)
except Exception as e:
logger.error(f"Error creating template role: {e}")
raise ValueError(f"Failed to create template role: {e}")
def getFeatureInterface(db: DatabaseConnector) -> FeatureInterface:
"""
Factory function to get a FeatureInterface instance.
Args:
db: DatabaseConnector instance (DbApp database)
Returns:
FeatureInterface instance
"""
return FeatureInterface(db)

View file

@ -3,6 +3,10 @@
"""
RBAC helper functions for interfaces.
Provides RBAC filtering for database queries without connectors importing security.
Multi-Tenant Design:
- mandateId kommt aus Request-Context (X-Mandate-Id Header)
- GROUP-Filter verwendet expliziten mandateId Parameter
"""
import logging
@ -24,24 +28,33 @@ def getRecordsetWithRBAC(
recordFilter: Dict[str, Any] = None,
orderBy: str = None,
limit: int = None,
mandateId: Optional[str] = None,
featureInstanceId: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""
Get records with RBAC filtering applied at database level.
This function wraps connector.getRecordset() with RBAC logic.
Multi-Tenant Design:
- mandateId wird explizit übergeben (aus Request-Context / X-Mandate-Id Header)
Args:
connector: DatabaseConnector instance
modelClass: Pydantic model class for the table
currentUser: User object with roleLabels
currentUser: User object
recordFilter: Additional record filters
orderBy: Field to order by (defaults to "id")
limit: Maximum number of records to return
mandateId: Explicit mandate context (from request header). Required for GROUP access.
featureInstanceId: Explicit feature instance context
Returns:
List of filtered records
"""
table = modelClass.__name__
effectiveMandateId = mandateId
try:
if not connector._ensureTableExists(modelClass):
return []
@ -53,7 +66,9 @@ def getRecordsetWithRBAC(
permissions = rbacInstance.getUserPermissions(
currentUser,
AccessRuleContext.DATA,
table
table,
mandateId=effectiveMandateId,
featureInstanceId=featureInstanceId
)
# Check view permission first
@ -66,7 +81,13 @@ def getRecordsetWithRBAC(
whereValues = []
# Add RBAC WHERE clause based on read permission
rbacWhereClause = buildRbacWhereClause(permissions, currentUser, table, connector)
rbacWhereClause = buildRbacWhereClause(
permissions,
currentUser,
table,
connector,
mandateId=effectiveMandateId
)
if rbacWhereClause:
whereConditions.append(rbacWhereClause["condition"])
whereValues.extend(rbacWhereClause["values"])
@ -155,17 +176,21 @@ def buildRbacWhereClause(
permissions: UserPermissions,
currentUser: User,
table: str,
connector # DatabaseConnector instance for connection access
connector, # DatabaseConnector instance for connection access
mandateId: Optional[str] = None
) -> Optional[Dict[str, Any]]:
"""
Build RBAC WHERE clause based on permissions and access level.
Moved from connector to interfaces.
Multi-Tenant Design:
- mandateId wird explizit übergeben (aus Request-Context / X-Mandate-Id Header)
Args:
permissions: UserPermissions object
currentUser: User object
table: Table name
connector: DatabaseConnector instance (needed for GROUP queries)
mandateId: Explicit mandate context (from request header). Required for GROUP access.
Returns:
Dictionary with "condition" and "values" keys, or None if no filtering needed
@ -201,7 +226,9 @@ def buildRbacWhereClause(
# Group records - filter by mandateId
if readLevel == AccessLevel.GROUP:
if not currentUser.mandateId:
effectiveMandateId = mandateId
if not effectiveMandateId:
logger.warning(f"User {currentUser.id} has no mandateId for GROUP access")
return {"condition": "1 = 0", "values": []}
@ -209,7 +236,7 @@ def buildRbacWhereClause(
if table == "UserInDB":
return {
"condition": '"mandateId" = %s',
"values": [currentUser.mandateId]
"values": [effectiveMandateId]
}
# For UserConnection, need to join with UserInDB or filter by mandateId in user
elif table == "UserConnection":
@ -218,7 +245,7 @@ def buildRbacWhereClause(
with connector.connection.cursor() as cursor:
cursor.execute(
'SELECT "id" FROM "UserInDB" WHERE "mandateId" = %s',
(currentUser.mandateId,)
(effectiveMandateId,)
)
users = cursor.fetchall()
userIds = [u["id"] for u in users]
@ -236,8 +263,7 @@ def buildRbacWhereClause(
else:
return {
"condition": '"mandateId" = %s',
"values": [currentUser.mandateId]
"values": [effectiveMandateId]
}
return None

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

@ -3,20 +3,70 @@
"""
Admin RBAC Roles Management routes.
Provides endpoints for managing roles and role assignments to users.
MULTI-TENANT: These are SYSTEM-LEVEL operations requiring isSysAdmin=true.
Roles are global system resources, not mandate-specific.
Role assignments are managed via UserMandateRole (not User.roleLabels).
"""
from fastapi import APIRouter, HTTPException, Depends, Query, Body, Path, Request
from typing import List, Dict, Any, Optional
from typing import List, Dict, Any, Optional, Set
import logging
from modules.auth import getCurrentUser, limiter
from modules.auth import limiter, requireSysAdmin, RequestContext
from modules.datamodels.datamodelUam import User, UserInDB
from modules.datamodels.datamodelRbac import Role
from modules.interfaces.interfaceDbAppObjects import getInterface
from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole
from modules.interfaces.interfaceDbAppObjects import getInterface, getRootInterface
# Configure logger
logger = logging.getLogger(__name__)
def _getUserRoleLabels(interface, userId: str) -> List[str]:
"""
Get role labels for a user from UserMandateRole (across all mandates).
Args:
interface: Database interface
userId: User ID
Returns:
List of role labels
"""
roleLabels: Set[str] = set()
# Get all UserMandate records for this user
userMandates = interface.db.getRecordset(UserMandate, recordFilter={"userId": userId})
for um in userMandates:
userMandateId = um.get("id")
if not userMandateId:
continue
# Get all UserMandateRole records for this membership
userMandateRoles = interface.db.getRecordset(
UserMandateRole,
recordFilter={"userMandateId": str(userMandateId)}
)
for umr in userMandateRoles:
roleId = umr.get("roleId")
if roleId:
# Get role by ID to get roleLabel
role = interface.getRole(str(roleId))
if role:
roleLabels.add(role.roleLabel)
return list(roleLabels)
def _hasRoleLabel(interface, userId: str, roleLabel: str) -> bool:
"""
Check if user has a specific role label (across all mandates).
"""
return roleLabel in _getUserRoleLabels(interface, userId)
router = APIRouter(
prefix="/api/admin/rbac/roles",
tags=["Admin RBAC Roles"],
@ -24,51 +74,27 @@ router = APIRouter(
)
def _ensureAdminAccess(currentUser: User) -> None:
"""Ensure current user has admin access to RBAC roles management."""
interface = getInterface(currentUser)
# Check if user has admin or sysadmin role
roleLabels = currentUser.roleLabels or []
if "sysadmin" not in roleLabels and "admin" not in roleLabels:
raise HTTPException(
status_code=403,
detail="Admin or sysadmin role required to manage RBAC roles"
)
# Additional RBAC check: verify user has permission to update UserInDB
# This is already covered by admin/sysadmin role check above, but we can add explicit RBAC check if needed
# For now, admin/sysadmin role check is sufficient
@router.get("/", response_model=List[Dict[str, Any]])
@limiter.limit("60/minute")
async def listRoles(
request: Request,
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(requireSysAdmin)
) -> List[Dict[str, Any]]:
"""
Get list of all available roles with metadata.
MULTI-TENANT: SysAdmin-only (roles are system resources).
Returns:
- List of role dictionaries with role label, description, and user count
"""
try:
_ensureAdminAccess(currentUser)
interface = getInterface(currentUser)
interface = getRootInterface()
# Get all roles from database
dbRoles = interface.getAllRoles()
# Get all users to count role assignments
allUsers = interface.getUsers()
# Count users per role
roleCounts: Dict[str, int] = {}
for user in allUsers:
for roleLabel in (user.roleLabels or []):
roleCounts[roleLabel] = roleCounts.get(roleLabel, 0) + 1
# Count role assignments from UserMandateRole table
roleCounts = interface.countRoleAssignments()
# Convert Role objects to dictionaries and add user counts
result = []
@ -77,22 +103,10 @@ async def listRoles(
"id": role.id,
"roleLabel": role.roleLabel,
"description": role.description,
"userCount": roleCounts.get(role.roleLabel, 0),
"userCount": roleCounts.get(str(role.id), 0),
"isSystemRole": role.isSystemRole
})
# Add any roles found in user assignments that don't exist in database
dbRoleLabels = {role.roleLabel for role in dbRoles}
for roleLabel, count in roleCounts.items():
if roleLabel not in dbRoleLabels:
result.append({
"id": None,
"roleLabel": roleLabel,
"description": {"en": f"Custom role: {roleLabel}", "fr": f"Rôle personnalisé : {roleLabel}"},
"userCount": count,
"isSystemRole": False
})
return result
except HTTPException:
@ -109,19 +123,17 @@ async def listRoles(
@limiter.limit("60/minute")
async def getRoleOptions(
request: Request,
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(requireSysAdmin)
) -> List[Dict[str, Any]]:
"""
Get role options for select dropdowns.
Returns roles in format suitable for frontend select components.
MULTI-TENANT: SysAdmin-only.
Returns:
- List of role option dictionaries with value and label
"""
try:
_ensureAdminAccess(currentUser)
interface = getInterface(currentUser)
interface = getRootInterface()
# Get all roles from database
dbRoles = interface.getAllRoles()
@ -153,10 +165,11 @@ async def getRoleOptions(
async def createRole(
request: Request,
role: Role = Body(...),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(requireSysAdmin)
) -> Dict[str, Any]:
"""
Create a new role.
MULTI-TENANT: SysAdmin-only (roles are system resources).
Request Body:
- role: Role object to create
@ -165,9 +178,7 @@ async def createRole(
- Created role dictionary
"""
try:
_ensureAdminAccess(currentUser)
interface = getInterface(currentUser)
interface = getRootInterface()
createdRole = interface.createRole(role)
@ -198,10 +209,11 @@ async def createRole(
async def getRole(
request: Request,
roleId: str = Path(..., description="Role ID"),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(requireSysAdmin)
) -> Dict[str, Any]:
"""
Get a role by ID.
MULTI-TENANT: SysAdmin-only.
Path Parameters:
- roleId: Role ID
@ -210,9 +222,7 @@ async def getRole(
- Role dictionary
"""
try:
_ensureAdminAccess(currentUser)
interface = getInterface(currentUser)
interface = getRootInterface()
role = interface.getRole(roleId)
if not role:
@ -244,10 +254,11 @@ async def updateRole(
request: Request,
roleId: str = Path(..., description="Role ID"),
role: Role = Body(...),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(requireSysAdmin)
) -> Dict[str, Any]:
"""
Update an existing role.
MULTI-TENANT: SysAdmin-only.
Path Parameters:
- roleId: Role ID
@ -259,9 +270,7 @@ async def updateRole(
- Updated role dictionary
"""
try:
_ensureAdminAccess(currentUser)
interface = getInterface(currentUser)
interface = getRootInterface()
updatedRole = interface.updateRole(roleId, role)
@ -292,10 +301,11 @@ async def updateRole(
async def deleteRole(
request: Request,
roleId: str = Path(..., description="Role ID"),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(requireSysAdmin)
) -> Dict[str, str]:
"""
Delete a role.
MULTI-TENANT: SysAdmin-only.
Path Parameters:
- roleId: Role ID
@ -304,9 +314,7 @@ async def deleteRole(
- Success message
"""
try:
_ensureAdminAccess(currentUser)
interface = getInterface(currentUser)
interface = getRootInterface()
success = interface.deleteRole(roleId)
if not success:
@ -337,48 +345,50 @@ async def deleteRole(
async def listUsersWithRoles(
request: Request,
roleLabel: Optional[str] = Query(None, description="Filter by role label"),
mandateId: Optional[str] = Query(None, description="Filter by mandate ID"),
currentUser: User = Depends(getCurrentUser)
mandateId: Optional[str] = Query(None, description="Filter by mandate ID (via UserMandate)"),
context: RequestContext = Depends(requireSysAdmin)
) -> List[Dict[str, Any]]:
"""
Get list of users with their role assignments.
MULTI-TENANT: SysAdmin-only, can see all users across mandates.
Query Parameters:
- roleLabel: Optional filter by role label
- mandateId: Optional filter by mandate ID
- mandateId: Optional filter by mandate ID (via UserMandate table)
Returns:
- List of user dictionaries with role assignments
"""
try:
_ensureAdminAccess(currentUser)
interface = getRootInterface()
interface = getInterface(currentUser)
# Get all users (SysAdmin sees all)
users = interface.getUsers()
# Get users based on filters
# Filter by mandate if specified (via UserMandate table)
if mandateId:
# Filter by mandate (if user has permission)
users = interface.getUsers()
users = [u for u in users if u.mandateId == mandateId]
else:
users = interface.getUsers()
from modules.datamodels.datamodelMembership import UserMandate
userMandates = interface.db.getRecordset(UserMandate, recordFilter={"mandateId": mandateId})
mandateUserIds = {str(um["userId"]) for um in userMandates}
users = [u for u in users if str(u.id) in mandateUserIds]
# Filter by role if specified
# Filter by role if specified (via UserMandateRole)
if roleLabel:
users = [u for u in users if roleLabel in (u.roleLabels or [])]
users = [u for u in users if _hasRoleLabel(interface, str(u.id), roleLabel)]
# Format response
result = []
for user in users:
userRoleLabels = _getUserRoleLabels(interface, str(user.id))
result.append({
"id": user.id,
"username": user.username,
"email": user.email,
"fullName": user.fullName,
"mandateId": user.mandateId,
"isSysAdmin": user.isSysAdmin,
"enabled": user.enabled,
"roleLabels": user.roleLabels or [],
"roleCount": len(user.roleLabels or [])
"roleLabels": userRoleLabels,
"roleCount": len(userRoleLabels)
})
return result
@ -398,10 +408,11 @@ async def listUsersWithRoles(
async def getUserRoles(
request: Request,
userId: str = Path(..., description="User ID"),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(requireSysAdmin)
) -> Dict[str, Any]:
"""
Get role assignments for a specific user.
MULTI-TENANT: SysAdmin-only.
Path Parameters:
- userId: User ID
@ -410,9 +421,7 @@ async def getUserRoles(
- User dictionary with role assignments
"""
try:
_ensureAdminAccess(currentUser)
interface = getInterface(currentUser)
interface = getRootInterface()
# Get user
user = interface.getUser(userId)
@ -422,15 +431,16 @@ async def getUserRoles(
detail=f"User {userId} not found"
)
userRoleLabels = _getUserRoleLabels(interface, str(user.id))
return {
"id": user.id,
"username": user.username,
"email": user.email,
"fullName": user.fullName,
"mandateId": user.mandateId,
"isSysAdmin": user.isSysAdmin,
"enabled": user.enabled,
"roleLabels": user.roleLabels or [],
"roleCount": len(user.roleLabels or [])
"roleLabels": userRoleLabels,
"roleCount": len(userRoleLabels)
}
except HTTPException:
@ -448,25 +458,24 @@ async def getUserRoles(
async def updateUserRoles(
request: Request,
userId: str = Path(..., description="User ID"),
roleLabels: List[str] = Body(..., description="List of role labels to assign"),
currentUser: User = Depends(getCurrentUser)
newRoleLabels: List[str] = Body(..., description="List of role labels to assign"),
context: RequestContext = Depends(requireSysAdmin)
) -> Dict[str, Any]:
"""
Update role assignments for a specific user.
MULTI-TENANT: SysAdmin-only. Updates roles in user's first mandate.
Path Parameters:
- userId: User ID
Request Body:
- roleLabels: List of role labels to assign (e.g., ["admin", "user"])
- newRoleLabels: List of role labels to assign (e.g., ["admin", "user"])
Returns:
- Updated user dictionary with role assignments
"""
try:
_ensureAdminAccess(currentUser)
interface = getInterface(currentUser)
interface = getRootInterface()
# Get user
user = interface.getUser(userId)
@ -478,28 +487,57 @@ async def updateUserRoles(
# Validate role labels (basic validation - check against standard roles)
standardRoles = ["sysadmin", "admin", "user", "viewer"]
for roleLabel in roleLabels:
for roleLabel in newRoleLabels:
if roleLabel not in standardRoles:
logger.warning(f"Non-standard role label assigned: {roleLabel}")
# Update user roles
userData = {
"roleLabels": roleLabels
}
# Get user's first mandate (for role assignment)
userMandates = interface.db.getRecordset(UserMandate, recordFilter={"userId": userId})
if not userMandates:
raise HTTPException(
status_code=400,
detail=f"User {userId} has no mandate memberships. Add to mandate first."
)
updatedUser = interface.updateUser(userId, userData)
userMandateId = str(userMandates[0].get("id"))
logger.info(f"Updated roles for user {userId}: {roleLabels}")
# Get current roles for this mandate
existingRoles = interface.db.getRecordset(
UserMandateRole,
recordFilter={"userMandateId": userMandateId}
)
existingRoleIds = {str(r.get("roleId")) for r in existingRoles}
# Convert roleLabels to roleIds
newRoleIds = set()
for roleLabel in newRoleLabels:
role = interface.getRoleByLabel(roleLabel)
if role:
newRoleIds.add(str(role.id))
# Remove roles that are no longer needed
for existingRole in existingRoles:
if str(existingRole.get("roleId")) not in newRoleIds:
interface.db.recordDelete(UserMandateRole, str(existingRole.get("id")))
# Add new roles
for roleId in newRoleIds:
if roleId not in existingRoleIds:
newRole = UserMandateRole(userMandateId=userMandateId, roleId=roleId)
interface.db.recordCreate(UserMandateRole, newRole.model_dump())
logger.info(f"Updated roles for user {userId}: {newRoleLabels} by SysAdmin {context.user.id}")
userRoleLabels = _getUserRoleLabels(interface, userId)
return {
"id": updatedUser.id,
"username": updatedUser.username,
"email": updatedUser.email,
"fullName": updatedUser.fullName,
"mandateId": updatedUser.mandateId,
"enabled": updatedUser.enabled,
"roleLabels": updatedUser.roleLabels or [],
"roleCount": len(updatedUser.roleLabels or [])
"id": user.id,
"username": user.username,
"email": user.email,
"fullName": user.fullName,
"isSysAdmin": user.isSysAdmin,
"enabled": user.enabled,
"roleLabels": userRoleLabels,
"roleCount": len(userRoleLabels)
}
except HTTPException:
@ -518,10 +556,11 @@ async def addUserRole(
request: Request,
userId: str = Path(..., description="User ID"),
roleLabel: str = Path(..., description="Role label to add"),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(requireSysAdmin)
) -> Dict[str, Any]:
"""
Add a role to a user (if not already assigned).
MULTI-TENANT: SysAdmin-only. Adds role to user's first mandate.
Path Parameters:
- userId: User ID
@ -531,9 +570,7 @@ async def addUserRole(
- Updated user dictionary with role assignments
"""
try:
_ensureAdminAccess(currentUser)
interface = getInterface(currentUser)
interface = getRootInterface()
# Get user
user = interface.getUser(userId)
@ -543,33 +580,46 @@ async def addUserRole(
detail=f"User {userId} not found"
)
# Get current roles
currentRoles = list(user.roleLabels or [])
# Get role by label
role = interface.getRoleByLabel(roleLabel)
if not role:
raise HTTPException(
status_code=404,
detail=f"Role '{roleLabel}' not found"
)
# Add role if not already present
if roleLabel not in currentRoles:
currentRoles.append(roleLabel)
# Update user roles
userData = {
"roleLabels": currentRoles
}
updatedUser = interface.updateUser(userId, userData)
logger.info(f"Added role {roleLabel} to user {userId}")
else:
updatedUser = user
# Get user's first mandate
userMandates = interface.db.getRecordset(UserMandate, recordFilter={"userId": userId})
if not userMandates:
raise HTTPException(
status_code=400,
detail=f"User {userId} has no mandate memberships. Add to mandate first."
)
userMandateId = str(userMandates[0].get("id"))
# Check if role is already assigned
existingAssignment = interface.db.getRecordset(
UserMandateRole,
recordFilter={"userMandateId": userMandateId, "roleId": str(role.id)}
)
if not existingAssignment:
# Add the role
newRole = UserMandateRole(userMandateId=userMandateId, roleId=str(role.id))
interface.db.recordCreate(UserMandateRole, newRole.model_dump())
logger.info(f"Added role {roleLabel} to user {userId} by SysAdmin {context.user.id}")
userRoleLabels = _getUserRoleLabels(interface, userId)
return {
"id": updatedUser.id,
"username": updatedUser.username,
"email": updatedUser.email,
"fullName": updatedUser.fullName,
"mandateId": updatedUser.mandateId,
"enabled": updatedUser.enabled,
"roleLabels": updatedUser.roleLabels or [],
"roleCount": len(updatedUser.roleLabels or [])
"id": user.id,
"username": user.username,
"email": user.email,
"fullName": user.fullName,
"isSysAdmin": user.isSysAdmin,
"enabled": user.enabled,
"roleLabels": userRoleLabels,
"roleCount": len(userRoleLabels)
}
except HTTPException:
@ -588,10 +638,11 @@ async def removeUserRole(
request: Request,
userId: str = Path(..., description="User ID"),
roleLabel: str = Path(..., description="Role label to remove"),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(requireSysAdmin)
) -> Dict[str, Any]:
"""
Remove a role from a user.
MULTI-TENANT: SysAdmin-only. Removes role from all user's mandates.
Path Parameters:
- userId: User ID
@ -601,9 +652,7 @@ async def removeUserRole(
- Updated user dictionary with role assignments
"""
try:
_ensureAdminAccess(currentUser)
interface = getInterface(currentUser)
interface = getRootInterface()
# Get user
user = interface.getUser(userId)
@ -613,38 +662,44 @@ async def removeUserRole(
detail=f"User {userId} not found"
)
# Get current roles
currentRoles = list(user.roleLabels or [])
# Get role by label
role = interface.getRoleByLabel(roleLabel)
if not role:
raise HTTPException(
status_code=404,
detail=f"Role '{roleLabel}' not found"
)
# Remove role if present
if roleLabel in currentRoles:
currentRoles.remove(roleLabel)
# Ensure user has at least one role (default to "user")
if not currentRoles:
currentRoles = ["user"]
logger.warning(f"User {userId} had all roles removed, defaulting to 'user' role")
# Update user roles
userData = {
"roleLabels": currentRoles
}
updatedUser = interface.updateUser(userId, userData)
logger.info(f"Removed role {roleLabel} from user {userId}")
else:
updatedUser = user
# Remove role from all user's mandates
userMandates = interface.db.getRecordset(UserMandate, recordFilter={"userId": userId})
roleRemoved = False
for um in userMandates:
userMandateId = str(um.get("id"))
# Find and delete the role assignment
assignments = interface.db.getRecordset(
UserMandateRole,
recordFilter={"userMandateId": userMandateId, "roleId": str(role.id)}
)
for assignment in assignments:
interface.db.recordDelete(UserMandateRole, str(assignment.get("id")))
roleRemoved = True
if roleRemoved:
logger.info(f"Removed role {roleLabel} from user {userId} by SysAdmin {context.user.id}")
userRoleLabels = _getUserRoleLabels(interface, userId)
return {
"id": updatedUser.id,
"username": updatedUser.username,
"email": updatedUser.email,
"fullName": updatedUser.fullName,
"mandateId": updatedUser.mandateId,
"enabled": updatedUser.enabled,
"roleLabels": updatedUser.roleLabels or [],
"roleCount": len(updatedUser.roleLabels or [])
"id": user.id,
"username": user.username,
"email": user.email,
"fullName": user.fullName,
"isSysAdmin": user.isSysAdmin,
"enabled": user.enabled,
"roleLabels": userRoleLabels,
"roleCount": len(userRoleLabels)
}
except HTTPException:
@ -662,49 +717,69 @@ async def removeUserRole(
async def getUsersWithRole(
request: Request,
roleLabel: str = Path(..., description="Role label"),
mandateId: Optional[str] = Query(None, description="Filter by mandate ID"),
currentUser: User = Depends(getCurrentUser)
mandateId: Optional[str] = Query(None, description="Filter by mandate ID (via UserMandate)"),
context: RequestContext = Depends(requireSysAdmin)
) -> List[Dict[str, Any]]:
"""
Get all users with a specific role.
MULTI-TENANT: SysAdmin-only.
Path Parameters:
- roleLabel: Role label
Query Parameters:
- mandateId: Optional filter by mandate ID
- mandateId: Optional filter by mandate ID (via UserMandate table)
Returns:
- List of users with the specified role
"""
try:
_ensureAdminAccess(currentUser)
interface = getRootInterface()
interface = getInterface(currentUser)
# Get role by label
role = interface.getRoleByLabel(roleLabel)
if not role:
raise HTTPException(
status_code=404,
detail=f"Role '{roleLabel}' not found"
)
# Get all users
users = interface.getUsers()
# Get all UserMandateRole assignments for this role
roleAssignments = interface.db.getRecordset(
UserMandateRole,
recordFilter={"roleId": str(role.id)}
)
# Filter by role
users = [u for u in users if roleLabel in (u.roleLabels or [])]
# Get unique userMandateIds
userMandateIds = {str(ra.get("userMandateId")) for ra in roleAssignments}
# Filter by mandate if specified
if mandateId:
users = [u for u in users if u.mandateId == mandateId]
# Get userIds from UserMandate records
userIds: Set[str] = set()
for userMandateId in userMandateIds:
umRecords = interface.db.getRecordset(UserMandate, recordFilter={"id": userMandateId})
if umRecords:
um = umRecords[0]
# Filter by mandate if specified
if mandateId and str(um.get("mandateId")) != mandateId:
continue
userIds.add(str(um.get("userId")))
# Format response
# Get users and format response
result = []
for user in users:
result.append({
"id": user.id,
"username": user.username,
"email": user.email,
"fullName": user.fullName,
"mandateId": user.mandateId,
"enabled": user.enabled,
"roleLabels": user.roleLabels or [],
"roleCount": len(user.roleLabels or [])
})
for userId in userIds:
user = interface.getUser(userId)
if user:
userRoleLabels = _getUserRoleLabels(interface, userId)
result.append({
"id": user.id,
"username": user.username,
"email": user.email,
"fullName": user.fullName,
"isSysAdmin": user.isSysAdmin,
"enabled": user.enabled,
"roleLabels": userRoleLabels,
"roleCount": len(userRoleLabels)
})
return result

View file

@ -117,7 +117,7 @@ async def create_automation(
chatInterface = getChatInterface(currentUser)
automationData = automation.model_dump()
created = chatInterface.createAutomationDefinition(automationData)
return AutomationDefinition(**created)
return created
except HTTPException:
raise
except Exception as e:
@ -171,7 +171,7 @@ async def get_automation(
detail=f"Automation {automationId} not found"
)
return AutomationDefinition(**automation)
return automation
except HTTPException:
raise
except Exception as e:
@ -194,7 +194,7 @@ async def update_automation(
chatInterface = getChatInterface(currentUser)
automationData = automation.model_dump()
updated = chatInterface.updateAutomationDefinition(automationId, automationData)
return AutomationDefinition(**updated)
return updated
except HTTPException:
raise
except PermissionError as e:

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
from modules.shared.attributeUtils import getModelAttributeDefinitions
from modules.shared.auditLogger import audit_logger
# Import the model classes
from modules.datamodels.datamodelUam import Mandate, User
from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole
from modules.datamodels.datamodelRbac import Role
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
# =============================================================================
# Request/Response Models for User Management
# =============================================================================
class UserMandateCreate(BaseModel):
"""Request model for adding a user to a mandate"""
targetUserId: str = Field(..., description="User ID to add to the mandate")
roleIds: List[str] = Field(..., description="Role IDs to assign to the user")
class UserMandateResponse(BaseModel):
"""Response model for user mandate membership"""
userMandateId: str
userId: str
mandateId: str
roleIds: List[str]
enabled: bool
class MandateUserInfo(BaseModel):
"""User info within a mandate context"""
userId: str
username: str
email: Optional[str]
firstname: Optional[str]
lastname: Optional[str]
userMandateId: str
roleIds: List[str]
enabled: bool
# Configure logger
logger = logging.getLogger(__name__)
@ -40,10 +79,11 @@ router = APIRouter(
async def get_mandates(
request: Request,
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(requireSysAdmin)
) -> PaginatedResponse[Mandate]:
"""
Get mandates with optional pagination, sorting, and filtering.
MULTI-TENANT: SysAdmin-only (mandates are system resources).
Query Parameters:
- pagination: JSON-encoded PaginationParams object, or None for no pagination
@ -67,7 +107,7 @@ async def get_mandates(
detail=f"Invalid pagination parameter: {str(e)}"
)
appInterface = interfaceDbAppObjects.getInterface(currentUser)
appInterface = interfaceDbAppObjects.getRootInterface()
result = appInterface.getAllMandates(pagination=paginationParams)
# If pagination was requested, result is PaginatedResult
@ -103,11 +143,14 @@ async def get_mandates(
async def get_mandate(
request: Request,
mandateId: str = Path(..., description="ID of the mandate"),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(requireSysAdmin)
) -> Mandate:
"""Get a specific mandate by ID"""
"""
Get a specific mandate by ID.
MULTI-TENANT: SysAdmin-only.
"""
try:
appInterface = interfaceDbAppObjects.getInterface(currentUser)
appInterface = interfaceDbAppObjects.getRootInterface()
mandate = appInterface.getMandate(mandateId)
if not mandate:
@ -131,9 +174,12 @@ async def get_mandate(
async def create_mandate(
request: Request,
mandateData: dict = Body(..., description="Mandate data with at least 'name' field"),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(requireSysAdmin)
) -> Mandate:
"""Create a new mandate"""
"""
Create a new mandate.
MULTI-TENANT: SysAdmin-only.
"""
try:
logger.debug(f"Creating mandate with data: {mandateData}")
@ -148,7 +194,7 @@ async def create_mandate(
# Get optional fields with defaults
language = mandateData.get('language', 'en')
appInterface = interfaceDbAppObjects.getInterface(currentUser)
appInterface = interfaceDbAppObjects.getRootInterface()
# Create mandate
newMandate = appInterface.createMandate(
@ -161,6 +207,8 @@ async def create_mandate(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create mandate"
)
logger.info(f"Mandate {newMandate.id} created by SysAdmin {context.user.id}")
return newMandate
except HTTPException:
@ -178,13 +226,16 @@ async def update_mandate(
request: Request,
mandateId: str = Path(..., description="ID of the mandate to update"),
mandateData: dict = Body(..., description="Mandate update data"),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(requireSysAdmin)
) -> Mandate:
"""Update an existing mandate"""
"""
Update an existing mandate.
MULTI-TENANT: SysAdmin-only.
"""
try:
logger.debug(f"Updating mandate {mandateId} with data: {mandateData}")
appInterface = interfaceDbAppObjects.getInterface(currentUser)
appInterface = interfaceDbAppObjects.getRootInterface()
# Check if mandate exists
existingMandate = appInterface.getMandate(mandateId)
@ -202,6 +253,8 @@ async def update_mandate(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update mandate"
)
logger.info(f"Mandate {mandateId} updated by SysAdmin {context.user.id}")
return updatedMandate
except HTTPException:
@ -218,11 +271,14 @@ async def update_mandate(
async def delete_mandate(
request: Request,
mandateId: str = Path(..., description="ID of the mandate to delete"),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(requireSysAdmin)
) -> Dict[str, Any]:
"""Delete a mandate"""
"""
Delete a mandate.
MULTI-TENANT: SysAdmin-only.
"""
try:
appInterface = interfaceDbAppObjects.getInterface(currentUser)
appInterface = interfaceDbAppObjects.getRootInterface()
# Check if mandate exists
existingMandate = appInterface.getMandate(mandateId)
@ -231,6 +287,13 @@ async def delete_mandate(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Mandate {mandateId} not found"
)
# MULTI-TENANT: Delete all UserMandate entries for this mandate first
from modules.datamodels.datamodelMembership import UserMandate
userMandates = appInterface.db.getRecordset(UserMandate, recordFilter={"mandateId": mandateId})
for um in userMandates:
appInterface.db.deleteRecord(UserMandate, um["id"])
logger.info(f"Deleted {len(userMandates)} UserMandate entries for mandate {mandateId}")
# Delete mandate
try:
@ -240,6 +303,8 @@ async def delete_mandate(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
logger.info(f"Mandate {mandateId} deleted by SysAdmin {context.user.id}")
return {"message": f"Mandate {mandateId} deleted successfully"}
except HTTPException:
@ -250,3 +315,456 @@ async def delete_mandate(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to delete mandate: {str(e)}"
)
# =============================================================================
# User Management within Mandates (Mandate-Admin)
# =============================================================================
@router.get("/{mandateId}/users", response_model=List[MandateUserInfo])
@limiter.limit("60/minute")
async def listMandateUsers(
request: Request,
mandateId: str = Path(..., description="ID of the mandate"),
context: RequestContext = Depends(getRequestContext)
) -> List[MandateUserInfo]:
"""
List all users in a mandate.
Requires Mandate-Admin role or SysAdmin.
"""
# Check permission
if not _hasMandateAdminRole(context, mandateId) and not context.isSysAdmin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Mandate-Admin role required"
)
try:
rootInterface = interfaceDbAppObjects.getRootInterface()
# Verify mandate exists
mandate = rootInterface.getMandate(mandateId)
if not mandate:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Mandate {mandateId} not found"
)
# Get all UserMandate entries for this mandate
userMandates = rootInterface.db.getRecordset(
UserMandate,
recordFilter={"mandateId": mandateId}
)
result = []
for um in userMandates:
# Get user info
user = rootInterface.getUserById(um.get("userId"))
if not user:
continue
# Get roles for this membership
roleIds = rootInterface.getRoleIdsForUserMandate(um.get("id"))
result.append(MandateUserInfo(
userId=str(user.id),
username=user.username,
email=user.email,
firstname=user.firstname,
lastname=user.lastname,
userMandateId=um.get("id"),
roleIds=roleIds,
enabled=um.get("enabled", True)
))
return result
except HTTPException:
raise
except Exception as e:
logger.error(f"Error listing users for mandate {mandateId}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to list users: {str(e)}"
)
@router.post("/{mandateId}/users", response_model=UserMandateResponse)
@limiter.limit("30/minute")
async def addUserToMandate(
request: Request,
mandateId: str = Path(..., description="ID of the mandate"),
data: UserMandateCreate = Body(...),
context: RequestContext = Depends(getRequestContext)
) -> UserMandateResponse:
"""
Add a user to a mandate with specified roles.
Requires Mandate-Admin role.
SysAdmin cannot add themselves (Self-Eskalation Prevention).
Args:
mandateId: Target mandate ID
data: User ID and role IDs to assign
"""
# 1. SysAdmin Self-Eskalation Prevention
if context.isSysAdmin and data.targetUserId == str(context.user.id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="SysAdmin cannot add themselves to a mandate. A Mandate-Admin must grant access."
)
# 2. Check Mandate-Admin permission
if not _hasMandateAdminRole(context, mandateId):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Mandate-Admin role required to add users"
)
try:
rootInterface = interfaceDbAppObjects.getRootInterface()
# 3. Verify mandate exists
mandate = rootInterface.getMandate(mandateId)
if not mandate:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Mandate {mandateId} not found"
)
# 4. Verify target user exists
targetUser = rootInterface.getUserById(data.targetUserId)
if not targetUser:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User {data.targetUserId} not found"
)
# 5. Check if user is already a member
existingMembership = rootInterface.getUserMandate(data.targetUserId, mandateId)
if existingMembership:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"User {data.targetUserId} is already a member of this mandate"
)
# 6. Validate roles (must exist and belong to this mandate or be global)
for roleId in data.roleIds:
roleRecords = rootInterface.db.getRecordset(Role, recordFilter={"id": roleId})
if not roleRecords:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Role {roleId} not found"
)
role = roleRecords[0]
roleMandateId = role.get("mandateId")
if roleMandateId and str(roleMandateId) != str(mandateId):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Role {roleId} belongs to a different mandate"
)
# 7. Create UserMandate
userMandate = rootInterface.createUserMandate(
userId=data.targetUserId,
mandateId=mandateId,
roleIds=data.roleIds
)
# 8. Audit
audit_logger.logSecurityEvent(
userId=str(context.user.id),
mandateId=mandateId,
action="user_added_to_mandate",
details=f"targetUser={data.targetUserId}, roles={data.roleIds}"
)
logger.info(
f"User {context.user.id} added user {data.targetUserId} to mandate {mandateId} "
f"with roles {data.roleIds}"
)
return UserMandateResponse(
userMandateId=str(userMandate.id),
userId=data.targetUserId,
mandateId=mandateId,
roleIds=data.roleIds,
enabled=True
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error adding user to mandate: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to add user to mandate: {str(e)}"
)
@router.delete("/{mandateId}/users/{targetUserId}", response_model=Dict[str, str])
@limiter.limit("30/minute")
async def removeUserFromMandate(
request: Request,
mandateId: str = Path(..., description="ID of the mandate"),
targetUserId: str = Path(..., description="ID of the user to remove"),
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, str]:
"""
Remove a user from a mandate.
Requires Mandate-Admin role.
Cannot remove the last admin from a mandate (orphan prevention).
Args:
mandateId: Target mandate ID
targetUserId: User ID to remove
"""
# Check Mandate-Admin permission
if not _hasMandateAdminRole(context, mandateId):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Mandate-Admin role required"
)
try:
rootInterface = interfaceDbAppObjects.getRootInterface()
# Verify mandate exists
mandate = rootInterface.getMandate(mandateId)
if not mandate:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Mandate {mandateId} not found"
)
# Get user's membership
membership = rootInterface.getUserMandate(targetUserId, mandateId)
if not membership:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User {targetUserId} is not a member of this mandate"
)
# Check if this is the last admin (orphan prevention)
if _isLastMandateAdmin(rootInterface, mandateId, targetUserId):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot remove the last admin from a mandate. Assign another admin first."
)
# Delete UserMandate (CASCADE will delete UserMandateRole entries)
rootInterface.deleteUserMandate(targetUserId, mandateId)
# Audit
audit_logger.logSecurityEvent(
userId=str(context.user.id),
mandateId=mandateId,
action="user_removed_from_mandate",
details=f"targetUser={targetUserId}"
)
logger.info(f"User {context.user.id} removed user {targetUserId} from mandate {mandateId}")
return {"message": "User removed from mandate", "userId": targetUserId}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error removing user from mandate: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to remove user from mandate: {str(e)}"
)
@router.put("/{mandateId}/users/{targetUserId}/roles", response_model=UserMandateResponse)
@limiter.limit("30/minute")
async def updateUserRolesInMandate(
request: Request,
mandateId: str = Path(..., description="ID of the mandate"),
targetUserId: str = Path(..., description="ID of the user"),
roleIds: List[str] = Body(..., description="New role IDs to assign"),
context: RequestContext = Depends(getRequestContext)
) -> UserMandateResponse:
"""
Update a user's roles within a mandate.
Replaces all existing roles with the new set.
Requires Mandate-Admin role.
Args:
mandateId: Target mandate ID
targetUserId: User ID to update
roleIds: New set of role IDs
"""
# Check Mandate-Admin permission
if not _hasMandateAdminRole(context, mandateId):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Mandate-Admin role required"
)
try:
rootInterface = interfaceDbAppObjects.getRootInterface()
# Get user's membership
membership = rootInterface.getUserMandate(targetUserId, mandateId)
if not membership:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User {targetUserId} is not a member of this mandate"
)
# Validate new roles
for roleId in roleIds:
roleRecords = rootInterface.db.getRecordset(Role, recordFilter={"id": roleId})
if not roleRecords:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Role {roleId} not found"
)
role = roleRecords[0]
roleMandateId = role.get("mandateId")
if roleMandateId and str(roleMandateId) != str(mandateId):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Role {roleId} belongs to a different mandate"
)
# Check if removing admin role would leave mandate without admins
currentRoleIds = rootInterface.getRoleIdsForUserMandate(str(membership.id))
isCurrentlyAdmin = _hasAdminRoleInList(rootInterface, currentRoleIds, mandateId)
willBeAdmin = _hasAdminRoleInList(rootInterface, roleIds, mandateId)
if isCurrentlyAdmin and not willBeAdmin:
if _isLastMandateAdmin(rootInterface, mandateId, targetUserId):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot remove admin role from the last admin. Assign another admin first."
)
# Remove existing role assignments
existingRoles = rootInterface.db.getRecordset(
UserMandateRole,
recordFilter={"userMandateId": str(membership.id)}
)
for er in existingRoles:
rootInterface.db.recordDelete(UserMandateRole, er.get("id"))
# Add new role assignments
for roleId in roleIds:
rootInterface.addRoleToUserMandate(str(membership.id), roleId)
# Audit
audit_logger.logSecurityEvent(
userId=str(context.user.id),
mandateId=mandateId,
action="user_roles_updated_in_mandate",
details=f"targetUser={targetUserId}, newRoles={roleIds}"
)
logger.info(
f"User {context.user.id} updated roles for user {targetUserId} "
f"in mandate {mandateId} to {roleIds}"
)
return UserMandateResponse(
userMandateId=str(membership.id),
userId=targetUserId,
mandateId=mandateId,
roleIds=roleIds,
enabled=membership.enabled
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error updating user roles in mandate: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to update user roles: {str(e)}"
)
# =============================================================================
# Helper Functions
# =============================================================================
def _hasMandateAdminRole(context: RequestContext, mandateId: str) -> bool:
"""
Check if the user has mandate admin role for the specified mandate.
"""
if context.isSysAdmin:
return True
# Must be in the same mandate context
if str(context.mandateId) != str(mandateId):
return False
if not context.roleIds:
return False
try:
rootInterface = interfaceDbAppObjects.getRootInterface()
for roleId in context.roleIds:
roleRecords = rootInterface.db.getRecordset(Role, recordFilter={"id": roleId})
if roleRecords:
role = roleRecords[0]
roleLabel = role.get("roleLabel", "")
# Admin role at mandate level (not feature-instance level)
if roleLabel == "admin" and role.get("mandateId") and not role.get("featureInstanceId"):
return True
return False
except Exception as e:
logger.error(f"Error checking mandate admin role: {e}")
return False
def _isLastMandateAdmin(interface, mandateId: str, excludeUserId: str) -> bool:
"""
Check if excluding this user would leave the mandate without any admins.
"""
try:
# Get all UserMandates for this mandate
userMandates = interface.db.getRecordset(
UserMandate,
recordFilter={"mandateId": mandateId, "enabled": True}
)
adminCount = 0
for um in userMandates:
if str(um.get("userId")) == str(excludeUserId):
continue
# Check if this user has admin role
roleIds = interface.getRoleIdsForUserMandate(um.get("id"))
if _hasAdminRoleInList(interface, roleIds, mandateId):
adminCount += 1
return adminCount == 0
except Exception as e:
logger.error(f"Error checking last admin: {e}")
return True # Fail-safe: assume they're the last admin
def _hasAdminRoleInList(interface, roleIds: List[str], mandateId: str) -> bool:
"""
Check if any of the role IDs is an admin role for the mandate.
"""
for roleId in roleIds:
roleRecords = interface.db.getRecordset(Role, recordFilter={"id": roleId})
if roleRecords:
role = roleRecords[0]
roleLabel = role.get("roleLabel", "")
roleMandateId = role.get("mandateId")
# Admin role at mandate level
if roleLabel == "admin" and (not roleMandateId or str(roleMandateId) == str(mandateId)):
if not role.get("featureInstanceId"):
return True
return False

View file

@ -3,6 +3,10 @@
"""
User routes for the backend API.
Implements the endpoints for user management.
MULTI-TENANT: User management requires RequestContext.
- mandateId from X-Mandate-Id header determines which users are visible
- SysAdmin can see all users across mandates
"""
from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Response, Query
@ -13,7 +17,7 @@ import json
# Import interfaces and models
import modules.interfaces.interfaceDbAppObjects as interfaceDbAppObjects
from modules.auth import getCurrentUser, limiter
from modules.auth import limiter, getRequestContext, RequestContext
# Import the attribute definition and helper functions
from modules.datamodels.datamodelUam import User
@ -32,19 +36,19 @@ router = APIRouter(
@limiter.limit("30/minute")
async def get_users(
request: Request,
mandateId: Optional[str] = Query(None, description="Mandate ID to filter users"),
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> PaginatedResponse[User]:
"""
Get users with optional pagination, sorting, and filtering.
MULTI-TENANT: mandateId from X-Mandate-Id header determines scope.
SysAdmin without mandateId sees all users.
Query Parameters:
- mandateId: Optional mandate ID to filter users
- pagination: JSON-encoded PaginationParams object, or None for no pagination
Examples:
- GET /api/users/ (no pagination - returns all users)
- GET /api/users/ (no pagination - returns all users in mandate)
- GET /api/users/?pagination={"page":1,"pageSize":10,"sort":[]}
"""
try:
@ -62,30 +66,77 @@ async def get_users(
detail=f"Invalid pagination parameter: {str(e)}"
)
appInterface = interfaceDbAppObjects.getInterface(currentUser)
# If mandateId is provided, use it, otherwise use the current user's mandate
targetMandateId = mandateId or currentUser.mandateId
# Get users with optional pagination
result = appInterface.getUsersByMandate(targetMandateId, pagination=paginationParams)
appInterface = interfaceDbAppObjects.getInterface(context.user)
# If pagination was requested, result is PaginatedResult
# If no pagination, result is List[User]
if paginationParams:
return PaginatedResponse(
items=result.items,
pagination=PaginationMetadata(
currentPage=paginationParams.page,
pageSize=paginationParams.pageSize,
totalItems=result.totalItems,
totalPages=result.totalPages,
sort=paginationParams.sort,
filters=paginationParams.filters
# MULTI-TENANT: Use mandateId from context (header)
# SysAdmin without mandateId can see all users
if context.mandateId:
# Get users for specific mandate via UserMandate table
from modules.datamodels.datamodelMembership import UserMandate
userMandates = appInterface.db.getRecordset(UserMandate, recordFilter={"mandateId": str(context.mandateId)})
userIds = [str(um["userId"]) for um in userMandates]
# Get all users and filter by mandate membership
allUsers = appInterface.getUsers()
users = [u for u in allUsers if str(u.id) in userIds]
# Apply pagination manually if needed
if paginationParams:
totalItems = len(users)
import math
totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0
startIdx = (paginationParams.page - 1) * paginationParams.pageSize
endIdx = startIdx + paginationParams.pageSize
paginatedUsers = users[startIdx:endIdx]
return PaginatedResponse(
items=paginatedUsers,
pagination=PaginationMetadata(
currentPage=paginationParams.page,
pageSize=paginationParams.pageSize,
totalItems=totalItems,
totalPages=totalPages,
sort=paginationParams.sort,
filters=paginationParams.filters
)
)
else:
return PaginatedResponse(
items=users,
pagination=None
)
elif context.isSysAdmin:
# SysAdmin without mandateId sees all users
result = appInterface.getUsers()
if paginationParams:
totalItems = len(result)
import math
totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0
startIdx = (paginationParams.page - 1) * paginationParams.pageSize
endIdx = startIdx + paginationParams.pageSize
paginatedUsers = result[startIdx:endIdx]
return PaginatedResponse(
items=paginatedUsers,
pagination=PaginationMetadata(
currentPage=paginationParams.page,
pageSize=paginationParams.pageSize,
totalItems=totalItems,
totalPages=totalPages,
sort=paginationParams.sort,
filters=paginationParams.filters
)
)
else:
return PaginatedResponse(
items=result,
pagination=None
)
)
else:
return PaginatedResponse(
items=result,
pagination=None
# Non-SysAdmin without mandateId - should not happen (getRequestContext enforces)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="X-Mandate-Id header is required"
)
except HTTPException:
raise
@ -101,11 +152,14 @@ async def get_users(
async def get_user(
request: Request,
userId: str = Path(..., description="ID of the user"),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> User:
"""Get a specific user by ID"""
"""
Get a specific user by ID.
MULTI-TENANT: User must be in the same mandate (via UserMandate) or caller is SysAdmin.
"""
try:
appInterface = interfaceDbAppObjects.getInterface(currentUser)
appInterface = interfaceDbAppObjects.getInterface(context.user)
# Get user without filtering by enabled status
user = appInterface.getUser(userId)
@ -114,6 +168,19 @@ async def get_user(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User with ID {userId} not found"
)
# MULTI-TENANT: Verify user is in the same mandate (unless SysAdmin)
if context.mandateId and not context.isSysAdmin:
from modules.datamodels.datamodelMembership import UserMandate
userMandate = appInterface.db.getRecordset(UserMandate, recordFilter={
"userId": userId,
"mandateId": str(context.mandateId)
})
if not userMandate:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User not in your mandate"
)
return user
except HTTPException:
@ -131,10 +198,13 @@ async def create_user(
request: Request,
user_data: User = Body(...),
password: Optional[str] = Body(None, embed=True),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> User:
"""Create a new user"""
appInterface = interfaceDbAppObjects.getInterface(currentUser)
"""
Create a new user.
MULTI-TENANT: User is created and automatically added to the current mandate.
"""
appInterface = interfaceDbAppObjects.getInterface(context.user)
# Extract fields from User model and call createUser with individual parameters
from modules.datamodels.datamodelUam import AuthAuthority
@ -145,10 +215,22 @@ async def create_user(
fullName=user_data.fullName,
language=user_data.language,
enabled=user_data.enabled,
roleLabels=user_data.roleLabels if user_data.roleLabels else ["user"],
authenticationAuthority=user_data.authenticationAuthority
)
# MULTI-TENANT: Add user to current mandate via UserMandate with default "user" role
if context.mandateId:
# Get "user" role ID
userRole = appInterface.getRoleByLabel("user")
roleIds = [str(userRole.id)] if userRole else []
appInterface.createUserMandate(
userId=str(newUser.id),
mandateId=str(context.mandateId),
roleIds=roleIds
)
logger.info(f"Created UserMandate for user {newUser.id} in mandate {context.mandateId}")
return newUser
@router.put("/{userId}", response_model=User)
@ -157,10 +239,13 @@ async def update_user(
request: Request,
userId: str = Path(..., description="ID of the user to update"),
userData: User = Body(...),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> User:
"""Update an existing user"""
appInterface = interfaceDbAppObjects.getInterface(currentUser)
"""
Update an existing user.
MULTI-TENANT: Can only update users in the same mandate (unless SysAdmin).
"""
appInterface = interfaceDbAppObjects.getInterface(context.user)
# Check if the user exists
existingUser = appInterface.getUser(userId)
@ -170,6 +255,19 @@ async def update_user(
detail=f"User with ID {userId} not found"
)
# MULTI-TENANT: Verify user is in the same mandate (unless SysAdmin)
if context.mandateId and not context.isSysAdmin:
from modules.datamodels.datamodelMembership import UserMandate
userMandate = appInterface.db.getRecordset(UserMandate, recordFilter={
"userId": userId,
"mandateId": str(context.mandateId)
})
if not userMandate:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot update user outside your mandate"
)
# Update user
updatedUser = appInterface.updateUser(userId, userData)
@ -187,19 +285,22 @@ async def reset_user_password(
request: Request,
userId: str = Path(..., description="ID of the user to reset password for"),
newPassword: str = Body(..., embed=True),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""Reset user password (Admin only)"""
"""
Reset user password (Admin only).
MULTI-TENANT: Can only reset passwords for users in the same mandate (unless SysAdmin).
"""
try:
# Check if current user is admin
if "admin" not in (currentUser.roleLabels or []) and "sysadmin" not in (currentUser.roleLabels or []):
if not context.isSysAdmin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only administrators can reset passwords"
)
# Get user interface
appInterface = interfaceDbAppObjects.getInterface(currentUser)
appInterface = interfaceDbAppObjects.getInterface(context.user)
# Get target user
target_user = appInterface.getUserById(userId)
@ -209,6 +310,19 @@ async def reset_user_password(
detail="User not found"
)
# MULTI-TENANT: Verify user is in the same mandate (unless SysAdmin)
if context.mandateId and not context.isSysAdmin:
from modules.datamodels.datamodelMembership import UserMandate
userMandate = appInterface.db.getRecordset(UserMandate, recordFilter={
"userId": userId,
"mandateId": str(context.mandateId)
})
if not userMandate:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot reset password for user outside your mandate"
)
# Validate password strength
if len(newPassword) < 8:
raise HTTPException(
@ -231,7 +345,7 @@ async def reset_user_password(
userId=userId,
authority=None, # Revoke all authorities
mandateId=None, # Revoke across all mandates
revokedBy=currentUser.id,
revokedBy=context.user.id,
reason="password_reset"
)
logger.info(f"Revoked {revoked_count} tokens for user {userId} after password reset")
@ -243,8 +357,8 @@ async def reset_user_password(
try:
from modules.shared.auditLogger import audit_logger
audit_logger.logSecurityEvent(
userId=str(currentUser.id),
mandateId=str(currentUser.mandateId),
userId=str(context.user.id),
mandateId=str(context.mandateId) if context.mandateId else "system",
action="password_reset",
details=f"Reset password for user {userId}"
)
@ -271,15 +385,18 @@ async def change_password(
request: Request,
currentPassword: str = Body(..., embed=True),
newPassword: str = Body(..., embed=True),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""Change current user's password"""
"""
Change current user's password.
MULTI-TENANT: User changes their own password (no mandate restriction).
"""
try:
# Get user interface
appInterface = interfaceDbAppObjects.getInterface(currentUser)
appInterface = interfaceDbAppObjects.getInterface(context.user)
# Verify current password
if not appInterface.verifyPassword(currentPassword, currentUser.passwordHash):
if not appInterface.verifyPassword(currentPassword, context.user.passwordHash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Current password is incorrect"
@ -293,7 +410,7 @@ async def change_password(
)
# Change password
success = appInterface.resetUserPassword(str(currentUser.id), newPassword)
success = appInterface.resetUserPassword(str(context.user.id), newPassword)
if not success:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@ -304,23 +421,23 @@ async def change_password(
try:
from modules.datamodels.datamodelUam import AuthAuthority
revoked_count = appInterface.revokeTokensByUser(
userId=str(currentUser.id),
userId=str(context.user.id),
authority=None, # Revoke all authorities
mandateId=None, # Revoke across all mandates
revokedBy=currentUser.id,
revokedBy=context.user.id,
reason="password_change"
)
logger.info(f"Revoked {revoked_count} tokens for user {currentUser.id} after password change")
logger.info(f"Revoked {revoked_count} tokens for user {context.user.id} after password change")
except Exception as e:
logger.error(f"Failed to revoke tokens after password change for user {currentUser.id}: {str(e)}")
logger.error(f"Failed to revoke tokens after password change for user {context.user.id}: {str(e)}")
# Don't fail the password change if token revocation fails
# Log password change
try:
from modules.shared.auditLogger import audit_logger
audit_logger.logSecurityEvent(
userId=str(currentUser.id),
mandateId=str(currentUser.mandateId),
userId=str(context.user.id),
mandateId=str(context.mandateId) if context.mandateId else "system",
action="password_change",
details="User changed their own password"
)
@ -346,9 +463,11 @@ async def sendPasswordLink(
request: Request,
userId: str = Path(..., description="ID of the user to send password setup link"),
frontendUrl: str = Body(..., embed=True),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""Send password setup/reset link to a user (admin function).
"""
Send password setup/reset link to a user (admin function).
MULTI-TENANT: Can only send to users in the same mandate (unless SysAdmin).
This allows admins to send a magic link to users to set or reset their password.
Used when creating users without password or to help users who forgot their password.
@ -362,7 +481,7 @@ async def sendPasswordLink(
from modules.interfaces.interfaceDbAppObjects import getRootInterface
# Get user interface
appInterface = interfaceDbAppObjects.getInterface(currentUser)
appInterface = interfaceDbAppObjects.getInterface(context.user)
# Get target user
targetUser = appInterface.getUser(userId)
@ -372,6 +491,19 @@ async def sendPasswordLink(
detail="User not found"
)
# MULTI-TENANT: Verify user is in the same mandate (unless SysAdmin)
if context.mandateId and not context.isSysAdmin:
from modules.datamodels.datamodelMembership import UserMandate
userMandate = appInterface.db.getRecordset(UserMandate, recordFilter={
"userId": userId,
"mandateId": str(context.mandateId)
})
if not userMandate:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot send password link to user outside your mandate"
)
# Check if user has an email
if not targetUser.email:
raise HTTPException(
@ -440,15 +572,15 @@ Falls Sie diese Anforderung nicht erwartet haben, kontaktieren Sie bitte Ihren A
try:
from modules.shared.auditLogger import audit_logger
audit_logger.logSecurityEvent(
userId=str(currentUser.id),
mandateId=str(currentUser.mandateId),
userId=str(context.user.id),
mandateId=str(context.mandateId) if context.mandateId else "system",
action="send_password_link",
details=f"Sent password setup link to user {userId} ({targetUser.email})"
)
except Exception:
pass
logger.info(f"Password setup link sent to {targetUser.email} for user {targetUser.username} by admin {currentUser.username}")
logger.info(f"Password setup link sent to {targetUser.email} for user {targetUser.username} by admin {context.user.username}")
return {
"message": f"Password setup link sent to {targetUser.email}",
@ -470,10 +602,13 @@ Falls Sie diese Anforderung nicht erwartet haben, kontaktieren Sie bitte Ihren A
async def delete_user(
request: Request,
userId: str = Path(..., description="ID of the user to delete"),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""Delete a user"""
appInterface = interfaceDbAppObjects.getInterface(currentUser)
"""
Delete a user.
MULTI-TENANT: Can only delete users in the same mandate (unless SysAdmin).
"""
appInterface = interfaceDbAppObjects.getInterface(context.user)
# Check if the user exists
existingUser = appInterface.getUser(userId)
@ -483,6 +618,25 @@ async def delete_user(
detail=f"User with ID {userId} not found"
)
# MULTI-TENANT: Verify user is in the same mandate (unless SysAdmin)
if context.mandateId and not context.isSysAdmin:
from modules.datamodels.datamodelMembership import UserMandate
userMandate = appInterface.db.getRecordset(UserMandate, recordFilter={
"userId": userId,
"mandateId": str(context.mandateId)
})
if not userMandate:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot delete user outside your mandate"
)
# Delete UserMandate entries for this user first
from modules.datamodels.datamodelMembership import UserMandate
userMandates = appInterface.db.getRecordset(UserMandate, recordFilter={"userId": userId})
for um in userMandates:
appInterface.db.deleteRecord(UserMandate, um["id"])
success = appInterface.deleteUser(userId)
if not success:
raise HTTPException(

View file

@ -12,8 +12,7 @@ import logging
# Import interfaces and models
import modules.interfaces.interfaceDbChatObjects as interfaceDbChatObjects
from modules.auth import getCurrentUser, limiter
from modules.datamodels.datamodelUam import User
from modules.auth import limiter, getRequestContext, requireSysAdmin, RequestContext
# Configure logger
logger = logging.getLogger(__name__)
@ -31,26 +30,16 @@ router = APIRouter(
}
)
def requireSysadmin(currentUser: User):
"""Require sysadmin role"""
if "sysadmin" not in (currentUser.roleLabels or []):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Sysadmin role required"
)
@router.get("")
@limiter.limit("30/minute")
async def get_all_automation_events(
request: Request,
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(requireSysAdmin)
) -> List[Dict[str, Any]]:
"""
Get all automation events across all mandates (sysadmin only).
Returns list of all registered events with their automation IDs and schedules.
"""
requireSysadmin(currentUser)
try:
from modules.shared.eventManagement import eventManager
@ -79,20 +68,18 @@ async def get_all_automation_events(
@limiter.limit("5/minute")
async def sync_all_automation_events(
request: Request,
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(requireSysAdmin)
) -> Dict[str, Any]:
"""
Manually trigger sync for all automations (sysadmin only).
This will register/remove events based on active flags.
"""
requireSysadmin(currentUser)
try:
from modules.interfaces.interfaceDbChatObjects import getInterface as getChatInterface
from modules.interfaces.interfaceDbAppObjects import getRootInterface
from modules.features.workflow import syncAutomationEvents
chatInterface = getChatInterface(currentUser)
chatInterface = getChatInterface(context.user)
# Get event user for sync operation (routes can import from interfaces)
rootInterface = getRootInterface()
eventUser = rootInterface.getUserByUsername("event")
@ -103,7 +90,7 @@ async def sync_all_automation_events(
)
from modules.services import getInterface as getServices
services = getServices(currentUser, None)
services = getServices(context.user, None)
result = await syncAutomationEvents(services, eventUser)
return {
"success": True,
@ -124,14 +111,12 @@ async def sync_all_automation_events(
async def remove_event(
request: Request,
eventId: str = Path(..., description="Event ID to remove"),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(requireSysAdmin)
) -> Dict[str, Any]:
"""
Manually remove a specific event from scheduler (sysadmin only).
Used for debugging and manual event cleanup.
"""
requireSysadmin(currentUser)
try:
from modules.shared.eventManagement import eventManager
@ -141,9 +126,9 @@ async def remove_event(
# Update automation's eventId if it exists
if eventId.startswith("automation."):
automation_id = eventId.replace("automation.", "")
chatInterface = interfaceDbChatObjects.getInterface(currentUser)
chatInterface = interfaceDbChatObjects.getInterface(context.user)
automation = chatInterface.getAutomationDefinition(automation_id)
if automation and automation.get("eventId") == eventId:
if automation and getattr(automation, "eventId", None) == eventId:
chatInterface.updateAutomationDefinition(automation_id, {"eventId": None})
return {
@ -157,4 +142,3 @@ async def remove_event(
status_code=500,
detail=f"Error removing event: {str(e)}"
)

View file

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

View file

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

View file

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

View file

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

View file

@ -13,7 +13,7 @@ import logging
import json
import io
from modules.auth import limiter, getCurrentUser
from modules.auth import limiter, getRequestContext, RequestContext
from modules.interfaces.interfaceDbTrusteeObjects import getInterface
from modules.datamodels.datamodelTrustee import (
TrusteeOrganisation,
@ -24,7 +24,6 @@ from modules.datamodels.datamodelTrustee import (
TrusteePosition,
TrusteePositionDocument,
)
from modules.datamodels.datamodelUam import User
from modules.datamodels.datamodelPagination import (
PaginationParams,
PaginatedResponse,
@ -67,13 +66,13 @@ def _parsePagination(pagination: Optional[str]) -> Optional[PaginationParams]:
async def getOrganisations(
request: Request,
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams"),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> PaginatedResponse[TrusteeOrganisation]:
"""Get all organisations with optional pagination."""
logger = logging.getLogger(__name__)
logger.debug(f"getOrganisations called for user {currentUser.id}, roles: {currentUser.roleLabels}")
logger.debug(f"getOrganisations called for user {context.user.id}, mandateId: {context.mandateId}")
paginationParams = _parsePagination(pagination)
interface = getInterface(currentUser)
interface = getInterface(context.user, mandateId=context.mandateId)
result = interface.getAllOrganisations(paginationParams)
logger.debug(f"getOrganisations returned {len(result.items)} items")
@ -97,14 +96,14 @@ async def getOrganisations(
async def getOrganisation(
request: Request,
orgId: str = Path(..., description="Organisation ID"),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> TrusteeOrganisation:
"""Get a single organisation by ID."""
interface = getInterface(currentUser)
interface = getInterface(context.user, mandateId=context.mandateId)
org = interface.getOrganisation(orgId)
if not org:
raise HTTPException(status_code=404, detail=f"Organisation {orgId} not found")
return TrusteeOrganisation(**org)
return org
@router.post("/organisations", response_model=TrusteeOrganisation, status_code=201)
@ -112,14 +111,14 @@ async def getOrganisation(
async def createOrganisation(
request: Request,
data: TrusteeOrganisation = Body(...),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> TrusteeOrganisation:
"""Create a new organisation."""
interface = getInterface(currentUser)
interface = getInterface(context.user, mandateId=context.mandateId)
result = interface.createOrganisation(data.model_dump())
if not result:
raise HTTPException(status_code=400, detail="Failed to create organisation")
return TrusteeOrganisation(**result)
return result
@router.put("/organisations/{orgId}", response_model=TrusteeOrganisation)
@ -128,10 +127,10 @@ async def updateOrganisation(
request: Request,
orgId: str = Path(..., description="Organisation ID"),
data: TrusteeOrganisation = Body(...),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> TrusteeOrganisation:
"""Update an organisation."""
interface = getInterface(currentUser)
interface = getInterface(context.user, mandateId=context.mandateId)
existing = interface.getOrganisation(orgId)
if not existing:
raise HTTPException(status_code=404, detail=f"Organisation {orgId} not found")
@ -139,7 +138,7 @@ async def updateOrganisation(
result = interface.updateOrganisation(orgId, data.model_dump(exclude={"id"}))
if not result:
raise HTTPException(status_code=400, detail="Failed to update organisation")
return TrusteeOrganisation(**result)
return result
@router.delete("/organisations/{orgId}")
@ -147,10 +146,10 @@ async def updateOrganisation(
async def deleteOrganisation(
request: Request,
orgId: str = Path(..., description="Organisation ID"),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""Delete an organisation."""
interface = getInterface(currentUser)
interface = getInterface(context.user, mandateId=context.mandateId)
existing = interface.getOrganisation(orgId)
if not existing:
raise HTTPException(status_code=404, detail=f"Organisation {orgId} not found")
@ -168,11 +167,11 @@ async def deleteOrganisation(
async def getRoles(
request: Request,
pagination: Optional[str] = Query(None),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> PaginatedResponse[TrusteeRole]:
"""Get all roles with optional pagination."""
paginationParams = _parsePagination(pagination)
interface = getInterface(currentUser)
interface = getInterface(context.user, mandateId=context.mandateId)
result = interface.getAllRoles(paginationParams)
if paginationParams:
@ -195,14 +194,14 @@ async def getRoles(
async def getRole(
request: Request,
roleId: str = Path(..., description="Role ID"),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> TrusteeRole:
"""Get a single role by ID."""
interface = getInterface(currentUser)
interface = getInterface(context.user, mandateId=context.mandateId)
role = interface.getRole(roleId)
if not role:
raise HTTPException(status_code=404, detail=f"Role {roleId} not found")
return TrusteeRole(**role)
return role
@router.post("/roles", response_model=TrusteeRole, status_code=201)
@ -210,14 +209,14 @@ async def getRole(
async def createRole(
request: Request,
data: TrusteeRole = Body(...),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> TrusteeRole:
"""Create a new role (sysadmin only)."""
interface = getInterface(currentUser)
interface = getInterface(context.user, mandateId=context.mandateId)
result = interface.createRole(data.model_dump())
if not result:
raise HTTPException(status_code=400, detail="Failed to create role")
return TrusteeRole(**result)
return result
@router.put("/roles/{roleId}", response_model=TrusteeRole)
@ -226,10 +225,10 @@ async def updateRole(
request: Request,
roleId: str = Path(...),
data: TrusteeRole = Body(...),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> TrusteeRole:
"""Update a role (sysadmin only)."""
interface = getInterface(currentUser)
interface = getInterface(context.user, mandateId=context.mandateId)
existing = interface.getRole(roleId)
if not existing:
raise HTTPException(status_code=404, detail=f"Role {roleId} not found")
@ -237,7 +236,7 @@ async def updateRole(
result = interface.updateRole(roleId, data.model_dump(exclude={"id"}))
if not result:
raise HTTPException(status_code=400, detail="Failed to update role")
return TrusteeRole(**result)
return result
@router.delete("/roles/{roleId}")
@ -245,10 +244,10 @@ async def updateRole(
async def deleteRole(
request: Request,
roleId: str = Path(...),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""Delete a role (sysadmin only, fails if in use)."""
interface = getInterface(currentUser)
interface = getInterface(context.user, mandateId=context.mandateId)
existing = interface.getRole(roleId)
if not existing:
raise HTTPException(status_code=404, detail=f"Role {roleId} not found")
@ -266,11 +265,11 @@ async def deleteRole(
async def getAllAccess(
request: Request,
pagination: Optional[str] = Query(None),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> PaginatedResponse[TrusteeAccess]:
"""Get all access records with optional pagination."""
paginationParams = _parsePagination(pagination)
interface = getInterface(currentUser)
interface = getInterface(context.user, mandateId=context.mandateId)
result = interface.getAllAccess(paginationParams)
if paginationParams:
@ -293,14 +292,14 @@ async def getAllAccess(
async def getAccess(
request: Request,
accessId: str = Path(...),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> TrusteeAccess:
"""Get a single access record by ID."""
interface = getInterface(currentUser)
interface = getInterface(context.user, mandateId=context.mandateId)
access = interface.getAccess(accessId)
if not access:
raise HTTPException(status_code=404, detail=f"Access {accessId} not found")
return TrusteeAccess(**access)
return access
@router.get("/access/organisation/{orgId}", response_model=List[TrusteeAccess])
@ -308,11 +307,11 @@ async def getAccess(
async def getAccessByOrganisation(
request: Request,
orgId: str = Path(...),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> List[TrusteeAccess]:
"""Get all access records for an organisation."""
interface = getInterface(currentUser)
return [TrusteeAccess(**a) for a in interface.getAccessByOrganisation(orgId)]
interface = getInterface(context.user, mandateId=context.mandateId)
return interface.getAccessByOrganisation(orgId)
@router.get("/access/user/{userId}", response_model=List[TrusteeAccess])
@ -320,11 +319,11 @@ async def getAccessByOrganisation(
async def getAccessByUser(
request: Request,
userId: str = Path(...),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> List[TrusteeAccess]:
"""Get all access records for a user."""
interface = getInterface(currentUser)
return [TrusteeAccess(**a) for a in interface.getAccessByUser(userId)]
interface = getInterface(context.user, mandateId=context.mandateId)
return interface.getAccessByUser(userId)
@router.post("/access", response_model=TrusteeAccess, status_code=201)
@ -332,14 +331,14 @@ async def getAccessByUser(
async def createAccess(
request: Request,
data: TrusteeAccess = Body(...),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> TrusteeAccess:
"""Create a new access record."""
interface = getInterface(currentUser)
interface = getInterface(context.user, mandateId=context.mandateId)
result = interface.createAccess(data.model_dump())
if not result:
raise HTTPException(status_code=400, detail="Failed to create access")
return TrusteeAccess(**result)
return result
@router.put("/access/{accessId}", response_model=TrusteeAccess)
@ -348,10 +347,10 @@ async def updateAccess(
request: Request,
accessId: str = Path(...),
data: TrusteeAccess = Body(...),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> TrusteeAccess:
"""Update an access record."""
interface = getInterface(currentUser)
interface = getInterface(context.user, mandateId=context.mandateId)
existing = interface.getAccess(accessId)
if not existing:
raise HTTPException(status_code=404, detail=f"Access {accessId} not found")
@ -359,7 +358,7 @@ async def updateAccess(
result = interface.updateAccess(accessId, data.model_dump(exclude={"id"}))
if not result:
raise HTTPException(status_code=400, detail="Failed to update access")
return TrusteeAccess(**result)
return result
@router.delete("/access/{accessId}")
@ -367,10 +366,10 @@ async def updateAccess(
async def deleteAccess(
request: Request,
accessId: str = Path(...),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""Delete an access record."""
interface = getInterface(currentUser)
interface = getInterface(context.user, mandateId=context.mandateId)
existing = interface.getAccess(accessId)
if not existing:
raise HTTPException(status_code=404, detail=f"Access {accessId} not found")
@ -388,11 +387,11 @@ async def deleteAccess(
async def getContracts(
request: Request,
pagination: Optional[str] = Query(None),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> PaginatedResponse[TrusteeContract]:
"""Get all contracts with optional pagination."""
paginationParams = _parsePagination(pagination)
interface = getInterface(currentUser)
interface = getInterface(context.user, mandateId=context.mandateId)
result = interface.getAllContracts(paginationParams)
if paginationParams:
@ -415,14 +414,14 @@ async def getContracts(
async def getContract(
request: Request,
contractId: str = Path(...),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> TrusteeContract:
"""Get a single contract by ID."""
interface = getInterface(currentUser)
interface = getInterface(context.user, mandateId=context.mandateId)
contract = interface.getContract(contractId)
if not contract:
raise HTTPException(status_code=404, detail=f"Contract {contractId} not found")
return TrusteeContract(**contract)
return contract
@router.get("/contracts/organisation/{orgId}", response_model=List[TrusteeContract])
@ -430,11 +429,11 @@ async def getContract(
async def getContractsByOrganisation(
request: Request,
orgId: str = Path(...),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> List[TrusteeContract]:
"""Get all contracts for an organisation."""
interface = getInterface(currentUser)
return [TrusteeContract(**c) for c in interface.getContractsByOrganisation(orgId)]
interface = getInterface(context.user, mandateId=context.mandateId)
return interface.getContractsByOrganisation(orgId)
@router.post("/contracts", response_model=TrusteeContract, status_code=201)
@ -442,14 +441,14 @@ async def getContractsByOrganisation(
async def createContract(
request: Request,
data: TrusteeContract = Body(...),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> TrusteeContract:
"""Create a new contract."""
interface = getInterface(currentUser)
interface = getInterface(context.user, mandateId=context.mandateId)
result = interface.createContract(data.model_dump())
if not result:
raise HTTPException(status_code=400, detail="Failed to create contract")
return TrusteeContract(**result)
return result
@router.put("/contracts/{contractId}", response_model=TrusteeContract)
@ -458,10 +457,10 @@ async def updateContract(
request: Request,
contractId: str = Path(...),
data: TrusteeContract = Body(...),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> TrusteeContract:
"""Update a contract (organisationId is immutable)."""
interface = getInterface(currentUser)
interface = getInterface(context.user, mandateId=context.mandateId)
existing = interface.getContract(contractId)
if not existing:
raise HTTPException(status_code=404, detail=f"Contract {contractId} not found")
@ -469,7 +468,7 @@ async def updateContract(
result = interface.updateContract(contractId, data.model_dump(exclude={"id"}))
if not result:
raise HTTPException(status_code=400, detail="Failed to update contract (organisationId cannot be changed)")
return TrusteeContract(**result)
return result
@router.delete("/contracts/{contractId}")
@ -477,10 +476,10 @@ async def updateContract(
async def deleteContract(
request: Request,
contractId: str = Path(...),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""Delete a contract."""
interface = getInterface(currentUser)
interface = getInterface(context.user, mandateId=context.mandateId)
existing = interface.getContract(contractId)
if not existing:
raise HTTPException(status_code=404, detail=f"Contract {contractId} not found")
@ -498,11 +497,11 @@ async def deleteContract(
async def getDocuments(
request: Request,
pagination: Optional[str] = Query(None),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> PaginatedResponse[TrusteeDocument]:
"""Get all documents (metadata only) with optional pagination."""
paginationParams = _parsePagination(pagination)
interface = getInterface(currentUser)
interface = getInterface(context.user, mandateId=context.mandateId)
result = interface.getAllDocuments(paginationParams)
if paginationParams:
@ -525,14 +524,14 @@ async def getDocuments(
async def getDocument(
request: Request,
documentId: str = Path(...),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> TrusteeDocument:
"""Get document metadata by ID."""
interface = getInterface(currentUser)
interface = getInterface(context.user, mandateId=context.mandateId)
doc = interface.getDocument(documentId)
if not doc:
raise HTTPException(status_code=404, detail=f"Document {documentId} not found")
return TrusteeDocument(**doc)
return doc
@router.get("/documents/{documentId}/data")
@ -540,10 +539,10 @@ async def getDocument(
async def getDocumentData(
request: Request,
documentId: str = Path(...),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
):
"""Download document binary data."""
interface = getInterface(currentUser)
interface = getInterface(context.user, mandateId=context.mandateId)
doc = interface.getDocument(documentId)
if not doc:
raise HTTPException(status_code=404, detail=f"Document {documentId} not found")
@ -554,8 +553,8 @@ async def getDocumentData(
return StreamingResponse(
io.BytesIO(data),
media_type=doc.get("documentMimeType", "application/octet-stream"),
headers={"Content-Disposition": f"attachment; filename={doc.get('documentName', 'document')}"}
media_type=doc.documentMimeType or "application/octet-stream",
headers={"Content-Disposition": f"attachment; filename={doc.documentName or 'document'}"}
)
@ -564,11 +563,11 @@ async def getDocumentData(
async def getDocumentsByContract(
request: Request,
contractId: str = Path(...),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> List[TrusteeDocument]:
"""Get all documents for a contract."""
interface = getInterface(currentUser)
return [TrusteeDocument(**d) for d in interface.getDocumentsByContract(contractId)]
interface = getInterface(context.user, mandateId=context.mandateId)
return interface.getDocumentsByContract(contractId)
@router.post("/documents", response_model=TrusteeDocument, status_code=201)
@ -576,14 +575,14 @@ async def getDocumentsByContract(
async def createDocument(
request: Request,
data: TrusteeDocument = Body(...),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> TrusteeDocument:
"""Create a new document."""
interface = getInterface(currentUser)
interface = getInterface(context.user, mandateId=context.mandateId)
result = interface.createDocument(data.model_dump())
if not result:
raise HTTPException(status_code=400, detail="Failed to create document")
return TrusteeDocument(**result)
return result
@router.put("/documents/{documentId}", response_model=TrusteeDocument)
@ -592,10 +591,10 @@ async def updateDocument(
request: Request,
documentId: str = Path(...),
data: TrusteeDocument = Body(...),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> TrusteeDocument:
"""Update document metadata."""
interface = getInterface(currentUser)
interface = getInterface(context.user, mandateId=context.mandateId)
existing = interface.getDocument(documentId)
if not existing:
raise HTTPException(status_code=404, detail=f"Document {documentId} not found")
@ -603,7 +602,7 @@ async def updateDocument(
result = interface.updateDocument(documentId, data.model_dump(exclude={"id"}))
if not result:
raise HTTPException(status_code=400, detail="Failed to update document")
return TrusteeDocument(**result)
return result
@router.delete("/documents/{documentId}")
@ -611,10 +610,10 @@ async def updateDocument(
async def deleteDocument(
request: Request,
documentId: str = Path(...),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""Delete a document."""
interface = getInterface(currentUser)
interface = getInterface(context.user, mandateId=context.mandateId)
existing = interface.getDocument(documentId)
if not existing:
raise HTTPException(status_code=404, detail=f"Document {documentId} not found")
@ -632,11 +631,11 @@ async def deleteDocument(
async def getPositions(
request: Request,
pagination: Optional[str] = Query(None),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> PaginatedResponse[TrusteePosition]:
"""Get all positions with optional pagination."""
paginationParams = _parsePagination(pagination)
interface = getInterface(currentUser)
interface = getInterface(context.user, mandateId=context.mandateId)
result = interface.getAllPositions(paginationParams)
if paginationParams:
@ -659,14 +658,14 @@ async def getPositions(
async def getPosition(
request: Request,
positionId: str = Path(...),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> TrusteePosition:
"""Get a single position by ID."""
interface = getInterface(currentUser)
interface = getInterface(context.user, mandateId=context.mandateId)
position = interface.getPosition(positionId)
if not position:
raise HTTPException(status_code=404, detail=f"Position {positionId} not found")
return TrusteePosition(**position)
return position
@router.get("/positions/contract/{contractId}", response_model=List[TrusteePosition])
@ -674,11 +673,11 @@ async def getPosition(
async def getPositionsByContract(
request: Request,
contractId: str = Path(...),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> List[TrusteePosition]:
"""Get all positions for a contract."""
interface = getInterface(currentUser)
return [TrusteePosition(**p) for p in interface.getPositionsByContract(contractId)]
interface = getInterface(context.user, mandateId=context.mandateId)
return interface.getPositionsByContract(contractId)
@router.get("/positions/organisation/{orgId}", response_model=List[TrusteePosition])
@ -686,11 +685,11 @@ async def getPositionsByContract(
async def getPositionsByOrganisation(
request: Request,
orgId: str = Path(...),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> List[TrusteePosition]:
"""Get all positions for an organisation."""
interface = getInterface(currentUser)
return [TrusteePosition(**p) for p in interface.getPositionsByOrganisation(orgId)]
interface = getInterface(context.user, mandateId=context.mandateId)
return interface.getPositionsByOrganisation(orgId)
@router.post("/positions", response_model=TrusteePosition, status_code=201)
@ -698,14 +697,14 @@ async def getPositionsByOrganisation(
async def createPosition(
request: Request,
data: TrusteePosition = Body(...),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> TrusteePosition:
"""Create a new position."""
interface = getInterface(currentUser)
interface = getInterface(context.user, mandateId=context.mandateId)
result = interface.createPosition(data.model_dump())
if not result:
raise HTTPException(status_code=400, detail="Failed to create position")
return TrusteePosition(**result)
return result
@router.put("/positions/{positionId}", response_model=TrusteePosition)
@ -714,10 +713,10 @@ async def updatePosition(
request: Request,
positionId: str = Path(...),
data: TrusteePosition = Body(...),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> TrusteePosition:
"""Update a position."""
interface = getInterface(currentUser)
interface = getInterface(context.user, mandateId=context.mandateId)
existing = interface.getPosition(positionId)
if not existing:
raise HTTPException(status_code=404, detail=f"Position {positionId} not found")
@ -725,7 +724,7 @@ async def updatePosition(
result = interface.updatePosition(positionId, data.model_dump(exclude={"id"}))
if not result:
raise HTTPException(status_code=400, detail="Failed to update position")
return TrusteePosition(**result)
return result
@router.delete("/positions/{positionId}")
@ -733,10 +732,10 @@ async def updatePosition(
async def deletePosition(
request: Request,
positionId: str = Path(...),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""Delete a position."""
interface = getInterface(currentUser)
interface = getInterface(context.user, mandateId=context.mandateId)
existing = interface.getPosition(positionId)
if not existing:
raise HTTPException(status_code=404, detail=f"Position {positionId} not found")
@ -754,11 +753,11 @@ async def deletePosition(
async def getPositionDocuments(
request: Request,
pagination: Optional[str] = Query(None),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> PaginatedResponse[TrusteePositionDocument]:
"""Get all position-document links with optional pagination."""
paginationParams = _parsePagination(pagination)
interface = getInterface(currentUser)
interface = getInterface(context.user, mandateId=context.mandateId)
result = interface.getAllPositionDocuments(paginationParams)
if paginationParams:
@ -781,14 +780,14 @@ async def getPositionDocuments(
async def getPositionDocument(
request: Request,
linkId: str = Path(...),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> TrusteePositionDocument:
"""Get a single position-document link by ID."""
interface = getInterface(currentUser)
interface = getInterface(context.user, mandateId=context.mandateId)
link = interface.getPositionDocument(linkId)
if not link:
raise HTTPException(status_code=404, detail=f"Link {linkId} not found")
return TrusteePositionDocument(**link)
return link
@router.get("/position-documents/position/{positionId}", response_model=List[TrusteePositionDocument])
@ -796,11 +795,11 @@ async def getPositionDocument(
async def getDocumentsForPosition(
request: Request,
positionId: str = Path(...),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> List[TrusteePositionDocument]:
"""Get all document links for a position."""
interface = getInterface(currentUser)
return [TrusteePositionDocument(**l) for l in interface.getDocumentsForPosition(positionId)]
interface = getInterface(context.user, mandateId=context.mandateId)
return interface.getDocumentsForPosition(positionId)
@router.get("/position-documents/document/{documentId}", response_model=List[TrusteePositionDocument])
@ -808,11 +807,11 @@ async def getDocumentsForPosition(
async def getPositionsForDocument(
request: Request,
documentId: str = Path(...),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> List[TrusteePositionDocument]:
"""Get all position links for a document."""
interface = getInterface(currentUser)
return [TrusteePositionDocument(**l) for l in interface.getPositionsForDocument(documentId)]
interface = getInterface(context.user, mandateId=context.mandateId)
return interface.getPositionsForDocument(documentId)
@router.post("/position-documents", response_model=TrusteePositionDocument, status_code=201)
@ -820,14 +819,14 @@ async def getPositionsForDocument(
async def createPositionDocument(
request: Request,
data: TrusteePositionDocument = Body(...),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> TrusteePositionDocument:
"""Create a new position-document link."""
interface = getInterface(currentUser)
interface = getInterface(context.user, mandateId=context.mandateId)
result = interface.createPositionDocument(data.model_dump())
if not result:
raise HTTPException(status_code=400, detail="Failed to create link")
return TrusteePositionDocument(**result)
return result
@router.delete("/position-documents/{linkId}")
@ -835,10 +834,10 @@ async def createPositionDocument(
async def deletePositionDocument(
request: Request,
linkId: str = Path(...),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""Delete a position-document link."""
interface = getInterface(currentUser)
interface = getInterface(context.user, mandateId=context.mandateId)
existing = interface.getPositionDocument(linkId)
if not existing:
raise HTTPException(status_code=404, detail=f"Link {linkId} not found")

View file

@ -0,0 +1,625 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Feature management routes for the backend API.
Implements endpoints for Feature and FeatureInstance management.
Multi-Tenant Design:
- Feature definitions are global (SysAdmin can manage)
- FeatureInstances belong to mandates (Mandate Admin can manage)
- Template roles are copied on instance creation
"""
from fastapi import APIRouter, HTTPException, Depends, Request, Query
from typing import List, Dict, Any, Optional
from fastapi import status
import logging
from pydantic import BaseModel, Field
from modules.auth import limiter, getRequestContext, RequestContext, requireSysAdmin
from modules.datamodels.datamodelUam import User
from modules.datamodels.datamodelFeatures import Feature, FeatureInstance
from modules.interfaces.interfaceDbAppObjects import getRootInterface
from modules.interfaces.interfaceFeatures import getFeatureInterface
logger = logging.getLogger(__name__)
router = APIRouter(
prefix="/api/features",
tags=["Features"],
responses={404: {"description": "Not found"}}
)
# =============================================================================
# Request/Response Models
# =============================================================================
class FeatureInstanceCreate(BaseModel):
"""Request model for creating a feature instance"""
featureCode: str = Field(..., description="Feature code (e.g., 'trustee', 'chatbot')")
label: str = Field(..., description="Instance label (e.g., 'Buchhaltung 2025')")
copyTemplateRoles: bool = Field(True, description="Whether to copy template roles on creation")
class FeatureInstanceResponse(BaseModel):
"""Response model for feature instance"""
id: str
featureCode: str
mandateId: str
label: str
enabled: bool
class SyncRolesResult(BaseModel):
"""Response model for role synchronization"""
added: int
removed: int
unchanged: int
# =============================================================================
# Feature Endpoints (Global - mostly read-only for non-SysAdmin)
# =============================================================================
@router.get("/", response_model=List[Dict[str, Any]])
@limiter.limit("60/minute")
async def listFeatures(
request: Request,
context: RequestContext = Depends(getRequestContext)
) -> List[Dict[str, Any]]:
"""
List all available features.
Returns global feature definitions that can be activated for mandates.
Any authenticated user can see available features.
"""
try:
rootInterface = getRootInterface()
featureInterface = getFeatureInterface(rootInterface.db)
features = featureInterface.getAllFeatures()
return [f.model_dump() for f in features]
except Exception as e:
logger.error(f"Error listing features: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to list features: {str(e)}"
)
@router.get("/{featureCode}", response_model=Dict[str, Any])
@limiter.limit("60/minute")
async def getFeature(
request: Request,
featureCode: str,
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""
Get a specific feature by code.
Args:
featureCode: Feature code (e.g., 'trustee', 'chatbot')
"""
try:
rootInterface = getRootInterface()
featureInterface = getFeatureInterface(rootInterface.db)
feature = featureInterface.getFeature(featureCode)
if not feature:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Feature '{featureCode}' not found"
)
return feature.model_dump()
except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting feature {featureCode}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get feature: {str(e)}"
)
@router.post("/", response_model=Dict[str, Any])
@limiter.limit("10/minute")
async def createFeature(
request: Request,
code: str = Query(..., description="Unique feature code"),
label: Dict[str, str] = None,
icon: str = Query("mdi-puzzle", description="Icon identifier"),
sysAdmin: User = Depends(requireSysAdmin)
) -> Dict[str, Any]:
"""
Create a new feature definition.
SysAdmin only - creates a global feature that can be activated for mandates.
Args:
code: Unique feature code (e.g., 'trustee')
label: I18n labels (e.g., {"en": "Trustee", "de": "Treuhand"})
icon: Icon identifier
"""
try:
rootInterface = getRootInterface()
featureInterface = getFeatureInterface(rootInterface.db)
# Check if feature already exists
existing = featureInterface.getFeature(code)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Feature '{code}' already exists"
)
feature = featureInterface.createFeature(
code=code,
label=label or {"en": code.title(), "de": code.title()},
icon=icon
)
logger.info(f"SysAdmin {sysAdmin.id} created feature '{code}'")
return feature.model_dump()
except HTTPException:
raise
except Exception as e:
logger.error(f"Error creating feature: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to create feature: {str(e)}"
)
# =============================================================================
# Feature Instance Endpoints (Mandate-scoped)
# =============================================================================
@router.get("/instances", response_model=List[Dict[str, Any]])
@limiter.limit("60/minute")
async def listFeatureInstances(
request: Request,
featureCode: Optional[str] = Query(None, description="Filter by feature code"),
context: RequestContext = Depends(getRequestContext)
) -> List[Dict[str, Any]]:
"""
List feature instances for the current mandate.
Returns instances the user has access to within the selected mandate.
Args:
featureCode: Optional filter by feature code
"""
if not context.mandateId:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="X-Mandate-Id header is required"
)
try:
rootInterface = getRootInterface()
featureInterface = getFeatureInterface(rootInterface.db)
instances = featureInterface.getFeatureInstancesForMandate(
mandateId=str(context.mandateId),
featureCode=featureCode
)
return [inst.model_dump() for inst in instances]
except HTTPException:
raise
except Exception as e:
logger.error(f"Error listing feature instances: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to list feature instances: {str(e)}"
)
@router.get("/instances/{instanceId}", response_model=Dict[str, Any])
@limiter.limit("60/minute")
async def getFeatureInstance(
request: Request,
instanceId: str,
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""
Get a specific feature instance.
Args:
instanceId: FeatureInstance ID
"""
try:
rootInterface = getRootInterface()
featureInterface = getFeatureInterface(rootInterface.db)
instance = featureInterface.getFeatureInstance(instanceId)
if not instance:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Feature instance '{instanceId}' not found"
)
# Verify mandate access (unless SysAdmin)
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
if not context.isSysAdmin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this feature instance"
)
return instance.model_dump()
except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting feature instance {instanceId}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get feature instance: {str(e)}"
)
@router.post("/instances", response_model=Dict[str, Any])
@limiter.limit("10/minute")
async def createFeatureInstance(
request: Request,
data: FeatureInstanceCreate,
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""
Create a new feature instance for the current mandate.
Requires Mandate-Admin role. Template roles are optionally copied.
Args:
data: Feature instance creation data
"""
if not context.mandateId:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="X-Mandate-Id header is required"
)
# Check mandate admin permission
if not _hasMandateAdminRole(context):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Mandate-Admin role required to create feature instances"
)
try:
rootInterface = getRootInterface()
featureInterface = getFeatureInterface(rootInterface.db)
# Verify feature exists
feature = featureInterface.getFeature(data.featureCode)
if not feature:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Feature '{data.featureCode}' not found"
)
instance = featureInterface.createFeatureInstance(
featureCode=data.featureCode,
mandateId=str(context.mandateId),
label=data.label,
copyTemplateRoles=data.copyTemplateRoles
)
logger.info(
f"User {context.user.id} created feature instance '{data.label}' "
f"for feature '{data.featureCode}' in mandate {context.mandateId}"
)
return instance.model_dump()
except HTTPException:
raise
except Exception as e:
logger.error(f"Error creating feature instance: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to create feature instance: {str(e)}"
)
@router.delete("/instances/{instanceId}", response_model=Dict[str, str])
@limiter.limit("10/minute")
async def deleteFeatureInstance(
request: Request,
instanceId: str,
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, str]:
"""
Delete a feature instance.
Requires Mandate-Admin role. CASCADE will delete associated roles and access records.
Args:
instanceId: FeatureInstance ID
"""
try:
rootInterface = getRootInterface()
featureInterface = getFeatureInterface(rootInterface.db)
# Get instance to verify access
instance = featureInterface.getFeatureInstance(instanceId)
if not instance:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Feature instance '{instanceId}' not found"
)
# Verify mandate access
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
if not context.isSysAdmin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this feature instance"
)
# Check mandate admin permission
if not _hasMandateAdminRole(context) and not context.isSysAdmin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Mandate-Admin role required to delete feature instances"
)
featureInterface.deleteFeatureInstance(instanceId)
logger.info(f"User {context.user.id} deleted feature instance {instanceId}")
return {"message": "Feature instance deleted", "instanceId": instanceId}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error deleting feature instance {instanceId}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to delete feature instance: {str(e)}"
)
@router.post("/instances/{instanceId}/sync-roles", response_model=SyncRolesResult)
@limiter.limit("10/minute")
async def syncInstanceRoles(
request: Request,
instanceId: str,
addOnly: bool = Query(True, description="Only add missing roles, don't remove extras"),
context: RequestContext = Depends(getRequestContext)
) -> SyncRolesResult:
"""
Synchronize roles of a feature instance with current templates.
IMPORTANT: Templates are only copied when a FeatureInstance is created.
This sync function is for manual re-synchronization, not automatic propagation.
Args:
instanceId: FeatureInstance ID
addOnly: If True, only add missing roles. If False, also remove extras.
"""
try:
rootInterface = getRootInterface()
featureInterface = getFeatureInterface(rootInterface.db)
# Get instance to verify access
instance = featureInterface.getFeatureInstance(instanceId)
if not instance:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Feature instance '{instanceId}' not found"
)
# Verify mandate access
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
if not context.isSysAdmin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this feature instance"
)
# Check admin permission (Mandate-Admin or Feature-Admin)
if not _hasMandateAdminRole(context) and not context.isSysAdmin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin role required to sync roles"
)
result = featureInterface.syncRolesFromTemplate(instanceId, addOnly)
logger.info(
f"User {context.user.id} synced roles for instance {instanceId}: {result}"
)
return SyncRolesResult(**result)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error syncing roles for instance {instanceId}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to sync roles: {str(e)}"
)
# =============================================================================
# Template Role Endpoints (SysAdmin only)
# =============================================================================
@router.get("/templates/roles", response_model=List[Dict[str, Any]])
@limiter.limit("60/minute")
async def listTemplateRoles(
request: Request,
featureCode: Optional[str] = Query(None, description="Filter by feature code"),
sysAdmin: User = Depends(requireSysAdmin)
) -> List[Dict[str, Any]]:
"""
List global template roles.
SysAdmin only - returns template roles that are copied to new feature instances.
Args:
featureCode: Optional filter by feature code
"""
try:
rootInterface = getRootInterface()
featureInterface = getFeatureInterface(rootInterface.db)
roles = featureInterface.getTemplateRoles(featureCode)
return [r.model_dump() for r in roles]
except Exception as e:
logger.error(f"Error listing template roles: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to list template roles: {str(e)}"
)
@router.post("/templates/roles", response_model=Dict[str, Any])
@limiter.limit("10/minute")
async def createTemplateRole(
request: Request,
roleLabel: str = Query(..., description="Role label (e.g., 'admin', 'viewer')"),
featureCode: str = Query(..., description="Feature code this role belongs to"),
description: Dict[str, str] = None,
sysAdmin: User = Depends(requireSysAdmin)
) -> Dict[str, Any]:
"""
Create a global template role for a feature.
SysAdmin only - new template roles are NOT automatically propagated to existing instances.
Use the sync-roles endpoint to manually synchronize.
Args:
roleLabel: Role label
featureCode: Feature code
description: I18n descriptions
"""
try:
rootInterface = getRootInterface()
featureInterface = getFeatureInterface(rootInterface.db)
# Verify feature exists
feature = featureInterface.getFeature(featureCode)
if not feature:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Feature '{featureCode}' not found"
)
role = featureInterface.createTemplateRole(
roleLabel=roleLabel,
featureCode=featureCode,
description=description
)
logger.info(
f"SysAdmin {sysAdmin.id} created template role '{roleLabel}' "
f"for feature '{featureCode}'"
)
return role.model_dump()
except HTTPException:
raise
except Exception as e:
logger.error(f"Error creating template role: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to create template role: {str(e)}"
)
# =============================================================================
# My Feature Instances (No mandate context needed)
# =============================================================================
@router.get("/my", response_model=List[Dict[str, Any]])
@limiter.limit("60/minute")
async def getMyFeatureInstances(
request: Request,
context: RequestContext = Depends(getRequestContext)
) -> List[Dict[str, Any]]:
"""
Get all feature instances the current user has access to.
Returns instances across all mandates the user is member of.
This endpoint does not require X-Mandate-Id header.
"""
try:
rootInterface = getRootInterface()
# Get all feature accesses for this user
featureAccesses = rootInterface.getFeatureAccessesForUser(str(context.user.id))
if not featureAccesses:
return []
featureInterface = getFeatureInterface(rootInterface.db)
result = []
for access in featureAccesses:
if not access.enabled:
continue
instance = featureInterface.getFeatureInstance(str(access.featureInstanceId))
if instance and instance.enabled:
result.append({
**instance.model_dump(),
"accessId": str(access.id)
})
return result
except Exception as e:
logger.error(f"Error getting user's feature instances: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get feature instances: {str(e)}"
)
# =============================================================================
# Helper Functions
# =============================================================================
def _hasMandateAdminRole(context: RequestContext) -> bool:
"""
Check if the user has mandate admin role in the current context.
A user is mandate admin if they have the 'admin' role at mandate level.
"""
if context.isSysAdmin:
return True
if not context.roleIds:
return False
# Check if any of the user's roles is an admin role
try:
rootInterface = getRootInterface()
from modules.datamodels.datamodelRbac import Role
for roleId in context.roleIds:
roleRecords = rootInterface.db.getRecordset(Role, recordFilter={"id": roleId})
if roleRecords:
role = roleRecords[0]
roleLabel = role.get("roleLabel", "")
# Admin role at mandate level (not feature-instance level)
if roleLabel == "admin" and role.get("mandateId") and not role.get("featureInstanceId"):
return True
return False
except Exception as e:
logger.error(f"Error checking mandate admin role: {e}")
return False

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

@ -0,0 +1,514 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
GDPR compliance routes for the backend API.
Implements data subject rights according to GDPR regulations.
GDPR Articles implemented:
- Article 15: Right of access (data export)
- Article 16: Right to rectification (via existing update endpoints)
- Article 17: Right to erasure (account deletion)
- Article 20: Right to data portability (machine-readable export)
"""
from fastapi import APIRouter, HTTPException, Depends, Request
from fastapi.responses import JSONResponse
from typing import List, Dict, Any, Optional
from fastapi import status
import logging
import json
from pydantic import BaseModel, Field
from modules.auth import limiter, getCurrentUser
from modules.datamodels.datamodelUam import User
from modules.interfaces.interfaceDbAppObjects import getRootInterface
from modules.shared.timeUtils import getUtcTimestamp
from modules.shared.auditLogger import audit_logger
logger = logging.getLogger(__name__)
router = APIRouter(
prefix="/api/user/me",
tags=["GDPR"],
responses={404: {"description": "Not found"}}
)
# =============================================================================
# Response Models
# =============================================================================
class DataExportResponse(BaseModel):
"""Response model for GDPR data export"""
exportedAt: float
userId: str
userData: Dict[str, Any]
mandates: List[Dict[str, Any]]
featureAccesses: List[Dict[str, Any]]
invitationsCreated: List[Dict[str, Any]]
invitationsUsed: List[Dict[str, Any]]
class DataPortabilityResponse(BaseModel):
"""Machine-readable data portability response (JSON-LD format)"""
context: str = Field(alias="@context")
type: str = Field(alias="@type")
identifier: str
exportDate: str
data: Dict[str, Any]
class DeletionResult(BaseModel):
"""Result of account deletion"""
success: bool
userId: str
deletedAt: float
deletedData: List[str]
message: str
# =============================================================================
# Article 15: Right of Access
# =============================================================================
@router.get("/data-export", response_model=DataExportResponse)
@limiter.limit("5/minute")
async def exportUserData(
request: Request,
currentUser: User = Depends(getCurrentUser)
) -> DataExportResponse:
"""
Export all personal data (GDPR Article 15).
Returns all data associated with the authenticated user including:
- User profile data
- Mandate memberships
- Feature access records
- Invitations created and used
Note: This exports Gateway-level data only. Feature-specific data
(e.g., chat workflows, trustee contracts) should be exported via
feature-specific endpoints.
"""
try:
rootInterface = getRootInterface()
# User data (exclude sensitive fields)
userData = {
"id": str(currentUser.id),
"username": currentUser.username,
"email": currentUser.email,
"firstname": currentUser.firstname,
"lastname": currentUser.lastname,
"enabled": currentUser.enabled,
"isSysAdmin": getattr(currentUser, "isSysAdmin", False),
"createdAt": getattr(currentUser, "createdAt", None),
"updatedAt": getattr(currentUser, "updatedAt", None),
"lastLogin": getattr(currentUser, "lastLogin", None),
"language": getattr(currentUser, "language", None),
"authenticationAuthority": str(getattr(currentUser, "authenticationAuthority", ""))
}
# Mandate memberships
from modules.datamodels.datamodelMembership import UserMandate
userMandates = rootInterface.db.getRecordset(
UserMandate,
recordFilter={"userId": str(currentUser.id)}
)
mandates = []
for um in userMandates:
mandateId = um.get("mandateId")
# Get mandate details
from modules.datamodels.datamodelUam import Mandate
mandateRecords = rootInterface.db.getRecordset(
Mandate,
recordFilter={"id": mandateId}
)
mandateName = mandateRecords[0].get("name") if mandateRecords else "Unknown"
# Get roles for this membership
roleIds = rootInterface.getRoleIdsForUserMandate(um.get("id"))
mandates.append({
"userMandateId": um.get("id"),
"mandateId": mandateId,
"mandateName": mandateName,
"enabled": um.get("enabled", True),
"roleIds": roleIds,
"joinedAt": um.get("createdAt")
})
# Feature access records
from modules.datamodels.datamodelMembership import FeatureAccess
featureAccesses = rootInterface.db.getRecordset(
FeatureAccess,
recordFilter={"userId": str(currentUser.id)}
)
featureAccessList = []
for fa in featureAccesses:
instanceId = fa.get("featureInstanceId")
# Get instance details
from modules.datamodels.datamodelFeatures import FeatureInstance
instanceRecords = rootInterface.db.getRecordset(
FeatureInstance,
recordFilter={"id": instanceId}
)
instanceInfo = instanceRecords[0] if instanceRecords else {}
roleIds = rootInterface.getRoleIdsForFeatureAccess(fa.get("id"))
featureAccessList.append({
"featureAccessId": fa.get("id"),
"featureInstanceId": instanceId,
"featureCode": instanceInfo.get("featureCode"),
"instanceLabel": instanceInfo.get("label"),
"enabled": fa.get("enabled", True),
"roleIds": roleIds
})
# Invitations created by user
from modules.datamodels.datamodelInvitation import Invitation
invitationsCreated = rootInterface.db.getRecordset(
Invitation,
recordFilter={"createdBy": str(currentUser.id)}
)
invitationsCreatedList = [
{
"id": inv.get("id"),
"mandateId": inv.get("mandateId"),
"createdAt": inv.get("createdAt"),
"expiresAt": inv.get("expiresAt"),
"maxUses": inv.get("maxUses"),
"currentUses": inv.get("currentUses")
}
for inv in invitationsCreated
]
# Invitations used by user
invitationsUsed = rootInterface.db.getRecordset(
Invitation,
recordFilter={"usedBy": str(currentUser.id)}
)
invitationsUsedList = [
{
"id": inv.get("id"),
"mandateId": inv.get("mandateId"),
"usedAt": inv.get("usedAt")
}
for inv in invitationsUsed
]
# Audit log
audit_logger.logSecurityEvent(
userId=str(currentUser.id),
mandateId="system",
action="gdpr_data_export",
details="User requested data export (Article 15)"
)
logger.info(f"User {currentUser.id} exported personal data (GDPR Art. 15)")
return DataExportResponse(
exportedAt=getUtcTimestamp(),
userId=str(currentUser.id),
userData=userData,
mandates=mandates,
featureAccesses=featureAccessList,
invitationsCreated=invitationsCreatedList,
invitationsUsed=invitationsUsedList
)
except Exception as e:
logger.error(f"Error exporting user data: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to export data: {str(e)}"
)
# =============================================================================
# Article 20: Right to Data Portability
# =============================================================================
@router.get("/data-portability")
@limiter.limit("5/minute")
async def exportPortableData(
request: Request,
currentUser: User = Depends(getCurrentUser)
) -> JSONResponse:
"""
Export data in portable, machine-readable format (GDPR Article 20).
Returns data in JSON-LD format suitable for transfer to another service.
This is a structured format that can be easily parsed by machines.
"""
try:
# Get full export data first
rootInterface = getRootInterface()
# Build portable data structure
portableData = {
"@context": "https://schema.org",
"@type": "Person",
"identifier": str(currentUser.id),
"name": f"{currentUser.firstname or ''} {currentUser.lastname or ''}".strip() or currentUser.username,
"email": currentUser.email,
"additionalProperty": []
}
# Add profile properties
if currentUser.firstname:
portableData["givenName"] = currentUser.firstname
if currentUser.lastname:
portableData["familyName"] = currentUser.lastname
# Add mandate memberships as organization affiliations
from modules.datamodels.datamodelMembership import UserMandate
userMandates = rootInterface.db.getRecordset(
UserMandate,
recordFilter={"userId": str(currentUser.id)}
)
affiliations = []
for um in userMandates:
from modules.datamodels.datamodelUam import Mandate
mandateRecords = rootInterface.db.getRecordset(
Mandate,
recordFilter={"id": um.get("mandateId")}
)
if mandateRecords:
mandate = mandateRecords[0]
affiliations.append({
"@type": "Organization",
"identifier": um.get("mandateId"),
"name": mandate.get("name"),
"membershipActive": um.get("enabled", True)
})
if affiliations:
portableData["affiliation"] = affiliations
# Wrap in export envelope
exportEnvelope = {
"@context": "https://schema.org",
"@type": "DataDownload",
"identifier": f"export-{currentUser.id}-{int(getUtcTimestamp())}",
"dateCreated": _timestampToIso(getUtcTimestamp()),
"encodingFormat": "application/ld+json",
"about": portableData
}
# Audit log
audit_logger.logSecurityEvent(
userId=str(currentUser.id),
mandateId="system",
action="gdpr_data_portability",
details="User requested portable data export (Article 20)"
)
logger.info(f"User {currentUser.id} exported portable data (GDPR Art. 20)")
return JSONResponse(
content=exportEnvelope,
media_type="application/ld+json"
)
except Exception as e:
logger.error(f"Error exporting portable data: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to export data: {str(e)}"
)
# =============================================================================
# Article 17: Right to Erasure
# =============================================================================
@router.delete("/", response_model=DeletionResult)
@limiter.limit("1/hour")
async def deleteAccount(
request: Request,
confirmDeletion: bool = False,
currentUser: User = Depends(getCurrentUser)
) -> DeletionResult:
"""
Delete own account and all associated data (GDPR Article 17).
IMPORTANT: This action is irreversible!
- All user data will be permanently deleted
- All mandate memberships will be removed
- All feature accesses will be removed
- All created invitations will be revoked
Args:
confirmDeletion: Must be True to confirm deletion
"""
if not confirmDeletion:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Deletion not confirmed. Set confirmDeletion=true to proceed."
)
# Prevent SysAdmin self-deletion (safety measure)
if getattr(currentUser, "isSysAdmin", False):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="SysAdmin accounts cannot be self-deleted. Contact another SysAdmin."
)
try:
rootInterface = getRootInterface()
deletedData = []
# 1. Revoke all invitations created by user
from modules.datamodels.datamodelInvitation import Invitation
userInvitations = rootInterface.db.getRecordset(
Invitation,
recordFilter={"createdBy": str(currentUser.id)}
)
for inv in userInvitations:
rootInterface.db.recordUpdate(
Invitation,
inv.get("id"),
{"revokedAt": getUtcTimestamp()}
)
deletedData.append(f"Invitations revoked: {len(userInvitations)}")
# 2. Delete feature accesses (CASCADE will delete FeatureAccessRoles)
from modules.datamodels.datamodelMembership import FeatureAccess
featureAccesses = rootInterface.db.getRecordset(
FeatureAccess,
recordFilter={"userId": str(currentUser.id)}
)
for fa in featureAccesses:
rootInterface.db.recordDelete(FeatureAccess, fa.get("id"))
deletedData.append(f"Feature accesses deleted: {len(featureAccesses)}")
# 3. Delete mandate memberships (CASCADE will delete UserMandateRoles)
from modules.datamodels.datamodelMembership import UserMandate
userMandates = rootInterface.db.getRecordset(
UserMandate,
recordFilter={"userId": str(currentUser.id)}
)
for um in userMandates:
rootInterface.db.recordDelete(UserMandate, um.get("id"))
deletedData.append(f"Mandate memberships deleted: {len(userMandates)}")
# 4. Delete active tokens
from modules.datamodels.datamodelSecurity import Token
userTokens = rootInterface.db.getRecordset(
Token,
recordFilter={"userId": str(currentUser.id)}
)
for token in userTokens:
rootInterface.db.recordDelete(Token, token.get("id"))
deletedData.append(f"Tokens deleted: {len(userTokens)}")
# 5. Delete user connections (OAuth)
from modules.datamodels.datamodelUam import UserConnection
userConnections = rootInterface.db.getRecordset(
UserConnection,
recordFilter={"userId": str(currentUser.id)}
)
for conn in userConnections:
rootInterface.db.recordDelete(UserConnection, conn.get("id"))
deletedData.append(f"Connections deleted: {len(userConnections)}")
# 6. Finally, delete the user
deletedAt = getUtcTimestamp()
rootInterface.db.recordDelete(User, str(currentUser.id))
deletedData.append("User account deleted")
# Audit log (before user is deleted)
audit_logger.logSecurityEvent(
userId=str(currentUser.id),
mandateId="system",
action="gdpr_account_deletion",
details=f"User deleted own account (Article 17). Data: {', '.join(deletedData)}"
)
logger.info(f"User {currentUser.id} deleted own account (GDPR Art. 17)")
return DeletionResult(
success=True,
userId=str(currentUser.id),
deletedAt=deletedAt,
deletedData=deletedData,
message="Account and all associated data have been permanently deleted."
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error deleting account: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to delete account: {str(e)}"
)
# =============================================================================
# Consent Information Endpoint
# =============================================================================
@router.get("/consent-info", response_model=Dict[str, Any])
@limiter.limit("30/minute")
async def getConsentInfo(
request: Request,
currentUser: User = Depends(getCurrentUser)
) -> Dict[str, Any]:
"""
Get information about data processing and user rights (GDPR transparency).
Returns information about:
- What data is collected
- How data is processed
- User rights under GDPR
- Contact information for data protection inquiries
"""
return {
"dataCollected": {
"profile": "Name, email, username, language preferences",
"authentication": "Login timestamps, authentication provider",
"memberships": "Mandate and feature access records",
"activity": "Audit logs for security-relevant actions"
},
"dataProcessing": {
"purpose": "Providing multi-tenant platform services",
"legalBasis": "Contract fulfillment and legitimate interest",
"retention": "Data retained while account is active, deleted upon account deletion"
},
"userRights": {
"access": "GET /api/user/me/data-export (Article 15)",
"portability": "GET /api/user/me/data-portability (Article 20)",
"erasure": "DELETE /api/user/me (Article 17)",
"rectification": "PUT /api/local/me (Article 16)"
},
"contact": {
"email": "privacy@example.com",
"note": "For data protection inquiries, please contact us with your user ID"
}
}
# =============================================================================
# Helper Functions
# =============================================================================
def _timestampToIso(timestamp: float) -> str:
"""Convert Unix timestamp to ISO 8601 format"""
from datetime import datetime, timezone
dt = datetime.fromtimestamp(timestamp, tz=timezone.utc)
return dt.isoformat()

View file

@ -0,0 +1,812 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Invitation routes for the backend API.
Implements token-based user invitations for self-service onboarding.
Multi-Tenant Design:
- Invitations are mandate-scoped (Mandate Admin creates them)
- Tokens are secure, time-limited, and optionally use-limited
- Users accept invitations to join mandates/features with predefined roles
"""
from fastapi import APIRouter, HTTPException, Depends, Request, Query
from typing import List, Dict, Any, Optional
from fastapi import status
import logging
from pydantic import BaseModel, Field
from modules.auth import limiter, getRequestContext, RequestContext, getCurrentUser
from modules.datamodels.datamodelUam import User
from modules.datamodels.datamodelInvitation import Invitation
from modules.interfaces.interfaceDbAppObjects import getRootInterface
from modules.shared.timeUtils import getUtcTimestamp
logger = logging.getLogger(__name__)
router = APIRouter(
prefix="/api/invitations",
tags=["Invitations"],
responses={404: {"description": "Not found"}}
)
# =============================================================================
# Request/Response Models
# =============================================================================
class InvitationCreate(BaseModel):
"""Request model for creating an invitation"""
email: Optional[str] = Field(None, description="Target email address (optional)")
roleIds: List[str] = Field(..., description="Role IDs to assign to the invited user")
featureInstanceId: Optional[str] = Field(None, description="Optional feature instance access")
expiresInHours: int = Field(
72,
ge=1,
le=720, # Max 30 days
description="Hours until invitation expires"
)
maxUses: int = Field(
1,
ge=1,
le=100,
description="Maximum number of times this invitation can be used"
)
class InvitationResponse(BaseModel):
"""Response model for invitation"""
id: str
token: str
mandateId: str
featureInstanceId: Optional[str]
roleIds: List[str]
email: Optional[str]
createdBy: str
createdAt: float
expiresAt: float
usedBy: Optional[str]
usedAt: Optional[float]
revokedAt: Optional[float]
maxUses: int
currentUses: int
inviteUrl: str # Full URL for the invitation
class InvitationValidation(BaseModel):
"""Response model for invitation validation"""
valid: bool
reason: Optional[str]
mandateId: Optional[str]
featureInstanceId: Optional[str]
roleIds: List[str]
class RegisterAndAcceptRequest(BaseModel):
"""Request model for combined registration + invitation acceptance"""
token: str = Field(..., description="Invitation token")
username: str = Field(..., min_length=3, max_length=50, description="Username for the new account")
email: str = Field(..., description="Email address")
password: str = Field(..., min_length=8, description="Password (min 8 characters)")
firstname: Optional[str] = Field(None, description="First name")
lastname: Optional[str] = Field(None, description="Last name")
class RegisterAndAcceptResponse(BaseModel):
"""Response model for combined registration + invitation acceptance"""
message: str
userId: str
mandateId: str
userMandateId: str
featureAccessId: Optional[str]
roleIds: List[str]
# =============================================================================
# Invitation CRUD Endpoints
# =============================================================================
@router.post("/", response_model=InvitationResponse)
@limiter.limit("30/minute")
async def createInvitation(
request: Request,
data: InvitationCreate,
context: RequestContext = Depends(getRequestContext)
) -> InvitationResponse:
"""
Create a new invitation for the current mandate.
Requires Mandate-Admin role. Creates a secure token that can be shared
with users to join the mandate with predefined roles.
Args:
data: Invitation creation data
"""
if not context.mandateId:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="X-Mandate-Id header is required"
)
# Check mandate admin permission
if not _hasMandateAdminRole(context):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Mandate-Admin role required to create invitations"
)
try:
rootInterface = getRootInterface()
# Validate role IDs exist and belong to this mandate or are global
for roleId in data.roleIds:
from modules.datamodels.datamodelRbac import Role
roleRecords = rootInterface.db.getRecordset(Role, recordFilter={"id": roleId})
if not roleRecords:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Role '{roleId}' not found"
)
role = roleRecords[0]
# Role must be global or belong to this mandate
roleMandateId = role.get("mandateId")
if roleMandateId and str(roleMandateId) != str(context.mandateId):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Role '{roleId}' belongs to a different mandate"
)
# Validate feature instance if provided
if data.featureInstanceId:
from modules.datamodels.datamodelFeatures import FeatureInstance
instanceRecords = rootInterface.db.getRecordset(
FeatureInstance,
recordFilter={"id": data.featureInstanceId}
)
if not instanceRecords:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Feature instance '{data.featureInstanceId}' not found"
)
instance = instanceRecords[0]
if str(instance.get("mandateId")) != str(context.mandateId):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Feature instance belongs to a different mandate"
)
# Calculate expiration time
currentTime = getUtcTimestamp()
expiresAt = currentTime + (data.expiresInHours * 3600)
# Create invitation
invitation = Invitation(
mandateId=str(context.mandateId),
featureInstanceId=data.featureInstanceId,
roleIds=data.roleIds,
email=data.email,
createdBy=str(context.user.id),
expiresAt=expiresAt,
maxUses=data.maxUses
)
createdRecord = rootInterface.db.recordCreate(Invitation, invitation.model_dump())
if not createdRecord:
raise ValueError("Failed to create invitation record")
# Build invite URL
from modules.shared.configuration import APP_CONFIG
frontendUrl = APP_CONFIG.get("APP_FRONTEND_URL", "http://localhost:8080")
inviteUrl = f"{frontendUrl}/invite/{invitation.token}"
logger.info(
f"User {context.user.id} created invitation for mandate {context.mandateId}, "
f"expires in {data.expiresInHours}h"
)
return InvitationResponse(
id=str(createdRecord.get("id")),
token=str(createdRecord.get("token")),
mandateId=str(createdRecord.get("mandateId")),
featureInstanceId=createdRecord.get("featureInstanceId"),
roleIds=createdRecord.get("roleIds", []),
email=createdRecord.get("email"),
createdBy=str(createdRecord.get("createdBy")),
createdAt=createdRecord.get("createdAt"),
expiresAt=createdRecord.get("expiresAt"),
usedBy=createdRecord.get("usedBy"),
usedAt=createdRecord.get("usedAt"),
revokedAt=createdRecord.get("revokedAt"),
maxUses=createdRecord.get("maxUses", 1),
currentUses=createdRecord.get("currentUses", 0),
inviteUrl=inviteUrl
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error creating invitation: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to create invitation: {str(e)}"
)
@router.get("/", response_model=List[Dict[str, Any]])
@limiter.limit("60/minute")
async def listInvitations(
request: Request,
includeUsed: bool = Query(False, description="Include already used invitations"),
includeExpired: bool = Query(False, description="Include expired invitations"),
context: RequestContext = Depends(getRequestContext)
) -> List[Dict[str, Any]]:
"""
List invitations for the current mandate.
Requires Mandate-Admin role. Returns all invitations created for this mandate.
Args:
includeUsed: Include invitations that have reached maxUses
includeExpired: Include expired invitations
"""
if not context.mandateId:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="X-Mandate-Id header is required"
)
# Check mandate admin permission
if not _hasMandateAdminRole(context):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Mandate-Admin role required to list invitations"
)
try:
rootInterface = getRootInterface()
# Get all invitations for this mandate
allInvitations = rootInterface.db.getRecordset(
Invitation,
recordFilter={"mandateId": str(context.mandateId)}
)
currentTime = getUtcTimestamp()
result = []
for inv in allInvitations:
# Skip revoked invitations
if inv.get("revokedAt"):
continue
# Filter by usage
if not includeUsed and inv.get("currentUses", 0) >= inv.get("maxUses", 1):
continue
# Filter by expiration
if not includeExpired and inv.get("expiresAt", 0) < currentTime:
continue
# Build invite URL
from modules.shared.configuration import APP_CONFIG
frontendUrl = APP_CONFIG.get("APP_FRONTEND_URL", "http://localhost:8080")
inviteUrl = f"{frontendUrl}/invite/{inv.get('token')}"
result.append({
**{k: v for k, v in inv.items() if not k.startswith("_")},
"inviteUrl": inviteUrl,
"isExpired": inv.get("expiresAt", 0) < currentTime,
"isUsedUp": inv.get("currentUses", 0) >= inv.get("maxUses", 1)
})
return result
except HTTPException:
raise
except Exception as e:
logger.error(f"Error listing invitations: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to list invitations: {str(e)}"
)
@router.delete("/{invitationId}", response_model=Dict[str, str])
@limiter.limit("30/minute")
async def revokeInvitation(
request: Request,
invitationId: str,
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, str]:
"""
Revoke an invitation.
Requires Mandate-Admin role. Revoked invitations cannot be used.
Args:
invitationId: Invitation ID
"""
if not context.mandateId:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="X-Mandate-Id header is required"
)
# Check mandate admin permission
if not _hasMandateAdminRole(context):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Mandate-Admin role required to revoke invitations"
)
try:
rootInterface = getRootInterface()
# Get invitation
invitationRecords = rootInterface.db.getRecordset(
Invitation,
recordFilter={"id": invitationId}
)
if not invitationRecords:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Invitation '{invitationId}' not found"
)
invitation = invitationRecords[0]
# Verify mandate access
if str(invitation.get("mandateId")) != str(context.mandateId):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this invitation"
)
# Already revoked?
if invitation.get("revokedAt"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invitation is already revoked"
)
# Revoke invitation
rootInterface.db.recordUpdate(
Invitation,
invitationId,
{"revokedAt": getUtcTimestamp()}
)
logger.info(f"User {context.user.id} revoked invitation {invitationId}")
return {"message": "Invitation revoked", "invitationId": invitationId}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error revoking invitation {invitationId}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to revoke invitation: {str(e)}"
)
# =============================================================================
# Public Invitation Endpoints (No auth required for validation)
# =============================================================================
@router.get("/validate/{token}", response_model=InvitationValidation)
@limiter.limit("30/minute")
async def validateInvitation(
request: Request,
token: str
) -> InvitationValidation:
"""
Validate an invitation token (public endpoint).
Used by the frontend to check if an invitation is valid before
showing the registration/acceptance form.
Args:
token: Invitation token
"""
try:
rootInterface = getRootInterface()
# Find invitation by token
invitationRecords = rootInterface.db.getRecordset(
Invitation,
recordFilter={"token": token}
)
if not invitationRecords:
return InvitationValidation(
valid=False,
reason="Invitation not found",
mandateId=None,
featureInstanceId=None,
roleIds=[]
)
invitation = invitationRecords[0]
# Check if revoked
if invitation.get("revokedAt"):
return InvitationValidation(
valid=False,
reason="Invitation has been revoked",
mandateId=None,
featureInstanceId=None,
roleIds=[]
)
# Check if expired
currentTime = getUtcTimestamp()
if invitation.get("expiresAt", 0) < currentTime:
return InvitationValidation(
valid=False,
reason="Invitation has expired",
mandateId=None,
featureInstanceId=None,
roleIds=[]
)
# Check if used up
if invitation.get("currentUses", 0) >= invitation.get("maxUses", 1):
return InvitationValidation(
valid=False,
reason="Invitation has reached maximum uses",
mandateId=None,
featureInstanceId=None,
roleIds=[]
)
return InvitationValidation(
valid=True,
reason=None,
mandateId=invitation.get("mandateId"),
featureInstanceId=invitation.get("featureInstanceId"),
roleIds=invitation.get("roleIds", [])
)
except Exception as e:
logger.error(f"Error validating invitation token: {e}")
return InvitationValidation(
valid=False,
reason="Validation error",
mandateId=None,
featureInstanceId=None,
roleIds=[]
)
@router.post("/accept/{token}", response_model=Dict[str, Any])
@limiter.limit("10/minute")
async def acceptInvitation(
request: Request,
token: str,
currentUser: User = Depends(getCurrentUser)
) -> Dict[str, Any]:
"""
Accept an invitation (requires authentication).
The authenticated user joins the mandate with the predefined roles.
If the user is already a member, their roles are updated.
Args:
token: Invitation token
"""
try:
rootInterface = getRootInterface()
# Find invitation by token
invitationRecords = rootInterface.db.getRecordset(
Invitation,
recordFilter={"token": token}
)
if not invitationRecords:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Invitation not found"
)
invitation = invitationRecords[0]
# Validate invitation
if invitation.get("revokedAt"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invitation has been revoked"
)
currentTime = getUtcTimestamp()
if invitation.get("expiresAt", 0) < currentTime:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invitation has expired"
)
if invitation.get("currentUses", 0) >= invitation.get("maxUses", 1):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invitation has reached maximum uses"
)
mandateId = invitation.get("mandateId")
roleIds = invitation.get("roleIds", [])
featureInstanceId = invitation.get("featureInstanceId")
# Check if user is already a member
existingMembership = rootInterface.getUserMandate(str(currentUser.id), mandateId)
if existingMembership:
# Update existing membership with additional roles
for roleId in roleIds:
try:
rootInterface.addRoleToUserMandate(str(existingMembership.id), roleId)
except Exception:
pass # Role might already be assigned
userMandateId = str(existingMembership.id)
message = "Roles updated for existing membership"
else:
# Create new membership
userMandate = rootInterface.createUserMandate(
userId=str(currentUser.id),
mandateId=mandateId,
roleIds=roleIds
)
userMandateId = str(userMandate.id)
message = "Successfully joined mandate"
# Grant feature access if specified
featureAccessId = None
if featureInstanceId:
existingAccess = rootInterface.getFeatureAccess(str(currentUser.id), featureInstanceId)
if not existingAccess:
# Create feature access with instance-level roles if any
instanceRoleIds = [r for r in roleIds if _isInstanceRole(rootInterface, r, featureInstanceId)]
featureAccess = rootInterface.createFeatureAccess(
userId=str(currentUser.id),
featureInstanceId=featureInstanceId,
roleIds=instanceRoleIds
)
featureAccessId = str(featureAccess.id)
# Update invitation usage
rootInterface.db.recordUpdate(
Invitation,
invitation.get("id"),
{
"currentUses": invitation.get("currentUses", 0) + 1,
"usedBy": str(currentUser.id),
"usedAt": currentTime
}
)
logger.info(
f"User {currentUser.id} accepted invitation {invitation.get('id')} "
f"for mandate {mandateId}"
)
return {
"message": message,
"mandateId": mandateId,
"userMandateId": userMandateId,
"featureAccessId": featureAccessId,
"roleIds": roleIds
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error accepting invitation: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to accept invitation: {str(e)}"
)
# =============================================================================
# Combined Registration + Accept Invitation
# =============================================================================
@router.post("/register-and-accept", response_model=RegisterAndAcceptResponse)
@limiter.limit("10/minute") # Stricter rate limit for registration
async def registerAndAcceptInvitation(
request: Request,
data: RegisterAndAcceptRequest
) -> RegisterAndAcceptResponse:
"""
Combined endpoint: Register a new user AND accept an invitation in one step.
This is a PUBLIC endpoint - no authentication required.
Flow:
1. Validate invitation token
2. Check email matches (if invitation has email restriction)
3. Create new user account
4. Create UserMandate membership with roles
5. Optionally grant FeatureAccess
6. Update invitation usage
The user can then login with their new credentials.
"""
try:
rootInterface = getRootInterface()
# 1. Validate invitation
invitation = rootInterface.getInvitationByToken(data.token)
if not invitation:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Invalid invitation token"
)
if invitation.get("revokedAt"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invitation has been revoked"
)
currentTime = getUtcTimestamp()
if invitation.get("expiresAt", 0) < currentTime:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invitation has expired"
)
if invitation.get("currentUses", 0) >= invitation.get("maxUses", 1):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invitation has reached maximum uses"
)
# 2. Check email restriction
invitationEmail = invitation.get("email")
if invitationEmail and invitationEmail.lower() != data.email.lower():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email does not match the invitation"
)
# 3. Check if username or email already exists
existingUsername = rootInterface.getUserByUsername(data.username)
if existingUsername:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Username already exists"
)
existingEmail = rootInterface.getUserByEmail(data.email)
if existingEmail:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Email already registered. Please login and accept the invitation."
)
# 4. Create new user
from modules.security.passwordUtils import hashPassword
hashedPassword = hashPassword(data.password)
newUser = rootInterface.createUser(
username=data.username,
email=data.email,
passwordHash=hashedPassword,
firstname=data.firstname,
lastname=data.lastname
)
if not newUser:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create user account"
)
userId = str(newUser.id)
mandateId = invitation.get("mandateId")
roleIds = invitation.get("roleIds", [])
featureInstanceId = invitation.get("featureInstanceId")
# 5. Create UserMandate membership
userMandate = rootInterface.createUserMandate(
userId=userId,
mandateId=mandateId,
roleIds=roleIds
)
userMandateId = str(userMandate.id)
# 6. Grant feature access if specified
featureAccessId = None
if featureInstanceId:
instanceRoleIds = [r for r in roleIds if _isInstanceRole(rootInterface, r, featureInstanceId)]
featureAccess = rootInterface.createFeatureAccess(
userId=userId,
featureInstanceId=featureInstanceId,
roleIds=instanceRoleIds
)
featureAccessId = str(featureAccess.id)
# 7. Update invitation usage
rootInterface.db.recordUpdate(
Invitation,
invitation.get("id"),
{
"currentUses": invitation.get("currentUses", 0) + 1,
"usedBy": userId,
"usedAt": currentTime
}
)
logger.info(
f"New user {userId} registered and accepted invitation {invitation.get('id')} "
f"for mandate {mandateId}"
)
return RegisterAndAcceptResponse(
message="Account created and invitation accepted successfully",
userId=userId,
mandateId=mandateId,
userMandateId=userMandateId,
featureAccessId=featureAccessId,
roleIds=roleIds
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error in register-and-accept: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to complete registration: {str(e)}"
)
# =============================================================================
# Helper Functions
# =============================================================================
def _hasMandateAdminRole(context: RequestContext) -> bool:
"""
Check if the user has mandate admin role in the current context.
"""
if context.isSysAdmin:
return True
if not context.roleIds:
return False
try:
rootInterface = getRootInterface()
from modules.datamodels.datamodelRbac import Role
for roleId in context.roleIds:
roleRecords = rootInterface.db.getRecordset(Role, recordFilter={"id": roleId})
if roleRecords:
role = roleRecords[0]
roleLabel = role.get("roleLabel", "")
# Admin role at mandate level
if roleLabel == "admin" and role.get("mandateId") and not role.get("featureInstanceId"):
return True
return False
except Exception as e:
logger.error(f"Error checking mandate admin role: {e}")
return False
def _isInstanceRole(interface, roleId: str, featureInstanceId: str) -> bool:
"""
Check if a role belongs to a specific feature instance.
"""
try:
from modules.datamodels.datamodelRbac import Role
roleRecords = interface.db.getRecordset(Role, recordFilter={"id": roleId})
if roleRecords:
role = roleRecords[0]
return str(role.get("featureInstanceId", "")) == str(featureInstanceId)
return False
except Exception:
return False

View file

@ -7,7 +7,8 @@ import logging
import json
# Import auth module
from modules.auth import limiter, getCurrentUser
from modules.auth import limiter, getCurrentUser, getRequestContext, RequestContext
from modules.datamodels.datamodelRbac import Role
# Import interfaces
import modules.interfaces.interfaceDbComponentObjects as interfaceDbComponentObjects
@ -379,16 +380,23 @@ async def triggerSubscription(
request: Request,
subscriptionId: str = Path(..., description="ID of the subscription to trigger"),
eventParameters: Dict[str, Any] = Body(...),
currentUser: User = Depends(getCurrentUser)
context: RequestContext = Depends(getRequestContext)
) -> MessagingSubscriptionExecutionResult:
"""Trigger a subscription with event parameters"""
# RBAC-Check: Nur Admin/Mandate-Admin kann triggern
# TODO: Add proper RBAC check here
"""
Trigger a subscription with event parameters.
Requires Mandate-Admin role or SysAdmin.
"""
# RBAC-Check: Admin or Mandate-Admin can trigger
if not _hasTriggerPermission(context):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin or Mandate-Admin role required to trigger subscriptions"
)
# Get messaging service from request app state
# We need to access services through the request
from modules.services import getInterface as getServicesInterface
services = getServicesInterface(currentUser, None)
services = getServicesInterface(context.user, None, mandateId=str(context.mandateId))
# Konvertiere Dict zu Pydantic Model
eventParams = MessagingEventParameters(triggerData=eventParameters)
@ -397,6 +405,37 @@ async def triggerSubscription(
return executionResult
def _hasTriggerPermission(context: RequestContext) -> bool:
"""
Check if user has permission to trigger subscriptions.
Requires admin or mandate-admin role.
"""
if context.isSysAdmin:
return True
if not context.roleIds:
return False
try:
from modules.interfaces.interfaceDbAppObjects import getRootInterface
rootInterface = getRootInterface()
for roleId in context.roleIds:
roleRecords = rootInterface.db.getRecordset(Role, recordFilter={"id": roleId})
if roleRecords:
role = roleRecords[0]
roleLabel = role.get("roleLabel", "")
# Admin role at mandate level or system admin
if roleLabel in ("admin", "sysadmin"):
return True
return False
except Exception as e:
logger.error(f"Error checking trigger permission: {e}")
return False
# Delivery Endpoints
@router.get("/deliveries", response_model=PaginatedResponse[MessagingDelivery])

View file

@ -3,6 +3,11 @@
"""
RBAC routes for the backend API.
Implements endpoints for role-based access control permissions.
MULTI-TENANT:
- Permission queries use RequestContext (mandateId from header)
- AccessRule management is SysAdmin-only (system resources)
- Role management is SysAdmin-only (system resources)
"""
from fastapi import APIRouter, HTTPException, Depends, Query, Body, Path, Request
@ -11,11 +16,11 @@ import logging
import json
import math
from modules.auth import getCurrentUser, limiter
from modules.auth import limiter, getRequestContext, requireSysAdmin, RequestContext
from modules.datamodels.datamodelUam import User, UserPermissions, AccessLevel
from modules.datamodels.datamodelRbac import AccessRuleContext, AccessRule, Role
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
from modules.interfaces.interfaceDbAppObjects import getInterface
from modules.interfaces.interfaceDbAppObjects import getInterface, getRootInterface
# Configure logger
logger = logging.getLogger(__name__)
@ -33,10 +38,11 @@ async def getPermissions(
request: Request,
context: str = Query(..., description="Context type: DATA, UI, or RESOURCE"),
item: Optional[str] = Query(None, description="Item identifier (table name, UI path, or resource path)"),
currentUser: User = Depends(getCurrentUser)
reqContext: RequestContext = Depends(getRequestContext)
) -> UserPermissions:
"""
Get RBAC permissions for the current user for a specific context and item.
MULTI-TENANT: Uses RequestContext (mandateId/featureInstanceId from headers).
Query Parameters:
- context: Context type (DATA, UI, or RESOURCE)
@ -63,16 +69,17 @@ async def getPermissions(
)
# Get interface and RBAC permissions
interface = getInterface(currentUser)
interface = getInterface(reqContext.user)
if not interface.rbac:
raise HTTPException(
status_code=500,
detail="RBAC interface not available"
)
# Get permissions
# MULTI-TENANT: Get permissions using context (mandateId/featureInstanceId)
# For now, pass user - RBAC will be extended to use context in later phases
permissions = interface.rbac.getUserPermissions(
currentUser,
reqContext.user,
accessContext,
item or ""
)
@ -94,10 +101,11 @@ async def getPermissions(
async def getAllPermissions(
request: Request,
context: Optional[str] = Query(None, description="Context type: UI or RESOURCE (if not provided, returns both)"),
currentUser: User = Depends(getCurrentUser)
reqContext: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""
Get all RBAC permissions for the current user for UI and/or RESOURCE contexts.
MULTI-TENANT: Uses RequestContext (mandateId/featureInstanceId from headers).
This endpoint is optimized for UI initialization to avoid multiple API calls.
Query Parameters:
@ -128,7 +136,7 @@ async def getAllPermissions(
"""
try:
# Get interface and RBAC permissions
interface = getInterface(currentUser)
interface = getInterface(reqContext.user)
if not interface.rbac:
raise HTTPException(
status_code=500,
@ -158,9 +166,9 @@ async def getAllPermissions(
result: Dict[str, Any] = {}
# Get all access rules for user's roles
roleLabels = currentUser.roleLabels or []
if not roleLabels:
# MULTI-TENANT: Get role IDs from context (computed from mandateId/featureInstanceId)
roleIds = reqContext.roleIds or []
if not roleIds and not reqContext.isSysAdmin:
# User has no roles, return empty permissions
for ctx in contextsToFetch:
result[ctx.value.lower()] = {}
@ -171,9 +179,9 @@ async def getAllPermissions(
for ctx in contextsToFetch:
allRules[ctx] = []
# Get all rules for user's roles in this context
for roleLabel in roleLabels:
for roleId in roleIds:
rules = interface.getAccessRules(
roleLabel=roleLabel,
roleId=str(roleId),
context=ctx,
pagination=None
)
@ -191,7 +199,7 @@ async def getAllPermissions(
# For each item, calculate user permissions
for item in sorted(items):
permissions = interface.rbac.getUserPermissions(currentUser, ctx, item)
permissions = interface.rbac.getUserPermissions(reqContext.user, ctx, item)
# Only include if user has view permission
if permissions.view:
result[ctx.value.lower()][item] = {
@ -222,11 +230,11 @@ async def getAccessRules(
context: Optional[str] = Query(None, description="Filter by context (DATA, UI, RESOURCE)"),
item: Optional[str] = Query(None, description="Filter by item identifier"),
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
currentUser: User = Depends(getCurrentUser)
reqContext: RequestContext = Depends(requireSysAdmin)
) -> PaginatedResponse:
"""
Get access rules with optional filters.
Only returns rules that the current user has permission to view.
MULTI-TENANT: SysAdmin-only (AccessRules are system resources).
Query Parameters:
- roleLabel: Optional role label filter
@ -237,29 +245,8 @@ async def getAccessRules(
- List of AccessRule objects
"""
try:
# Get interface
interface = getInterface(currentUser)
# Check if user has permission to view access rules
# For now, only sysadmin can view rules
if not interface.rbac:
raise HTTPException(
status_code=500,
detail="RBAC interface not available"
)
# Check permission - only sysadmin can view rules
permissions = interface.rbac.getUserPermissions(
currentUser,
AccessRuleContext.DATA,
"AccessRule"
)
if not permissions.view or permissions.read == AccessLevel.NONE:
raise HTTPException(
status_code=403,
detail="No permission to view access rules"
)
# Get interface - SysAdmin uses root interface
interface = getRootInterface()
# Parse context if provided
accessContext = None
@ -329,11 +316,11 @@ async def getAccessRules(
async def getAccessRule(
request: Request,
ruleId: str = Path(..., description="Access rule ID"),
currentUser: User = Depends(getCurrentUser)
reqContext: RequestContext = Depends(requireSysAdmin)
) -> dict:
"""
Get a specific access rule by ID.
Only returns rule if the current user has permission to view it.
MULTI-TENANT: SysAdmin-only.
Path Parameters:
- ruleId: Access rule ID
@ -342,28 +329,8 @@ async def getAccessRule(
- AccessRule object
"""
try:
# Get interface
interface = getInterface(currentUser)
# Check if user has permission to view access rules
if not interface.rbac:
raise HTTPException(
status_code=500,
detail="RBAC interface not available"
)
# Check permission - only sysadmin can view rules
permissions = interface.rbac.getUserPermissions(
currentUser,
AccessRuleContext.DATA,
"AccessRule"
)
if not permissions.view or permissions.read == AccessLevel.NONE:
raise HTTPException(
status_code=403,
detail="No permission to view access rules"
)
# Get interface - SysAdmin uses root interface
interface = getRootInterface()
# Get rule
rule = interface.getAccessRule(ruleId)
@ -391,11 +358,11 @@ async def getAccessRule(
async def createAccessRule(
request: Request,
accessRuleData: dict = Body(..., description="Access rule data"),
currentUser: User = Depends(getCurrentUser)
reqContext: RequestContext = Depends(requireSysAdmin)
) -> dict:
"""
Create a new access rule.
Only sysadmin can create access rules.
MULTI-TENANT: SysAdmin-only.
Request Body:
- AccessRule object data (roleLabel, context, item, view, read, create, update, delete)
@ -404,28 +371,8 @@ async def createAccessRule(
- Created AccessRule object
"""
try:
# Get interface
interface = getInterface(currentUser)
# Check if user has permission to create access rules
if not interface.rbac:
raise HTTPException(
status_code=500,
detail="RBAC interface not available"
)
# Check permission - only sysadmin can create rules
permissions = interface.rbac.getUserPermissions(
currentUser,
AccessRuleContext.DATA,
"AccessRule"
)
if not permissions.create or permissions.create == AccessLevel.NONE:
raise HTTPException(
status_code=403,
detail="No permission to create access rules"
)
# Get interface - SysAdmin uses root interface
interface = getRootInterface()
# Validate and parse access rule data
try:
@ -457,7 +404,7 @@ async def createAccessRule(
# Create rule
createdRule = interface.createAccessRule(accessRule)
logger.info(f"Created access rule {createdRule.id} by user {currentUser.id}")
logger.info(f"Created access rule {createdRule.id} by SysAdmin {reqContext.user.id}")
# Convert to dict for JSON serialization
return createdRule.model_dump()
@ -478,11 +425,11 @@ async def updateAccessRule(
request: Request,
ruleId: str = Path(..., description="Access rule ID"),
accessRuleData: dict = Body(..., description="Updated access rule data"),
currentUser: User = Depends(getCurrentUser)
reqContext: RequestContext = Depends(requireSysAdmin)
) -> dict:
"""
Update an existing access rule.
Only sysadmin can update access rules.
MULTI-TENANT: SysAdmin-only.
Path Parameters:
- ruleId: Access rule ID
@ -494,28 +441,8 @@ async def updateAccessRule(
- Updated AccessRule object
"""
try:
# Get interface
interface = getInterface(currentUser)
# Check if user has permission to update access rules
if not interface.rbac:
raise HTTPException(
status_code=500,
detail="RBAC interface not available"
)
# Check permission - only sysadmin can update rules
permissions = interface.rbac.getUserPermissions(
currentUser,
AccessRuleContext.DATA,
"AccessRule"
)
if not permissions.update or permissions.update == AccessLevel.NONE:
raise HTTPException(
status_code=403,
detail="No permission to update access rules"
)
# Get interface - SysAdmin uses root interface
interface = getRootInterface()
# Get existing rule to ensure it exists
existingRule = interface.getAccessRule(ruleId)
@ -560,7 +487,7 @@ async def updateAccessRule(
# Update rule
updatedRule = interface.updateAccessRule(ruleId, accessRule)
logger.info(f"Updated access rule {ruleId} by user {currentUser.id}")
logger.info(f"Updated access rule {ruleId} by SysAdmin {reqContext.user.id}")
# Convert to dict for JSON serialization
return updatedRule.model_dump()
@ -580,11 +507,11 @@ async def updateAccessRule(
async def deleteAccessRule(
request: Request,
ruleId: str = Path(..., description="Access rule ID"),
currentUser: User = Depends(getCurrentUser)
reqContext: RequestContext = Depends(requireSysAdmin)
) -> dict:
"""
Delete an access rule.
Only sysadmin can delete access rules.
MULTI-TENANT: SysAdmin-only.
Path Parameters:
- ruleId: Access rule ID
@ -593,28 +520,8 @@ async def deleteAccessRule(
- Success message
"""
try:
# Get interface
interface = getInterface(currentUser)
# Check if user has permission to delete access rules
if not interface.rbac:
raise HTTPException(
status_code=500,
detail="RBAC interface not available"
)
# Check permission - only sysadmin can delete rules
permissions = interface.rbac.getUserPermissions(
currentUser,
AccessRuleContext.DATA,
"AccessRule"
)
if not permissions.delete or permissions.delete == AccessLevel.NONE:
raise HTTPException(
status_code=403,
detail="No permission to delete access rules"
)
# Get interface - SysAdmin uses root interface
interface = getRootInterface()
# Get existing rule to ensure it exists
existingRule = interface.getAccessRule(ruleId)
@ -633,7 +540,7 @@ async def deleteAccessRule(
detail=f"Failed to delete access rule {ruleId}"
)
logger.info(f"Deleted access rule {ruleId} by user {currentUser.id}")
logger.info(f"Deleted access rule {ruleId} by SysAdmin {reqContext.user.id}")
return {"success": True, "message": f"Access rule {ruleId} deleted successfully"}
@ -649,38 +556,26 @@ async def deleteAccessRule(
# ============================================================================
# Role Management Endpoints
# MULTI-TENANT: All role management is SysAdmin-only (roles are system resources)
# ============================================================================
def _ensureAdminAccess(currentUser: User) -> None:
"""Ensure current user has admin access to RBAC roles management."""
interface = getInterface(currentUser)
# Check if user has admin or sysadmin role
roleLabels = currentUser.roleLabels or []
if "sysadmin" not in roleLabels and "admin" not in roleLabels:
raise HTTPException(
status_code=403,
detail="Admin or sysadmin role required to manage RBAC roles"
)
@router.get("/roles", response_model=PaginatedResponse)
@limiter.limit("60/minute")
async def listRoles(
request: Request,
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
currentUser: User = Depends(getCurrentUser)
reqContext: RequestContext = Depends(requireSysAdmin)
) -> PaginatedResponse:
"""
Get list of all available roles with metadata.
MULTI-TENANT: SysAdmin-only (roles are system resources).
Returns:
- List of role dictionaries with role label, description, and user count
"""
try:
_ensureAdminAccess(currentUser)
interface = getInterface(currentUser)
interface = getRootInterface()
# Parse pagination parameter
paginationParams = None
@ -696,21 +591,11 @@ async def listRoles(
detail=f"Invalid pagination parameter: {str(e)}"
)
# Get all roles from database (without pagination) to enrich with user counts and add custom roles
# Note: We get all roles first because we need to add custom roles before pagination
# Get all roles from database
dbRoles = interface.getAllRoles(pagination=None)
# Get all users to count role assignments
# Since _ensureAdminAccess ensures user is sysadmin or admin,
# and getUsersByMandate returns all users for sysadmin regardless of mandateId,
# we can pass the current user's mandateId (for sysadmin it will be ignored by RBAC)
allUsers = interface.getUsersByMandate(currentUser.mandateId or "", pagination=None)
# Count users per role
roleCounts: Dict[str, int] = {}
for user in allUsers:
for roleLabel in (user.roleLabels or []):
roleCounts[roleLabel] = roleCounts.get(roleLabel, 0) + 1
# Count role assignments from UserMandateRole table
roleCounts = interface.countRoleAssignments()
# Convert Role objects to dictionaries and add user counts
result = []
@ -719,22 +604,10 @@ async def listRoles(
"id": role.id,
"roleLabel": role.roleLabel,
"description": role.description,
"userCount": roleCounts.get(role.roleLabel, 0),
"userCount": roleCounts.get(str(role.id), 0),
"isSystemRole": role.isSystemRole
})
# Add any roles found in user assignments that don't exist in database
dbRoleLabels = {role.roleLabel for role in dbRoles}
for roleLabel, count in roleCounts.items():
if roleLabel not in dbRoleLabels:
result.append({
"id": None,
"roleLabel": roleLabel,
"description": {"en": f"Custom role: {roleLabel}", "fr": f"Rôle personnalisé : {roleLabel}"},
"userCount": count,
"isSystemRole": False
})
# Apply filtering and sorting if pagination requested
if paginationParams:
# Apply filtering (if filters provided)
@ -789,19 +662,17 @@ async def listRoles(
@limiter.limit("60/minute")
async def getRoleOptions(
request: Request,
currentUser: User = Depends(getCurrentUser)
reqContext: RequestContext = Depends(requireSysAdmin)
) -> List[Dict[str, Any]]:
"""
Get role options for select dropdowns.
Returns roles in format suitable for frontend select components.
MULTI-TENANT: SysAdmin-only.
Returns:
- List of role option dictionaries with value and label
"""
try:
_ensureAdminAccess(currentUser)
interface = getInterface(currentUser)
interface = getRootInterface()
# Get all roles from database
dbRoles = interface.getAllRoles()
@ -833,10 +704,11 @@ async def getRoleOptions(
async def createRole(
request: Request,
role: Role = Body(...),
currentUser: User = Depends(getCurrentUser)
reqContext: RequestContext = Depends(requireSysAdmin)
) -> Dict[str, Any]:
"""
Create a new role.
MULTI-TENANT: SysAdmin-only.
Request Body:
- role: Role object to create
@ -845,12 +717,12 @@ async def createRole(
- Created role dictionary
"""
try:
_ensureAdminAccess(currentUser)
interface = getInterface(currentUser)
interface = getRootInterface()
createdRole = interface.createRole(role)
logger.info(f"Created role {createdRole.roleLabel} by SysAdmin {reqContext.user.id}")
return {
"id": createdRole.id,
"roleLabel": createdRole.roleLabel,
@ -878,10 +750,11 @@ async def createRole(
async def getRole(
request: Request,
roleId: str = Path(..., description="Role ID"),
currentUser: User = Depends(getCurrentUser)
reqContext: RequestContext = Depends(requireSysAdmin)
) -> Dict[str, Any]:
"""
Get a role by ID.
MULTI-TENANT: SysAdmin-only.
Path Parameters:
- roleId: Role ID
@ -890,9 +763,7 @@ async def getRole(
- Role dictionary
"""
try:
_ensureAdminAccess(currentUser)
interface = getInterface(currentUser)
interface = getRootInterface()
role = interface.getRole(roleId)
if not role:
@ -924,10 +795,11 @@ async def updateRole(
request: Request,
roleId: str = Path(..., description="Role ID"),
role: Role = Body(...),
currentUser: User = Depends(getCurrentUser)
reqContext: RequestContext = Depends(requireSysAdmin)
) -> Dict[str, Any]:
"""
Update an existing role.
MULTI-TENANT: SysAdmin-only.
Path Parameters:
- roleId: Role ID
@ -939,12 +811,12 @@ async def updateRole(
- Updated role dictionary
"""
try:
_ensureAdminAccess(currentUser)
interface = getInterface(currentUser)
interface = getRootInterface()
updatedRole = interface.updateRole(roleId, role)
logger.info(f"Updated role {roleId} by SysAdmin {reqContext.user.id}")
return {
"id": updatedRole.id,
"roleLabel": updatedRole.roleLabel,
@ -972,10 +844,11 @@ async def updateRole(
async def deleteRole(
request: Request,
roleId: str = Path(..., description="Role ID"),
currentUser: User = Depends(getCurrentUser)
reqContext: RequestContext = Depends(requireSysAdmin)
) -> Dict[str, str]:
"""
Delete a role.
MULTI-TENANT: SysAdmin-only.
Path Parameters:
- roleId: Role ID
@ -984,9 +857,7 @@ async def deleteRole(
- Success message
"""
try:
_ensureAdminAccess(currentUser)
interface = getInterface(currentUser)
interface = getRootInterface()
success = interface.deleteRole(roleId)
if not success:
@ -995,6 +866,8 @@ async def deleteRole(
detail=f"Role {roleId} not found"
)
logger.info(f"Deleted role {roleId} by SysAdmin {reqContext.user.id}")
return {"message": f"Role {roleId} deleted successfully"}
except HTTPException:

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.interfaceDbAppObjects import getRootInterface
from modules.shared.timeUtils import getUtcTimestamp
logger = logging.getLogger(__name__)
router = APIRouter(
prefix="/api/rbac",
tags=["RBAC Export/Import"],
responses={404: {"description": "Not found"}}
)
# =============================================================================
# Request/Response Models
# =============================================================================
class RoleExport(BaseModel):
"""Export model for a role with its access rules"""
roleLabel: str
description: Dict[str, str]
featureCode: Optional[str]
isSystemRole: bool
accessRules: List[Dict[str, Any]]
class RbacExportData(BaseModel):
"""Complete RBAC export data"""
exportVersion: str = "1.0"
exportedAt: float
exportedBy: str
scope: str # "global" or "mandate"
mandateId: Optional[str]
roles: List[RoleExport]
class RbacImportResult(BaseModel):
"""Result of RBAC import operation"""
rolesCreated: int
rolesUpdated: int
rolesSkipped: int
rulesCreated: int
rulesUpdated: int
errors: List[str]
# =============================================================================
# Global RBAC Export/Import (SysAdmin only)
# =============================================================================
@router.get("/export/global", response_model=RbacExportData)
@limiter.limit("10/minute")
async def exportGlobalRbac(
request: Request,
sysAdmin: User = Depends(requireSysAdmin)
) -> RbacExportData:
"""
Export global (template) RBAC rules.
SysAdmin only - exports template roles that are copied to new feature instances.
These are roles with mandateId=NULL.
"""
try:
rootInterface = getRootInterface()
# Get all global template roles (mandateId is NULL)
allRoles = rootInterface.db.getRecordset(Role)
globalRoles = [r for r in allRoles if r.get("mandateId") is None]
exportRoles = []
for role in globalRoles:
roleId = role.get("id")
# Get access rules for this role
accessRules = rootInterface.db.getRecordset(
AccessRule,
recordFilter={"roleId": roleId}
)
exportRoles.append(RoleExport(
roleLabel=role.get("roleLabel"),
description=role.get("description", {}),
featureCode=role.get("featureCode"),
isSystemRole=role.get("isSystemRole", False),
accessRules=[
{
"context": r.get("context"),
"item": r.get("item"),
"view": r.get("view", False),
"read": r.get("read"),
"create": r.get("create"),
"update": r.get("update"),
"delete": r.get("delete")
}
for r in accessRules
]
))
logger.info(f"SysAdmin {sysAdmin.id} exported global RBAC ({len(exportRoles)} roles)")
return RbacExportData(
exportedAt=getUtcTimestamp(),
exportedBy=str(sysAdmin.id),
scope="global",
mandateId=None,
roles=exportRoles
)
except Exception as e:
logger.error(f"Error exporting global RBAC: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to export RBAC: {str(e)}"
)
@router.post("/import/global", response_model=RbacImportResult)
@limiter.limit("5/minute")
async def importGlobalRbac(
request: Request,
file: UploadFile = File(..., description="JSON file with RBAC export data"),
updateExisting: bool = False,
sysAdmin: User = Depends(requireSysAdmin)
) -> RbacImportResult:
"""
Import global (template) RBAC rules.
SysAdmin only - imports template roles and their access rules.
Args:
file: JSON file containing RbacExportData
updateExisting: If True, update existing roles. If False, skip them.
"""
try:
# Read and parse file
content = await file.read()
try:
data = json.loads(content.decode("utf-8"))
except json.JSONDecodeError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid JSON: {str(e)}"
)
# Validate structure
if "roles" not in data:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Missing 'roles' field in import data"
)
rootInterface = getRootInterface()
result = RbacImportResult(
rolesCreated=0,
rolesUpdated=0,
rolesSkipped=0,
rulesCreated=0,
rulesUpdated=0,
errors=[]
)
for roleData in data.get("roles", []):
try:
roleLabel = roleData.get("roleLabel")
featureCode = roleData.get("featureCode")
if not roleLabel:
result.errors.append(f"Role without label skipped")
result.rolesSkipped += 1
continue
# Check if role exists (global role with same label and featureCode)
existingRoles = rootInterface.db.getRecordset(
Role,
recordFilter={
"roleLabel": roleLabel,
"mandateId": None,
"featureCode": featureCode
}
)
if existingRoles:
if updateExisting:
# Update existing role
existingRole = existingRoles[0]
roleId = existingRole.get("id")
rootInterface.db.recordUpdate(
Role,
roleId,
{
"description": roleData.get("description", {}),
"isSystemRole": roleData.get("isSystemRole", False)
}
)
# Update access rules
result.rulesUpdated += _updateAccessRules(
rootInterface,
roleId,
roleData.get("accessRules", [])
)
result.rolesUpdated += 1
else:
result.rolesSkipped += 1
continue
else:
# Create new role
newRole = Role(
roleLabel=roleLabel,
description=roleData.get("description", {}),
featureCode=featureCode,
mandateId=None,
featureInstanceId=None,
isSystemRole=roleData.get("isSystemRole", False)
)
createdRole = rootInterface.db.recordCreate(Role, newRole.model_dump())
roleId = createdRole.get("id")
# Create access rules
for ruleData in roleData.get("accessRules", []):
newRule = AccessRule(
roleId=roleId,
context=ruleData.get("context"),
item=ruleData.get("item"),
view=ruleData.get("view", False),
read=ruleData.get("read"),
create=ruleData.get("create"),
update=ruleData.get("update"),
delete=ruleData.get("delete")
)
rootInterface.db.recordCreate(AccessRule, newRule.model_dump())
result.rulesCreated += 1
result.rolesCreated += 1
except Exception as e:
result.errors.append(f"Error processing role '{roleData.get('roleLabel', 'unknown')}': {str(e)}")
logger.info(
f"SysAdmin {sysAdmin.id} imported global RBAC: "
f"{result.rolesCreated} created, {result.rolesUpdated} updated, "
f"{result.rolesSkipped} skipped"
)
return result
except HTTPException:
raise
except Exception as e:
logger.error(f"Error importing global RBAC: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to import RBAC: {str(e)}"
)
# =============================================================================
# Mandate RBAC Export/Import (Mandate Admin)
# =============================================================================
@router.get("/export/mandate", response_model=RbacExportData)
@limiter.limit("10/minute")
async def exportMandateRbac(
request: Request,
includeFeatureInstances: bool = True,
context: RequestContext = Depends(getRequestContext)
) -> RbacExportData:
"""
Export RBAC rules for the current mandate.
Requires Mandate-Admin role. Exports mandate-level roles and optionally
feature instance roles.
Args:
includeFeatureInstances: Include feature instance roles in export
"""
if not context.mandateId:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="X-Mandate-Id header is required"
)
# Check mandate admin permission
if not _hasMandateAdminRole(context):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Mandate-Admin role required to export RBAC"
)
try:
rootInterface = getRootInterface()
# Get mandate-level roles
allRoles = rootInterface.db.getRecordset(Role)
mandateRoles = [
r for r in allRoles
if str(r.get("mandateId")) == str(context.mandateId)
]
# Filter by feature instance if not including them
if not includeFeatureInstances:
mandateRoles = [r for r in mandateRoles if not r.get("featureInstanceId")]
exportRoles = []
for role in mandateRoles:
roleId = role.get("id")
# Get access rules for this role
accessRules = rootInterface.db.getRecordset(
AccessRule,
recordFilter={"roleId": roleId}
)
exportRoles.append(RoleExport(
roleLabel=role.get("roleLabel"),
description=role.get("description", {}),
featureCode=role.get("featureCode"),
isSystemRole=role.get("isSystemRole", False),
accessRules=[
{
"context": r.get("context"),
"item": r.get("item"),
"view": r.get("view", False),
"read": r.get("read"),
"create": r.get("create"),
"update": r.get("update"),
"delete": r.get("delete")
}
for r in accessRules
]
))
logger.info(
f"User {context.user.id} exported mandate {context.mandateId} RBAC "
f"({len(exportRoles)} roles)"
)
return RbacExportData(
exportedAt=getUtcTimestamp(),
exportedBy=str(context.user.id),
scope="mandate",
mandateId=str(context.mandateId),
roles=exportRoles
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error exporting mandate RBAC: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to export RBAC: {str(e)}"
)
@router.post("/import/mandate", response_model=RbacImportResult)
@limiter.limit("5/minute")
async def importMandateRbac(
request: Request,
file: UploadFile = File(..., description="JSON file with RBAC export data"),
updateExisting: bool = False,
context: RequestContext = Depends(getRequestContext)
) -> RbacImportResult:
"""
Import RBAC rules for the current mandate.
Requires Mandate-Admin role. Imports roles as mandate-level roles
(not feature instance roles - those are created via template copying).
Args:
file: JSON file containing RbacExportData
updateExisting: If True, update existing roles. If False, skip them.
"""
if not context.mandateId:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="X-Mandate-Id header is required"
)
# Check mandate admin permission
if not _hasMandateAdminRole(context):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Mandate-Admin role required to import RBAC"
)
try:
# Read and parse file
content = await file.read()
try:
data = json.loads(content.decode("utf-8"))
except json.JSONDecodeError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid JSON: {str(e)}"
)
# Validate structure
if "roles" not in data:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Missing 'roles' field in import data"
)
rootInterface = getRootInterface()
result = RbacImportResult(
rolesCreated=0,
rolesUpdated=0,
rolesSkipped=0,
rulesCreated=0,
rulesUpdated=0,
errors=[]
)
for roleData in data.get("roles", []):
try:
roleLabel = roleData.get("roleLabel")
featureCode = roleData.get("featureCode")
if not roleLabel:
result.errors.append(f"Role without label skipped")
result.rolesSkipped += 1
continue
# System roles cannot be imported at mandate level
if roleData.get("isSystemRole", False):
result.errors.append(f"System role '{roleLabel}' skipped (SysAdmin only)")
result.rolesSkipped += 1
continue
# Check if role exists (mandate role with same label)
existingRoles = rootInterface.db.getRecordset(
Role,
recordFilter={
"roleLabel": roleLabel,
"mandateId": str(context.mandateId),
"featureInstanceId": None # Only mandate-level roles
}
)
if existingRoles:
if updateExisting:
# Update existing role
existingRole = existingRoles[0]
roleId = existingRole.get("id")
rootInterface.db.recordUpdate(
Role,
roleId,
{"description": roleData.get("description", {})}
)
# Update access rules
result.rulesUpdated += _updateAccessRules(
rootInterface,
roleId,
roleData.get("accessRules", [])
)
result.rolesUpdated += 1
else:
result.rolesSkipped += 1
continue
else:
# Create new role at mandate level
newRole = Role(
roleLabel=roleLabel,
description=roleData.get("description", {}),
featureCode=featureCode,
mandateId=str(context.mandateId),
featureInstanceId=None,
isSystemRole=False # Never create system roles via import
)
createdRole = rootInterface.db.recordCreate(Role, newRole.model_dump())
roleId = createdRole.get("id")
# Create access rules
for ruleData in roleData.get("accessRules", []):
newRule = AccessRule(
roleId=roleId,
context=ruleData.get("context"),
item=ruleData.get("item"),
view=ruleData.get("view", False),
read=ruleData.get("read"),
create=ruleData.get("create"),
update=ruleData.get("update"),
delete=ruleData.get("delete")
)
rootInterface.db.recordCreate(AccessRule, newRule.model_dump())
result.rulesCreated += 1
result.rolesCreated += 1
except Exception as e:
result.errors.append(f"Error processing role '{roleData.get('roleLabel', 'unknown')}': {str(e)}")
logger.info(
f"User {context.user.id} imported mandate {context.mandateId} RBAC: "
f"{result.rolesCreated} created, {result.rolesUpdated} updated, "
f"{result.rolesSkipped} skipped"
)
return result
except HTTPException:
raise
except Exception as e:
logger.error(f"Error importing mandate RBAC: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to import RBAC: {str(e)}"
)
# =============================================================================
# Helper Functions
# =============================================================================
def _hasMandateAdminRole(context: RequestContext) -> bool:
"""
Check if the user has mandate admin role in the current context.
"""
if context.isSysAdmin:
return True
if not context.roleIds:
return False
try:
rootInterface = getRootInterface()
for roleId in context.roleIds:
roleRecords = rootInterface.db.getRecordset(Role, recordFilter={"id": roleId})
if roleRecords:
role = roleRecords[0]
roleLabel = role.get("roleLabel", "")
# Admin role at mandate level
if roleLabel == "admin" and role.get("mandateId") and not role.get("featureInstanceId"):
return True
return False
except Exception as e:
logger.error(f"Error checking mandate admin role: {e}")
return False
def _updateAccessRules(interface, roleId: str, newRules: List[Dict[str, Any]]) -> int:
"""
Update access rules for a role.
Replaces existing rules with new ones.
Returns:
Number of rules created/updated
"""
try:
# Delete existing rules for this role
existingRules = interface.db.getRecordset(AccessRule, recordFilter={"roleId": roleId})
for rule in existingRules:
interface.db.recordDelete(AccessRule, rule.get("id"))
# Create new rules
count = 0
for ruleData in newRules:
newRule = AccessRule(
roleId=roleId,
context=ruleData.get("context"),
item=ruleData.get("item"),
view=ruleData.get("view", False),
read=ruleData.get("read"),
create=ruleData.get("create"),
update=ruleData.get("update"),
delete=ruleData.get("delete")
)
interface.db.recordCreate(AccessRule, newRule.model_dump())
count += 1
return count
except Exception as e:
logger.error(f"Error updating access rules: {e}")
return 0

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.interfaceDbAppObjects import getRootInterface
from modules.datamodels.datamodelUam import User, UserInDB, AuthAuthority
from modules.datamodels.datamodelSecurity import Token
from modules.shared.configuration import APP_CONFIG
@ -26,13 +32,63 @@ router = APIRouter(
}
)
def _ensure_admin_scope(current_user: User, target_mandate_id: Optional[str] = None) -> None:
roleLabels = current_user.roleLabels or []
if "admin" not in roleLabels and "sysadmin" not in roleLabels:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin privileges required")
if "admin" in roleLabels and "sysadmin" not in roleLabels:
if target_mandate_id and str(target_mandate_id) != str(current_user.mandateId):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden for target mandate")
def _getPoweronDatabases() -> List[str]:
"""Load databases from PostgreSQL host matching poweron_%."""
dbHost = APP_CONFIG.get("DB_HOST")
dbUser = APP_CONFIG.get("DB_USER")
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
# Connect to 'postgres' system database to query all databases
connector = DatabaseConnector(
dbHost=dbHost,
dbDatabase="postgres",
dbUser=dbUser,
dbPassword=dbPassword,
dbPort=dbPort,
userId=None
)
try:
with connector.connection.cursor() as cursor:
cursor.execute(
"""
SELECT datname
FROM pg_database
WHERE datname LIKE 'poweron_%'
AND datistemplate = false
ORDER BY datname
"""
)
rows = cursor.fetchall()
return [row["datname"] for row in rows if row.get("datname")]
finally:
connector.close()
def _getDatabaseConnector(databaseName: str, userId: str = None) -> DatabaseConnector:
"""
Create a generic DatabaseConnector for any poweron_* database.
Fully dynamic - no interface mapping needed.
"""
if not databaseName.startswith("poweron_"):
raise ValueError(f"Invalid database name: {databaseName}")
dbHost = APP_CONFIG.get("DB_HOST")
dbUser = APP_CONFIG.get("DB_USER")
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
connector = DatabaseConnector(
dbHost=dbHost,
dbDatabase=databaseName,
dbUser=dbUser,
dbPassword=dbPassword,
dbPort=dbPort,
userId=userId
)
return connector
# ----------------------
@ -43,17 +99,19 @@ def _ensure_admin_scope(current_user: User, target_mandate_id: Optional[str] = N
@limiter.limit("30/minute")
async def list_tokens(
request: Request,
currentUser: User = Depends(getCurrentUser),
currentUser: User = Depends(requireSysAdmin),
userId: Optional[str] = None,
authority: Optional[str] = None,
sessionId: Optional[str] = None,
statusFilter: Optional[str] = None,
connectionId: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""
List all tokens in the system.
MULTI-TENANT: SysAdmin-only, no mandate filter (system-level view).
"""
try:
appInterface = getRootInterface()
target_mandate = currentUser.mandateId
_ensure_admin_scope(currentUser, target_mandate)
recordFilter: Dict[str, Any] = {}
if userId:
@ -66,9 +124,7 @@ async def list_tokens(
recordFilter["connectionId"] = connectionId
if statusFilter:
recordFilter["status"] = statusFilter
roleLabels = currentUser.roleLabels or []
if "admin" in roleLabels and "sysadmin" not in roleLabels:
recordFilter["mandateId"] = str(currentUser.mandateId)
# MULTI-TENANT: SysAdmin sees ALL tokens (no mandate filter)
tokens = appInterface.db.getRecordset(Token, recordFilter=recordFilter)
return tokens
@ -83,27 +139,26 @@ async def list_tokens(
@limiter.limit("30/minute")
async def revoke_tokens_by_user(
request: Request,
currentUser: User = Depends(getCurrentUser),
currentUser: User = Depends(requireSysAdmin),
payload: Dict[str, Any] = Body(...)
) -> Dict[str, Any]:
"""
Revoke all tokens for a user.
MULTI-TENANT: SysAdmin-only, can revoke across all mandates.
"""
try:
userId = payload.get("userId")
authority = payload.get("authority")
reason = payload.get("reason", "admin revoke")
reason = payload.get("reason", "sysadmin revoke")
if not userId:
raise HTTPException(status_code=400, detail="userId is required")
appInterface = getRootInterface()
# Tenant scope check
target_user = appInterface.db.getRecordset(User, recordFilter={"id": userId})
target_mandate = target_user[0].get("mandateId") if target_user else None
_ensure_admin_scope(currentUser, target_mandate)
roleLabels = currentUser.roleLabels or []
# MULTI-TENANT: SysAdmin can revoke any user's tokens (no mandate restriction)
count = appInterface.revokeTokensByUser(
userId=userId,
authority=AuthAuthority(authority) if authority else None,
mandateId=None if "sysadmin" in roleLabels else str(currentUser.mandateId),
mandateId=None, # SysAdmin: no mandate filter
revokedBy=currentUser.id,
reason=reason
)
@ -119,22 +174,23 @@ async def revoke_tokens_by_user(
@limiter.limit("30/minute")
async def revoke_tokens_by_session(
request: Request,
currentUser: User = Depends(getCurrentUser),
currentUser: User = Depends(requireSysAdmin),
payload: Dict[str, Any] = Body(...)
) -> Dict[str, Any]:
"""
Revoke all tokens for a specific session.
MULTI-TENANT: SysAdmin-only.
"""
try:
userId = payload.get("userId")
sessionId = payload.get("sessionId")
authority = payload.get("authority", "local")
reason = payload.get("reason", "admin session revoke")
reason = payload.get("reason", "sysadmin session revoke")
if not userId or not sessionId:
raise HTTPException(status_code=400, detail="userId and sessionId are required")
appInterface = getRootInterface()
target_user = appInterface.db.getRecordset(User, recordFilter={"id": userId})
target_mandate = target_user[0].get("mandateId") if target_user else None
_ensure_admin_scope(currentUser, target_mandate)
# MULTI-TENANT: SysAdmin can revoke any session (no mandate check)
count = appInterface.revokeTokensBySessionId(
sessionId=sessionId,
userId=userId,
@ -154,22 +210,20 @@ async def revoke_tokens_by_session(
@limiter.limit("30/minute")
async def revoke_token_by_id(
request: Request,
currentUser: User = Depends(getCurrentUser),
currentUser: User = Depends(requireSysAdmin),
payload: Dict[str, Any] = Body(...)
) -> Dict[str, Any]:
"""
Revoke a specific token by ID.
MULTI-TENANT: SysAdmin-only.
"""
try:
tokenId = payload.get("tokenId")
reason = payload.get("reason", "admin revoke")
reason = payload.get("reason", "sysadmin revoke")
if not tokenId:
raise HTTPException(status_code=400, detail="tokenId is required")
appInterface = getRootInterface()
# Load token to check tenant scope for admins
tokens = appInterface.db.getRecordset(Token, recordFilter={"id": tokenId})
if not tokens:
return {"revoked": 0}
target_mandate = tokens[0].get("mandateId")
_ensure_admin_scope(currentUser, target_mandate)
# MULTI-TENANT: SysAdmin can revoke any token (no mandate check)
ok = appInterface.revokeTokenById(tokenId, revokedBy=currentUser.id, reason=reason)
return {"revoked": 1 if ok else 0}
except HTTPException:
@ -183,29 +237,34 @@ async def revoke_token_by_id(
@limiter.limit("10/minute")
async def revoke_tokens_by_mandate(
request: Request,
currentUser: User = Depends(getCurrentUser),
currentUser: User = Depends(requireSysAdmin),
payload: Dict[str, Any] = Body(...)
) -> Dict[str, Any]:
"""
Revoke all tokens for users in a mandate.
MULTI-TENANT: SysAdmin-only, can revoke tokens for any mandate.
"""
try:
mandateId = payload.get("mandateId")
authority = payload.get("authority", "local")
reason = payload.get("reason", "admin mandate revoke")
reason = payload.get("reason", "sysadmin mandate revoke")
if not mandateId:
raise HTTPException(status_code=400, detail="mandateId is required")
_ensure_admin_scope(currentUser, mandateId)
# Revoke for all users in mandate
# MULTI-TENANT: SysAdmin can revoke tokens for any mandate
appInterface = getRootInterface()
# IMPORTANT: user rows are stored as UserInDB in the database
users = appInterface.db.getRecordset(UserInDB, recordFilter={"mandateId": mandateId})
# Get all UserMandate entries for this mandate to find users
# Note: In new model, users are linked via UserMandate, not User.mandateId
from modules.datamodels.datamodelMembership import UserMandate
userMandates = appInterface.db.getRecordset(UserMandate, recordFilter={"mandateId": mandateId})
total = 0
for u in users:
# Revoke regardless of token.mandateId to also catch legacy tokens without mandateId
for um in userMandates:
total += appInterface.revokeTokensByUser(
userId=u["id"],
authority=AuthAuthority(authority),
mandateId=None,
userId=um["userId"],
authority=AuthAuthority(authority) if authority else None,
mandateId=None, # Revoke all tokens for user
revokedBy=currentUser.id,
reason=reason
)
@ -225,10 +284,13 @@ async def revoke_tokens_by_mandate(
@limiter.limit("60/minute")
async def download_log(
request: Request,
currentUser: User = Depends(getCurrentUser),
currentUser: User = Depends(requireSysAdmin),
log_name: str = "poweron"
):
_ensure_admin_scope(currentUser)
"""
Download server logs.
MULTI-TENANT: SysAdmin-only (infrastructure management).
"""
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# base_dir -> gateway
if log_name == "poweron":
@ -251,33 +313,18 @@ async def download_log(
@limiter.limit("10/minute")
async def list_databases(
request: Request,
currentUser: User = Depends(getCurrentUser)
currentUser: User = Depends(requireSysAdmin)
) -> Dict[str, Any]:
_ensure_admin_scope(currentUser)
# Get database names from configuration for each interface
databases = []
# App database (interfaceDbAppObjects.py)
app_db = APP_CONFIG.get("DB_APP_DATABASE")
if app_db:
databases.append(app_db)
# Chat database (interfaceDbChatObjects.py)
chat_db = APP_CONFIG.get("DB_CHAT_DATABASE")
if chat_db:
databases.append(chat_db)
# Management database (interfaceDbComponentObjects.py)
management_db = APP_CONFIG.get("DB_MANAGEMENT_DATABASE")
if management_db:
databases.append(management_db)
# Fallback to default if no databases configured
if not databases:
databases = ["poweron"]
return {"databases": databases}
"""
List all poweron_* databases.
MULTI-TENANT: SysAdmin-only (infrastructure management).
"""
try:
databases = _getPoweronDatabases()
return {"databases": databases}
except Exception as e:
logger.error(f"Failed to load databases from host: {e}")
raise HTTPException(status_code=500, detail="Failed to load databases from host")
@router.get("/databases/{database_name}/tables")
@ -285,48 +332,28 @@ async def list_databases(
async def get_database_tables(
request: Request,
database_name: str,
currentUser: User = Depends(getCurrentUser)
currentUser: User = Depends(requireSysAdmin)
) -> Dict[str, Any]:
_ensure_admin_scope(currentUser)
# Get all configured database names
configured_dbs = []
app_db = APP_CONFIG.get("DB_APP_DATABASE")
if app_db:
configured_dbs.append(app_db)
chat_db = APP_CONFIG.get("DB_CHAT_DATABASE")
if chat_db:
configured_dbs.append(chat_db)
management_db = APP_CONFIG.get("DB_MANAGEMENT_DATABASE")
if management_db:
configured_dbs.append(management_db)
if not configured_dbs:
configured_dbs = ["poweron"]
if database_name not in configured_dbs:
raise HTTPException(status_code=400, detail=f"Invalid database name. Available databases: {configured_dbs}")
"""
List tables in a database.
MULTI-TENANT: SysAdmin-only (infrastructure management).
"""
if not database_name.startswith("poweron_"):
raise HTTPException(status_code=400, detail="Invalid database name format")
connector = None
try:
# Use the appropriate interface based on database name
if database_name == app_db:
appInterface = getRootInterface()
tables = appInterface.db.getTables()
elif database_name == chat_db:
from modules.interfaces.interfaceDbChatObjects import getInterface as getChatInterface
chatInterface = getChatInterface(currentUser)
tables = chatInterface.db.getTables()
elif database_name == management_db:
from modules.interfaces.interfaceDbComponentObjects import getInterface as getComponentInterface
componentInterface = getComponentInterface(currentUser)
tables = componentInterface.db.getTables()
else:
raise HTTPException(status_code=400, detail="Database not found")
connector = _getDatabaseConnector(database_name, currentUser.id)
tables = connector.getTables()
return {"tables": tables}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Error getting database tables: {str(e)}")
raise HTTPException(status_code=500, detail="Failed to get database tables")
raise HTTPException(status_code=500, detail=f"Failed to get database tables: {str(e)}")
finally:
if connector:
connector.close()
@router.post("/databases/{database_name}/tables/{table_name}/drop")
@ -335,43 +362,20 @@ async def drop_table(
request: Request,
database_name: str,
table_name: str,
currentUser: User = Depends(getCurrentUser),
currentUser: User = Depends(requireSysAdmin),
payload: Dict[str, Any] = Body(...)
) -> Dict[str, Any]:
_ensure_admin_scope(currentUser)
# Get all configured database names
configured_dbs = []
app_db = APP_CONFIG.get("DB_APP_DATABASE")
if app_db:
configured_dbs.append(app_db)
chat_db = APP_CONFIG.get("DB_CHAT_DATABASE")
if chat_db:
configured_dbs.append(chat_db)
management_db = APP_CONFIG.get("DB_MANAGEMENT_DATABASE")
if management_db:
configured_dbs.append(management_db)
if not configured_dbs:
configured_dbs = ["poweron"]
if database_name not in configured_dbs:
raise HTTPException(status_code=400, detail=f"Invalid database name. Available databases: {configured_dbs}")
"""
Drop a table from a database.
MULTI-TENANT: SysAdmin-only (infrastructure management).
"""
if not database_name.startswith("poweron_"):
raise HTTPException(status_code=400, detail="Invalid database name format")
connector = None
try:
# Use the appropriate interface based on database name
if database_name == app_db:
interface = getRootInterface()
elif database_name == chat_db:
from modules.interfaces.interfaceDbChatObjects import getInterface as getChatInterface
interface = getChatInterface(currentUser)
elif database_name == management_db:
from modules.interfaces.interfaceDbComponentObjects import getInterface as getComponentInterface
interface = getComponentInterface(currentUser)
else:
raise HTTPException(status_code=400, detail="Database not found")
conn = interface.db.connection
connector = _getDatabaseConnector(database_name, currentUser.id)
conn = connector.connection
with conn.cursor() as cursor:
# Check if table exists
cursor.execute("""
@ -388,57 +392,50 @@ async def drop_table(
return {"message": f"Table '{table_name}' dropped successfully from database '{database_name}'"}
except HTTPException:
raise
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Error dropping table: {str(e)}")
if 'interface' in locals() and interface and interface.db and interface.db.connection:
interface.db.connection.rollback()
if connector and connector.connection:
connector.connection.rollback()
raise HTTPException(status_code=500, detail="Failed to drop table")
finally:
if connector:
connector.close()
@router.post("/databases/drop")
@limiter.limit("5/minute")
async def drop_database(
request: Request,
currentUser: User = Depends(getCurrentUser),
currentUser: User = Depends(requireSysAdmin),
payload: Dict[str, Any] = Body(...)
) -> Dict[str, Any]:
_ensure_admin_scope(currentUser)
db_name = payload.get("database")
"""
Drop all tables in a database.
MULTI-TENANT: SysAdmin-only (infrastructure management).
"""
dbName = payload.get("database")
# Get all configured database names
configured_dbs = []
app_db = APP_CONFIG.get("DB_APP_DATABASE")
if app_db:
configured_dbs.append(app_db)
chat_db = APP_CONFIG.get("DB_CHAT_DATABASE")
if chat_db:
configured_dbs.append(chat_db)
management_db = APP_CONFIG.get("DB_MANAGEMENT_DATABASE")
if management_db:
configured_dbs.append(management_db)
if not dbName or not dbName.startswith("poweron_"):
raise HTTPException(status_code=400, detail="Invalid database name")
if not configured_dbs:
configured_dbs = ["poweron"]
if not db_name or db_name not in configured_dbs:
raise HTTPException(status_code=400, detail=f"Invalid database name. Available databases: {configured_dbs}")
# Validate database exists
try:
# Use the appropriate interface based on database name
if db_name == app_db:
interface = getRootInterface()
elif db_name == chat_db:
from modules.interfaces.interfaceDbChatObjects import getInterface as getChatInterface
interface = getChatInterface(currentUser)
elif db_name == management_db:
from modules.interfaces.interfaceDbComponentObjects import getInterface as getComponentInterface
interface = getComponentInterface(currentUser)
else:
raise HTTPException(status_code=400, detail="Database not found")
conn = interface.db.connection
configuredDbs = _getPoweronDatabases()
except Exception as e:
logger.warning(f"Failed to load databases from host: {e}")
configuredDbs = []
if configuredDbs and dbName not in configuredDbs:
raise HTTPException(status_code=400, detail=f"Database not found. Available: {configuredDbs}")
connector = None
try:
connector = _getDatabaseConnector(dbName, currentUser.id)
conn = connector.connection
with conn.cursor() as cursor:
# Drop all user tables (public schema) except system table
# Drop all user tables (public schema)
cursor.execute("""
SELECT table_name FROM information_schema.tables
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
@ -449,12 +446,17 @@ async def drop_database(
cursor.execute(f'DROP TABLE IF EXISTS "{tbl}" CASCADE')
dropped.append(tbl)
conn.commit()
logger.warning(f"Admin drop_database executed by {currentUser.id}: dropped tables from '{db_name}': {dropped}")
logger.warning(f"Admin drop_database executed by {currentUser.id}: dropped tables from '{dbName}': {dropped}")
return {"droppedTables": dropped}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Error dropping database tables: {str(e)}")
if 'interface' in locals() and interface and interface.db and interface.db.connection:
interface.db.connection.rollback()
if connector and connector.connection:
connector.connection.rollback()
raise HTTPException(status_code=500, detail="Failed to drop database tables")
finally:
if connector:
connector.close()

View file

@ -340,11 +340,12 @@ async def auth_callback(code: str, state: str, request: Request, response: Respo
)
# Create JWT token data (like Microsoft does)
# MULTI-TENANT: Token does NOT contain mandateId anymore
jwt_token_data = {
"sub": user.username,
"mandateId": str(user.mandateId),
"userId": str(user.id),
"authenticationAuthority": AuthAuthority.GOOGLE.value
# NO mandateId in token - stateless multi-tenant design
}
# Create JWT access token
@ -360,6 +361,7 @@ async def auth_callback(code: str, state: str, request: Request, response: Respo
jti = payload.get("jti")
# Create JWT token with matching id
# MULTI-TENANT: Token model no longer has mandateId field
token = Token(
id=jti,
userId=user.id, # Use local user's ID
@ -368,8 +370,8 @@ async def auth_callback(code: str, state: str, request: Request, response: Respo
tokenRefresh=token_response.get("refresh_token", ""),
tokenType="bearer",
expiresAt=jwt_expires_at.timestamp(),
createdAt=getUtcTimestamp(),
mandateId=str(user.mandateId)
createdAt=getUtcTimestamp()
# NO mandateId - Token is not mandate-bound
)
# Save access token (no connectionId)
@ -615,11 +617,12 @@ async def logout(
appInterface.logout()
# Log successful logout
# MULTI-TENANT: Logout is a system-level function, no mandate context
try:
from modules.shared.auditLogger import audit_logger
audit_logger.logUserAccess(
userId=str(currentUser.id),
mandateId=str(currentUser.mandateId),
mandateId="system",
action="logout",
successInfo="google_auth_logout"
)

View file

@ -83,11 +83,13 @@ async def login(
)
# Create token data
# MULTI-TENANT: Token does NOT contain mandateId anymore
# Mandate context is determined per request via X-Mandate-Id header
token_data = {
"sub": user.username,
"mandateId": str(user.mandateId),
"userId": str(user.id),
"authenticationAuthority": AuthAuthority.LOCAL
# NO mandateId in token - stateless multi-tenant design
}
# Create session id and include in token claims for session-scoped logout
@ -116,7 +118,8 @@ async def login(
# Get jti from already decoded payload
jti = payload.get("jti")
# Create token
# Create token record in database
# MULTI-TENANT: Token model no longer has mandateId field
token = Token(
id=jti,
userId=user.id,
@ -124,19 +127,20 @@ async def login(
tokenAccess=access_token,
tokenType="bearer",
expiresAt=expires_at.timestamp(),
sessionId=session_id,
mandateId=str(user.mandateId)
sessionId=session_id
# NO mandateId - Token is not mandate-bound
)
# Save access token
userInterface.saveAccessToken(token)
# Log successful login
# MULTI-TENANT: Login is a system-level function, no mandate context
try:
from modules.shared.auditLogger import audit_logger
audit_logger.logUserAccess(
userId=str(user.id),
mandateId=str(user.mandateId),
mandateId="system",
action="login",
successInfo="local_auth_success"
)
@ -236,7 +240,6 @@ async def register_user(
fullName=userData.fullName,
language=userData.language,
enabled=True, # Users are enabled by default (can login after setting password)
roleLabels=["user"], # Default role for new registrations
authenticationAuthority=AuthAuthority.LOCAL
)
@ -358,11 +361,12 @@ async def refresh_token(
raise HTTPException(status_code=500, detail="Failed to validate user")
# Create new token data
# MULTI-TENANT: Token does NOT contain mandateId anymore
token_data = {
"sub": current_user.username,
"mandateId": str(current_user.mandateId),
"userId": str(current_user.id),
"authenticationAuthority": current_user.authenticationAuthority
# NO mandateId in token
}
# Create new access token + set cookie
@ -427,11 +431,12 @@ async def logout(request: Request, response: Response, currentUser: User = Depen
revoked = 1
# Log successful logout
# MULTI-TENANT: Logout is a system-level function, no mandate context
try:
from modules.shared.auditLogger import audit_logger
audit_logger.logUserAccess(
userId=str(currentUser.id),
mandateId=str(currentUser.mandateId),
mandateId="system",
action="logout",
successInfo=f"revoked_tokens: {revoked}"
)

View file

@ -348,11 +348,12 @@ async def auth_callback(code: str, state: str, request: Request, response: Respo
appInterface.saveAccessToken(token)
# Create JWT token data
# MULTI-TENANT: Token does NOT contain mandateId anymore
jwt_token_data = {
"sub": user.username,
"mandateId": str(user.mandateId),
"userId": str(user.id),
"authenticationAuthority": AuthAuthority.MSFT.value
# NO mandateId in token - stateless multi-tenant design
}
# Create JWT access token
@ -368,6 +369,7 @@ async def auth_callback(code: str, state: str, request: Request, response: Respo
jti = payload.get("jti")
# Create JWT token with matching id
# MULTI-TENANT: Token model no longer has mandateId field
jwt_token_obj = Token(
id=jti,
userId=user.id,
@ -375,8 +377,8 @@ async def auth_callback(code: str, state: str, request: Request, response: Respo
tokenAccess=jwt_token,
tokenType="bearer",
expiresAt=jwt_expires_at.timestamp(),
createdAt=getUtcTimestamp(),
mandateId=str(user.mandateId)
createdAt=getUtcTimestamp()
# NO mandateId - Token is not mandate-bound
)
# Save JWT access token
@ -625,11 +627,12 @@ async def logout(
appInterface.logout()
# Log successful logout
# MULTI-TENANT: Logout is a system-level function, no mandate context
try:
from modules.shared.auditLogger import audit_logger
audit_logger.logUserAccess(
userId=str(currentUser.id),
mandateId=str(currentUser.mandateId),
mandateId="system",
action="logout",
successInfo="microsoft_auth_logout"
)

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 typing import List, Optional, TYPE_CHECKING
from modules.datamodels.datamodelRbac import AccessRule, AccessRuleContext, Role
from modules.datamodels.datamodelUam import User, UserPermissions, AccessLevel
from modules.datamodels.datamodelMembership import (
UserMandate,
UserMandateRole,
FeatureAccess,
FeatureAccessRole
)
if TYPE_CHECKING:
from modules.connectors.connectorDbPostgre import DatabaseConnector
@ -20,6 +30,11 @@ logger = logging.getLogger(__name__)
class RbacClass:
"""
RBAC interface for permission resolution and rule validation.
Multi-Tenant Design:
- Lädt Rollen über UserMandate + UserMandateRole
- AccessRules werden über roleId gefunden
- isSysAdmin für System-Level Operationen (ohne Mandant)
"""
def __init__(self, db: "DatabaseConnector", dbApp: "DatabaseConnector"):
@ -34,14 +49,27 @@ class RbacClass:
self.db = db
self.dbApp = dbApp
def getUserPermissions(self, user: User, context: AccessRuleContext, item: str) -> UserPermissions:
def getUserPermissions(
self,
user: User,
context: AccessRuleContext,
item: str,
mandateId: Optional[str] = None,
featureInstanceId: Optional[str] = None
) -> UserPermissions:
"""
Get combined permissions for a user across all their roles.
Multi-Tenant Design:
- Lädt Rollen aus UserMandate + UserMandateRole wenn mandateId gegeben
- isSysAdmin gibt vollen Zugriff auf System-Level (kein mandateId)
Args:
user: User object with roleLabels
user: User object
context: Access rule context (DATA, UI, RESOURCE)
item: Item identifier (table name, UI path, resource path)
mandateId: Optional mandate context for role lookup
featureInstanceId: Optional feature instance context
Returns:
UserPermissions object with combined permissions
@ -54,23 +82,37 @@ class RbacClass:
delete=AccessLevel.NONE
)
if not hasattr(user, 'roleLabels') or not user.roleLabels:
# SysAdmin auf System-Level (kein Mandant) hat vollen Zugriff
if hasattr(user, 'isSysAdmin') and user.isSysAdmin and not mandateId:
return UserPermissions(
view=True,
read=AccessLevel.ALL,
create=AccessLevel.ALL,
update=AccessLevel.ALL,
delete=AccessLevel.ALL
)
# Lade Role-IDs für den User via UserMandate + UserMandateRole
roleIds = self._getRoleIdsForUser(user, mandateId, featureInstanceId)
if not roleIds:
return permissions
# Step 1: For each role, find the most specific matching rule (most specific wins within role)
rolePermissions = {}
for roleLabel in user.roleLabels:
# Get all rules for this role and context
allRules = self._getRulesForRole(roleLabel, context)
# Find most specific rule for this item (longest matching prefix)
mostSpecificRule = self.findMostSpecificRule(allRules, item)
if mostSpecificRule:
rolePermissions[roleLabel] = mostSpecificRule
# Lade alle relevanten Regeln für alle Rollen
allRulesWithPriority = self._getRulesForRoleIds(roleIds, context, mandateId, featureInstanceId)
# Step 2: Combine permissions across roles using opening (union) logic
for roleLabel, rule in rolePermissions.items():
# Für jede Rolle die spezifischste Regel finden
rolePermissions = {}
for priority, rule in allRulesWithPriority:
# Find most specific rule for this item
if self._ruleMatchesItem(rule, item):
roleId = rule.roleId
# Speichere mit Priorität (höhere Priorität überschreibt)
if roleId not in rolePermissions or priority > rolePermissions[roleId][0]:
rolePermissions[roleId] = (priority, rule)
# Combine permissions across roles using opening (union) logic
for roleId, (priority, rule) in rolePermissions.items():
# View: union logic - if ANY role has view=true, then view=true
if rule.view:
permissions.view = True
@ -88,6 +130,274 @@ class RbacClass:
return permissions
def _getRoleIdsForUser(
self,
user: User,
mandateId: Optional[str],
featureInstanceId: Optional[str]
) -> List[str]:
"""
Get all role IDs for a user in the given context.
Uses UserMandate + UserMandateRole for the new multi-tenant model.
Args:
user: User object
mandateId: Mandate context
featureInstanceId: Feature instance context
Returns:
List of role IDs
"""
roleIds = []
if not mandateId:
return roleIds
try:
# Lade UserMandate
userMandates = self.dbApp.getRecordset(
UserMandate,
recordFilter={"userId": user.id, "mandateId": mandateId, "enabled": True}
)
if not userMandates:
return roleIds
userMandateId = userMandates[0].get("id")
# Lade UserMandateRoles (Mandate-level roles)
userMandateRoles = self.dbApp.getRecordset(
UserMandateRole,
recordFilter={"userMandateId": userMandateId}
)
roleIds.extend([r.get("roleId") for r in userMandateRoles if r.get("roleId")])
# Load FeatureAccess + FeatureAccessRole (Instance-level roles)
if featureInstanceId:
featureAccessRecords = self.dbApp.getRecordset(
FeatureAccess,
recordFilter={
"userId": user.id,
"featureInstanceId": featureInstanceId,
"enabled": True
}
)
if featureAccessRecords:
featureAccessId = featureAccessRecords[0].get("id")
featureAccessRoles = self.dbApp.getRecordset(
FeatureAccessRole,
recordFilter={"featureAccessId": featureAccessId}
)
roleIds.extend([r.get("roleId") for r in featureAccessRoles if r.get("roleId")])
except Exception as e:
logger.error(f"Error loading role IDs for user {user.id}: {e}")
return roleIds
def getRulesForUserBulk(
self,
userId: str,
mandateId: str,
featureInstanceId: Optional[str] = None
) -> List[tuple]:
"""
Lädt alle relevanten Regeln für einen User in EINEM Query.
Stateless: Kein Cache, direkt aus DB.
Optimiert für Multi-Tenant mit Junction Tables:
- Mandant-Rollen via UserMandate UserMandateRole
- Instanz-Rollen via FeatureAccess FeatureAccessRole
Args:
userId: User ID
mandateId: Mandate context
featureInstanceId: Optional feature instance context
Returns:
Liste von (priority, AccessRule) Tupeln
"""
if not mandateId:
return []
try:
conn = self.dbApp.connection
roleIds = set()
# 1. Mandant-Rollen via UserMandate → UserMandateRole (SINGLE Query)
with conn.cursor() as cursor:
cursor.execute(
"""
SELECT umr."roleId"
FROM "UserMandate" um
JOIN "UserMandateRole" umr ON umr."userMandateId" = um.id
WHERE um."userId" = %s AND um."mandateId" = %s AND um."enabled" = true
""",
(userId, mandateId)
)
mandateRoles = cursor.fetchall()
roleIds.update(r["roleId"] for r in mandateRoles if r.get("roleId"))
# 2. Instanz-Rollen via FeatureAccess → FeatureAccessRole (SINGLE Query)
if featureInstanceId:
with conn.cursor() as cursor:
cursor.execute(
"""
SELECT far."roleId"
FROM "FeatureAccess" fa
JOIN "FeatureAccessRole" far ON far."featureAccessId" = fa.id
WHERE fa."userId" = %s AND fa."featureInstanceId" = %s AND fa."enabled" = true
""",
(userId, featureInstanceId)
)
instanceRoles = cursor.fetchall()
roleIds.update(r["roleId"] for r in instanceRoles if r.get("roleId"))
if not roleIds:
return []
# 3. BULK Query: Alle Regeln für alle Rollen + zugehörige Role-Daten
# SINGLE Query mit JOIN statt N+1
roleIdsList = list(roleIds)
with conn.cursor() as cursor:
cursor.execute(
"""
SELECT ar.*, r."mandateId" as "roleMandateId",
r."featureInstanceId" as "roleInstanceId"
FROM "AccessRule" ar
JOIN "Role" r ON ar."roleId" = r.id
WHERE ar."roleId" = ANY(%s)
""",
(roleIdsList,)
)
allRulesWithContext = cursor.fetchall()
# 4. Priorität zuweisen basierend auf Role-Scope
rulesWithPriority = []
for ruleRecord in allRulesWithContext:
ruleDict = dict(ruleRecord)
# Bestimme Priorität
if ruleDict.get("roleInstanceId"):
priority = 3 # Instance-Rolle = höchste Priorität
elif ruleDict.get("roleMandateId"):
priority = 2 # Mandate-Rolle
else:
priority = 1 # Global-Rolle = niedrigste Priorität
# Entferne Hilfsspalten vor AccessRule-Erstellung
ruleDict.pop("roleMandateId", None)
ruleDict.pop("roleInstanceId", None)
try:
rule = AccessRule(**ruleDict)
rulesWithPriority.append((priority, rule))
except Exception as e:
logger.error(f"Error converting rule record: {e}")
return rulesWithPriority
except Exception as e:
logger.error(f"Error in getRulesForUserBulk: {e}")
return []
def _getRulesForRoleIds(
self,
roleIds: List[str],
context: AccessRuleContext,
mandateId: Optional[str],
featureInstanceId: Optional[str]
) -> List[tuple]:
"""
Get all access rules for the given role IDs with priority.
Priority:
- 3: Instance-specific role (featureInstanceId set)
- 2: Mandate-specific role (mandateId set, no featureInstanceId)
- 1: Global role (no mandateId)
Args:
roleIds: List of role IDs
context: Access rule context
mandateId: Current mandate context
featureInstanceId: Current feature instance context
Returns:
List of (priority, AccessRule) tuples
"""
rulesWithPriority = []
if not roleIds:
return rulesWithPriority
try:
# Lade alle Regeln für alle Rollen
for roleId in roleIds:
rules = self.dbApp.getRecordset(
AccessRule,
recordFilter={"roleId": roleId, "context": context.value}
)
# Lade Role um Priorität zu bestimmen
roleRecords = self.dbApp.getRecordset(Role, recordFilter={"id": roleId})
if not roleRecords:
continue
role = roleRecords[0]
# Bestimme Priorität basierend auf Role-Scope
if role.get("featureInstanceId"):
priority = 3 # Instance-specific
elif role.get("mandateId"):
priority = 2 # Mandate-specific
else:
priority = 1 # Global
for ruleRecord in rules:
try:
rule = AccessRule(**ruleRecord)
rulesWithPriority.append((priority, rule))
except Exception as e:
logger.error(f"Error converting rule record: {e}")
except Exception as e:
logger.error(f"Error loading rules for role IDs: {e}")
return rulesWithPriority
def _ruleMatchesItem(self, rule: AccessRule, item: str) -> bool:
"""
Check if a rule matches the given item.
Args:
rule: Access rule to check
item: Item to match against
Returns:
True if rule matches item
"""
if rule.item is None:
# Generic rule matches everything
return True
if not item:
# No item specified, only generic rules match
return rule.item is None
# Exact match
if rule.item == item:
return True
# Prefix match (e.g., "trustee" matches "trustee.contract")
if item.startswith(rule.item + "."):
return True
return False
def findMostSpecificRule(self, rules: List[AccessRule], item: str) -> Optional[AccessRule]:
"""
Find the most specific rule for an item (longest matching prefix wins).
@ -105,7 +415,6 @@ class RbacClass:
return genericRules[0] if genericRules else None
# Find longest matching prefix
itemParts = item.split(".")
bestMatch = None
bestMatchLength = -1
@ -176,39 +485,3 @@ class RbacClass:
AccessLevel.ALL: 3
}
return hierarchy.get(level1, 0) > hierarchy.get(level2, 0)
def _getRulesForRole(self, roleLabel: str, context: AccessRuleContext) -> List[AccessRule]:
"""
Get all access rules for a specific role and context.
Always queries from DbApp database, not the current database.
Args:
roleLabel: Role label to get rules for
context: Context type
Returns:
List of AccessRule objects
"""
try:
# Always use DbApp database for AccessRule queries
rules = self.dbApp.getRecordset(
AccessRule,
recordFilter={
"roleLabel": roleLabel,
"context": context.value
}
)
# Convert dict records to AccessRule objects
accessRules = []
for record in rules:
try:
accessRule = AccessRule(**record)
accessRules.append(accessRule)
except Exception as e:
logger.error(f"Error converting rule record to AccessRule: {e}, record={record}")
return accessRules
except Exception as e:
logger.error(f"Error getting rules for role {roleLabel} and context {context.value}: {e}", exc_info=True)
return []

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

View file

@ -1,6 +1,6 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
from typing import Any
from typing import Any, Optional
from modules.datamodels.datamodelUam import User
from modules.datamodels.datamodelChat import ChatWorkflow
@ -40,25 +40,26 @@ class PublicService:
class Services:
def __init__(self, user: User, workflow: ChatWorkflow = None):
def __init__(self, user: User, workflow: ChatWorkflow = None, mandateId: Optional[str] = None):
self.user: User = user
self.workflow: ChatWorkflow = workflow
self.mandateId: Optional[str] = mandateId
self.currentUserPrompt: str = "" # Cleaned/normalized user intent for the current round
self.rawUserPrompt: str = "" # Original raw user message for the current round
# Initialize interfaces
# Initialize interfaces with explicit mandateId
from modules.interfaces.interfaceDbChatObjects import getInterface as getChatInterface
self.interfaceDbChat = getChatInterface(user)
self.interfaceDbChat = getChatInterface(user, mandateId=mandateId)
from modules.interfaces.interfaceDbAppObjects import getInterface as getAppInterface
self.interfaceDbApp = getAppInterface(user)
self.interfaceDbApp = getAppInterface(user, mandateId=mandateId)
from modules.interfaces.interfaceDbComponentObjects import getInterface as getComponentInterface
self.interfaceDbComponent = getComponentInterface(user)
self.interfaceDbComponent = getComponentInterface(user, mandateId=mandateId)
from modules.interfaces.interfaceDbTrusteeObjects import getInterface as getTrusteeInterface
self.interfaceDbTrustee = getTrusteeInterface(user)
self.interfaceDbTrustee = getTrusteeInterface(user, mandateId=mandateId)
# Expose RBAC directly on services for convenience
self.rbac = self.interfaceDbApp.rbac if self.interfaceDbApp else None
@ -99,7 +100,15 @@ class Services:
self.messaging = PublicService(MessagingService(self))
def getInterface(user: User, workflow: ChatWorkflow) -> Services:
return Services(user, workflow)
def getInterface(user: User, workflow: ChatWorkflow = None, mandateId: Optional[str] = None) -> Services:
"""
Get Services instance for the given user and mandate context.
Args:
user: The authenticated user
workflow: Optional ChatWorkflow context
mandateId: Explicit mandate context (from RequestContext / X-Mandate-Id header). Required.
"""
return Services(user, workflow, mandateId=mandateId)

View file

@ -0,0 +1,438 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Database optimizations for Multi-Tenant model.
Applies indexes, immutable triggers, and foreign key constraints
for the junction tables used in the multi-tenant mandate model.
Usage:
from modules.shared.dbMultiTenantOptimizations import applyMultiTenantOptimizations
# Call after database tables are created
applyMultiTenantOptimizations(dbConnector)
All operations are idempotent (safe to call multiple times).
"""
import logging
from typing import Optional, List
logger = logging.getLogger(__name__)
# =============================================================================
# Index Definitions
# =============================================================================
_INDEXES = [
# UserMandate indexes
("UserMandate", "idx_usermandate_user", ["userId"]),
("UserMandate", "idx_usermandate_user_mandate", ["userId", "mandateId"]),
("UserMandate", "idx_usermandate_mandate", ["mandateId"]),
# UserMandateRole indexes
("UserMandateRole", "idx_usermandaterole_usermandate", ["userMandateId"]),
("UserMandateRole", "idx_usermandaterole_role", ["roleId"]),
# FeatureAccess indexes
("FeatureAccess", "idx_featureaccess_user_instance", ["userId", "featureInstanceId"]),
("FeatureAccess", "idx_featureaccess_user", ["userId"]),
("FeatureAccess", "idx_featureaccess_instance", ["featureInstanceId"]),
# FeatureAccessRole indexes
("FeatureAccessRole", "idx_featureaccessrole_featureaccess", ["featureAccessId"]),
("FeatureAccessRole", "idx_featureaccessrole_role", ["roleId"]),
# AccessRule indexes
("AccessRule", "idx_accessrule_roleid", ["roleId"]),
("AccessRule", "idx_accessrule_context_roleid", ["context", "roleId"]),
# Role indexes
("Role", "idx_role_mandate_instance", ["mandateId", "featureInstanceId"]),
("Role", "idx_role_label", ["roleLabel"]),
# FeatureInstance indexes
("FeatureInstance", "idx_featureinstance_mandate", ["mandateId"]),
("FeatureInstance", "idx_featureinstance_mandate_code", ["mandateId", "featureCode"]),
# Invitation indexes
("Invitation", "idx_invitation_mandate", ["mandateId"]),
("Invitation", "idx_invitation_createdby", ["createdBy"]),
]
# Unique indexes (separate list)
_UNIQUE_INDEXES = [
("Invitation", "idx_invitation_token", ["token"]),
]
# Partial indexes (with WHERE clause)
_PARTIAL_INDEXES = [
("UserMandate", "idx_usermandate_user_enabled", ["userId"], '"enabled" = true'),
("Role", "idx_role_featurecode", ["featureCode"], '"mandateId" IS NULL'),
]
# =============================================================================
# Foreign Key Definitions
# =============================================================================
_FOREIGN_KEYS = [
# UserMandate FKs
("UserMandate", "fk_usermandate_mandate", "mandateId", "Mandate", "id"),
("UserMandate", "fk_usermandate_user", "userId", "User", "id"),
# FeatureInstance FKs
("FeatureInstance", "fk_featureinstance_mandate", "mandateId", "Mandate", "id"),
# Role FKs (nullable - only cascade when not null)
("Role", "fk_role_mandate", "mandateId", "Mandate", "id"),
("Role", "fk_role_instance", "featureInstanceId", "FeatureInstance", "id"),
# FeatureAccess FKs
("FeatureAccess", "fk_featureaccess_instance", "featureInstanceId", "FeatureInstance", "id"),
("FeatureAccess", "fk_featureaccess_user", "userId", "User", "id"),
# AccessRule FKs
("AccessRule", "fk_accessrule_role", "roleId", "Role", "id"),
# Junction table FKs
("UserMandateRole", "fk_usermandaterole_usermandate", "userMandateId", "UserMandate", "id"),
("UserMandateRole", "fk_usermandaterole_role", "roleId", "Role", "id"),
("FeatureAccessRole", "fk_featureaccessrole_featureaccess", "featureAccessId", "FeatureAccess", "id"),
("FeatureAccessRole", "fk_featureaccessrole_role", "roleId", "Role", "id"),
# Invitation FKs
("Invitation", "fk_invitation_mandate", "mandateId", "Mandate", "id"),
]
# =============================================================================
# Immutable Trigger Definitions
# =============================================================================
_IMMUTABLE_TRIGGERS = [
# Role: mandateId, featureInstanceId, featureCode are immutable
("Role", "tr_role_immutable", ["mandateId", "featureInstanceId", "featureCode"]),
# AccessRule: context, roleId are immutable
("AccessRule", "tr_accessrule_immutable", ["context", "roleId"]),
]
# =============================================================================
# Main Functions
# =============================================================================
def applyMultiTenantOptimizations(dbConnector, tables: Optional[List[str]] = None) -> dict:
"""
Apply all multi-tenant database optimizations.
Args:
dbConnector: Database connector with execute capability
tables: Optional list of table names to optimize. If None, optimizes all.
Returns:
dict with counts of created indexes, triggers, and foreign keys
"""
results = {
"indexesCreated": 0,
"triggersCreated": 0,
"foreignKeysCreated": 0,
"errors": []
}
try:
# Get a connection from the connector
conn = dbConnector._get_connection()
conn.autocommit = True
with conn.cursor() as cursor:
# Apply indexes
results["indexesCreated"] = _applyIndexes(cursor, tables)
# Apply foreign keys
results["foreignKeysCreated"] = _applyForeignKeys(cursor, tables)
# Apply immutable triggers
results["triggersCreated"] = _applyImmutableTriggers(cursor, tables)
logger.info(
f"Multi-tenant optimizations applied: "
f"{results['indexesCreated']} indexes, "
f"{results['triggersCreated']} triggers, "
f"{results['foreignKeysCreated']} foreign keys"
)
except Exception as e:
logger.error(f"Error applying multi-tenant optimizations: {e}")
results["errors"].append(str(e))
return results
def applyIndexesOnly(dbConnector, tables: Optional[List[str]] = None) -> int:
"""Apply only indexes (lighter operation, safe for frequent calls)."""
try:
conn = dbConnector._get_connection()
conn.autocommit = True
with conn.cursor() as cursor:
return _applyIndexes(cursor, tables)
except Exception as e:
logger.error(f"Error applying indexes: {e}")
return 0
# =============================================================================
# Internal Implementation
# =============================================================================
def _tableExists(cursor, tableName: str) -> bool:
"""Check if a table exists in the database."""
cursor.execute("""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = %s
)
""", (tableName,))
return cursor.fetchone()[0]
def _indexExists(cursor, indexName: str) -> bool:
"""Check if an index exists."""
cursor.execute("""
SELECT EXISTS (
SELECT FROM pg_indexes
WHERE indexname = %s
)
""", (indexName,))
return cursor.fetchone()[0]
def _constraintExists(cursor, constraintName: str) -> bool:
"""Check if a constraint exists."""
cursor.execute("""
SELECT EXISTS (
SELECT FROM pg_constraint
WHERE conname = %s
)
""", (constraintName,))
return cursor.fetchone()[0]
def _triggerExists(cursor, triggerName: str) -> bool:
"""Check if a trigger exists."""
cursor.execute("""
SELECT EXISTS (
SELECT FROM pg_trigger
WHERE tgname = %s
)
""", (triggerName,))
return cursor.fetchone()[0]
def _applyIndexes(cursor, tables: Optional[List[str]]) -> int:
"""Apply all indexes. Returns count of newly created indexes."""
created = 0
# Regular indexes
for tableName, indexName, columns in _INDEXES:
if tables and tableName not in tables:
continue
if not _tableExists(cursor, tableName):
continue
if _indexExists(cursor, indexName):
continue
try:
columnList = ", ".join(f'"{c}"' for c in columns)
cursor.execute(f'CREATE INDEX "{indexName}" ON "{tableName}" ({columnList})')
created += 1
logger.debug(f"Created index {indexName} on {tableName}")
except Exception as e:
logger.warning(f"Failed to create index {indexName}: {e}")
# Unique indexes
for tableName, indexName, columns in _UNIQUE_INDEXES:
if tables and tableName not in tables:
continue
if not _tableExists(cursor, tableName):
continue
if _indexExists(cursor, indexName):
continue
try:
columnList = ", ".join(f'"{c}"' for c in columns)
cursor.execute(f'CREATE UNIQUE INDEX "{indexName}" ON "{tableName}" ({columnList})')
created += 1
logger.debug(f"Created unique index {indexName} on {tableName}")
except Exception as e:
logger.warning(f"Failed to create unique index {indexName}: {e}")
# Partial indexes
for tableName, indexName, columns, whereClause in _PARTIAL_INDEXES:
if tables and tableName not in tables:
continue
if not _tableExists(cursor, tableName):
continue
if _indexExists(cursor, indexName):
continue
try:
columnList = ", ".join(f'"{c}"' for c in columns)
cursor.execute(f'CREATE INDEX "{indexName}" ON "{tableName}" ({columnList}) WHERE {whereClause}')
created += 1
logger.debug(f"Created partial index {indexName} on {tableName}")
except Exception as e:
logger.warning(f"Failed to create partial index {indexName}: {e}")
return created
def _applyForeignKeys(cursor, tables: Optional[List[str]]) -> int:
"""Apply foreign key constraints with CASCADE DELETE. Returns count created."""
created = 0
for tableName, constraintName, column, refTable, refColumn in _FOREIGN_KEYS:
if tables and tableName not in tables:
continue
if not _tableExists(cursor, tableName):
continue
if not _tableExists(cursor, refTable):
continue
if _constraintExists(cursor, constraintName):
continue
try:
cursor.execute(f"""
ALTER TABLE "{tableName}"
ADD CONSTRAINT "{constraintName}"
FOREIGN KEY ("{column}")
REFERENCES "{refTable}"("{refColumn}")
ON DELETE CASCADE
""")
created += 1
logger.debug(f"Created FK {constraintName} on {tableName}")
except Exception as e:
logger.warning(f"Failed to create FK {constraintName}: {e}")
return created
def _applyImmutableTriggers(cursor, tables: Optional[List[str]]) -> int:
"""Apply immutable field triggers. Returns count created."""
created = 0
for tableName, triggerName, immutableFields in _IMMUTABLE_TRIGGERS:
if tables and tableName not in tables:
continue
if not _tableExists(cursor, tableName):
continue
if _triggerExists(cursor, triggerName):
continue
try:
# Create the function
functionName = f"fn_{triggerName}"
checks = []
for field in immutableFields:
checks.append(f"""
IF OLD."{field}" IS DISTINCT FROM NEW."{field}" THEN
RAISE EXCEPTION '{field} is immutable on {tableName}. Delete and recreate instead.';
END IF;
""")
functionBody = "\n".join(checks)
cursor.execute(f"""
CREATE OR REPLACE FUNCTION "{functionName}"()
RETURNS TRIGGER AS $$
BEGIN
{functionBody}
RETURN NEW;
END;
$$ LANGUAGE plpgsql
""")
# Create the trigger
cursor.execute(f"""
CREATE TRIGGER "{triggerName}"
BEFORE UPDATE ON "{tableName}"
FOR EACH ROW
EXECUTE FUNCTION "{functionName}"()
""")
created += 1
logger.debug(f"Created immutable trigger {triggerName} on {tableName}")
except Exception as e:
logger.warning(f"Failed to create trigger {triggerName}: {e}")
return created
# =============================================================================
# Utility: Check optimization status
# =============================================================================
def getOptimizationStatus(dbConnector) -> dict:
"""
Check which optimizations are already applied.
Returns dict with lists of applied and missing optimizations.
"""
status = {
"indexes": {"applied": [], "missing": []},
"uniqueIndexes": {"applied": [], "missing": []},
"partialIndexes": {"applied": [], "missing": []},
"foreignKeys": {"applied": [], "missing": []},
"triggers": {"applied": [], "missing": []}
}
try:
conn = dbConnector._get_connection()
with conn.cursor() as cursor:
# Check regular indexes
for tableName, indexName, _ in _INDEXES:
if _tableExists(cursor, tableName):
if _indexExists(cursor, indexName):
status["indexes"]["applied"].append(indexName)
else:
status["indexes"]["missing"].append(indexName)
# Check unique indexes
for tableName, indexName, _ in _UNIQUE_INDEXES:
if _tableExists(cursor, tableName):
if _indexExists(cursor, indexName):
status["uniqueIndexes"]["applied"].append(indexName)
else:
status["uniqueIndexes"]["missing"].append(indexName)
# Check partial indexes
for tableName, indexName, _, _ in _PARTIAL_INDEXES:
if _tableExists(cursor, tableName):
if _indexExists(cursor, indexName):
status["partialIndexes"]["applied"].append(indexName)
else:
status["partialIndexes"]["missing"].append(indexName)
# Check foreign keys
for tableName, constraintName, _, _, _ in _FOREIGN_KEYS:
if _tableExists(cursor, tableName):
if _constraintExists(cursor, constraintName):
status["foreignKeys"]["applied"].append(constraintName)
else:
status["foreignKeys"]["missing"].append(constraintName)
# Check triggers
for tableName, triggerName, _ in _IMMUTABLE_TRIGGERS:
if _tableExists(cursor, tableName):
if _triggerExists(cursor, triggerName):
status["triggers"]["applied"].append(triggerName)
else:
status["triggers"]["missing"].append(triggerName)
except Exception as e:
logger.error(f"Error checking optimization status: {e}")
return status

View file

@ -96,7 +96,7 @@ class WorkflowManager:
"currentAction": 0,
"totalTasks": 0,
"totalActions": 0,
"mandateId": self.services.user.mandateId,
"mandateId": self.services.mandateId,
"messageIds": [],
"workflowMode": workflowMode,
"maxSteps": 10 , # Set maxSteps