implemented multimandate
This commit is contained in:
parent
89202eb040
commit
5c0ab3f893
53 changed files with 7558 additions and 2458 deletions
24
app.py
24
app.py
|
|
@ -405,7 +405,7 @@ app.include_router(userRouter)
|
|||
from modules.routes.routeDataFiles import router as fileRouter
|
||||
app.include_router(fileRouter)
|
||||
|
||||
from modules.routes.routeDataNeutralization import router as neutralizationRouter
|
||||
from modules.routes.routeFeatureNeutralization import router as neutralizationRouter
|
||||
app.include_router(neutralizationRouter)
|
||||
|
||||
from modules.routes.routeDataPrompts import router as promptRouter
|
||||
|
|
@ -417,10 +417,10 @@ app.include_router(connectionsRouter)
|
|||
from modules.routes.routeWorkflows import router as workflowRouter
|
||||
app.include_router(workflowRouter)
|
||||
|
||||
from modules.routes.routeChatPlayground import router as chatPlaygroundRouter
|
||||
from modules.routes.routeFeatureChatDynamic import router as chatPlaygroundRouter
|
||||
app.include_router(chatPlaygroundRouter)
|
||||
|
||||
from modules.routes.routeRealEstate import router as realEstateRouter
|
||||
from modules.routes.routeFeatureRealEstate import router as realEstateRouter
|
||||
app.include_router(realEstateRouter)
|
||||
|
||||
from modules.routes.routeSecurityLocal import router as localRouter
|
||||
|
|
@ -444,7 +444,7 @@ app.include_router(sharepointRouter)
|
|||
from modules.routes.routeDataAutomation import router as automationRouter
|
||||
app.include_router(automationRouter)
|
||||
|
||||
from modules.routes.routeAdminAutomationEvents import router as adminAutomationEventsRouter
|
||||
from modules.routes.routeFeatureAutomation import router as adminAutomationEventsRouter
|
||||
app.include_router(adminAutomationEventsRouter)
|
||||
|
||||
from modules.routes.routeRbac import router as rbacRouter
|
||||
|
|
@ -456,9 +456,21 @@ app.include_router(optionsRouter)
|
|||
from modules.routes.routeMessaging import router as messagingRouter
|
||||
app.include_router(messagingRouter)
|
||||
|
||||
from modules.routes.routeChatbot import router as chatbotRouter
|
||||
from modules.routes.routeFeatureChatbot import router as chatbotRouter
|
||||
app.include_router(chatbotRouter)
|
||||
|
||||
from modules.routes.routeDataTrustee import router as trusteeRouter
|
||||
from modules.routes.routeFeatureTrustee import router as trusteeRouter
|
||||
app.include_router(trusteeRouter)
|
||||
|
||||
# Phase 8: New Feature Routes
|
||||
from modules.routes.routeFeatures import router as featuresRouter
|
||||
app.include_router(featuresRouter)
|
||||
|
||||
from modules.routes.routeInvitations import router as invitationsRouter
|
||||
app.include_router(invitationsRouter)
|
||||
|
||||
from modules.routes.routeRbacExport import router as rbacExportRouter
|
||||
app.include_router(rbacExportRouter)
|
||||
|
||||
from modules.routes.routeGdpr import router as gdprRouter
|
||||
app.include_router(gdprRouter)
|
||||
|
|
|
|||
32
env_dev.env
32
env_dev.env
|
|
@ -8,33 +8,11 @@ APP_KEY_SYSVAR = D:/Athi/Local/Web/poweron/local/key.txt
|
|||
APP_INIT_PASS_ADMIN_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEeFFtRGtQeVUtcjlrU3dab1ZxUm9WSks0MlJVYUtERFlqUElHemZrOGNENk1tcmJNX3Vxc01UMDhlNU40VzZZRVBpUGNmT3podzZrOGhOeEJIUEt4eVlSWG5UYXA3d09DVXlLT21Kb1JYSUU9
|
||||
APP_INIT_PASS_EVENT_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERzZjNm56WGVBdjJTeG5Udjd6OGQwUVotYXUzQjJ1YVNyVXVBa3NZVml3ODU0MVNkZjhWWmJwNUFkc19BcHlHMTU1Q3BRcHU0cDBoZkFlR2l6UEZQU3d2U3MtMDh5UDZteGFoQ0EyMUE1ckE9
|
||||
|
||||
# PostgreSQL Storage (new)
|
||||
DB_APP_HOST=localhost
|
||||
DB_APP_DATABASE=poweron_app
|
||||
DB_APP_USER=poweron_dev
|
||||
DB_APP_PASSWORD_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEcUIxNEFfQ2xnS0RrSC1KNnUxTlVvTGZoMHgzaEI4Z3NlVzVROTVLak5Ubi1vaEZubFZaMTFKMGd6MXAxekN2d2NvMy1hRjg2UVhybktlcFA5anZ1WjFlQmZhcXdwaGhWdzRDc3ExeUhzWTg9
|
||||
DB_APP_PORT=5432
|
||||
|
||||
# PostgreSQL Storage (new)
|
||||
DB_CHAT_HOST=localhost
|
||||
DB_CHAT_DATABASE=poweron_chat
|
||||
DB_CHAT_USER=poweron_dev
|
||||
DB_CHAT_PASSWORD_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERFNzNVhoalpCR0QxYXAwdEpXWXVVOTdZdWtqWW5FNXFGcFl2amNYLWYwYl9STXltRlFxLWNzVWlMVnNYdXk0RklnRExFT0FaQjg2aGswNnhhSGhCN29KN2VEb2FlUV9NTlV3b0tLelplSVU9
|
||||
DB_CHAT_PORT=5432
|
||||
|
||||
# PostgreSQL Storage (new)
|
||||
DB_MANAGEMENT_HOST=localhost
|
||||
DB_MANAGEMENT_DATABASE=poweron_management
|
||||
DB_MANAGEMENT_USER=poweron_dev
|
||||
DB_MANAGEMENT_PASSWORD_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEUldqSTVpUnFqdGhITDYzT3RScGlMYVdTMmZhOXdudDRCc3dhdllOd3l6MS1vWHY2MjVsTUF1Sk9saEJOSk9ONUlBZjQwb2c2T1gtWWJhcXFzVVVXd01xc0U0b0lJX0JyVDRxaDhNS01JcWs9
|
||||
DB_MANAGEMENT_PORT=5432
|
||||
|
||||
# PostgreSQL Storage (new)
|
||||
DB_REALESTATE_HOST=localhost
|
||||
DB_REALESTATE_DATABASE=poweron_realestate
|
||||
DB_REALESTATE_USER=poweron_dev
|
||||
DB_REALESTATE_PASSWORD_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEcUIxNEFfQ2xnS0RrSC1KNnUxTlVvTGZoMHgzaEI4Z3NlVzVROTVLak5Ubi1vaEZubFZaMTFKMGd6MXAxekN2d2NvMy1hRjg2UVhybktlcFA5anZ1WjFlQmZhcXdwaGhWdzRDc3ExeUhzWTg9
|
||||
DB_REALESTATE_PORT=5432
|
||||
# PostgreSQL DB Host
|
||||
DB_HOST=localhost
|
||||
DB_USER=poweron_dev
|
||||
DB_PASSWORD_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEcUIxNEFfQ2xnS0RrSC1KNnUxTlVvTGZoMHgzaEI4Z3NlVzVROTVLak5Ubi1vaEZubFZaMTFKMGd6MXAxekN2d2NvMy1hRjg2UVhybktlcFA5anZ1WjFlQmZhcXdwaGhWdzRDc3ExeUhzWTg9
|
||||
DB_PORT=5432
|
||||
|
||||
# Security Configuration
|
||||
APP_JWT_KEY_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERjlrSktmZHVuQnJ1VVJDdndLaUcxZGJsT2ZlUFRlcFdOZ001RnlzM2FhLWhRV2tjWWFhaWQwQ3hkcUFvbThMcndxSjFpYTdfRV9OZGhTcksxbXFTZWg5MDZvOHpCVXBHcDJYaHlJM0tyNWRZckZsVHpQcmxTZHJoZUs1M3lfU2ljRnJaTmNSQ0w0X085OXI0QW80M2xfQnJqZmZ6VEh3TUltX0xzeE42SGtZPQ==
|
||||
|
|
|
|||
32
env_int.env
32
env_int.env
|
|
@ -8,33 +8,11 @@ APP_KEY_SYSVAR = CONFIG_KEY
|
|||
APP_INIT_PASS_ADMIN_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjWm41MWZ4TUZGaVlrX3pWZWNwakJsY3Facm0wLVZDd1VKeTFoZEVZQnItcEdUUnVJS1NXeDBpM2xKbGRsYmxOSmRhc29PZjJSU2txQjdLbUVrTTE1NEJjUXBHbV9NOVJWZUR3QlJkQnJvTEU9
|
||||
APP_INIT_PASS_EVENT_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjdmtrakgxa0djekZVNGtTZV8wM2I5UUpCZllveVBMWXROYk5yS3BiV3JEelJSM09VYTRONHpnY3VtMGxDRk5JTEZSRFhtcDZ0RVRmZ1RicTFhb3c5dVZRQ1o4SmlkLVpPTW5MMTU2eTQ0Vkk9
|
||||
|
||||
# PostgreSQL Storage (new)
|
||||
DB_APP_HOST=gateway-int-server.postgres.database.azure.com
|
||||
DB_APP_DATABASE=poweron_app
|
||||
DB_APP_USER=heeshkdlby
|
||||
DB_APP_PASSWORD_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjb2dka2pnN0tUbW1EU0w1Rk1jNERKQ0Z1U3JkVDhuZWZDM0g5M0kwVDE5VHdubkZna3gtZVAxTnl4MDdrR1c1ZXJ3ejJHYkZvcGUwbHJaajBGOWJob0EzRXVHc0JnZkJyNGhHZTZHOXBxd2c9
|
||||
DB_APP_PORT=5432
|
||||
|
||||
# PostgreSQL Storage (new)
|
||||
DB_CHAT_HOST=gateway-int-server.postgres.database.azure.com
|
||||
DB_CHAT_DATABASE=poweron_chat
|
||||
DB_CHAT_USER=heeshkdlby
|
||||
DB_CHAT_PASSWORD_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjczYzOUtTa21MMGJVTUQ5UmFfdWc3YlhCbWZOeXFaNEE1QzdJV3BLVjhnalBkLVVCMm5BZzdxdlFXQXc2RHYzLWtPSFZkZE1iWG9rQ1NkVWlpRnF5TURVbnl1cm9iYXlSMGYxd1BGYVc0VDA9
|
||||
DB_CHAT_PORT=5432
|
||||
|
||||
# PostgreSQL Storage (new)
|
||||
DB_MANAGEMENT_HOST=gateway-int-server.postgres.database.azure.com
|
||||
DB_MANAGEMENT_DATABASE=poweron_management
|
||||
DB_MANAGEMENT_USER=heeshkdlby
|
||||
DB_MANAGEMENT_PASSWORD_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjTnJKNlJMNmEwQ0Y5dVNrR3pkZk9SQXVvLTRTNW9lQ1g3TTE5cFhBNTd5UENqWW9qdWd3NWNseWhnUHJveDJyd1Z3X1czS3VuZnAwZHBXYVNQWlZsRy12ME42NndEVlR5X3ZPdFBNNmhLYm89
|
||||
DB_MANAGEMENT_PORT=5432
|
||||
|
||||
# PostgreSQL Storage (new)
|
||||
DB_REALESTATE_HOST=localhost
|
||||
DB_REALESTATE_DATABASE=poweron_realestate
|
||||
DB_REALESTATE_USER=poweron_dev
|
||||
DB_REALESTATE_PASSWORD_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjTnJKNlJMNmEwQ0Y5dVNrR3pkZk9SQXVvLTRTNW9lQ1g3TTE5cFhBNTd5UENqWW9qdWd3NWNseWhnUHJveDJyd1Z3X1czS3VuZnAwZHBXYVNQWlZsRy12ME42NndEVlR5X3ZPdFBNNmhLYm89
|
||||
DB_REALESTATE_PORT=5432
|
||||
# PostgreSQL DB Host
|
||||
DB_HOST=gateway-int-server.postgres.database.azure.com
|
||||
DB_USER=heeshkdlby
|
||||
DB_PASSWORD_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjczYzOUtTa21MMGJVTUQ5UmFfdWc3YlhCbWZOeXFaNEE1QzdJV3BLVjhnalBkLVVCMm5BZzdxdlFXQXc2RHYzLWtPSFZkZE1iWG9rQ1NkVWlpRnF5TURVbnl1cm9iYXlSMGYxd1BGYVc0VDA9
|
||||
DB_PORT=5432
|
||||
|
||||
# Security Configuration
|
||||
APP_JWT_KEY_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNUctb2RwU25iR3ZnanBOdHZhWUtIajZ1RnZzTEp4aDR0MktWRjNoeVBrY1Npd1R0VE9YVHp3M2w1cXRzbUxNaU82QUJvaDNFeVQyN05KblRWblBvbWtoT0VXbkNBbDQ5OHhwSUFnaDZGRG10Vmgtdm1YUkRsYUhFMzRVZURmSFlDTFIzVWg4MXNueDZyMGc5aVpFdWRxY3dkTExGM093ZTVUZVl5LUhGWnlRPQ==
|
||||
|
|
|
|||
32
env_prod.env
32
env_prod.env
|
|
@ -8,33 +8,11 @@ APP_INIT_PASS_ADMIN_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3UnJRV0sySFlDblpXUlREclREaW
|
|||
APP_INIT_PASS_EVENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3QVpIY19DQVZSSzJmc2F0VEZvQlU1cHBhTEgxdHdnR3g4eW01aTEzYTUxc1gxTDR1RVVpSHRXYjV6N1BLZUdCUGlfOW1qdy0xSHFVRkNBcGZvaGlSSkZycXRuUllaWnpyVGRoeFg1dGEyNUk9
|
||||
APP_API_URL = https://gateway-prod.poweron-center.net
|
||||
|
||||
# PostgreSQL Storage (new)
|
||||
DB_APP_HOST=gateway-prod-server.postgres.database.azure.com
|
||||
DB_APP_DATABASE=poweron_app
|
||||
DB_APP_USER=gzxxmcrdhn
|
||||
DB_APP_PASSWORD_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3cm5LQWV1OURQanVyTklVaVhJbDI2Y1Itb29pTWFmR2RYM0pyYUhhRUpWZ29tWWwzSmdQeVhScHlHQWVyY0xUTElIdVBJUjh5Zm9ZMzg1ZERNQXZ6TXlGb2tYOGpDX1gzXzB3UUlCM1ZaYWM9
|
||||
DB_APP_PORT=5432
|
||||
|
||||
# PostgreSQL Storage (new)
|
||||
DB_CHAT_HOST=gateway-prod-server.postgres.database.azure.com
|
||||
DB_CHAT_DATABASE=poweron_chat
|
||||
DB_CHAT_USER=gzxxmcrdhn
|
||||
DB_CHAT_PASSWORD_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3Y1JScGxjZG9TdUkwaHRzSHZhRHpNcDV3N1U2TnIwZ21PRG5TWFFfR1k0N3BiRk5WelVadjlnXzVSTDZ6NXFQNFpqbnJ1R3dNVkJocm1zVEgtSk0xaDRiR19zNDBEbVIzSk51ekNlQ0Z3b0U9
|
||||
DB_CHAT_PORT=5432
|
||||
|
||||
# PostgreSQL Storage (new)
|
||||
DB_MANAGEMENT_HOST=gateway-prod-server.postgres.database.azure.com
|
||||
DB_MANAGEMENT_DATABASE=poweron_management
|
||||
DB_MANAGEMENT_USER=gzxxmcrdhn
|
||||
DB_MANAGEMENT_PASSWORD_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3ZWpySThqdlVmWWd5dGxmWE91RVBsenZrQmNhSzVxbktmYzZ1RlM3cXhTMUdXRV9wX1lfLTJXLTFzeUo0R3pWLXlmUWdrZ2x6QkFlZVRXaEF6aUdRbDlzb1FfcWtub0dxSGp3OVVQWGg3enM9
|
||||
DB_MANAGEMENT_PORT=5432
|
||||
|
||||
# PostgreSQL Storage (new)
|
||||
DB_REALESTATE_HOST=localhost
|
||||
DB_REALESTATE_DATABASE=poweron_realestate
|
||||
DB_REALESTATE_USER=poweron_dev
|
||||
DB_REALESTATE_PASSWORD_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3ZWpySThqdlVmWWd5dGxmWE91RVBsenZrQmNhSzVxbktmYzZ1RlM3cXhTMUdXRV9wX1lfLTJXLTFzeUo0R3pWLXlmUWdrZ2x6QkFlZVRXaEF6aUdRbDlzb1FfcWtub0dxSGp3OVVQWGg3enM9
|
||||
DB_REALESTATE_PORT=5432
|
||||
# PostgreSQL DB Host
|
||||
DB_HOST=gateway-prod-server.postgres.database.azure.com
|
||||
DB_USER=gzxxmcrdhn
|
||||
DB_PASSWORD_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3Y1JScGxjZG9TdUkwaHRzSHZhRHpNcDV3N1U2TnIwZ21PRG5TWFFfR1k0N3BiRk5WelVadjlnXzVSTDZ6NXFQNFpqbnJ1R3dNVkJocm1zVEgtSk0xaDRiR19zNDBEbVIzSk51ekNlQ0Z3b0U9
|
||||
DB_PORT=5432
|
||||
|
||||
# Security Configuration
|
||||
APP_JWT_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3elhfV0Rnd2pQRjlMdkVwX1FnSmRhSzNZUlV5SVpaWXBNX1hpa2xPZGdMSWpnN2ZINHQxeGZnNHJweU5pZjlyYlY5Qm9zOUZEbl9wUEgtZHZXd1NhR19JSG9kbFU4MnFGQnllbFhRQVphRGQyNHlFVWR5VHQyUUpqN0stUmRuY2QyTi1oalczRHpLTEJqWURjZWs4YjZvT2U5YnFqcXEwdEpxV05fX05QMmtrPQ==
|
||||
|
|
|
|||
|
|
@ -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 = ""
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
83
modules/datamodels/datamodelFeatures.py
Normal file
83
modules/datamodels/datamodelFeatures.py
Normal 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é"},
|
||||
},
|
||||
)
|
||||
120
modules/datamodels/datamodelInvitation.py
Normal file
120
modules/datamodels/datamodelInvitation.py
Normal 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"},
|
||||
},
|
||||
)
|
||||
150
modules/datamodels/datamodelMembership.py
Normal file
150
modules/datamodels/datamodelMembership.py
Normal 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"},
|
||||
},
|
||||
)
|
||||
|
|
@ -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."
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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'):
|
||||
|
|
|
|||
|
|
@ -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)}")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
478
modules/interfaces/interfaceFeatures.py
Normal file
478
modules/interfaces/interfaceFeatures.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)}"
|
||||
)
|
||||
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)}"
|
||||
)
|
||||
|
||||
|
|
@ -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:
|
||||
|
|
@ -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)}"
|
||||
)
|
||||
|
||||
|
|
@ -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")
|
||||
625
modules/routes/routeFeatures.py
Normal file
625
modules/routes/routeFeatures.py
Normal 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
514
modules/routes/routeGdpr.py
Normal 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()
|
||||
812
modules/routes/routeInvitations.py
Normal file
812
modules/routes/routeInvitations.py
Normal 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
|
||||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
608
modules/routes/routeRbacExport.py
Normal file
608
modules/routes/routeRbacExport.py
Normal 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
|
||||
|
|
@ -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()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 []
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
438
modules/shared/dbMultiTenantOptimizations.py
Normal file
438
modules/shared/dbMultiTenantOptimizations.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue