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
|
from modules.routes.routeDataFiles import router as fileRouter
|
||||||
app.include_router(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)
|
app.include_router(neutralizationRouter)
|
||||||
|
|
||||||
from modules.routes.routeDataPrompts import router as promptRouter
|
from modules.routes.routeDataPrompts import router as promptRouter
|
||||||
|
|
@ -417,10 +417,10 @@ app.include_router(connectionsRouter)
|
||||||
from modules.routes.routeWorkflows import router as workflowRouter
|
from modules.routes.routeWorkflows import router as workflowRouter
|
||||||
app.include_router(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)
|
app.include_router(chatPlaygroundRouter)
|
||||||
|
|
||||||
from modules.routes.routeRealEstate import router as realEstateRouter
|
from modules.routes.routeFeatureRealEstate import router as realEstateRouter
|
||||||
app.include_router(realEstateRouter)
|
app.include_router(realEstateRouter)
|
||||||
|
|
||||||
from modules.routes.routeSecurityLocal import router as localRouter
|
from modules.routes.routeSecurityLocal import router as localRouter
|
||||||
|
|
@ -444,7 +444,7 @@ app.include_router(sharepointRouter)
|
||||||
from modules.routes.routeDataAutomation import router as automationRouter
|
from modules.routes.routeDataAutomation import router as automationRouter
|
||||||
app.include_router(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)
|
app.include_router(adminAutomationEventsRouter)
|
||||||
|
|
||||||
from modules.routes.routeRbac import router as rbacRouter
|
from modules.routes.routeRbac import router as rbacRouter
|
||||||
|
|
@ -456,9 +456,21 @@ app.include_router(optionsRouter)
|
||||||
from modules.routes.routeMessaging import router as messagingRouter
|
from modules.routes.routeMessaging import router as messagingRouter
|
||||||
app.include_router(messagingRouter)
|
app.include_router(messagingRouter)
|
||||||
|
|
||||||
from modules.routes.routeChatbot import router as chatbotRouter
|
from modules.routes.routeFeatureChatbot import router as chatbotRouter
|
||||||
app.include_router(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)
|
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_ADMIN_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEeFFtRGtQeVUtcjlrU3dab1ZxUm9WSks0MlJVYUtERFlqUElHemZrOGNENk1tcmJNX3Vxc01UMDhlNU40VzZZRVBpUGNmT3podzZrOGhOeEJIUEt4eVlSWG5UYXA3d09DVXlLT21Kb1JYSUU9
|
||||||
APP_INIT_PASS_EVENT_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERzZjNm56WGVBdjJTeG5Udjd6OGQwUVotYXUzQjJ1YVNyVXVBa3NZVml3ODU0MVNkZjhWWmJwNUFkc19BcHlHMTU1Q3BRcHU0cDBoZkFlR2l6UEZQU3d2U3MtMDh5UDZteGFoQ0EyMUE1ckE9
|
APP_INIT_PASS_EVENT_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERzZjNm56WGVBdjJTeG5Udjd6OGQwUVotYXUzQjJ1YVNyVXVBa3NZVml3ODU0MVNkZjhWWmJwNUFkc19BcHlHMTU1Q3BRcHU0cDBoZkFlR2l6UEZQU3d2U3MtMDh5UDZteGFoQ0EyMUE1ckE9
|
||||||
|
|
||||||
# PostgreSQL Storage (new)
|
# PostgreSQL DB Host
|
||||||
DB_APP_HOST=localhost
|
DB_HOST=localhost
|
||||||
DB_APP_DATABASE=poweron_app
|
DB_USER=poweron_dev
|
||||||
DB_APP_USER=poweron_dev
|
DB_PASSWORD_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEcUIxNEFfQ2xnS0RrSC1KNnUxTlVvTGZoMHgzaEI4Z3NlVzVROTVLak5Ubi1vaEZubFZaMTFKMGd6MXAxekN2d2NvMy1hRjg2UVhybktlcFA5anZ1WjFlQmZhcXdwaGhWdzRDc3ExeUhzWTg9
|
||||||
DB_APP_PASSWORD_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEcUIxNEFfQ2xnS0RrSC1KNnUxTlVvTGZoMHgzaEI4Z3NlVzVROTVLak5Ubi1vaEZubFZaMTFKMGd6MXAxekN2d2NvMy1hRjg2UVhybktlcFA5anZ1WjFlQmZhcXdwaGhWdzRDc3ExeUhzWTg9
|
DB_PORT=5432
|
||||||
DB_APP_PORT=5432
|
|
||||||
|
|
||||||
# PostgreSQL Storage (new)
|
|
||||||
DB_CHAT_HOST=localhost
|
|
||||||
DB_CHAT_DATABASE=poweron_chat
|
|
||||||
DB_CHAT_USER=poweron_dev
|
|
||||||
DB_CHAT_PASSWORD_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERFNzNVhoalpCR0QxYXAwdEpXWXVVOTdZdWtqWW5FNXFGcFl2amNYLWYwYl9STXltRlFxLWNzVWlMVnNYdXk0RklnRExFT0FaQjg2aGswNnhhSGhCN29KN2VEb2FlUV9NTlV3b0tLelplSVU9
|
|
||||||
DB_CHAT_PORT=5432
|
|
||||||
|
|
||||||
# PostgreSQL Storage (new)
|
|
||||||
DB_MANAGEMENT_HOST=localhost
|
|
||||||
DB_MANAGEMENT_DATABASE=poweron_management
|
|
||||||
DB_MANAGEMENT_USER=poweron_dev
|
|
||||||
DB_MANAGEMENT_PASSWORD_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEUldqSTVpUnFqdGhITDYzT3RScGlMYVdTMmZhOXdudDRCc3dhdllOd3l6MS1vWHY2MjVsTUF1Sk9saEJOSk9ONUlBZjQwb2c2T1gtWWJhcXFzVVVXd01xc0U0b0lJX0JyVDRxaDhNS01JcWs9
|
|
||||||
DB_MANAGEMENT_PORT=5432
|
|
||||||
|
|
||||||
# PostgreSQL Storage (new)
|
|
||||||
DB_REALESTATE_HOST=localhost
|
|
||||||
DB_REALESTATE_DATABASE=poweron_realestate
|
|
||||||
DB_REALESTATE_USER=poweron_dev
|
|
||||||
DB_REALESTATE_PASSWORD_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEcUIxNEFfQ2xnS0RrSC1KNnUxTlVvTGZoMHgzaEI4Z3NlVzVROTVLak5Ubi1vaEZubFZaMTFKMGd6MXAxekN2d2NvMy1hRjg2UVhybktlcFA5anZ1WjFlQmZhcXdwaGhWdzRDc3ExeUhzWTg9
|
|
||||||
DB_REALESTATE_PORT=5432
|
|
||||||
|
|
||||||
# Security Configuration
|
# Security Configuration
|
||||||
APP_JWT_KEY_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERjlrSktmZHVuQnJ1VVJDdndLaUcxZGJsT2ZlUFRlcFdOZ001RnlzM2FhLWhRV2tjWWFhaWQwQ3hkcUFvbThMcndxSjFpYTdfRV9OZGhTcksxbXFTZWg5MDZvOHpCVXBHcDJYaHlJM0tyNWRZckZsVHpQcmxTZHJoZUs1M3lfU2ljRnJaTmNSQ0w0X085OXI0QW80M2xfQnJqZmZ6VEh3TUltX0xzeE42SGtZPQ==
|
APP_JWT_KEY_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERjlrSktmZHVuQnJ1VVJDdndLaUcxZGJsT2ZlUFRlcFdOZ001RnlzM2FhLWhRV2tjWWFhaWQwQ3hkcUFvbThMcndxSjFpYTdfRV9OZGhTcksxbXFTZWg5MDZvOHpCVXBHcDJYaHlJM0tyNWRZckZsVHpQcmxTZHJoZUs1M3lfU2ljRnJaTmNSQ0w0X085OXI0QW80M2xfQnJqZmZ6VEh3TUltX0xzeE42SGtZPQ==
|
||||||
|
|
|
||||||
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_ADMIN_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjWm41MWZ4TUZGaVlrX3pWZWNwakJsY3Facm0wLVZDd1VKeTFoZEVZQnItcEdUUnVJS1NXeDBpM2xKbGRsYmxOSmRhc29PZjJSU2txQjdLbUVrTTE1NEJjUXBHbV9NOVJWZUR3QlJkQnJvTEU9
|
||||||
APP_INIT_PASS_EVENT_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjdmtrakgxa0djekZVNGtTZV8wM2I5UUpCZllveVBMWXROYk5yS3BiV3JEelJSM09VYTRONHpnY3VtMGxDRk5JTEZSRFhtcDZ0RVRmZ1RicTFhb3c5dVZRQ1o4SmlkLVpPTW5MMTU2eTQ0Vkk9
|
APP_INIT_PASS_EVENT_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjdmtrakgxa0djekZVNGtTZV8wM2I5UUpCZllveVBMWXROYk5yS3BiV3JEelJSM09VYTRONHpnY3VtMGxDRk5JTEZSRFhtcDZ0RVRmZ1RicTFhb3c5dVZRQ1o4SmlkLVpPTW5MMTU2eTQ0Vkk9
|
||||||
|
|
||||||
# PostgreSQL Storage (new)
|
# PostgreSQL DB Host
|
||||||
DB_APP_HOST=gateway-int-server.postgres.database.azure.com
|
DB_HOST=gateway-int-server.postgres.database.azure.com
|
||||||
DB_APP_DATABASE=poweron_app
|
DB_USER=heeshkdlby
|
||||||
DB_APP_USER=heeshkdlby
|
DB_PASSWORD_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjczYzOUtTa21MMGJVTUQ5UmFfdWc3YlhCbWZOeXFaNEE1QzdJV3BLVjhnalBkLVVCMm5BZzdxdlFXQXc2RHYzLWtPSFZkZE1iWG9rQ1NkVWlpRnF5TURVbnl1cm9iYXlSMGYxd1BGYVc0VDA9
|
||||||
DB_APP_PASSWORD_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjb2dka2pnN0tUbW1EU0w1Rk1jNERKQ0Z1U3JkVDhuZWZDM0g5M0kwVDE5VHdubkZna3gtZVAxTnl4MDdrR1c1ZXJ3ejJHYkZvcGUwbHJaajBGOWJob0EzRXVHc0JnZkJyNGhHZTZHOXBxd2c9
|
DB_PORT=5432
|
||||||
DB_APP_PORT=5432
|
|
||||||
|
|
||||||
# PostgreSQL Storage (new)
|
|
||||||
DB_CHAT_HOST=gateway-int-server.postgres.database.azure.com
|
|
||||||
DB_CHAT_DATABASE=poweron_chat
|
|
||||||
DB_CHAT_USER=heeshkdlby
|
|
||||||
DB_CHAT_PASSWORD_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjczYzOUtTa21MMGJVTUQ5UmFfdWc3YlhCbWZOeXFaNEE1QzdJV3BLVjhnalBkLVVCMm5BZzdxdlFXQXc2RHYzLWtPSFZkZE1iWG9rQ1NkVWlpRnF5TURVbnl1cm9iYXlSMGYxd1BGYVc0VDA9
|
|
||||||
DB_CHAT_PORT=5432
|
|
||||||
|
|
||||||
# PostgreSQL Storage (new)
|
|
||||||
DB_MANAGEMENT_HOST=gateway-int-server.postgres.database.azure.com
|
|
||||||
DB_MANAGEMENT_DATABASE=poweron_management
|
|
||||||
DB_MANAGEMENT_USER=heeshkdlby
|
|
||||||
DB_MANAGEMENT_PASSWORD_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjTnJKNlJMNmEwQ0Y5dVNrR3pkZk9SQXVvLTRTNW9lQ1g3TTE5cFhBNTd5UENqWW9qdWd3NWNseWhnUHJveDJyd1Z3X1czS3VuZnAwZHBXYVNQWlZsRy12ME42NndEVlR5X3ZPdFBNNmhLYm89
|
|
||||||
DB_MANAGEMENT_PORT=5432
|
|
||||||
|
|
||||||
# PostgreSQL Storage (new)
|
|
||||||
DB_REALESTATE_HOST=localhost
|
|
||||||
DB_REALESTATE_DATABASE=poweron_realestate
|
|
||||||
DB_REALESTATE_USER=poweron_dev
|
|
||||||
DB_REALESTATE_PASSWORD_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjTnJKNlJMNmEwQ0Y5dVNrR3pkZk9SQXVvLTRTNW9lQ1g3TTE5cFhBNTd5UENqWW9qdWd3NWNseWhnUHJveDJyd1Z3X1czS3VuZnAwZHBXYVNQWlZsRy12ME42NndEVlR5X3ZPdFBNNmhLYm89
|
|
||||||
DB_REALESTATE_PORT=5432
|
|
||||||
|
|
||||||
# Security Configuration
|
# Security Configuration
|
||||||
APP_JWT_KEY_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNUctb2RwU25iR3ZnanBOdHZhWUtIajZ1RnZzTEp4aDR0MktWRjNoeVBrY1Npd1R0VE9YVHp3M2w1cXRzbUxNaU82QUJvaDNFeVQyN05KblRWblBvbWtoT0VXbkNBbDQ5OHhwSUFnaDZGRG10Vmgtdm1YUkRsYUhFMzRVZURmSFlDTFIzVWg4MXNueDZyMGc5aVpFdWRxY3dkTExGM093ZTVUZVl5LUhGWnlRPQ==
|
APP_JWT_KEY_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNUctb2RwU25iR3ZnanBOdHZhWUtIajZ1RnZzTEp4aDR0MktWRjNoeVBrY1Npd1R0VE9YVHp3M2w1cXRzbUxNaU82QUJvaDNFeVQyN05KblRWblBvbWtoT0VXbkNBbDQ5OHhwSUFnaDZGRG10Vmgtdm1YUkRsYUhFMzRVZURmSFlDTFIzVWg4MXNueDZyMGc5aVpFdWRxY3dkTExGM093ZTVUZVl5LUhGWnlRPQ==
|
||||||
|
|
|
||||||
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_INIT_PASS_EVENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3QVpIY19DQVZSSzJmc2F0VEZvQlU1cHBhTEgxdHdnR3g4eW01aTEzYTUxc1gxTDR1RVVpSHRXYjV6N1BLZUdCUGlfOW1qdy0xSHFVRkNBcGZvaGlSSkZycXRuUllaWnpyVGRoeFg1dGEyNUk9
|
||||||
APP_API_URL = https://gateway-prod.poweron-center.net
|
APP_API_URL = https://gateway-prod.poweron-center.net
|
||||||
|
|
||||||
# PostgreSQL Storage (new)
|
# PostgreSQL DB Host
|
||||||
DB_APP_HOST=gateway-prod-server.postgres.database.azure.com
|
DB_HOST=gateway-prod-server.postgres.database.azure.com
|
||||||
DB_APP_DATABASE=poweron_app
|
DB_USER=gzxxmcrdhn
|
||||||
DB_APP_USER=gzxxmcrdhn
|
DB_PASSWORD_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3Y1JScGxjZG9TdUkwaHRzSHZhRHpNcDV3N1U2TnIwZ21PRG5TWFFfR1k0N3BiRk5WelVadjlnXzVSTDZ6NXFQNFpqbnJ1R3dNVkJocm1zVEgtSk0xaDRiR19zNDBEbVIzSk51ekNlQ0Z3b0U9
|
||||||
DB_APP_PASSWORD_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3cm5LQWV1OURQanVyTklVaVhJbDI2Y1Itb29pTWFmR2RYM0pyYUhhRUpWZ29tWWwzSmdQeVhScHlHQWVyY0xUTElIdVBJUjh5Zm9ZMzg1ZERNQXZ6TXlGb2tYOGpDX1gzXzB3UUlCM1ZaYWM9
|
DB_PORT=5432
|
||||||
DB_APP_PORT=5432
|
|
||||||
|
|
||||||
# PostgreSQL Storage (new)
|
|
||||||
DB_CHAT_HOST=gateway-prod-server.postgres.database.azure.com
|
|
||||||
DB_CHAT_DATABASE=poweron_chat
|
|
||||||
DB_CHAT_USER=gzxxmcrdhn
|
|
||||||
DB_CHAT_PASSWORD_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3Y1JScGxjZG9TdUkwaHRzSHZhRHpNcDV3N1U2TnIwZ21PRG5TWFFfR1k0N3BiRk5WelVadjlnXzVSTDZ6NXFQNFpqbnJ1R3dNVkJocm1zVEgtSk0xaDRiR19zNDBEbVIzSk51ekNlQ0Z3b0U9
|
|
||||||
DB_CHAT_PORT=5432
|
|
||||||
|
|
||||||
# PostgreSQL Storage (new)
|
|
||||||
DB_MANAGEMENT_HOST=gateway-prod-server.postgres.database.azure.com
|
|
||||||
DB_MANAGEMENT_DATABASE=poweron_management
|
|
||||||
DB_MANAGEMENT_USER=gzxxmcrdhn
|
|
||||||
DB_MANAGEMENT_PASSWORD_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3ZWpySThqdlVmWWd5dGxmWE91RVBsenZrQmNhSzVxbktmYzZ1RlM3cXhTMUdXRV9wX1lfLTJXLTFzeUo0R3pWLXlmUWdrZ2x6QkFlZVRXaEF6aUdRbDlzb1FfcWtub0dxSGp3OVVQWGg3enM9
|
|
||||||
DB_MANAGEMENT_PORT=5432
|
|
||||||
|
|
||||||
# PostgreSQL Storage (new)
|
|
||||||
DB_REALESTATE_HOST=localhost
|
|
||||||
DB_REALESTATE_DATABASE=poweron_realestate
|
|
||||||
DB_REALESTATE_USER=poweron_dev
|
|
||||||
DB_REALESTATE_PASSWORD_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3ZWpySThqdlVmWWd5dGxmWE91RVBsenZrQmNhSzVxbktmYzZ1RlM3cXhTMUdXRV9wX1lfLTJXLTFzeUo0R3pWLXlmUWdrZ2x6QkFlZVRXaEF6aUdRbDlzb1FfcWtub0dxSGp3OVVQWGg3enM9
|
|
||||||
DB_REALESTATE_PORT=5432
|
|
||||||
|
|
||||||
# Security Configuration
|
# Security Configuration
|
||||||
APP_JWT_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3elhfV0Rnd2pQRjlMdkVwX1FnSmRhSzNZUlV5SVpaWXBNX1hpa2xPZGdMSWpnN2ZINHQxeGZnNHJweU5pZjlyYlY5Qm9zOUZEbl9wUEgtZHZXd1NhR19JSG9kbFU4MnFGQnllbFhRQVphRGQyNHlFVWR5VHQyUUpqN0stUmRuY2QyTi1oalczRHpLTEJqWURjZWs4YjZvT2U5YnFqcXEwdEpxV05fX05QMmtrPQ==
|
APP_JWT_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3elhfV0Rnd2pQRjlMdkVwX1FnSmRhSzNZUlV5SVpaWXBNX1hpa2xPZGdMSWpnN2ZINHQxeGZnNHJweU5pZjlyYlY5Qm9zOUZEbl9wUEgtZHZXd1NhR19JSG9kbFU4MnFGQnllbFhRQVphRGQyNHlFVWR5VHQyUUpqN0stUmRuY2QyTi1oalczRHpLTEJqWURjZWs4YjZvT2U5YnFqcXEwdEpxV05fX05QMmtrPQ==
|
||||||
|
|
|
||||||
|
|
@ -728,8 +728,7 @@ class AiTavily(BaseConnectorAi):
|
||||||
maxBreadth=webCrawlPrompt.maxWidth or 40 # Use same as limit for breadth
|
maxBreadth=webCrawlPrompt.maxWidth or 40 # Use same as limit for breadth
|
||||||
)
|
)
|
||||||
|
|
||||||
# If we got multiple pages from the crawl, we need to format them differently
|
# Format multiple pages from the crawl into a single response
|
||||||
# Return the first result for backwards compatibility, but include total page count
|
|
||||||
if crawlResults and len(crawlResults) > 0:
|
if crawlResults and len(crawlResults) > 0:
|
||||||
# Get all pages content with error handling
|
# Get all pages content with error handling
|
||||||
allContent = ""
|
allContent = ""
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,23 @@
|
||||||
"""
|
"""
|
||||||
Authentication and authorization modules for routes and services.
|
Authentication and authorization modules for routes and services.
|
||||||
High-level security functionality that depends on FastAPI and interfaces.
|
High-level security functionality that depends on FastAPI and interfaces.
|
||||||
|
|
||||||
|
Multi-Tenant Design:
|
||||||
|
- RequestContext: Per-request context with user, mandate, feature instance, roles
|
||||||
|
- getRequestContext: FastAPI dependency to extract context from X-Mandate-Id header
|
||||||
|
- requireSysAdmin: FastAPI dependency for system-level admin operations
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .authentication import getCurrentUser, limiter, SECRET_KEY, ALGORITHM, cookieAuth
|
from .authentication import (
|
||||||
|
getCurrentUser,
|
||||||
|
limiter,
|
||||||
|
SECRET_KEY,
|
||||||
|
ALGORITHM,
|
||||||
|
cookieAuth,
|
||||||
|
RequestContext,
|
||||||
|
getRequestContext,
|
||||||
|
requireSysAdmin,
|
||||||
|
)
|
||||||
from .jwtService import (
|
from .jwtService import (
|
||||||
createAccessToken,
|
createAccessToken,
|
||||||
createRefreshToken,
|
createRefreshToken,
|
||||||
|
|
@ -20,22 +34,30 @@ from .tokenRefreshMiddleware import TokenRefreshMiddleware, ProactiveTokenRefres
|
||||||
from .csrf import CSRFMiddleware
|
from .csrf import CSRFMiddleware
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
# Authentication
|
||||||
"getCurrentUser",
|
"getCurrentUser",
|
||||||
"limiter",
|
"limiter",
|
||||||
"SECRET_KEY",
|
"SECRET_KEY",
|
||||||
"ALGORITHM",
|
"ALGORITHM",
|
||||||
"cookieAuth",
|
"cookieAuth",
|
||||||
|
# Multi-Tenant Context
|
||||||
|
"RequestContext",
|
||||||
|
"getRequestContext",
|
||||||
|
"requireSysAdmin",
|
||||||
|
# JWT Service
|
||||||
"createAccessToken",
|
"createAccessToken",
|
||||||
"createRefreshToken",
|
"createRefreshToken",
|
||||||
"setAccessTokenCookie",
|
"setAccessTokenCookie",
|
||||||
"setRefreshTokenCookie",
|
"setRefreshTokenCookie",
|
||||||
"clearAccessTokenCookie",
|
"clearAccessTokenCookie",
|
||||||
"clearRefreshTokenCookie",
|
"clearRefreshTokenCookie",
|
||||||
|
# Token Management
|
||||||
"TokenManager",
|
"TokenManager",
|
||||||
"token_refresh_service",
|
"token_refresh_service",
|
||||||
"TokenRefreshService",
|
"TokenRefreshService",
|
||||||
"TokenRefreshMiddleware",
|
"TokenRefreshMiddleware",
|
||||||
"ProactiveTokenRefreshMiddleware",
|
"ProactiveTokenRefreshMiddleware",
|
||||||
|
# CSRF
|
||||||
"CSRFMiddleware",
|
"CSRFMiddleware",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,16 @@
|
||||||
"""
|
"""
|
||||||
Authentication module for backend API.
|
Authentication module for backend API.
|
||||||
Handles JWT-based authentication, token generation, and user context.
|
Handles JWT-based authentication, token generation, and user context.
|
||||||
|
|
||||||
|
Multi-Tenant Design:
|
||||||
|
- Token ist NICHT an einen Mandanten gebunden
|
||||||
|
- User arbeitet parallel in mehreren Mandanten (z.B. mehrere Browser-Tabs)
|
||||||
|
- Mandant-Kontext wird per Request-Header (X-Mandate-Id) bestimmt
|
||||||
|
- Request-Context kapselt User + Mandant + Feature-Instanz + geladene Rollen
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Optional, Dict, Any, Tuple
|
from typing import Optional, Dict, Any, Tuple, List
|
||||||
from fastapi import Depends, HTTPException, status, Request, Response
|
from fastapi import Depends, HTTPException, status, Request, Response, Header
|
||||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
from jose import JWTError, jwt
|
from jose import JWTError, jwt
|
||||||
import logging
|
import logging
|
||||||
|
|
@ -15,9 +21,10 @@ from slowapi.util import get_remote_address
|
||||||
|
|
||||||
from modules.shared.configuration import APP_CONFIG
|
from modules.shared.configuration import APP_CONFIG
|
||||||
from modules.security.rootAccess import getRootDbAppConnector, getRootUser
|
from modules.security.rootAccess import getRootDbAppConnector, getRootUser
|
||||||
from modules.interfaces.interfaceDbAppObjects import getInterface
|
from modules.interfaces.interfaceDbAppObjects import getInterface, getRootInterface
|
||||||
from modules.datamodels.datamodelUam import User, AuthAuthority
|
from modules.datamodels.datamodelUam import User, AuthAuthority, AccessLevel
|
||||||
from modules.datamodels.datamodelSecurity import Token
|
from modules.datamodels.datamodelSecurity import Token
|
||||||
|
from modules.datamodels.datamodelRbac import AccessRule
|
||||||
|
|
||||||
# Get Config Data
|
# Get Config Data
|
||||||
SECRET_KEY = APP_CONFIG.get("APP_JWT_KEY_SECRET")
|
SECRET_KEY = APP_CONFIG.get("APP_JWT_KEY_SECRET")
|
||||||
|
|
@ -98,15 +105,16 @@ def _getUserBase(token: str = Depends(cookieAuth)) -> User:
|
||||||
if username is None:
|
if username is None:
|
||||||
raise credentialsException
|
raise credentialsException
|
||||||
|
|
||||||
# Extract mandate ID and user ID from token
|
# Extract user ID from token
|
||||||
mandateId: str = payload.get("mandateId")
|
# MULTI-TENANT: mandateId is NO LONGER in the token - it comes from X-Mandate-Id header
|
||||||
userId: str = payload.get("userId")
|
userId: str = payload.get("userId")
|
||||||
authority: str = payload.get("authenticationAuthority")
|
authority: str = payload.get("authenticationAuthority")
|
||||||
tokenId: Optional[str] = payload.get("jti")
|
tokenId: Optional[str] = payload.get("jti")
|
||||||
sessionId: Optional[str] = payload.get("sid") or payload.get("sessionId")
|
sessionId: Optional[str] = payload.get("sid") or payload.get("sessionId")
|
||||||
|
|
||||||
if not mandateId or not userId:
|
# Only userId is required in token now (no mandateId)
|
||||||
logger.error(f"Missing context in token: mandateId={mandateId}, userId={userId}")
|
if not userId:
|
||||||
|
logger.error(f"Missing userId in token")
|
||||||
raise credentialsException
|
raise credentialsException
|
||||||
|
|
||||||
except JWTError:
|
except JWTError:
|
||||||
|
|
@ -129,9 +137,10 @@ def _getUserBase(token: str = Depends(cookieAuth)) -> User:
|
||||||
logger.warning(f"User {username} is disabled")
|
logger.warning(f"User {username} is disabled")
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is disabled")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is disabled")
|
||||||
|
|
||||||
# Ensure the user has the correct context
|
# Ensure the user ID in token matches the user in database
|
||||||
if str(user.mandateId) != str(mandateId) or str(user.id) != str(userId):
|
# MULTI-TENANT: mandateId is NO LONGER checked here - it comes from headers
|
||||||
logger.error(f"User context mismatch: token(mandateId={mandateId}, userId={userId}) vs user(mandateId={user.mandateId}, id={user.id})")
|
if str(user.id) != str(userId):
|
||||||
|
logger.error(f"User ID mismatch: token(userId={userId}) vs user(id={user.id})")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="User context has changed. Please log in again.",
|
detail="User context has changed. Please log in again.",
|
||||||
|
|
@ -166,17 +175,18 @@ def _getUserBase(token: str = Depends(cookieAuth)) -> User:
|
||||||
db_token = db_tokens[0]
|
db_token = db_tokens[0]
|
||||||
token_authority = str(db_token.get("authority", "")).lower()
|
token_authority = str(db_token.get("authority", "")).lower()
|
||||||
if token_authority == str(AuthAuthority.LOCAL.value):
|
if token_authority == str(AuthAuthority.LOCAL.value):
|
||||||
# Must be active and match user/session/mandate
|
# Must be active and match user/session
|
||||||
|
# MULTI-TENANT: mandateId is NOT checked here - tokens are no longer mandate-bound
|
||||||
active_token = appInterface.findActiveTokenById(
|
active_token = appInterface.findActiveTokenById(
|
||||||
tokenId=tokenId,
|
tokenId=tokenId,
|
||||||
userId=user.id,
|
userId=user.id,
|
||||||
authority=AuthAuthority.LOCAL,
|
authority=AuthAuthority.LOCAL,
|
||||||
sessionId=sessionId,
|
sessionId=sessionId,
|
||||||
mandateId=str(mandateId) if mandateId else None,
|
mandateId=None, # Token is no longer mandate-bound
|
||||||
)
|
)
|
||||||
if not active_token:
|
if not active_token:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Local JWT db record not active/valid: jti={tokenId}, userId={user.id}, mandateId={mandateId}, sessionId={sessionId}"
|
f"Local JWT db record not active/valid: jti={tokenId}, userId={user.id}, sessionId={sessionId}"
|
||||||
)
|
)
|
||||||
raise credentialsException
|
raise credentialsException
|
||||||
else:
|
else:
|
||||||
|
|
@ -203,3 +213,171 @@ def getCurrentUser(currentUser: User = Depends(_getUserBase)) -> User:
|
||||||
return currentUser
|
return currentUser
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# MULTI-TENANT: Request Context System
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class RequestContext:
|
||||||
|
"""
|
||||||
|
Request context for multi-tenant operations.
|
||||||
|
|
||||||
|
Contains user, mandate context, feature instance context, and loaded role IDs.
|
||||||
|
This context is per-request (not persisted) - follows stateless design.
|
||||||
|
|
||||||
|
IMPORTANT: SysAdmin also needs explicit membership for mandate context!
|
||||||
|
isSysAdmin flag does NOT give implicit access to mandate data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, user: User):
|
||||||
|
self.user: User = user
|
||||||
|
self.mandateId: Optional[str] = None
|
||||||
|
self.featureInstanceId: Optional[str] = None
|
||||||
|
self.roleIds: List[str] = []
|
||||||
|
|
||||||
|
# Request-scoped cache: rules loaded only once per request
|
||||||
|
self._cachedRules: Optional[List[tuple]] = None
|
||||||
|
|
||||||
|
def getRules(self) -> List[tuple]:
|
||||||
|
"""
|
||||||
|
Loads rules once per request (not across requests).
|
||||||
|
Returns list of (priority, AccessRule) tuples.
|
||||||
|
"""
|
||||||
|
if self._cachedRules is None:
|
||||||
|
if not self.mandateId:
|
||||||
|
# No mandate context = no rules
|
||||||
|
self._cachedRules = []
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
rootUser = getRootUser()
|
||||||
|
appInterface = getInterface(rootUser)
|
||||||
|
self._cachedRules = appInterface.rbac.getRulesForUserBulk(
|
||||||
|
self.user.id,
|
||||||
|
self.mandateId,
|
||||||
|
self.featureInstanceId
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error loading RBAC rules: {e}")
|
||||||
|
self._cachedRules = []
|
||||||
|
return self._cachedRules
|
||||||
|
|
||||||
|
@property
|
||||||
|
def isSysAdmin(self) -> bool:
|
||||||
|
"""Convenience property to check if user is a system admin."""
|
||||||
|
return getattr(self.user, 'isSysAdmin', False)
|
||||||
|
|
||||||
|
|
||||||
|
def getRequestContext(
|
||||||
|
request: Request,
|
||||||
|
mandateId: Optional[str] = Header(None, alias="X-Mandate-Id"),
|
||||||
|
featureInstanceId: Optional[str] = Header(None, alias="X-Instance-Id"),
|
||||||
|
currentUser: User = Depends(getCurrentUser)
|
||||||
|
) -> RequestContext:
|
||||||
|
"""
|
||||||
|
Determines request context from headers.
|
||||||
|
Checks authorization and loads role IDs.
|
||||||
|
|
||||||
|
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)
|
# Only set _createdBy if userId is valid (not None or empty string)
|
||||||
if self.userId:
|
if self.userId:
|
||||||
record["_createdBy"] = self.userId
|
record["_createdBy"] = self.userId
|
||||||
else:
|
# No warning - empty userId is normal during bootstrap
|
||||||
logger.warning(f"Attempting to create record with empty userId - _createdBy will not be set")
|
|
||||||
# Also ensure _createdBy is set even if _createdAt exists but _createdBy is missing/empty
|
# Also ensure _createdBy is set even if _createdAt exists but _createdBy is missing/empty
|
||||||
elif "_createdBy" not in record or not record.get("_createdBy"):
|
elif "_createdBy" not in record or not record.get("_createdBy"):
|
||||||
if self.userId:
|
if self.userId:
|
||||||
record["_createdBy"] = self.userId
|
record["_createdBy"] = self.userId
|
||||||
else:
|
# No warning - empty userId is normal during bootstrap
|
||||||
logger.warning(f"Attempting to set _createdBy with empty userId for record {recordId}")
|
|
||||||
# Always update modification metadata
|
# Always update modification metadata
|
||||||
record["_modifiedAt"] = currentTime
|
record["_modifiedAt"] = currentTime
|
||||||
if self.userId:
|
if self.userId:
|
||||||
|
|
|
||||||
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
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""RBAC models: AccessRule, AccessRuleContext, Role."""
|
"""
|
||||||
|
RBAC models: AccessRule, AccessRuleContext, Role.
|
||||||
|
|
||||||
|
Multi-Tenant Design:
|
||||||
|
- Role hat einen unveränderlichen Kontext (mandateId, featureInstanceId, featureCode)
|
||||||
|
- AccessRule referenziert Role via roleId (FK), nicht via roleLabel
|
||||||
|
- Kontext-Felder sind IMMUTABLE nach Erstellung
|
||||||
|
"""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Optional, Dict
|
from typing import Optional
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from modules.shared.attributeUtils import registerModelLabels
|
from modules.shared.attributeUtils import registerModelLabels
|
||||||
|
|
@ -19,7 +26,17 @@ class AccessRuleContext(str, Enum):
|
||||||
|
|
||||||
|
|
||||||
class Role(BaseModel):
|
class Role(BaseModel):
|
||||||
"""Data model for RBAC roles"""
|
"""
|
||||||
|
Data model for RBAC roles.
|
||||||
|
|
||||||
|
Kernkonzept: Eine Rolle existiert immer in einem spezifischen Kontext.
|
||||||
|
Der Kontext ist IMMUTABLE nach Erstellung.
|
||||||
|
|
||||||
|
Kontext-Typen:
|
||||||
|
- mandateId=None, featureInstanceId=None → GLOBAL (Template-Rolle)
|
||||||
|
- mandateId=X, featureInstanceId=None → MANDATE-Rolle
|
||||||
|
- mandateId=X, featureInstanceId=Y → INSTANCE-Rolle
|
||||||
|
"""
|
||||||
id: str = Field(
|
id: str = Field(
|
||||||
default_factory=lambda: str(uuid.uuid4()),
|
default_factory=lambda: str(uuid.uuid4()),
|
||||||
description="Unique ID of the role",
|
description="Unique ID of the role",
|
||||||
|
|
@ -33,106 +50,163 @@ class Role(BaseModel):
|
||||||
description="Role description in multiple languages",
|
description="Role description in multiple languages",
|
||||||
json_schema_extra={"frontend_type": "multilingual", "frontend_readonly": False, "frontend_required": True}
|
json_schema_extra={"frontend_type": "multilingual", "frontend_readonly": False, "frontend_required": True}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# KONTEXT - IMMUTABLE nach Create (nur Create/Delete, kein Update!)
|
||||||
|
mandateId: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="FK → Mandate.id (CASCADE DELETE). Null = Global/Template role.",
|
||||||
|
json_schema_extra={"frontend_type": "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(
|
isSystemRole: bool = Field(
|
||||||
False,
|
default=False,
|
||||||
description="Whether this is a system role that cannot be deleted",
|
description="Whether this is a system role that cannot be deleted",
|
||||||
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": False}
|
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": False}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
registerModelLabels(
|
registerModelLabels(
|
||||||
"Role",
|
"Role",
|
||||||
{"en": "Role", "fr": "Rôle"},
|
{"en": "Role", "de": "Rolle", "fr": "Rôle"},
|
||||||
{
|
{
|
||||||
"id": {"en": "ID", "fr": "ID"},
|
"id": {"en": "ID", "de": "ID", "fr": "ID"},
|
||||||
"roleLabel": {"en": "Role Label", "fr": "Label du rôle"},
|
"roleLabel": {"en": "Role Label", "de": "Rollen-Label", "fr": "Label du rôle"},
|
||||||
"description": {"en": "Description", "fr": "Description"},
|
"description": {"en": "Description", "de": "Beschreibung", "fr": "Description"},
|
||||||
"isSystemRole": {"en": "System Role", "fr": "Rôle système"},
|
"mandateId": {"en": "Mandate", "de": "Mandant", "fr": "Mandat"},
|
||||||
|
"featureInstanceId": {"en": "Feature Instance", "de": "Feature-Instanz", "fr": "Instance"},
|
||||||
|
"featureCode": {"en": "Feature Code", "de": "Feature-Code", "fr": "Code fonctionnalité"},
|
||||||
|
"isSystemRole": {"en": "System Role", "de": "System-Rolle", "fr": "Rôle système"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class AccessRule(BaseModel):
|
class AccessRule(BaseModel):
|
||||||
"""Data model for access control rules"""
|
"""
|
||||||
|
Data model for access control rules.
|
||||||
|
|
||||||
|
WICHTIG: roleId referenziert die Role via FK (nicht mehr roleLabel!)
|
||||||
|
Die Felder 'context' und 'roleId' sind IMMUTABLE nach Erstellung.
|
||||||
|
"""
|
||||||
id: str = Field(
|
id: str = Field(
|
||||||
default_factory=lambda: str(uuid.uuid4()),
|
default_factory=lambda: str(uuid.uuid4()),
|
||||||
description="Unique ID of the access rule",
|
description="Unique ID of the access rule",
|
||||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
|
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
|
||||||
)
|
)
|
||||||
roleLabel: str = Field(
|
roleId: str = Field(
|
||||||
description="Role label this rule applies to",
|
description="FK → Role.id (CASCADE DELETE!)",
|
||||||
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_options": "user.role"}
|
json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": True}
|
||||||
)
|
)
|
||||||
context: AccessRuleContext = Field(
|
context: AccessRuleContext = Field(
|
||||||
description="Context type: DATA (database), UI (interface), RESOURCE (system resources)",
|
description="Context type: DATA (database), UI (interface), RESOURCE (system resources). IMMUTABLE!",
|
||||||
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_options": [
|
json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": True, "frontend_options": [
|
||||||
{"value": "DATA", "label": {"en": "Data", "fr": "Données"}},
|
{"value": "DATA", "label": {"en": "Data", "de": "Daten", "fr": "Données"}},
|
||||||
{"value": "UI", "label": {"en": "UI", "fr": "Interface"}},
|
{"value": "UI", "label": {"en": "UI", "de": "Oberfläche", "fr": "Interface"}},
|
||||||
{"value": "RESOURCE", "label": {"en": "Resource", "fr": "Ressource"}}
|
{"value": "RESOURCE", "label": {"en": "Resource", "de": "Ressource", "fr": "Ressource"}}
|
||||||
]}
|
]}
|
||||||
)
|
)
|
||||||
item: Optional[str] = Field(
|
item: Optional[str] = Field(
|
||||||
None,
|
default=None,
|
||||||
description="Item identifier (null = all items in context). Format: DATA: '<table>' or '<table>.<field>', UI: cascading string (e.g., 'playground.voice.settings'), RESOURCE: cascading string (e.g., 'ai.model.anthropic')",
|
description="Item identifier (null = all items in context). Format: DATA: '<table>' or '<table>.<field>', UI: cascading string (e.g., 'playground.voice.settings'), RESOURCE: cascading string (e.g., 'ai.model.anthropic')",
|
||||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False}
|
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False}
|
||||||
)
|
)
|
||||||
view: bool = Field(
|
view: bool = Field(
|
||||||
False,
|
default=False,
|
||||||
description="View permission: if true, item is visible/enabled. Only objects with view=true are shown.",
|
description="View permission: if true, item is visible/enabled. Only objects with view=true are shown.",
|
||||||
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": True}
|
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": True}
|
||||||
)
|
)
|
||||||
read: Optional[AccessLevel] = Field(
|
read: Optional[AccessLevel] = Field(
|
||||||
None,
|
default=None,
|
||||||
description="Read permission level (only for DATA context)",
|
description="Read permission level (only for DATA context)",
|
||||||
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
|
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
|
||||||
{"value": "a", "label": {"en": "All Records", "fr": "Tous les enregistrements"}},
|
{"value": "a", "label": {"en": "All Records", "de": "Alle Datensätze", "fr": "Tous les enregistrements"}},
|
||||||
{"value": "m", "label": {"en": "My Records", "fr": "Mes enregistrements"}},
|
{"value": "m", "label": {"en": "My Records", "de": "Meine Datensätze", "fr": "Mes enregistrements"}},
|
||||||
{"value": "g", "label": {"en": "Group Records", "fr": "Enregistrements du groupe"}},
|
{"value": "g", "label": {"en": "Group Records", "de": "Gruppen-Datensätze", "fr": "Enregistrements du groupe"}},
|
||||||
{"value": "n", "label": {"en": "No Access", "fr": "Aucun accès"}}
|
{"value": "n", "label": {"en": "No Access", "de": "Kein Zugriff", "fr": "Aucun accès"}}
|
||||||
]}
|
]}
|
||||||
)
|
)
|
||||||
create: Optional[AccessLevel] = Field(
|
create: Optional[AccessLevel] = Field(
|
||||||
None,
|
default=None,
|
||||||
description="Create permission level (only for DATA context)",
|
description="Create permission level (only for DATA context)",
|
||||||
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
|
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
|
||||||
{"value": "a", "label": {"en": "All Records", "fr": "Tous les enregistrements"}},
|
{"value": "a", "label": {"en": "All Records", "de": "Alle Datensätze", "fr": "Tous les enregistrements"}},
|
||||||
{"value": "m", "label": {"en": "My Records", "fr": "Mes enregistrements"}},
|
{"value": "m", "label": {"en": "My Records", "de": "Meine Datensätze", "fr": "Mes enregistrements"}},
|
||||||
{"value": "g", "label": {"en": "Group Records", "fr": "Enregistrements du groupe"}},
|
{"value": "g", "label": {"en": "Group Records", "de": "Gruppen-Datensätze", "fr": "Enregistrements du groupe"}},
|
||||||
{"value": "n", "label": {"en": "No Access", "fr": "Aucun accès"}}
|
{"value": "n", "label": {"en": "No Access", "de": "Kein Zugriff", "fr": "Aucun accès"}}
|
||||||
]}
|
]}
|
||||||
)
|
)
|
||||||
update: Optional[AccessLevel] = Field(
|
update: Optional[AccessLevel] = Field(
|
||||||
None,
|
default=None,
|
||||||
description="Update permission level (only for DATA context)",
|
description="Update permission level (only for DATA context)",
|
||||||
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
|
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
|
||||||
{"value": "a", "label": {"en": "All Records", "fr": "Tous les enregistrements"}},
|
{"value": "a", "label": {"en": "All Records", "de": "Alle Datensätze", "fr": "Tous les enregistrements"}},
|
||||||
{"value": "m", "label": {"en": "My Records", "fr": "Mes enregistrements"}},
|
{"value": "m", "label": {"en": "My Records", "de": "Meine Datensätze", "fr": "Mes enregistrements"}},
|
||||||
{"value": "g", "label": {"en": "Group Records", "fr": "Enregistrements du groupe"}},
|
{"value": "g", "label": {"en": "Group Records", "de": "Gruppen-Datensätze", "fr": "Enregistrements du groupe"}},
|
||||||
{"value": "n", "label": {"en": "No Access", "fr": "Aucun accès"}}
|
{"value": "n", "label": {"en": "No Access", "de": "Kein Zugriff", "fr": "Aucun accès"}}
|
||||||
]}
|
]}
|
||||||
)
|
)
|
||||||
delete: Optional[AccessLevel] = Field(
|
delete: Optional[AccessLevel] = Field(
|
||||||
None,
|
default=None,
|
||||||
description="Delete permission level (only for DATA context)",
|
description="Delete permission level (only for DATA context)",
|
||||||
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
|
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
|
||||||
{"value": "a", "label": {"en": "All Records", "fr": "Tous les enregistrements"}},
|
{"value": "a", "label": {"en": "All Records", "de": "Alle Datensätze", "fr": "Tous les enregistrements"}},
|
||||||
{"value": "m", "label": {"en": "My Records", "fr": "Mes enregistrements"}},
|
{"value": "m", "label": {"en": "My Records", "de": "Meine Datensätze", "fr": "Mes enregistrements"}},
|
||||||
{"value": "g", "label": {"en": "Group Records", "fr": "Enregistrements du groupe"}},
|
{"value": "g", "label": {"en": "Group Records", "de": "Gruppen-Datensätze", "fr": "Enregistrements du groupe"}},
|
||||||
{"value": "n", "label": {"en": "No Access", "fr": "Aucun accès"}}
|
{"value": "n", "label": {"en": "No Access", "de": "Kein Zugriff", "fr": "Aucun accès"}}
|
||||||
]}
|
]}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
registerModelLabels(
|
registerModelLabels(
|
||||||
"AccessRule",
|
"AccessRule",
|
||||||
{"en": "Access Rule", "fr": "Règle d'accès"},
|
{"en": "Access Rule", "de": "Zugriffsregel", "fr": "Règle d'accès"},
|
||||||
{
|
{
|
||||||
"id": {"en": "ID", "fr": "ID"},
|
"id": {"en": "ID", "de": "ID", "fr": "ID"},
|
||||||
"roleLabel": {"en": "Role Label", "fr": "Label du rôle"},
|
"roleId": {"en": "Role", "de": "Rolle", "fr": "Rôle"},
|
||||||
"context": {"en": "Context", "fr": "Contexte"},
|
"context": {"en": "Context", "de": "Kontext", "fr": "Contexte"},
|
||||||
"item": {"en": "Item", "fr": "Élément"},
|
"item": {"en": "Item", "de": "Element", "fr": "Élément"},
|
||||||
"view": {"en": "View", "fr": "Vue"},
|
"view": {"en": "View", "de": "Anzeigen", "fr": "Vue"},
|
||||||
"read": {"en": "Read", "fr": "Lecture"},
|
"read": {"en": "Read", "de": "Lesen", "fr": "Lecture"},
|
||||||
"create": {"en": "Create", "fr": "Créer"},
|
"create": {"en": "Create", "de": "Erstellen", "fr": "Créer"},
|
||||||
"update": {"en": "Update", "fr": "Mettre à jour"},
|
"update": {"en": "Update", "de": "Aktualisieren", "fr": "Mettre à jour"},
|
||||||
"delete": {"en": "Delete", "fr": "Supprimer"},
|
"delete": {"en": "Delete", "de": "Löschen", "fr": "Supprimer"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# IMMUTABLE Fields Definition - für Enforcement auf Application-Level
|
||||||
|
IMMUTABLE_FIELDS = {
|
||||||
|
"Role": ["mandateId", "featureInstanceId", "featureCode"],
|
||||||
|
"AccessRule": ["context", "roleId"]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def validateUpdateNotImmutable(model: str, updateData: dict):
|
||||||
|
"""
|
||||||
|
Blockiert Updates auf immutable Felder.
|
||||||
|
Wirft ValueError wenn versucht wird, Kontext-Felder zu ändern.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model: Model name (z.B. "Role", "AccessRule")
|
||||||
|
updateData: Dictionary mit Update-Daten
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: Wenn immutable Felder im Update enthalten sind
|
||||||
|
"""
|
||||||
|
forbidden = IMMUTABLE_FIELDS.get(model, [])
|
||||||
|
violations = [f for f in forbidden if f in updateData]
|
||||||
|
|
||||||
|
if violations:
|
||||||
|
raise ValueError(
|
||||||
|
f"Cannot update immutable fields on {model}: {violations}. "
|
||||||
|
f"Delete and recreate instead."
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,13 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""Security models: Token and AuthEvent."""
|
"""
|
||||||
|
Security models: Token and AuthEvent.
|
||||||
|
|
||||||
|
Multi-Tenant Design:
|
||||||
|
- Token ist NICHT an einen Mandanten gebunden
|
||||||
|
- User arbeitet parallel in mehreren Mandanten (z.B. mehrere Browser-Tabs)
|
||||||
|
- Mandant-Kontext wird per Request-Header (X-Mandate-Id) bestimmt
|
||||||
|
"""
|
||||||
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from pydantic import BaseModel, Field, ConfigDict
|
from pydantic import BaseModel, Field, ConfigDict
|
||||||
|
|
@ -17,6 +24,14 @@ class TokenStatus(str, Enum):
|
||||||
|
|
||||||
|
|
||||||
class Token(BaseModel):
|
class Token(BaseModel):
|
||||||
|
"""
|
||||||
|
Authentication Token model.
|
||||||
|
|
||||||
|
Multi-Tenant Design:
|
||||||
|
- Token ist User-gebunden, NICHT Mandant-gebunden
|
||||||
|
- Ermöglicht parallele Arbeit in mehreren Mandanten
|
||||||
|
- Mandant-Kontext wird per Request-Header bestimmt
|
||||||
|
"""
|
||||||
id: Optional[str] = None
|
id: Optional[str] = None
|
||||||
userId: str
|
userId: str
|
||||||
authority: AuthAuthority
|
authority: AuthAuthority
|
||||||
|
|
@ -45,37 +60,36 @@ class Token(BaseModel):
|
||||||
sessionId: Optional[str] = Field(
|
sessionId: Optional[str] = Field(
|
||||||
None, description="Logical session grouping for logout revocation"
|
None, description="Logical session grouping for logout revocation"
|
||||||
)
|
)
|
||||||
mandateId: Optional[str] = Field(
|
# ENTFERNT: mandateId - Token ist nicht mehr Mandant-spezifisch
|
||||||
None, description="Mandate ID for tenant scoping of the token"
|
# Mandant-Kontext wird per Request-Header (X-Mandate-Id) bestimmt
|
||||||
)
|
|
||||||
|
|
||||||
model_config = ConfigDict(use_enum_values=True)
|
model_config = ConfigDict(use_enum_values=True)
|
||||||
|
|
||||||
|
|
||||||
registerModelLabels(
|
registerModelLabels(
|
||||||
"Token",
|
"Token",
|
||||||
{"en": "Token", "fr": "Jeton"},
|
{"en": "Token", "de": "Token", "fr": "Jeton"},
|
||||||
{
|
{
|
||||||
"id": {"en": "ID", "fr": "ID"},
|
"id": {"en": "ID", "de": "ID", "fr": "ID"},
|
||||||
"userId": {"en": "User ID", "fr": "ID utilisateur"},
|
"userId": {"en": "User ID", "de": "Benutzer-ID", "fr": "ID utilisateur"},
|
||||||
"authority": {"en": "Authority", "fr": "Autorité"},
|
"authority": {"en": "Authority", "de": "Autorität", "fr": "Autorité"},
|
||||||
"connectionId": {"en": "Connection ID", "fr": "ID de connexion"},
|
"connectionId": {"en": "Connection ID", "de": "Verbindungs-ID", "fr": "ID de connexion"},
|
||||||
"tokenAccess": {"en": "Access Token", "fr": "Jeton d'accès"},
|
"tokenAccess": {"en": "Access Token", "de": "Zugriffstoken", "fr": "Jeton d'accès"},
|
||||||
"tokenType": {"en": "Token Type", "fr": "Type de jeton"},
|
"tokenType": {"en": "Token Type", "de": "Token-Typ", "fr": "Type de jeton"},
|
||||||
"expiresAt": {"en": "Expires At", "fr": "Expire le"},
|
"expiresAt": {"en": "Expires At", "de": "Läuft ab am", "fr": "Expire le"},
|
||||||
"tokenRefresh": {"en": "Refresh Token", "fr": "Jeton de rafraîchissement"},
|
"tokenRefresh": {"en": "Refresh Token", "de": "Refresh-Token", "fr": "Jeton de rafraîchissement"},
|
||||||
"createdAt": {"en": "Created At", "fr": "Créé le"},
|
"createdAt": {"en": "Created At", "de": "Erstellt am", "fr": "Créé le"},
|
||||||
"status": {"en": "Status", "fr": "Statut"},
|
"status": {"en": "Status", "de": "Status", "fr": "Statut"},
|
||||||
"revokedAt": {"en": "Revoked At", "fr": "Révoqué le"},
|
"revokedAt": {"en": "Revoked At", "de": "Widerrufen am", "fr": "Révoqué le"},
|
||||||
"revokedBy": {"en": "Revoked By", "fr": "Révoqué par"},
|
"revokedBy": {"en": "Revoked By", "de": "Widerrufen von", "fr": "Révoqué par"},
|
||||||
"reason": {"en": "Reason", "fr": "Raison"},
|
"reason": {"en": "Reason", "de": "Grund", "fr": "Raison"},
|
||||||
"sessionId": {"en": "Session ID", "fr": "ID de session"},
|
"sessionId": {"en": "Session ID", "de": "Sitzungs-ID", "fr": "ID de session"},
|
||||||
"mandateId": {"en": "Mandate ID", "fr": "ID de mandat"},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class AuthEvent(BaseModel):
|
class AuthEvent(BaseModel):
|
||||||
|
"""Authentication event for audit logging."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the auth event", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the auth event", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||||
userId: str = Field(description="ID of the user this event belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
|
userId: str = Field(description="ID of the user this event belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
|
||||||
eventType: str = Field(description="Type of authentication event (e.g., 'login', 'logout', 'token_refresh')", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
|
eventType: str = Field(description="Type of authentication event (e.g., 'login', 'logout', 'token_refresh')", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
|
||||||
|
|
@ -88,15 +102,15 @@ class AuthEvent(BaseModel):
|
||||||
|
|
||||||
registerModelLabels(
|
registerModelLabels(
|
||||||
"AuthEvent",
|
"AuthEvent",
|
||||||
{"en": "Authentication Event", "fr": "Événement d'authentification"},
|
{"en": "Authentication Event", "de": "Authentifizierungsereignis", "fr": "Événement d'authentification"},
|
||||||
{
|
{
|
||||||
"id": {"en": "ID", "fr": "ID"},
|
"id": {"en": "ID", "de": "ID", "fr": "ID"},
|
||||||
"userId": {"en": "User ID", "fr": "ID utilisateur"},
|
"userId": {"en": "User ID", "de": "Benutzer-ID", "fr": "ID utilisateur"},
|
||||||
"eventType": {"en": "Event Type", "fr": "Type d'événement"},
|
"eventType": {"en": "Event Type", "de": "Ereignistyp", "fr": "Type d'événement"},
|
||||||
"timestamp": {"en": "Timestamp", "fr": "Horodatage"},
|
"timestamp": {"en": "Timestamp", "de": "Zeitstempel", "fr": "Horodatage"},
|
||||||
"ipAddress": {"en": "IP Address", "fr": "Adresse IP"},
|
"ipAddress": {"en": "IP Address", "de": "IP-Adresse", "fr": "Adresse IP"},
|
||||||
"userAgent": {"en": "User Agent", "fr": "Agent utilisateur"},
|
"userAgent": {"en": "User Agent", "de": "User-Agent", "fr": "Agent utilisateur"},
|
||||||
"success": {"en": "Success", "fr": "Succès"},
|
"success": {"en": "Success", "de": "Erfolgreich", "fr": "Succès"},
|
||||||
"details": {"en": "Details", "fr": "Détails"},
|
"details": {"en": "Details", "de": "Details", "fr": "Détails"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,18 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""UAM models: User, Mandate, UserConnection."""
|
"""
|
||||||
|
UAM models: User, Mandate, UserConnection.
|
||||||
|
|
||||||
|
Multi-Tenant Design:
|
||||||
|
- User gehört NICHT direkt zu einem Mandanten
|
||||||
|
- Zugehörigkeit wird über UserMandate gesteuert (siehe datamodelMembership.py)
|
||||||
|
- isSysAdmin ist globales Admin-Flag für System-Zugriff (KEIN Daten-Zugriff!)
|
||||||
|
"""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from pydantic import BaseModel, Field, EmailStr
|
from pydantic import BaseModel, Field, EmailStr, field_validator
|
||||||
from modules.shared.attributeUtils import registerModelLabels
|
from modules.shared.attributeUtils import registerModelLabels
|
||||||
from modules.shared.timeUtils import getUtcTimestamp
|
from modules.shared.timeUtils import getUtcTimestamp
|
||||||
|
|
||||||
|
|
@ -51,7 +58,12 @@ class UserPermissions(BaseModel):
|
||||||
description="Delete permission level"
|
description="Delete permission level"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class Mandate(BaseModel):
|
class Mandate(BaseModel):
|
||||||
|
"""
|
||||||
|
Mandate (Mandant/Tenant) model.
|
||||||
|
Ein Mandant ist ein isolierter Bereich für Daten und Berechtigungen.
|
||||||
|
"""
|
||||||
id: str = Field(
|
id: str = Field(
|
||||||
default_factory=lambda: str(uuid.uuid4()),
|
default_factory=lambda: str(uuid.uuid4()),
|
||||||
description="Unique ID of the mandate",
|
description="Unique ID of the mandate",
|
||||||
|
|
@ -61,37 +73,24 @@ class Mandate(BaseModel):
|
||||||
description="Name of the mandate",
|
description="Name of the mandate",
|
||||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True}
|
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True}
|
||||||
)
|
)
|
||||||
language: str = Field(
|
|
||||||
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(
|
enabled: bool = Field(
|
||||||
default=True,
|
default=True,
|
||||||
description="Indicates whether the mandate is enabled",
|
description="Indicates whether the mandate is enabled",
|
||||||
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
|
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
registerModelLabels(
|
registerModelLabels(
|
||||||
"Mandate",
|
"Mandate",
|
||||||
{"en": "Mandate", "fr": "Mandat"},
|
{"en": "Mandate", "de": "Mandant", "fr": "Mandat"},
|
||||||
{
|
{
|
||||||
"id": {"en": "ID", "fr": "ID"},
|
"id": {"en": "ID", "de": "ID", "fr": "ID"},
|
||||||
"name": {"en": "Name", "fr": "Nom"},
|
"name": {"en": "Name", "de": "Name", "fr": "Nom"},
|
||||||
"language": {"en": "Language", "fr": "Langue"},
|
"enabled": {"en": "Enabled", "de": "Aktiviert", "fr": "Activé"},
|
||||||
"enabled": {"en": "Enabled", "fr": "Activé"},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class UserConnection(BaseModel):
|
class UserConnection(BaseModel):
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the connection", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the connection", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||||
userId: str = Field(description="ID of the user this connection belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
userId: str = Field(description="ID of the user this connection belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||||
|
|
@ -109,70 +108,123 @@ class UserConnection(BaseModel):
|
||||||
{"value": "none", "label": {"en": "None", "fr": "Aucun"}},
|
{"value": "none", "label": {"en": "None", "fr": "Aucun"}},
|
||||||
]})
|
]})
|
||||||
tokenExpiresAt: Optional[float] = Field(None, description="When the current token expires (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False})
|
tokenExpiresAt: Optional[float] = Field(None, description="When the current token expires (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False})
|
||||||
|
|
||||||
|
|
||||||
registerModelLabels(
|
registerModelLabels(
|
||||||
"UserConnection",
|
"UserConnection",
|
||||||
{"en": "User Connection", "fr": "Connexion utilisateur"},
|
{"en": "User Connection", "de": "Benutzerverbindung", "fr": "Connexion utilisateur"},
|
||||||
{
|
{
|
||||||
"id": {"en": "ID", "fr": "ID"},
|
"id": {"en": "ID", "de": "ID", "fr": "ID"},
|
||||||
"userId": {"en": "User ID", "fr": "ID utilisateur"},
|
"userId": {"en": "User ID", "de": "Benutzer-ID", "fr": "ID utilisateur"},
|
||||||
"authority": {"en": "Authority", "fr": "Autorité"},
|
"authority": {"en": "Authority", "de": "Autorität", "fr": "Autorité"},
|
||||||
"externalId": {"en": "External ID", "fr": "ID externe"},
|
"externalId": {"en": "External ID", "de": "Externe ID", "fr": "ID externe"},
|
||||||
"externalUsername": {"en": "External Username", "fr": "Nom d'utilisateur externe"},
|
"externalUsername": {"en": "External Username", "de": "Externer Benutzername", "fr": "Nom d'utilisateur externe"},
|
||||||
"externalEmail": {"en": "External Email", "fr": "Email externe"},
|
"externalEmail": {"en": "External Email", "de": "Externe E-Mail", "fr": "Email externe"},
|
||||||
"status": {"en": "Status", "fr": "Statut"},
|
"status": {"en": "Status", "de": "Status", "fr": "Statut"},
|
||||||
"connectedAt": {"en": "Connected At", "fr": "Connecté le"},
|
"connectedAt": {"en": "Connected At", "de": "Verbunden am", "fr": "Connecté le"},
|
||||||
"lastChecked": {"en": "Last Checked", "fr": "Dernière vérification"},
|
"lastChecked": {"en": "Last Checked", "de": "Zuletzt geprüft", "fr": "Dernière vérification"},
|
||||||
"expiresAt": {"en": "Expires At", "fr": "Expire le"},
|
"expiresAt": {"en": "Expires At", "de": "Läuft ab am", "fr": "Expire le"},
|
||||||
"tokenStatus": {"en": "Connection Status", "fr": "Statut de connexion"},
|
"tokenStatus": {"en": "Connection Status", "de": "Verbindungsstatus", "fr": "Statut de connexion"},
|
||||||
"tokenExpiresAt": {"en": "Expires At", "fr": "Expire le"},
|
"tokenExpiresAt": {"en": "Expires At", "de": "Läuft ab am", "fr": "Expire le"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class User(BaseModel):
|
class User(BaseModel):
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the user", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
"""
|
||||||
username: str = Field(description="Username for login", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True})
|
User model.
|
||||||
email: Optional[EmailStr] = Field(None, description="Email address of the user", json_schema_extra={"frontend_type": "email", "frontend_readonly": False, "frontend_required": True})
|
|
||||||
fullName: Optional[str] = Field(None, description="Full name of the user", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False})
|
Multi-Tenant Design:
|
||||||
language: str = Field(default="en", description="Preferred language of the user", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_options": [
|
- User gehört NICHT direkt zu einem Mandanten
|
||||||
|
- 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}
|
||||||
|
)
|
||||||
|
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": "de", "label": {"en": "Deutsch", "fr": "Allemand"}},
|
||||||
{"value": "en", "label": {"en": "English", "fr": "Anglais"}},
|
{"value": "en", "label": {"en": "English", "fr": "Anglais"}},
|
||||||
{"value": "fr", "label": {"en": "Français", "fr": "Français"}},
|
{"value": "fr", "label": {"en": "Français", "fr": "Français"}},
|
||||||
{"value": "it", "label": {"en": "Italiano", "fr": "Italien"}},
|
{"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"}
|
|
||||||
)
|
)
|
||||||
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"})
|
enabled: bool = Field(
|
||||||
mandateId: Optional[str] = Field(None, description="ID of the mandate this user belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
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(
|
registerModelLabels(
|
||||||
"User",
|
"User",
|
||||||
{"en": "User", "fr": "Utilisateur"},
|
{"en": "User", "de": "Benutzer", "fr": "Utilisateur"},
|
||||||
{
|
{
|
||||||
"id": {"en": "ID", "fr": "ID"},
|
"id": {"en": "ID", "de": "ID", "fr": "ID"},
|
||||||
"username": {"en": "Username", "fr": "Nom d'utilisateur"},
|
"username": {"en": "Username", "de": "Benutzername", "fr": "Nom d'utilisateur"},
|
||||||
"email": {"en": "Email", "fr": "Email"},
|
"email": {"en": "Email", "de": "E-Mail", "fr": "Email"},
|
||||||
"fullName": {"en": "Full Name", "fr": "Nom complet"},
|
"fullName": {"en": "Full Name", "de": "Vollständiger Name", "fr": "Nom complet"},
|
||||||
"language": {"en": "Language", "fr": "Langue"},
|
"language": {"en": "Language", "de": "Sprache", "fr": "Langue"},
|
||||||
"enabled": {"en": "Enabled", "fr": "Activé"},
|
"enabled": {"en": "Enabled", "de": "Aktiviert", "fr": "Activé"},
|
||||||
"roleLabels": {"en": "Role Labels", "fr": "Labels de rôle"},
|
"isSysAdmin": {"en": "System Admin", "de": "System-Admin", "fr": "Admin système"},
|
||||||
"authenticationAuthority": {"en": "Auth Authority", "fr": "Autorité d'authentification"},
|
"authenticationAuthority": {"en": "Auth Authority", "de": "Authentifizierung", "fr": "Autorité d'authentification"},
|
||||||
"mandateId": {"en": "Mandate ID", "fr": "ID de mandat"},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class UserInDB(User):
|
class UserInDB(User):
|
||||||
|
"""User model with password hash for database storage."""
|
||||||
hashedPassword: Optional[str] = Field(None, description="Hash of the user password")
|
hashedPassword: Optional[str] = Field(None, description="Hash of the user password")
|
||||||
resetToken: Optional[str] = Field(None, description="Password reset token (UUID)")
|
resetToken: Optional[str] = Field(None, description="Password reset token (UUID)")
|
||||||
resetTokenExpires: Optional[float] = Field(None, description="Reset token expiration (UTC timestamp in seconds)")
|
resetTokenExpires: Optional[float] = Field(None, description="Reset token expiration (UTC timestamp in seconds)")
|
||||||
|
|
||||||
|
|
||||||
registerModelLabels(
|
registerModelLabels(
|
||||||
"UserInDB",
|
"UserInDB",
|
||||||
{"en": "User Access", "fr": "Accès de l'utilisateur"},
|
{"en": "User Access", "de": "Benutzerzugang", "fr": "Accès de l'utilisateur"},
|
||||||
{
|
{
|
||||||
"hashedPassword": {"en": "Password hash", "fr": "Hachage de mot de passe"},
|
"hashedPassword": {"en": "Password hash", "de": "Passwort-Hash", "fr": "Hachage de mot de passe"},
|
||||||
"resetToken": {"en": "Reset Token", "fr": "Jeton de réinitialisation"},
|
"resetToken": {"en": "Reset Token", "de": "Reset-Token", "fr": "Jeton de réinitialisation"},
|
||||||
"resetTokenExpires": {"en": "Reset Token Expires", "fr": "Expiration du jeton"},
|
"resetTokenExpires": {"en": "Reset Token Expires", "de": "Token läuft ab", "fr": "Expiration du jeton"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ def _extractJsonFromResponse(content: str) -> Optional[dict]:
|
||||||
|
|
||||||
async def chatProcess(
|
async def chatProcess(
|
||||||
currentUser: User,
|
currentUser: User,
|
||||||
|
mandateId: str,
|
||||||
userInput: UserInputRequest,
|
userInput: UserInputRequest,
|
||||||
workflowId: Optional[str] = None
|
workflowId: Optional[str] = None
|
||||||
) -> ChatWorkflow:
|
) -> ChatWorkflow:
|
||||||
|
|
@ -76,6 +77,7 @@ async def chatProcess(
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
currentUser: Current user
|
currentUser: Current user
|
||||||
|
mandateId: Mandate context (from RequestContext / X-Mandate-Id header)
|
||||||
userInput: User input request
|
userInput: User input request
|
||||||
workflowId: Optional workflow ID to continue existing conversation
|
workflowId: Optional workflow ID to continue existing conversation
|
||||||
|
|
||||||
|
|
@ -83,8 +85,8 @@ async def chatProcess(
|
||||||
ChatWorkflow instance
|
ChatWorkflow instance
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Get services
|
# Get services with mandate context
|
||||||
services = getServices(currentUser, None)
|
services = getServices(currentUser, None, mandateId=mandateId)
|
||||||
interfaceDbChat = services.interfaceDbChat
|
interfaceDbChat = services.interfaceDbChat
|
||||||
|
|
||||||
# Get event manager and create queue if needed
|
# Get event manager and create queue if needed
|
||||||
|
|
@ -120,7 +122,7 @@ async def chatProcess(
|
||||||
# Create new workflow
|
# Create new workflow
|
||||||
workflowData = {
|
workflowData = {
|
||||||
"id": str(uuid.uuid4()),
|
"id": str(uuid.uuid4()),
|
||||||
"mandateId": currentUser.mandateId,
|
"mandateId": mandateId,
|
||||||
"status": "running",
|
"status": "running",
|
||||||
"name": conversation_name,
|
"name": conversation_name,
|
||||||
"currentRound": 1,
|
"currentRound": 1,
|
||||||
|
|
@ -687,12 +689,13 @@ async def _convert_file_ids_to_document_references(
|
||||||
# Search database if not found in messages
|
# Search database if not found in messages
|
||||||
if not document_id:
|
if not document_id:
|
||||||
try:
|
try:
|
||||||
from modules.shared.databaseUtils import getRecordsetWithRBAC
|
from modules.interfaces.interfaceRbac import getRecordsetWithRBAC
|
||||||
documents = getRecordsetWithRBAC(
|
documents = getRecordsetWithRBAC(
|
||||||
services.interfaceDbChat.db,
|
services.interfaceDbChat.db,
|
||||||
ChatDocument,
|
ChatDocument,
|
||||||
services.currentUser,
|
services.user,
|
||||||
recordFilter={"fileId": file_id}
|
recordFilter={"fileId": file_id},
|
||||||
|
mandateId=services.mandateId
|
||||||
)
|
)
|
||||||
if documents:
|
if documents:
|
||||||
workflow_message_ids = {msg.id for msg in workflow.messages} if workflow.messages else set()
|
workflow_message_ids = {msg.id for msg in workflow.messages} if workflow.messages else set()
|
||||||
|
|
|
||||||
|
|
@ -126,7 +126,7 @@ def getOptions(optionsName: str, services, currentUser: Optional[User] = None) -
|
||||||
return []
|
return []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
users = services.interfaceDbApp.getUsersByMandate(currentUser.mandateId)
|
users = services.interfaceDbApp.getUsersByMandate(services.mandateId)
|
||||||
|
|
||||||
# Handle both list and PaginatedResult
|
# Handle both list and PaginatedResult
|
||||||
if hasattr(users, 'items'):
|
if hasattr(users, 'items'):
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,10 @@ logger = logging.getLogger(__name__)
|
||||||
class NeutralizationPlayground:
|
class NeutralizationPlayground:
|
||||||
"""Feature/UI wrapper around NeutralizationService for playground & routes."""
|
"""Feature/UI wrapper around NeutralizationService for playground & routes."""
|
||||||
|
|
||||||
def __init__(self, currentUser: User):
|
def __init__(self, currentUser: User, mandateId: str):
|
||||||
self.currentUser = currentUser
|
self.currentUser = currentUser
|
||||||
self.services = getServices(currentUser, None)
|
self.mandateId = mandateId
|
||||||
|
self.services = getServices(currentUser, None, mandateId=mandateId)
|
||||||
|
|
||||||
def processText(self, text: str) -> Dict[str, Any]:
|
def processText(self, text: str) -> Dict[str, Any]:
|
||||||
return self.services.neutralization.processText(text)
|
return self.services.neutralization.processText(text)
|
||||||
|
|
@ -81,7 +82,7 @@ class NeutralizationPlayground:
|
||||||
'total_attributes': len(allAttributes),
|
'total_attributes': len(allAttributes),
|
||||||
'unique_files': len(uniqueFiles),
|
'unique_files': len(uniqueFiles),
|
||||||
'pattern_counts': patternCounts,
|
'pattern_counts': patternCounts,
|
||||||
'mandate_id': self.currentUser.mandateId if self.currentUser else None,
|
'mandate_id': self.mandateId,
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting stats: {str(e)}")
|
logger.error(f"Error getting stats: {str(e)}")
|
||||||
|
|
|
||||||
|
|
@ -346,6 +346,7 @@ async def fetch_parcel_polygon_from_swisstopo(
|
||||||
|
|
||||||
async def executeDirectQuery(
|
async def executeDirectQuery(
|
||||||
currentUser: User,
|
currentUser: User,
|
||||||
|
mandateId: str,
|
||||||
queryText: str,
|
queryText: str,
|
||||||
parameters: Optional[Dict[str, Any]] = None,
|
parameters: Optional[Dict[str, Any]] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
|
|
@ -354,6 +355,7 @@ async def executeDirectQuery(
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
currentUser: Current authenticated user
|
currentUser: Current authenticated user
|
||||||
|
mandateId: Mandate context (from RequestContext / X-Mandate-Id header)
|
||||||
queryText: SQL query text
|
queryText: SQL query text
|
||||||
parameters: Optional parameters for parameterized queries
|
parameters: Optional parameters for parameterized queries
|
||||||
|
|
||||||
|
|
@ -364,16 +366,15 @@ async def executeDirectQuery(
|
||||||
- No session or query history is saved
|
- No session or query history is saved
|
||||||
- Query is executed directly and result is returned
|
- Query is executed directly and result is returned
|
||||||
- For production, validate and sanitize queries before execution
|
- For production, validate and sanitize queries before execution
|
||||||
- TODO: Implement actual database query execution via interface
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
logger.info(f"Executing direct query for user {currentUser.id} (mandate: {currentUser.mandateId})")
|
logger.info(f"Executing direct query for user {currentUser.id} (mandate: {mandateId})")
|
||||||
logger.debug(f"Query text: {queryText}")
|
logger.debug(f"Query text: {queryText}")
|
||||||
if parameters:
|
if parameters:
|
||||||
logger.debug(f"Query parameters: {parameters}")
|
logger.debug(f"Query parameters: {parameters}")
|
||||||
|
|
||||||
# Execute query via Real Estate interface (stateless)
|
# Execute query via Real Estate interface (stateless)
|
||||||
realEstateInterface = getRealEstateInterface(currentUser)
|
realEstateInterface = getRealEstateInterface(currentUser, mandateId=mandateId)
|
||||||
result = realEstateInterface.executeQuery(queryText, parameters)
|
result = realEstateInterface.executeQuery(queryText, parameters)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
@ -529,6 +530,7 @@ def _formatEntitySummary(entity_type: str, items: List[Dict[str, Any]], filters:
|
||||||
|
|
||||||
async def processNaturalLanguageCommand(
|
async def processNaturalLanguageCommand(
|
||||||
currentUser: User,
|
currentUser: User,
|
||||||
|
mandateId: str,
|
||||||
userInput: str,
|
userInput: str,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
|
|
@ -539,6 +541,7 @@ async def processNaturalLanguageCommand(
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
currentUser: Current authenticated user
|
currentUser: Current authenticated user
|
||||||
|
mandateId: Mandate context (from RequestContext / X-Mandate-Id header)
|
||||||
userInput: Natural language command from user
|
userInput: Natural language command from user
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
|
@ -552,11 +555,11 @@ async def processNaturalLanguageCommand(
|
||||||
- "SELECT * FROM Projekt WHERE plz = '8000'"
|
- "SELECT * FROM Projekt WHERE plz = '8000'"
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
logger.info(f"Processing natural language command for user {currentUser.id} (mandate: {currentUser.mandateId})")
|
logger.info(f"Processing natural language command for user {currentUser.id} (mandate: {mandateId})")
|
||||||
logger.debug(f"User input: {userInput}")
|
logger.debug(f"User input: {userInput}")
|
||||||
|
|
||||||
# Initialize services for AI access
|
# Initialize services for AI access
|
||||||
services = getServices(currentUser, workflow=None)
|
services = getServices(currentUser, workflow=None, mandateId=mandateId)
|
||||||
aiService = services.ai
|
aiService = services.ai
|
||||||
|
|
||||||
# Step 1: Analyze user intent with AI
|
# Step 1: Analyze user intent with AI
|
||||||
|
|
@ -567,6 +570,7 @@ async def processNaturalLanguageCommand(
|
||||||
# Step 2: Execute CRUD operation based on intent
|
# Step 2: Execute CRUD operation based on intent
|
||||||
result = await executeIntentBasedOperation(
|
result = await executeIntentBasedOperation(
|
||||||
currentUser=currentUser,
|
currentUser=currentUser,
|
||||||
|
mandateId=mandateId,
|
||||||
intent=intentAnalysis["intent"],
|
intent=intentAnalysis["intent"],
|
||||||
entity=intentAnalysis.get("entity"),
|
entity=intentAnalysis.get("entity"),
|
||||||
parameters=intentAnalysis.get("parameters", {}),
|
parameters=intentAnalysis.get("parameters", {}),
|
||||||
|
|
@ -839,6 +843,7 @@ IMPORTANT EXTRACTION RULES:
|
||||||
|
|
||||||
async def executeIntentBasedOperation(
|
async def executeIntentBasedOperation(
|
||||||
currentUser: User,
|
currentUser: User,
|
||||||
|
mandateId: str,
|
||||||
intent: str,
|
intent: str,
|
||||||
entity: Optional[str],
|
entity: Optional[str],
|
||||||
parameters: Dict[str, Any],
|
parameters: Dict[str, Any],
|
||||||
|
|
@ -848,6 +853,7 @@ async def executeIntentBasedOperation(
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
currentUser: Current authenticated user
|
currentUser: Current authenticated user
|
||||||
|
mandateId: Mandate context (from RequestContext / X-Mandate-Id header)
|
||||||
intent: Intent from AI analysis (CREATE, READ, UPDATE, DELETE, QUERY)
|
intent: Intent from AI analysis (CREATE, READ, UPDATE, DELETE, QUERY)
|
||||||
entity: Entity type from AI analysis
|
entity: Entity type from AI analysis
|
||||||
parameters: Extracted parameters from AI analysis
|
parameters: Extracted parameters from AI analysis
|
||||||
|
|
@ -856,8 +862,8 @@ async def executeIntentBasedOperation(
|
||||||
Operation result
|
Operation result
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
- TODO: Implement actual interface calls once datamodels are ready
|
- Supports CREATE, READ, UPDATE, DELETE, QUERY intents
|
||||||
- Currently returns test responses showing what would be executed
|
- Entity types: Projekt, Parzelle, Gemeinde, Kanton, Land, Dokument
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
logger.info(f"Executing intent-based operation: intent={intent}, entity={entity}")
|
logger.info(f"Executing intent-based operation: intent={intent}, entity={entity}")
|
||||||
|
|
@ -872,6 +878,7 @@ async def executeIntentBasedOperation(
|
||||||
|
|
||||||
result = await executeDirectQuery(
|
result = await executeDirectQuery(
|
||||||
currentUser=currentUser,
|
currentUser=currentUser,
|
||||||
|
mandateId=mandateId,
|
||||||
queryText=queryText,
|
queryText=queryText,
|
||||||
parameters=parameters.get("queryParameters"),
|
parameters=parameters.get("queryParameters"),
|
||||||
)
|
)
|
||||||
|
|
@ -879,12 +886,12 @@ async def executeIntentBasedOperation(
|
||||||
|
|
||||||
elif intent == "CREATE":
|
elif intent == "CREATE":
|
||||||
# Create new entity
|
# Create new entity
|
||||||
realEstateInterface = getRealEstateInterface(currentUser)
|
realEstateInterface = getRealEstateInterface(currentUser, mandateId=mandateId)
|
||||||
|
|
||||||
if entity == "Projekt":
|
if entity == "Projekt":
|
||||||
# Create Projekt from parameters
|
# Create Projekt from parameters
|
||||||
projekt = Projekt(
|
projekt = Projekt(
|
||||||
mandateId=currentUser.mandateId,
|
mandateId=mandateId,
|
||||||
label=parameters.get("label", ""),
|
label=parameters.get("label", ""),
|
||||||
statusProzess=StatusProzess(parameters.get("statusProzess", "EINGANG")) if parameters.get("statusProzess") else None,
|
statusProzess=StatusProzess(parameters.get("statusProzess", "EINGANG")) if parameters.get("statusProzess") else None,
|
||||||
)
|
)
|
||||||
|
|
@ -902,7 +909,7 @@ async def executeIntentBasedOperation(
|
||||||
|
|
||||||
# Build parzelle data with all extracted parameters
|
# Build parzelle data with all extracted parameters
|
||||||
parzelle_data = {
|
parzelle_data = {
|
||||||
"mandateId": currentUser.mandateId,
|
"mandateId": mandateId,
|
||||||
"label": parameters.get("label", ""),
|
"label": parameters.get("label", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -985,7 +992,7 @@ async def executeIntentBasedOperation(
|
||||||
# Create Gemeinde from parameters
|
# Create Gemeinde from parameters
|
||||||
from modules.datamodels.datamodelRealEstate import Gemeinde
|
from modules.datamodels.datamodelRealEstate import Gemeinde
|
||||||
gemeinde = Gemeinde(
|
gemeinde = Gemeinde(
|
||||||
mandateId=currentUser.mandateId,
|
mandateId=mandateId,
|
||||||
label=parameters.get("label", ""),
|
label=parameters.get("label", ""),
|
||||||
id_kanton=parameters.get("id_kanton"),
|
id_kanton=parameters.get("id_kanton"),
|
||||||
plz=parameters.get("plz"),
|
plz=parameters.get("plz"),
|
||||||
|
|
@ -1000,7 +1007,7 @@ async def executeIntentBasedOperation(
|
||||||
# Create Kanton from parameters
|
# Create Kanton from parameters
|
||||||
from modules.datamodels.datamodelRealEstate import Kanton
|
from modules.datamodels.datamodelRealEstate import Kanton
|
||||||
kanton = Kanton(
|
kanton = Kanton(
|
||||||
mandateId=currentUser.mandateId,
|
mandateId=mandateId,
|
||||||
label=parameters.get("label", ""),
|
label=parameters.get("label", ""),
|
||||||
id_land=parameters.get("id_land"),
|
id_land=parameters.get("id_land"),
|
||||||
abk=parameters.get("abk"),
|
abk=parameters.get("abk"),
|
||||||
|
|
@ -1015,7 +1022,7 @@ async def executeIntentBasedOperation(
|
||||||
# Create Land from parameters
|
# Create Land from parameters
|
||||||
from modules.datamodels.datamodelRealEstate import Land
|
from modules.datamodels.datamodelRealEstate import Land
|
||||||
land = Land(
|
land = Land(
|
||||||
mandateId=currentUser.mandateId,
|
mandateId=mandateId,
|
||||||
label=parameters.get("label", ""),
|
label=parameters.get("label", ""),
|
||||||
abk=parameters.get("abk"),
|
abk=parameters.get("abk"),
|
||||||
)
|
)
|
||||||
|
|
@ -1029,7 +1036,7 @@ async def executeIntentBasedOperation(
|
||||||
# Create Dokument from parameters
|
# Create Dokument from parameters
|
||||||
from modules.datamodels.datamodelRealEstate import Dokument
|
from modules.datamodels.datamodelRealEstate import Dokument
|
||||||
dokument = Dokument(
|
dokument = Dokument(
|
||||||
mandateId=currentUser.mandateId,
|
mandateId=mandateId,
|
||||||
label=parameters.get("label", ""),
|
label=parameters.get("label", ""),
|
||||||
dokumentReferenz=parameters.get("dokumentReferenz", ""),
|
dokumentReferenz=parameters.get("dokumentReferenz", ""),
|
||||||
versionsbezeichnung=parameters.get("versionsbezeichnung"),
|
versionsbezeichnung=parameters.get("versionsbezeichnung"),
|
||||||
|
|
@ -1474,6 +1481,7 @@ async def executeIntentBasedOperation(
|
||||||
|
|
||||||
async def create_project_with_parcel_data(
|
async def create_project_with_parcel_data(
|
||||||
currentUser: User,
|
currentUser: User,
|
||||||
|
mandateId: str,
|
||||||
projekt_label: str,
|
projekt_label: str,
|
||||||
parzellen_data: List[Dict[str, Any]],
|
parzellen_data: List[Dict[str, Any]],
|
||||||
status_prozess: Optional[str] = None,
|
status_prozess: Optional[str] = None,
|
||||||
|
|
@ -1483,6 +1491,7 @@ async def create_project_with_parcel_data(
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
currentUser: Current authenticated user
|
currentUser: Current authenticated user
|
||||||
|
mandateId: Mandate context (from RequestContext / X-Mandate-Id header)
|
||||||
projekt_label: Label for the Projekt
|
projekt_label: Label for the Projekt
|
||||||
parzellen_data: List of dictionaries containing parcel information from request
|
parzellen_data: List of dictionaries containing parcel information from request
|
||||||
status_prozess: Optional project status (defaults to "Eingang")
|
status_prozess: Optional project status (defaults to "Eingang")
|
||||||
|
|
@ -1496,8 +1505,8 @@ async def create_project_with_parcel_data(
|
||||||
try:
|
try:
|
||||||
logger.info(f"Creating project '{projekt_label}' with {len(parzellen_data)} parcel(s) for user {currentUser.id}")
|
logger.info(f"Creating project '{projekt_label}' with {len(parzellen_data)} parcel(s) for user {currentUser.id}")
|
||||||
|
|
||||||
# Get interface
|
# Get interface with mandate context
|
||||||
realEstateInterface = getRealEstateInterface(currentUser)
|
realEstateInterface = getRealEstateInterface(currentUser, mandateId=mandateId)
|
||||||
|
|
||||||
# Validate required fields
|
# Validate required fields
|
||||||
if not projekt_label:
|
if not projekt_label:
|
||||||
|
|
@ -1587,7 +1596,7 @@ async def create_project_with_parcel_data(
|
||||||
|
|
||||||
# Check if Parzelle with this label already exists
|
# Check if Parzelle with this label already exists
|
||||||
existing_parzellen = realEstateInterface.getParzellen(
|
existing_parzellen = realEstateInterface.getParzellen(
|
||||||
recordFilter={"label": parcel_label, "mandateId": currentUser.mandateId}
|
recordFilter={"label": parcel_label, "mandateId": mandateId}
|
||||||
)
|
)
|
||||||
|
|
||||||
if existing_parzellen and len(existing_parzellen) > 0:
|
if existing_parzellen and len(existing_parzellen) > 0:
|
||||||
|
|
@ -1630,7 +1639,7 @@ async def create_project_with_parcel_data(
|
||||||
if not laender:
|
if not laender:
|
||||||
logger.info("Creating Land 'Schweiz'")
|
logger.info("Creating Land 'Schweiz'")
|
||||||
land = Land(
|
land = Land(
|
||||||
mandateId=currentUser.mandateId,
|
mandateId=mandateId,
|
||||||
label="Schweiz",
|
label="Schweiz",
|
||||||
abk="CH"
|
abk="CH"
|
||||||
)
|
)
|
||||||
|
|
@ -1648,7 +1657,7 @@ async def create_project_with_parcel_data(
|
||||||
logger.info(f"Kanton '{canton_abk}' not found, creating it")
|
logger.info(f"Kanton '{canton_abk}' not found, creating it")
|
||||||
kanton_label = canton_names.get(canton_abk, canton_abk) # Use mapping or fallback to abk
|
kanton_label = canton_names.get(canton_abk, canton_abk) # Use mapping or fallback to abk
|
||||||
kanton = Kanton(
|
kanton = Kanton(
|
||||||
mandateId=currentUser.mandateId,
|
mandateId=mandateId,
|
||||||
label=kanton_label,
|
label=kanton_label,
|
||||||
abk=canton_abk,
|
abk=canton_abk,
|
||||||
id_land=land.id
|
id_land=land.id
|
||||||
|
|
@ -1668,7 +1677,7 @@ async def create_project_with_parcel_data(
|
||||||
if not gemeinden:
|
if not gemeinden:
|
||||||
logger.info(f"Gemeinde '{municipality_name}' in Kanton '{canton_abk}' not found, creating it")
|
logger.info(f"Gemeinde '{municipality_name}' in Kanton '{canton_abk}' not found, creating it")
|
||||||
gemeinde = Gemeinde(
|
gemeinde = Gemeinde(
|
||||||
mandateId=currentUser.mandateId,
|
mandateId=mandateId,
|
||||||
label=municipality_name,
|
label=municipality_name,
|
||||||
id_kanton=kanton.id,
|
id_kanton=kanton.id,
|
||||||
plz=parzelle_data.get("plz") # Use PLZ directly from Swiss Topo API
|
plz=parzelle_data.get("plz") # Use PLZ directly from Swiss Topo API
|
||||||
|
|
@ -1837,7 +1846,7 @@ async def create_project_with_parcel_data(
|
||||||
|
|
||||||
# Build Parzelle data
|
# Build Parzelle data
|
||||||
parzelle_create_data = {
|
parzelle_create_data = {
|
||||||
"mandateId": currentUser.mandateId,
|
"mandateId": mandateId,
|
||||||
"label": parcel_label, # Use the label we determined earlier for uniqueness check
|
"label": parcel_label, # Use the label we determined earlier for uniqueness check
|
||||||
"parzellenAliasTags": alias_tags,
|
"parzellenAliasTags": alias_tags,
|
||||||
"eigentuemerschaft": None,
|
"eigentuemerschaft": None,
|
||||||
|
|
@ -1979,7 +1988,7 @@ async def create_project_with_parcel_data(
|
||||||
project_perimeter = created_parzellen[0].perimeter if created_parzellen else None
|
project_perimeter = created_parzellen[0].perimeter if created_parzellen else None
|
||||||
|
|
||||||
projekt_create_data = {
|
projekt_create_data = {
|
||||||
"mandateId": currentUser.mandateId,
|
"mandateId": mandateId,
|
||||||
"label": projekt_label,
|
"label": projekt_label,
|
||||||
"statusProzess": status_prozess_enum,
|
"statusProzess": status_prozess_enum,
|
||||||
"perimeter": project_perimeter, # Use first parcel perimeter as project perimeter
|
"perimeter": project_perimeter, # Use first parcel perimeter as project perimeter
|
||||||
|
|
|
||||||
|
|
@ -83,8 +83,8 @@ async def executeAutomation(automationId: str, services) -> ChatWorkflow:
|
||||||
executionLog["messages"].append(f"Started execution at {executionStartTime}")
|
executionLog["messages"].append(f"Started execution at {executionStartTime}")
|
||||||
|
|
||||||
# 2. Replace placeholders in template to generate plan
|
# 2. Replace placeholders in template to generate plan
|
||||||
template = automation.get("template", "")
|
template = automation.template or ""
|
||||||
placeholders = automation.get("placeholders", {})
|
placeholders = automation.placeholders or {}
|
||||||
planJson = replacePlaceholders(template, placeholders)
|
planJson = replacePlaceholders(template, placeholders)
|
||||||
try:
|
try:
|
||||||
plan = json.loads(planJson)
|
plan = json.loads(planJson)
|
||||||
|
|
@ -102,7 +102,7 @@ async def executeAutomation(automationId: str, services) -> ChatWorkflow:
|
||||||
executionLog["messages"].append("Template placeholders replaced successfully")
|
executionLog["messages"].append("Template placeholders replaced successfully")
|
||||||
|
|
||||||
# 3. Get user who created automation
|
# 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
|
# CRITICAL: Automation MUST run as creator user only, or fail
|
||||||
if not creatorUserId:
|
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)")
|
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
|
# Set workflow name with "automated" prefix
|
||||||
automationLabel = automation.get("label", "Unknown Automation")
|
automationLabel = automation.label or "Unknown Automation"
|
||||||
workflowName = f"automated: {automationLabel}"
|
workflowName = f"automated: {automationLabel}"
|
||||||
workflow = services.interfaceDbChat.updateWorkflow(workflow.id, {"name": workflowName})
|
workflow = services.interfaceDbChat.updateWorkflow(workflow.id, {"name": workflowName})
|
||||||
logger.info(f"Set workflow {workflow.id} name to: {workflowName}")
|
logger.info(f"Set workflow {workflow.id} name to: {workflowName}")
|
||||||
|
|
||||||
# Update automation with execution log
|
# Update automation with execution log
|
||||||
executionLogs = automation.get("executionLogs", [])
|
executionLogs = list(automation.executionLogs or [])
|
||||||
executionLogs.append(executionLog)
|
executionLogs.append(executionLog)
|
||||||
# Keep only last 50 executions
|
# Keep only last 50 executions
|
||||||
if len(executionLogs) > 50:
|
if len(executionLogs) > 50:
|
||||||
|
|
@ -174,7 +174,7 @@ async def executeAutomation(automationId: str, services) -> ChatWorkflow:
|
||||||
try:
|
try:
|
||||||
automation = services.interfaceDbChat.getAutomationDefinition(automationId)
|
automation = services.interfaceDbChat.getAutomationDefinition(automationId)
|
||||||
if automation:
|
if automation:
|
||||||
executionLogs = automation.get("executionLogs", [])
|
executionLogs = list(automation.executionLogs or [])
|
||||||
executionLogs.append(executionLog)
|
executionLogs.append(executionLog)
|
||||||
if len(executionLogs) > 50:
|
if len(executionLogs) > 50:
|
||||||
executionLogs = executionLogs[-50:]
|
executionLogs = executionLogs[-50:]
|
||||||
|
|
@ -204,10 +204,10 @@ async def syncAutomationEvents(services, eventUser) -> Dict[str, Any]:
|
||||||
registeredEvents = {}
|
registeredEvents = {}
|
||||||
|
|
||||||
for automation in filtered:
|
for automation in filtered:
|
||||||
automationId = automation.get("id")
|
automationId = automation.id
|
||||||
isActive = automation.get("active", False)
|
isActive = automation.active if hasattr(automation, 'active') else False
|
||||||
currentEventId = automation.get("eventId")
|
currentEventId = automation.eventId if hasattr(automation, 'eventId') else None
|
||||||
schedule = automation.get("schedule")
|
schedule = automation.schedule if hasattr(automation, 'schedule') else None
|
||||||
|
|
||||||
if not schedule:
|
if not schedule:
|
||||||
logger.warning(f"Automation {automationId} has no schedule, skipping")
|
logger.warning(f"Automation {automationId} has no schedule, skipping")
|
||||||
|
|
@ -288,12 +288,12 @@ def createAutomationEventHandler(automationId: str, eventUser):
|
||||||
|
|
||||||
# Load automation using event user context
|
# Load automation using event user context
|
||||||
automation = eventServices.interfaceDbChat.getAutomationDefinition(automationId)
|
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")
|
logger.warning(f"Automation {automationId} not found or not active, skipping execution")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Get creator user
|
# Get creator user
|
||||||
creatorUserId = automation.get("_createdBy")
|
creatorUserId = getattr(automation, "_createdBy", None)
|
||||||
if not creatorUserId:
|
if not creatorUserId:
|
||||||
logger.error(f"Automation {automationId} has no creator user")
|
logger.error(f"Automation {automationId} has no creator user")
|
||||||
return
|
return
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -3,6 +3,10 @@
|
||||||
"""
|
"""
|
||||||
Interface to the Gateway system.
|
Interface to the Gateway system.
|
||||||
Manages users and mandates for authentication.
|
Manages users and mandates for authentication.
|
||||||
|
|
||||||
|
Multi-Tenant Design:
|
||||||
|
- User gehört nicht mehr direkt zu einem Mandanten
|
||||||
|
- mandateId wird aus Request-Context übergeben (X-Mandate-Id Header)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
@ -37,6 +41,14 @@ from modules.datamodels.datamodelNeutralizer import (
|
||||||
DataNeutralizerAttributes,
|
DataNeutralizerAttributes,
|
||||||
)
|
)
|
||||||
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResult
|
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResult
|
||||||
|
from modules.datamodels.datamodelMembership import (
|
||||||
|
UserMandate,
|
||||||
|
UserMandateRole,
|
||||||
|
FeatureAccess,
|
||||||
|
FeatureAccessRole,
|
||||||
|
)
|
||||||
|
from modules.datamodels.datamodelFeatures import Feature, FeatureInstance
|
||||||
|
from modules.datamodels.datamodelInvitation import Invitation
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -61,7 +73,7 @@ class AppObjects:
|
||||||
# Initialize variables
|
# Initialize variables
|
||||||
self.currentUser = currentUser # Store User object directly
|
self.currentUser = currentUser # Store User object directly
|
||||||
self.userId = currentUser.id if currentUser else None
|
self.userId = currentUser.id if currentUser else None
|
||||||
self.mandateId = currentUser.mandateId if currentUser else None
|
self.mandateId = None # mandateId comes from setUserContext, not from User
|
||||||
|
|
||||||
# Initialize database
|
# Initialize database
|
||||||
self._initializeDatabase()
|
self._initializeDatabase()
|
||||||
|
|
@ -73,25 +85,42 @@ class AppObjects:
|
||||||
if currentUser:
|
if currentUser:
|
||||||
self.setUserContext(currentUser)
|
self.setUserContext(currentUser)
|
||||||
|
|
||||||
def setUserContext(self, currentUser: User):
|
def setUserContext(self, currentUser: User, mandateId: Optional[str] = None):
|
||||||
"""Sets the user context for the interface."""
|
"""
|
||||||
|
Sets the user context for the interface.
|
||||||
|
|
||||||
|
Multi-Tenant Design:
|
||||||
|
- mandateId wird explizit übergeben (aus Request-Context / X-Mandate-Id Header)
|
||||||
|
- isSysAdmin User brauchen kein mandateId für System-Operationen
|
||||||
|
|
||||||
|
Args:
|
||||||
|
currentUser: User object
|
||||||
|
mandateId: Explicit mandate context (from request header). Required for non-sysadmin.
|
||||||
|
"""
|
||||||
if not currentUser:
|
if not currentUser:
|
||||||
logger.info("Initializing interface without user context")
|
logger.info("Initializing interface without user context")
|
||||||
return
|
return
|
||||||
|
|
||||||
self.currentUser = currentUser # Store User object directly
|
self.currentUser = currentUser # Store User object directly
|
||||||
self.userId = currentUser.id
|
self.userId = currentUser.id
|
||||||
self.mandateId = currentUser.mandateId
|
|
||||||
|
|
||||||
if not self.userId or not self.mandateId:
|
# mandateId comes from parameter only
|
||||||
raise ValueError("Invalid user context: id and mandateId are required")
|
self.mandateId = mandateId
|
||||||
|
|
||||||
|
# Validate: userId is always required
|
||||||
|
if not self.userId:
|
||||||
|
raise ValueError("Invalid user context: id is required")
|
||||||
|
|
||||||
|
# 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
|
# Add language settings
|
||||||
self.userLanguage = currentUser.language # Default user language
|
self.userLanguage = currentUser.language # Default user language
|
||||||
|
|
||||||
# Initialize RBAC interface
|
# Initialize RBAC interface
|
||||||
if not currentUser:
|
|
||||||
raise ValueError("User context is required for RBAC")
|
|
||||||
# Pass self.db as dbApp since this interface uses DbApp database
|
# Pass self.db as dbApp since this interface uses DbApp database
|
||||||
self.rbac = RbacClass(self.db, dbApp=self.db)
|
self.rbac = RbacClass(self.db, dbApp=self.db)
|
||||||
|
|
||||||
|
|
@ -110,11 +139,11 @@ class AppObjects:
|
||||||
"""Initializes the database connection directly."""
|
"""Initializes the database connection directly."""
|
||||||
try:
|
try:
|
||||||
# Get configuration values with defaults
|
# Get configuration values with defaults
|
||||||
dbHost = APP_CONFIG.get("DB_APP_HOST", "_no_config_default_data")
|
dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data")
|
||||||
dbDatabase = APP_CONFIG.get("DB_APP_DATABASE", "app")
|
dbDatabase = "poweron_app"
|
||||||
dbUser = APP_CONFIG.get("DB_APP_USER")
|
dbUser = APP_CONFIG.get("DB_USER")
|
||||||
dbPassword = APP_CONFIG.get("DB_APP_PASSWORD_SECRET")
|
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
|
||||||
dbPort = int(APP_CONFIG.get("DB_APP_PORT", 5432))
|
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
|
||||||
|
|
||||||
# Create database connector directly
|
# Create database connector directly
|
||||||
self.db = DatabaseConnector(
|
self.db = DatabaseConnector(
|
||||||
|
|
@ -615,13 +644,17 @@ class AppObjects:
|
||||||
fullName: str = None,
|
fullName: str = None,
|
||||||
language: str = "en",
|
language: str = "en",
|
||||||
enabled: bool = True,
|
enabled: bool = True,
|
||||||
roleLabels: List[str] = None,
|
|
||||||
authenticationAuthority: AuthAuthority = AuthAuthority.LOCAL,
|
authenticationAuthority: AuthAuthority = AuthAuthority.LOCAL,
|
||||||
externalId: str = None,
|
externalId: str = None,
|
||||||
externalUsername: str = None,
|
externalUsername: str = None,
|
||||||
externalEmail: str = None,
|
externalEmail: str = None,
|
||||||
|
isSysAdmin: bool = False,
|
||||||
) -> User:
|
) -> User:
|
||||||
"""Create a new user with optional external connection"""
|
"""
|
||||||
|
Create a new user.
|
||||||
|
|
||||||
|
Note: Role assignment is done via createUserMandate(), not via User fields.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
# Ensure username is a string
|
# Ensure username is a string
|
||||||
username = str(username).strip()
|
username = str(username).strip()
|
||||||
|
|
@ -638,28 +671,17 @@ class AppObjects:
|
||||||
if not password.strip():
|
if not password.strip():
|
||||||
raise ValueError("Password cannot be empty")
|
raise ValueError("Password cannot be empty")
|
||||||
|
|
||||||
# Ensure mandateId is set - use self.mandateId or default mandate
|
|
||||||
mandateId = self.mandateId
|
|
||||||
if not mandateId:
|
|
||||||
mandateId = self._getDefaultMandateId()
|
|
||||||
logger.warning(f"Using default mandate ID {mandateId} for new user {username}")
|
|
||||||
|
|
||||||
# Default roleLabels to ["user"] if not provided
|
|
||||||
if roleLabels is None or not roleLabels:
|
|
||||||
roleLabels = ["user"]
|
|
||||||
|
|
||||||
# Create user data using UserInDB model
|
# Create user data using UserInDB model
|
||||||
|
# Note: mandateId and roleLabels are REMOVED - use UserMandate + UserMandateRole
|
||||||
userData = UserInDB(
|
userData = UserInDB(
|
||||||
username=username,
|
username=username,
|
||||||
email=email,
|
email=email,
|
||||||
fullName=fullName,
|
fullName=fullName,
|
||||||
language=language,
|
language=language,
|
||||||
mandateId=mandateId,
|
|
||||||
enabled=enabled,
|
enabled=enabled,
|
||||||
roleLabels=roleLabels,
|
isSysAdmin=isSysAdmin,
|
||||||
authenticationAuthority=authenticationAuthority,
|
authenticationAuthority=authenticationAuthority,
|
||||||
hashedPassword=self._getPasswordHash(password) if password else None,
|
hashedPassword=self._getPasswordHash(password) if password else None,
|
||||||
connections=[],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create user record
|
# Create user record
|
||||||
|
|
@ -712,25 +734,11 @@ class AppObjects:
|
||||||
# Remove id field from updateDict if present - we'll use userId from parameter
|
# Remove id field from updateDict if present - we'll use userId from parameter
|
||||||
updateDict.pop("id", None)
|
updateDict.pop("id", None)
|
||||||
|
|
||||||
# Ensure mandateId is set - if missing or None, use default mandate
|
|
||||||
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
|
# Update user data using model
|
||||||
updatedData = user.model_dump()
|
updatedData = user.model_dump()
|
||||||
updatedData.update(updateDict)
|
updatedData.update(updateDict)
|
||||||
# Ensure ID matches userId parameter
|
# Ensure ID matches userId parameter
|
||||||
updatedData["id"] = userId
|
updatedData["id"] = userId
|
||||||
# Ensure mandateId is set in final data
|
|
||||||
if not updatedData.get("mandateId"):
|
|
||||||
updatedData["mandateId"] = self._getDefaultMandateId()
|
|
||||||
updatedUser = User(**updatedData)
|
updatedUser = User(**updatedData)
|
||||||
|
|
||||||
# Update user record
|
# Update user record
|
||||||
|
|
@ -1382,6 +1390,325 @@ class AppObjects:
|
||||||
logger.error(f"Error deleting mandate: {str(e)}")
|
logger.error(f"Error deleting mandate: {str(e)}")
|
||||||
raise ValueError(f"Failed to delete mandate: {str(e)}")
|
raise ValueError(f"Failed to delete mandate: {str(e)}")
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# User-Mandate Membership Methods (Multi-Tenant)
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
def getUserMandate(self, userId: str, mandateId: str) -> Optional[UserMandate]:
|
||||||
|
"""
|
||||||
|
Get UserMandate record for a user in a specific mandate.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
userId: User ID
|
||||||
|
mandateId: Mandate ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
UserMandate object or None
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
records = self.db.getRecordset(
|
||||||
|
UserMandate,
|
||||||
|
recordFilter={"userId": userId, "mandateId": mandateId}
|
||||||
|
)
|
||||||
|
if not records:
|
||||||
|
return None
|
||||||
|
cleanedRecord = {k: v for k, v in records[0].items() if not k.startswith("_")}
|
||||||
|
return UserMandate(**cleanedRecord)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting UserMandate: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def getUserMandates(self, userId: str) -> List[UserMandate]:
|
||||||
|
"""
|
||||||
|
Get all mandates a user is member of.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
userId: User ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of UserMandate objects
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
records = self.db.getRecordset(
|
||||||
|
UserMandate,
|
||||||
|
recordFilter={"userId": userId, "enabled": True}
|
||||||
|
)
|
||||||
|
result = []
|
||||||
|
for record in records:
|
||||||
|
cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_")}
|
||||||
|
result.append(UserMandate(**cleanedRecord))
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting UserMandates: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def createUserMandate(self, userId: str, mandateId: str, roleIds: List[str] = None) -> UserMandate:
|
||||||
|
"""
|
||||||
|
Create a UserMandate record (add user to mandate).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
userId: User ID
|
||||||
|
mandateId: Mandate ID
|
||||||
|
roleIds: Optional list of role IDs to assign
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created UserMandate object
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Check if already exists
|
||||||
|
existing = self.getUserMandate(userId, mandateId)
|
||||||
|
if existing:
|
||||||
|
raise ValueError(f"User {userId} is already member of mandate {mandateId}")
|
||||||
|
|
||||||
|
# Create UserMandate
|
||||||
|
userMandate = UserMandate(
|
||||||
|
userId=userId,
|
||||||
|
mandateId=mandateId,
|
||||||
|
enabled=True
|
||||||
|
)
|
||||||
|
createdRecord = self.db.recordCreate(UserMandate, userMandate.model_dump())
|
||||||
|
|
||||||
|
# Assign roles via junction table
|
||||||
|
if roleIds and createdRecord:
|
||||||
|
userMandateId = createdRecord.get("id")
|
||||||
|
for roleId in roleIds:
|
||||||
|
userMandateRole = UserMandateRole(
|
||||||
|
userMandateId=userMandateId,
|
||||||
|
roleId=roleId
|
||||||
|
)
|
||||||
|
self.db.recordCreate(UserMandateRole, userMandateRole.model_dump())
|
||||||
|
|
||||||
|
cleanedRecord = {k: v for k, v in createdRecord.items() if not k.startswith("_")}
|
||||||
|
return UserMandate(**cleanedRecord)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating UserMandate: {e}")
|
||||||
|
raise ValueError(f"Failed to create UserMandate: {e}")
|
||||||
|
|
||||||
|
def deleteUserMandate(self, userId: str, mandateId: str) -> bool:
|
||||||
|
"""
|
||||||
|
Delete a UserMandate record (remove user from mandate).
|
||||||
|
CASCADE will delete UserMandateRole entries.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
userId: User ID
|
||||||
|
mandateId: Mandate ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if deleted, False if not found
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
existing = self.getUserMandate(userId, mandateId)
|
||||||
|
if not existing:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return self.db.recordDelete(UserMandate, existing.id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting UserMandate: {e}")
|
||||||
|
raise ValueError(f"Failed to delete UserMandate: {e}")
|
||||||
|
|
||||||
|
def getRoleIdsForUserMandate(self, userMandateId: str) -> List[str]:
|
||||||
|
"""
|
||||||
|
Get all role IDs assigned to a UserMandate.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
userMandateId: UserMandate ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of role IDs
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
records = self.db.getRecordset(
|
||||||
|
UserMandateRole,
|
||||||
|
recordFilter={"userMandateId": userMandateId}
|
||||||
|
)
|
||||||
|
return [r.get("roleId") for r in records if r.get("roleId")]
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting role IDs for UserMandate: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def addRoleToUserMandate(self, userMandateId: str, roleId: str) -> UserMandateRole:
|
||||||
|
"""
|
||||||
|
Add a role to a UserMandate.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
userMandateId: UserMandate ID
|
||||||
|
roleId: Role ID to add
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created UserMandateRole object
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Check if already exists
|
||||||
|
existing = self.db.getRecordset(
|
||||||
|
UserMandateRole,
|
||||||
|
recordFilter={"userMandateId": userMandateId, "roleId": roleId}
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
cleanedRecord = {k: v for k, v in existing[0].items() if not k.startswith("_")}
|
||||||
|
return UserMandateRole(**cleanedRecord)
|
||||||
|
|
||||||
|
userMandateRole = UserMandateRole(
|
||||||
|
userMandateId=userMandateId,
|
||||||
|
roleId=roleId
|
||||||
|
)
|
||||||
|
createdRecord = self.db.recordCreate(UserMandateRole, userMandateRole.model_dump())
|
||||||
|
cleanedRecord = {k: v for k, v in createdRecord.items() if not k.startswith("_")}
|
||||||
|
return UserMandateRole(**cleanedRecord)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error adding role to UserMandate: {e}")
|
||||||
|
raise ValueError(f"Failed to add role: {e}")
|
||||||
|
|
||||||
|
def removeRoleFromUserMandate(self, userMandateId: str, roleId: str) -> bool:
|
||||||
|
"""
|
||||||
|
Remove a role from a UserMandate.
|
||||||
|
If no roles remain, the UserMandate is deleted (Application-Level Cleanup).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
userMandateId: UserMandate ID
|
||||||
|
roleId: Role ID to remove
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if removed
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Find and delete the junction record
|
||||||
|
records = self.db.getRecordset(
|
||||||
|
UserMandateRole,
|
||||||
|
recordFilter={"userMandateId": userMandateId, "roleId": roleId}
|
||||||
|
)
|
||||||
|
if not records:
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.db.recordDelete(UserMandateRole, records[0].get("id"))
|
||||||
|
|
||||||
|
# Application-Level Cleanup: Delete UserMandate if no roles remain
|
||||||
|
remainingRoles = self.db.getRecordset(
|
||||||
|
UserMandateRole,
|
||||||
|
recordFilter={"userMandateId": userMandateId}
|
||||||
|
)
|
||||||
|
if not remainingRoles:
|
||||||
|
self.db.recordDelete(UserMandate, userMandateId)
|
||||||
|
logger.info(f"Deleted empty UserMandate {userMandateId}")
|
||||||
|
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error removing role from UserMandate: {e}")
|
||||||
|
raise ValueError(f"Failed to remove role: {e}")
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Feature Access Methods (Multi-Tenant)
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
def getFeatureAccess(self, userId: str, featureInstanceId: str) -> Optional[FeatureAccess]:
|
||||||
|
"""
|
||||||
|
Get FeatureAccess record for a user to a specific feature instance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
userId: User ID
|
||||||
|
featureInstanceId: FeatureInstance ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
FeatureAccess object or None
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
records = self.db.getRecordset(
|
||||||
|
FeatureAccess,
|
||||||
|
recordFilter={"userId": userId, "featureInstanceId": featureInstanceId}
|
||||||
|
)
|
||||||
|
if not records:
|
||||||
|
return None
|
||||||
|
cleanedRecord = {k: v for k, v in records[0].items() if not k.startswith("_")}
|
||||||
|
return FeatureAccess(**cleanedRecord)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting FeatureAccess: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def getFeatureAccessesForUser(self, userId: str) -> List[FeatureAccess]:
|
||||||
|
"""
|
||||||
|
Get all feature accesses for a user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
userId: User ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of FeatureAccess objects
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
records = self.db.getRecordset(
|
||||||
|
FeatureAccess,
|
||||||
|
recordFilter={"userId": userId, "enabled": True}
|
||||||
|
)
|
||||||
|
result = []
|
||||||
|
for record in records:
|
||||||
|
cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_")}
|
||||||
|
result.append(FeatureAccess(**cleanedRecord))
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting FeatureAccesses: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def createFeatureAccess(self, userId: str, featureInstanceId: str, roleIds: List[str] = None) -> FeatureAccess:
|
||||||
|
"""
|
||||||
|
Create a FeatureAccess record (grant user access to feature instance).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
userId: User ID
|
||||||
|
featureInstanceId: FeatureInstance ID
|
||||||
|
roleIds: Optional list of role IDs to assign
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created FeatureAccess object
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Check if already exists
|
||||||
|
existing = self.getFeatureAccess(userId, featureInstanceId)
|
||||||
|
if existing:
|
||||||
|
raise ValueError(f"User {userId} already has access to feature instance {featureInstanceId}")
|
||||||
|
|
||||||
|
# Create FeatureAccess
|
||||||
|
featureAccess = FeatureAccess(
|
||||||
|
userId=userId,
|
||||||
|
featureInstanceId=featureInstanceId,
|
||||||
|
enabled=True
|
||||||
|
)
|
||||||
|
createdRecord = self.db.recordCreate(FeatureAccess, featureAccess.model_dump())
|
||||||
|
|
||||||
|
# Assign roles via junction table
|
||||||
|
if roleIds and createdRecord:
|
||||||
|
featureAccessId = createdRecord.get("id")
|
||||||
|
for roleId in roleIds:
|
||||||
|
featureAccessRole = FeatureAccessRole(
|
||||||
|
featureAccessId=featureAccessId,
|
||||||
|
roleId=roleId
|
||||||
|
)
|
||||||
|
self.db.recordCreate(FeatureAccessRole, featureAccessRole.model_dump())
|
||||||
|
|
||||||
|
cleanedRecord = {k: v for k, v in createdRecord.items() if not k.startswith("_")}
|
||||||
|
return FeatureAccess(**cleanedRecord)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating FeatureAccess: {e}")
|
||||||
|
raise ValueError(f"Failed to create FeatureAccess: {e}")
|
||||||
|
|
||||||
|
def getRoleIdsForFeatureAccess(self, featureAccessId: str) -> List[str]:
|
||||||
|
"""
|
||||||
|
Get all role IDs assigned to a FeatureAccess.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
featureAccessId: FeatureAccess ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of role IDs
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
records = self.db.getRecordset(
|
||||||
|
FeatureAccessRole,
|
||||||
|
recordFilter={"featureAccessId": featureAccessId}
|
||||||
|
)
|
||||||
|
return [r.get("roleId") for r in records if r.get("roleId")]
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting role IDs for FeatureAccess: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
# Token methods
|
# Token methods
|
||||||
|
|
||||||
def saveAccessToken(self, token: Token, replace_existing: bool = True) -> None:
|
def saveAccessToken(self, token: Token, replace_existing: bool = True) -> None:
|
||||||
|
|
@ -1902,6 +2229,7 @@ class AppObjects:
|
||||||
def getAccessRules(
|
def getAccessRules(
|
||||||
self,
|
self,
|
||||||
roleLabel: Optional[str] = None,
|
roleLabel: Optional[str] = None,
|
||||||
|
roleId: Optional[str] = None,
|
||||||
context: Optional[AccessRuleContext] = None,
|
context: Optional[AccessRuleContext] = None,
|
||||||
item: Optional[str] = None,
|
item: Optional[str] = None,
|
||||||
pagination: Optional[PaginationParams] = None
|
pagination: Optional[PaginationParams] = None
|
||||||
|
|
@ -1910,7 +2238,8 @@ class AppObjects:
|
||||||
Get access rules with optional filters and pagination.
|
Get access rules with optional filters and pagination.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
roleLabel: Optional role label filter
|
roleLabel: Optional role label filter (deprecated, use roleId)
|
||||||
|
roleId: Optional role ID filter
|
||||||
context: Optional context filter
|
context: Optional context filter
|
||||||
item: Optional item filter
|
item: Optional item filter
|
||||||
pagination: Optional pagination parameters. If None, returns all items.
|
pagination: Optional pagination parameters. If None, returns all items.
|
||||||
|
|
@ -1921,7 +2250,9 @@ class AppObjects:
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
recordFilter = {}
|
recordFilter = {}
|
||||||
if roleLabel:
|
if roleId:
|
||||||
|
recordFilter["roleId"] = roleId
|
||||||
|
elif roleLabel:
|
||||||
recordFilter["roleLabel"] = roleLabel
|
recordFilter["roleLabel"] = roleLabel
|
||||||
if context:
|
if context:
|
||||||
recordFilter["context"] = context.value
|
recordFilter["context"] = context.value
|
||||||
|
|
@ -2134,6 +2465,29 @@ class AppObjects:
|
||||||
else:
|
else:
|
||||||
return PaginatedResult(items=[], totalItems=0, totalPages=0)
|
return PaginatedResult(items=[], totalItems=0, totalPages=0)
|
||||||
|
|
||||||
|
def countRoleAssignments(self) -> Dict[str, int]:
|
||||||
|
"""
|
||||||
|
Count the number of user assignments per role from UserMandateRole table.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary mapping roleId to count of user assignments
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get all UserMandateRole records
|
||||||
|
assignments = self.db.getRecordset(UserMandateRole)
|
||||||
|
|
||||||
|
# Count assignments per roleId
|
||||||
|
roleCounts: Dict[str, int] = {}
|
||||||
|
for assignment in assignments:
|
||||||
|
roleId = str(assignment.get("roleId", ""))
|
||||||
|
if roleId:
|
||||||
|
roleCounts[roleId] = roleCounts.get(roleId, 0) + 1
|
||||||
|
|
||||||
|
return roleCounts
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error counting role assignments: {str(e)}")
|
||||||
|
return {}
|
||||||
|
|
||||||
def updateRole(self, roleId: str, role: Role) -> Role:
|
def updateRole(self, roleId: str, role: Role) -> Role:
|
||||||
"""
|
"""
|
||||||
Update an existing role.
|
Update an existing role.
|
||||||
|
|
@ -2185,14 +2539,13 @@ class AppObjects:
|
||||||
if role.isSystemRole:
|
if role.isSystemRole:
|
||||||
raise ValueError(f"Cannot delete system role '{role.roleLabel}'")
|
raise ValueError(f"Cannot delete system role '{role.roleLabel}'")
|
||||||
|
|
||||||
# Check if role is assigned to any users
|
# Check if role is assigned to any users via UserMandateRole
|
||||||
allUsers = self.getUsersByMandate(None) # Get all users across all mandates
|
roleAssignments = self.db.getRecordset(UserMandateRole, recordFilter={"roleId": roleId})
|
||||||
for user in allUsers:
|
if roleAssignments:
|
||||||
if role.roleLabel in (user.roleLabels or []):
|
|
||||||
raise ValueError(f"Cannot delete role '{role.roleLabel}' - it is assigned to users")
|
raise ValueError(f"Cannot delete role '{role.roleLabel}' - it is assigned to users")
|
||||||
|
|
||||||
# Check if role is used in any access rules
|
# Check if role is used in any access rules
|
||||||
accessRules = self.getAccessRules(roleLabel=role.roleLabel)
|
accessRules = self.getAccessRules(roleId=roleId)
|
||||||
if accessRules:
|
if accessRules:
|
||||||
raise ValueError(f"Cannot delete role '{role.roleLabel}' - it is used in access rules")
|
raise ValueError(f"Cannot delete role '{role.roleLabel}' - it is used in access rules")
|
||||||
|
|
||||||
|
|
@ -2207,20 +2560,34 @@ class AppObjects:
|
||||||
# Public Methods
|
# Public Methods
|
||||||
|
|
||||||
|
|
||||||
def getInterface(currentUser: User) -> AppObjects:
|
def getInterface(currentUser: User, mandateId: Optional[str] = None) -> AppObjects:
|
||||||
"""
|
"""
|
||||||
Returns a AppObjects instance for the current user.
|
Returns a AppObjects instance for the current user.
|
||||||
Handles initialization of database and records.
|
Handles initialization of database and records.
|
||||||
|
|
||||||
|
Multi-Tenant Design:
|
||||||
|
- mandateId wird explizit übergeben (aus Request-Context / X-Mandate-Id Header)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
currentUser: User object
|
||||||
|
mandateId: Explicit mandate context (from request header). Required for non-sysadmin.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AppObjects instance for the user context
|
||||||
"""
|
"""
|
||||||
if not currentUser:
|
if not currentUser:
|
||||||
raise ValueError("Invalid user context: user is required")
|
raise ValueError("Invalid user context: user is required")
|
||||||
|
|
||||||
# Create context key
|
effectiveMandateId = mandateId
|
||||||
contextKey = f"{currentUser.mandateId}_{currentUser.id}"
|
|
||||||
|
# Create context key (user + mandate combination)
|
||||||
|
contextKey = f"{effectiveMandateId}_{currentUser.id}"
|
||||||
|
|
||||||
# Create new instance if not exists
|
# Create new instance if not exists
|
||||||
if contextKey not in _gatewayInterfaces:
|
if contextKey not in _gatewayInterfaces:
|
||||||
_gatewayInterfaces[contextKey] = AppObjects(currentUser)
|
instance = AppObjects(currentUser)
|
||||||
|
instance.setUserContext(currentUser, mandateId=effectiveMandateId)
|
||||||
|
_gatewayInterfaces[contextKey] = instance
|
||||||
|
|
||||||
return _gatewayInterfaces[contextKey]
|
return _gatewayInterfaces[contextKey]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -178,12 +178,18 @@ class ChatObjects:
|
||||||
Uses the JSON connector for data access with added language support.
|
Uses the JSON connector for data access with added language support.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, currentUser: Optional[User] = None):
|
def __init__(self, currentUser: Optional[User] = None, mandateId: Optional[str] = None):
|
||||||
"""Initializes the Chat Interface."""
|
"""Initializes the Chat Interface.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
currentUser: The authenticated user
|
||||||
|
mandateId: The mandate ID from RequestContext (X-Mandate-Id header)
|
||||||
|
"""
|
||||||
# Initialize variables
|
# Initialize variables
|
||||||
self.currentUser = currentUser # Store User object directly
|
self.currentUser = currentUser # Store User object directly
|
||||||
self.userId = currentUser.id if currentUser else None
|
self.userId = currentUser.id if currentUser else None
|
||||||
self.mandateId = currentUser.mandateId if currentUser else None
|
# Use mandateId from parameter (Request-Context), not from user object
|
||||||
|
self.mandateId = mandateId
|
||||||
self.rbac = None # RBAC interface
|
self.rbac = None # RBAC interface
|
||||||
|
|
||||||
# Initialize services
|
# Initialize services
|
||||||
|
|
@ -194,7 +200,7 @@ class ChatObjects:
|
||||||
|
|
||||||
# Set user context if provided
|
# Set user context if provided
|
||||||
if currentUser:
|
if currentUser:
|
||||||
self.setUserContext(currentUser)
|
self.setUserContext(currentUser, mandateId=mandateId)
|
||||||
|
|
||||||
# ===== Generic Utility Methods =====
|
# ===== Generic Utility Methods =====
|
||||||
|
|
||||||
|
|
@ -257,14 +263,24 @@ class ChatObjects:
|
||||||
def _initializeServices(self):
|
def _initializeServices(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def setUserContext(self, currentUser: User):
|
def setUserContext(self, currentUser: User, mandateId: Optional[str] = None):
|
||||||
"""Sets the user context for the interface."""
|
"""Sets the user context for the interface.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
currentUser: The authenticated user
|
||||||
|
mandateId: The mandate ID from RequestContext (X-Mandate-Id header)
|
||||||
|
"""
|
||||||
self.currentUser = currentUser # Store User object directly
|
self.currentUser = currentUser # Store User object directly
|
||||||
self.userId = currentUser.id
|
self.userId = currentUser.id
|
||||||
self.mandateId = currentUser.mandateId
|
# Use mandateId from parameter (Request-Context), not from user object
|
||||||
|
self.mandateId = mandateId
|
||||||
|
|
||||||
if not self.userId or not self.mandateId:
|
if not self.userId:
|
||||||
raise ValueError("Invalid user context: id and mandateId are required")
|
raise ValueError("Invalid user context: id is required")
|
||||||
|
|
||||||
|
# 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
|
# Add language settings
|
||||||
self.userLanguage = currentUser.language # Default user language
|
self.userLanguage = currentUser.language # Default user language
|
||||||
|
|
@ -293,11 +309,11 @@ class ChatObjects:
|
||||||
"""Initializes the database connection directly."""
|
"""Initializes the database connection directly."""
|
||||||
try:
|
try:
|
||||||
# Get configuration values with defaults
|
# Get configuration values with defaults
|
||||||
dbHost = APP_CONFIG.get("DB_CHAT_HOST", "_no_config_default_data")
|
dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data")
|
||||||
dbDatabase = APP_CONFIG.get("DB_CHAT_DATABASE", "chat")
|
dbDatabase = "poweron_chat"
|
||||||
dbUser = APP_CONFIG.get("DB_CHAT_USER")
|
dbUser = APP_CONFIG.get("DB_USER")
|
||||||
dbPassword = APP_CONFIG.get("DB_CHAT_PASSWORD_SECRET")
|
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
|
||||||
dbPort = int(APP_CONFIG.get("DB_CHAT_PORT", 5432))
|
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
|
||||||
|
|
||||||
# Create database connector directly
|
# Create database connector directly
|
||||||
self.db = DatabaseConnector(
|
self.db = DatabaseConnector(
|
||||||
|
|
@ -654,7 +670,7 @@ class ChatObjects:
|
||||||
logs=logs,
|
logs=logs,
|
||||||
messages=messages,
|
messages=messages,
|
||||||
stats=stats,
|
stats=stats,
|
||||||
mandateId=workflow.get("mandateId", self.currentUser.mandateId)
|
mandateId=workflow.get("mandateId", self.mandateId)
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error validating workflow data: {str(e)}")
|
logger.error(f"Error validating workflow data: {str(e)}")
|
||||||
|
|
@ -695,7 +711,7 @@ class ChatObjects:
|
||||||
logs=[],
|
logs=[],
|
||||||
messages=[],
|
messages=[],
|
||||||
stats=[],
|
stats=[],
|
||||||
mandateId=created.get("mandateId", self.currentUser.mandateId),
|
mandateId=created.get("mandateId", self.mandateId),
|
||||||
workflowMode=created["workflowMode"],
|
workflowMode=created["workflowMode"],
|
||||||
maxSteps=created.get("maxSteps", 1)
|
maxSteps=created.get("maxSteps", 1)
|
||||||
)
|
)
|
||||||
|
|
@ -1088,7 +1104,7 @@ class ChatObjects:
|
||||||
logger.error(f"Error creating workflow message: {str(e)}")
|
logger.error(f"Error creating workflow message: {str(e)}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def updateMessage(self, messageId: str, messageData: Dict[str, Any]) -> Dict[str, Any]:
|
def updateMessage(self, messageId: str, messageData: Dict[str, Any]) -> Optional[ChatMessage]:
|
||||||
"""Updates a workflow message if user has access to the workflow."""
|
"""Updates a workflow message if user has access to the workflow."""
|
||||||
try:
|
try:
|
||||||
|
|
||||||
|
|
@ -1174,8 +1190,10 @@ class ChatObjects:
|
||||||
logger.error(f"Error updating message documents: {str(e)}")
|
logger.error(f"Error updating message documents: {str(e)}")
|
||||||
if not updatedMessage:
|
if not updatedMessage:
|
||||||
logger.warning(f"Failed to update message {messageId}")
|
logger.warning(f"Failed to update message {messageId}")
|
||||||
|
return None
|
||||||
|
|
||||||
return updatedMessage
|
# Convert to ChatMessage model
|
||||||
|
return ChatMessage(**updatedMessage)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error updating message {messageId}: {str(e)}", exc_info=True)
|
logger.error(f"Error updating message {messageId}: {str(e)}", exc_info=True)
|
||||||
raise ValueError(f"Error updating message {messageId}: {str(e)}")
|
raise ValueError(f"Error updating message {messageId}: {str(e)}")
|
||||||
|
|
@ -1716,7 +1734,7 @@ class ChatObjects:
|
||||||
totalPages=totalPages
|
totalPages=totalPages
|
||||||
)
|
)
|
||||||
|
|
||||||
def getAutomationDefinition(self, automationId: str) -> Optional[Dict[str, Any]]:
|
def getAutomationDefinition(self, automationId: str) -> Optional[AutomationDefinition]:
|
||||||
"""Returns an automation definition by ID if user has access, with computed status."""
|
"""Returns an automation definition by ID if user has access, with computed status."""
|
||||||
try:
|
try:
|
||||||
# Use RBAC filtering
|
# Use RBAC filtering
|
||||||
|
|
@ -1736,12 +1754,14 @@ class ChatObjects:
|
||||||
automation["executionLogs"] = []
|
automation["executionLogs"] = []
|
||||||
# Enrich with user and mandate names
|
# Enrich with user and mandate names
|
||||||
self._enrichAutomationWithUserAndMandate(automation)
|
self._enrichAutomationWithUserAndMandate(automation)
|
||||||
return automation
|
# Clean metadata fields and return Pydantic model
|
||||||
|
cleanedRecord = {k: v for k, v in automation.items() if not k.startswith("_")}
|
||||||
|
return AutomationDefinition(**cleanedRecord)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting automation definition: {str(e)}")
|
logger.error(f"Error getting automation definition: {str(e)}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def createAutomationDefinition(self, automationData: Dict[str, Any]) -> Dict[str, Any]:
|
def createAutomationDefinition(self, automationData: Dict[str, Any]) -> AutomationDefinition:
|
||||||
"""Creates a new automation definition, then triggers sync."""
|
"""Creates a new automation definition, then triggers sync."""
|
||||||
try:
|
try:
|
||||||
# Ensure ID is present
|
# Ensure ID is present
|
||||||
|
|
@ -1777,12 +1797,14 @@ class ChatObjects:
|
||||||
# Trigger automation change callback (async, don't wait)
|
# Trigger automation change callback (async, don't wait)
|
||||||
asyncio.create_task(self._notifyAutomationChanged())
|
asyncio.create_task(self._notifyAutomationChanged())
|
||||||
|
|
||||||
return createdAutomation
|
# Clean metadata fields and return Pydantic model
|
||||||
|
cleanedRecord = {k: v for k, v in createdAutomation.items() if not k.startswith("_")}
|
||||||
|
return AutomationDefinition(**cleanedRecord)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error creating automation definition: {str(e)}")
|
logger.error(f"Error creating automation definition: {str(e)}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def updateAutomationDefinition(self, automationId: str, automationData: Dict[str, Any]) -> Dict[str, Any]:
|
def updateAutomationDefinition(self, automationId: str, automationData: Dict[str, Any]) -> AutomationDefinition:
|
||||||
"""Updates an automation definition, then triggers sync."""
|
"""Updates an automation definition, then triggers sync."""
|
||||||
try:
|
try:
|
||||||
# Check access
|
# Check access
|
||||||
|
|
@ -1808,7 +1830,9 @@ class ChatObjects:
|
||||||
# Trigger automation change callback (async, don't wait)
|
# Trigger automation change callback (async, don't wait)
|
||||||
asyncio.create_task(self._notifyAutomationChanged())
|
asyncio.create_task(self._notifyAutomationChanged())
|
||||||
|
|
||||||
return updatedAutomation
|
# Clean metadata fields and return Pydantic model
|
||||||
|
cleanedRecord = {k: v for k, v in updatedAutomation.items() if not k.startswith("_")}
|
||||||
|
return AutomationDefinition(**cleanedRecord)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error updating automation definition: {str(e)}")
|
logger.error(f"Error updating automation definition: {str(e)}")
|
||||||
raise
|
raise
|
||||||
|
|
@ -1870,19 +1894,28 @@ class ChatObjects:
|
||||||
logger.error(f"Error notifying automation change: {str(e)}")
|
logger.error(f"Error notifying automation change: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
def getInterface(currentUser: Optional[User] = None) -> 'ChatObjects':
|
def getInterface(currentUser: Optional[User] = None, mandateId: Optional[str] = None) -> 'ChatObjects':
|
||||||
"""
|
"""
|
||||||
Returns a ChatObjects instance for the current user.
|
Returns a ChatObjects instance for the current user.
|
||||||
Handles initialization of database and records.
|
Handles initialization of database and records.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
currentUser: The authenticated user
|
||||||
|
mandateId: The mandate ID from RequestContext (X-Mandate-Id header). Required.
|
||||||
"""
|
"""
|
||||||
if not currentUser:
|
if not currentUser:
|
||||||
raise ValueError("Invalid user context: user is required")
|
raise ValueError("Invalid user context: user is required")
|
||||||
|
|
||||||
|
effectiveMandateId = str(mandateId) if mandateId else None
|
||||||
|
|
||||||
# Create context key
|
# Create context key
|
||||||
contextKey = f"{currentUser.mandateId}_{currentUser.id}"
|
contextKey = f"{effectiveMandateId}_{currentUser.id}"
|
||||||
|
|
||||||
# Create new instance if not exists
|
# Create new instance if not exists
|
||||||
if contextKey not in _chatInterfaces:
|
if contextKey not in _chatInterfaces:
|
||||||
_chatInterfaces[contextKey] = ChatObjects(currentUser)
|
_chatInterfaces[contextKey] = ChatObjects(currentUser, mandateId=effectiveMandateId)
|
||||||
|
else:
|
||||||
|
# Update user context if needed
|
||||||
|
_chatInterfaces[contextKey].setUserContext(currentUser, mandateId=effectiveMandateId)
|
||||||
|
|
||||||
return _chatInterfaces[contextKey]
|
return _chatInterfaces[contextKey]
|
||||||
|
|
|
||||||
|
|
@ -76,14 +76,21 @@ class ComponentObjects:
|
||||||
# Initialize standard records if needed
|
# Initialize standard records if needed
|
||||||
self._initRecords()
|
self._initRecords()
|
||||||
|
|
||||||
def setUserContext(self, currentUser: User):
|
def setUserContext(self, currentUser: User, mandateId: Optional[str] = None):
|
||||||
"""Sets the user context for the interface."""
|
"""Sets the user context for the interface.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
currentUser: The authenticated user
|
||||||
|
mandateId: The mandate ID from RequestContext (X-Mandate-Id header)
|
||||||
|
"""
|
||||||
if not currentUser:
|
if not currentUser:
|
||||||
logger.info("Initializing interface without user context")
|
logger.info("Initializing interface without user context")
|
||||||
return
|
return
|
||||||
|
|
||||||
self.currentUser = currentUser # Store User object directly
|
self.currentUser = currentUser # Store User object directly
|
||||||
self.userId = currentUser.id
|
self.userId = currentUser.id
|
||||||
|
# Use mandateId from parameter (Request-Context), not from user object
|
||||||
|
self.mandateId = mandateId
|
||||||
|
|
||||||
if not self.userId:
|
if not self.userId:
|
||||||
raise ValueError("Invalid user context: id is required")
|
raise ValueError("Invalid user context: id is required")
|
||||||
|
|
@ -116,11 +123,11 @@ class ComponentObjects:
|
||||||
"""Initializes the database connection directly."""
|
"""Initializes the database connection directly."""
|
||||||
try:
|
try:
|
||||||
# Get configuration values with defaults
|
# Get configuration values with defaults
|
||||||
dbHost = APP_CONFIG.get("DB_MANAGEMENT_HOST", "_no_config_default_data")
|
dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data")
|
||||||
dbDatabase = APP_CONFIG.get("DB_MANAGEMENT_DATABASE", "management")
|
dbDatabase = "poweron_management"
|
||||||
dbUser = APP_CONFIG.get("DB_MANAGEMENT_USER")
|
dbUser = APP_CONFIG.get("DB_USER")
|
||||||
dbPassword = APP_CONFIG.get("DB_MANAGEMENT_PASSWORD_SECRET")
|
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
|
||||||
dbPort = int(APP_CONFIG.get("DB_MANAGEMENT_PORT"))
|
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
|
||||||
|
|
||||||
# Create database connector directly
|
# Create database connector directly
|
||||||
self.db = DatabaseConnector(
|
self.db = DatabaseConnector(
|
||||||
|
|
@ -979,8 +986,8 @@ class ComponentObjects:
|
||||||
fileSize = len(content)
|
fileSize = len(content)
|
||||||
fileHash = hashlib.sha256(content).hexdigest()
|
fileHash = hashlib.sha256(content).hexdigest()
|
||||||
|
|
||||||
# Ensure mandateId is valid
|
# Use mandateId from context
|
||||||
mandateId = self.currentUser.mandateId or "default"
|
mandateId = self.mandateId
|
||||||
|
|
||||||
# Create FileItem instance
|
# Create FileItem instance
|
||||||
fileItem = FileItem(
|
fileItem = FileItem(
|
||||||
|
|
@ -1320,9 +1327,9 @@ class ComponentObjects:
|
||||||
if "userId" not in settingsData:
|
if "userId" not in settingsData:
|
||||||
settingsData["userId"] = self.userId
|
settingsData["userId"] = self.userId
|
||||||
|
|
||||||
# Ensure mandateId is set
|
# Ensure mandateId is set from context
|
||||||
if "mandateId" not in settingsData:
|
if "mandateId" not in settingsData:
|
||||||
settingsData["mandateId"] = self.currentUser.mandateId if self.currentUser else "default"
|
settingsData["mandateId"] = self.mandateId
|
||||||
|
|
||||||
# Check if settings already exist for this user
|
# Check if settings already exist for this user
|
||||||
existingSettings = self.getVoiceSettings(settingsData["userId"])
|
existingSettings = self.getVoiceSettings(settingsData["userId"])
|
||||||
|
|
@ -1406,7 +1413,7 @@ class ComponentObjects:
|
||||||
# Create default settings
|
# Create default settings
|
||||||
defaultSettings = {
|
defaultSettings = {
|
||||||
"userId": targetUserId,
|
"userId": targetUserId,
|
||||||
"mandateId": self.currentUser.mandateId if self.currentUser else "default",
|
"mandateId": self.mandateId,
|
||||||
"sttLanguage": "de-DE",
|
"sttLanguage": "de-DE",
|
||||||
"ttsLanguage": "de-DE",
|
"ttsLanguage": "de-DE",
|
||||||
"ttsVoice": "de-DE-KatjaNeural",
|
"ttsVoice": "de-DE-KatjaNeural",
|
||||||
|
|
@ -1494,9 +1501,9 @@ class ComponentObjects:
|
||||||
if not all(c.isalpha() or c == "_" for c in subscriptionId):
|
if not all(c.isalpha() or c == "_" for c in subscriptionId):
|
||||||
raise ValueError("subscriptionId must contain only letters and underscores")
|
raise ValueError("subscriptionId must contain only letters and underscores")
|
||||||
|
|
||||||
# Set mandateId if not provided
|
# Set mandateId from context
|
||||||
if "mandateId" not in subscriptionData:
|
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)
|
createdRecord = self.db.recordCreate(MessagingSubscription, subscriptionData)
|
||||||
if not createdRecord or not createdRecord.get("id"):
|
if not createdRecord or not createdRecord.get("id"):
|
||||||
|
|
@ -1741,12 +1748,18 @@ class ComponentObjects:
|
||||||
return MessagingDelivery(**filteredDeliveries[0]) if filteredDeliveries else None
|
return MessagingDelivery(**filteredDeliveries[0]) if filteredDeliveries else None
|
||||||
|
|
||||||
|
|
||||||
def getInterface(currentUser: Optional[User] = None) -> 'ComponentObjects':
|
def getInterface(currentUser: Optional[User] = None, mandateId: Optional[str] = None) -> 'ComponentObjects':
|
||||||
"""
|
"""
|
||||||
Returns a ComponentObjects instance.
|
Returns a ComponentObjects instance.
|
||||||
If currentUser is provided, initializes with user context.
|
If currentUser is provided, initializes with user context.
|
||||||
Otherwise, returns an instance with only database access.
|
Otherwise, returns an instance with only database access.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
currentUser: The authenticated user
|
||||||
|
mandateId: The mandate ID from RequestContext (X-Mandate-Id header). Required.
|
||||||
"""
|
"""
|
||||||
|
effectiveMandateId = str(mandateId) if mandateId else None
|
||||||
|
|
||||||
# Create new instance if not exists
|
# Create new instance if not exists
|
||||||
if "default" not in _instancesManagement:
|
if "default" not in _instancesManagement:
|
||||||
_instancesManagement["default"] = ComponentObjects()
|
_instancesManagement["default"] = ComponentObjects()
|
||||||
|
|
@ -1754,7 +1767,7 @@ def getInterface(currentUser: Optional[User] = None) -> 'ComponentObjects':
|
||||||
interface = _instancesManagement["default"]
|
interface = _instancesManagement["default"]
|
||||||
|
|
||||||
if currentUser:
|
if currentUser:
|
||||||
interface.setUserContext(currentUser)
|
interface.setUserContext(currentUser, mandateId=effectiveMandateId)
|
||||||
else:
|
else:
|
||||||
logger.info("Returning interface without user context")
|
logger.info("Returning interface without user context")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,11 +39,17 @@ class RealEstateObjects:
|
||||||
Handles CRUD operations on Real Estate entities.
|
Handles CRUD operations on Real Estate entities.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, currentUser: Optional[User] = None):
|
def __init__(self, currentUser: Optional[User] = None, mandateId: Optional[str] = None):
|
||||||
"""Initializes the Real Estate Interface."""
|
"""Initializes the Real Estate Interface.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
currentUser: The authenticated user
|
||||||
|
mandateId: The mandate ID from RequestContext (X-Mandate-Id header)
|
||||||
|
"""
|
||||||
self.currentUser = currentUser
|
self.currentUser = currentUser
|
||||||
self.userId = currentUser.id if currentUser else None
|
self.userId = currentUser.id if currentUser else None
|
||||||
self.mandateId = currentUser.mandateId if currentUser else None
|
# Use mandateId from parameter (Request-Context), not from user object
|
||||||
|
self.mandateId = mandateId
|
||||||
self.rbac = None # RBAC interface
|
self.rbac = None # RBAC interface
|
||||||
|
|
||||||
# Initialize database
|
# Initialize database
|
||||||
|
|
@ -51,17 +57,17 @@ class RealEstateObjects:
|
||||||
|
|
||||||
# Set user context if provided
|
# Set user context if provided
|
||||||
if currentUser:
|
if currentUser:
|
||||||
self.setUserContext(currentUser)
|
self.setUserContext(currentUser, mandateId=mandateId)
|
||||||
|
|
||||||
def _initializeDatabase(self):
|
def _initializeDatabase(self):
|
||||||
"""Initialize PostgreSQL database connection."""
|
"""Initialize PostgreSQL database connection."""
|
||||||
try:
|
try:
|
||||||
# Get database configuration from environment
|
# Get database configuration from environment
|
||||||
dbHost = APP_CONFIG.get("DB_REALESTATE_HOST", "localhost")
|
dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data")
|
||||||
dbDatabase = APP_CONFIG.get("DB_REALESTATE_DATABASE", "poweron_realestate")
|
dbDatabase = "poweron_realestate"
|
||||||
dbUser = APP_CONFIG.get("DB_REALESTATE_USER")
|
dbUser = APP_CONFIG.get("DB_USER")
|
||||||
dbPassword = APP_CONFIG.get("DB_REALESTATE_PASSWORD_SECRET")
|
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
|
||||||
dbPort = int(APP_CONFIG.get("DB_REALESTATE_PORT", 5432))
|
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
|
||||||
|
|
||||||
# Initialize database connector
|
# Initialize database connector
|
||||||
self.db = DatabaseConnector(
|
self.db = DatabaseConnector(
|
||||||
|
|
@ -101,14 +107,24 @@ class RealEstateObjects:
|
||||||
logger.warning(f"Error ensuring supporting tables exist: {e}")
|
logger.warning(f"Error ensuring supporting tables exist: {e}")
|
||||||
# Don't raise - tables will be created on-demand anyway
|
# Don't raise - tables will be created on-demand anyway
|
||||||
|
|
||||||
def setUserContext(self, currentUser: User):
|
def setUserContext(self, currentUser: User, mandateId: Optional[str] = None):
|
||||||
"""Sets the user context for the interface."""
|
"""Sets the user context for the interface.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
currentUser: The authenticated user
|
||||||
|
mandateId: The mandate ID from RequestContext (X-Mandate-Id header)
|
||||||
|
"""
|
||||||
self.currentUser = currentUser
|
self.currentUser = currentUser
|
||||||
self.userId = currentUser.id
|
self.userId = currentUser.id
|
||||||
self.mandateId = currentUser.mandateId
|
# Use mandateId from parameter (Request-Context), not from user object
|
||||||
|
self.mandateId = mandateId
|
||||||
|
|
||||||
if not self.userId or not self.mandateId:
|
if not self.userId:
|
||||||
raise ValueError("Invalid user context: id and mandateId are required")
|
raise ValueError("Invalid user context: id is required")
|
||||||
|
|
||||||
|
# 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
|
# Initialize RBAC interface
|
||||||
if not self.currentUser:
|
if not self.currentUser:
|
||||||
|
|
@ -239,14 +255,8 @@ class RealEstateObjects:
|
||||||
|
|
||||||
def getParzellen(self, recordFilter: Optional[Dict[str, Any]] = None) -> List[Parzelle]:
|
def getParzellen(self, recordFilter: Optional[Dict[str, Any]] = None) -> List[Parzelle]:
|
||||||
"""Get all plots matching the filter."""
|
"""Get all plots matching the filter."""
|
||||||
original_gemeinde_value = None
|
|
||||||
|
|
||||||
# Resolve location names to IDs if needed
|
# Resolve location names to IDs if needed
|
||||||
if recordFilter:
|
if recordFilter:
|
||||||
# Save original value before resolution for fallback search
|
|
||||||
if "kontextGemeinde" in recordFilter:
|
|
||||||
original_gemeinde_value = recordFilter["kontextGemeinde"]
|
|
||||||
|
|
||||||
recordFilter = self._resolveLocationFilters(recordFilter)
|
recordFilter = self._resolveLocationFilters(recordFilter)
|
||||||
|
|
||||||
records = getRecordsetWithRBAC(
|
records = getRecordsetWithRBAC(
|
||||||
|
|
@ -256,23 +266,6 @@ class RealEstateObjects:
|
||||||
recordFilter=recordFilter or {}
|
recordFilter=recordFilter or {}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Fallback: If no records found and we resolved a Gemeinde name,
|
|
||||||
# try searching with the original name for backwards compatibility
|
|
||||||
# (handles case where data has string names instead of UUIDs)
|
|
||||||
if not records and original_gemeinde_value and recordFilter and "kontextGemeinde" in recordFilter:
|
|
||||||
if recordFilter["kontextGemeinde"] != original_gemeinde_value:
|
|
||||||
logger.info(f"No results with resolved UUID, trying with original name '{original_gemeinde_value}'")
|
|
||||||
fallback_filter = recordFilter.copy()
|
|
||||||
fallback_filter["kontextGemeinde"] = original_gemeinde_value
|
|
||||||
records = getRecordsetWithRBAC(
|
|
||||||
self.db,
|
|
||||||
Parzelle,
|
|
||||||
self.currentUser,
|
|
||||||
recordFilter=fallback_filter
|
|
||||||
)
|
|
||||||
if records:
|
|
||||||
logger.info(f"Found {len(records)} records using original name (legacy data format)")
|
|
||||||
|
|
||||||
return [Parzelle(**r) for r in records]
|
return [Parzelle(**r) for r in records]
|
||||||
|
|
||||||
def _resolveLocationFilters(self, recordFilter: Dict[str, Any]) -> Dict[str, Any]:
|
def _resolveLocationFilters(self, recordFilter: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
|
@ -799,15 +792,24 @@ class RealEstateObjects:
|
||||||
raise
|
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.
|
Factory function to get or create a Real Estate interface instance for a user.
|
||||||
Uses singleton pattern per user.
|
Uses singleton pattern per user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
currentUser: The authenticated user
|
||||||
|
mandateId: The mandate ID from RequestContext (X-Mandate-Id header). Required.
|
||||||
"""
|
"""
|
||||||
userKey = f"{currentUser.id}_{currentUser.mandateId}"
|
effectiveMandateId = str(mandateId) if mandateId else None
|
||||||
|
|
||||||
|
userKey = f"{currentUser.id}_{effectiveMandateId}"
|
||||||
|
|
||||||
if userKey not in _realEstateInterfaces:
|
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]
|
return _realEstateInterfaces[userKey]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,8 @@ Manages trustee organisations, roles, access, contracts, documents, and position
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import math
|
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.connectors.connectorDbPostgre import DatabaseConnector
|
||||||
from modules.shared.configuration import APP_CONFIG
|
from modules.shared.configuration import APP_CONFIG
|
||||||
|
|
@ -32,20 +33,27 @@ logger = logging.getLogger(__name__)
|
||||||
_trusteeInterfaces = {}
|
_trusteeInterfaces = {}
|
||||||
|
|
||||||
|
|
||||||
def getInterface(currentUser: User) -> "TrusteeObjects":
|
def getInterface(currentUser: User, mandateId: Optional[Union[str, uuid.UUID]] = None) -> "TrusteeObjects":
|
||||||
"""Get or create a TrusteeObjects instance for the given user context."""
|
"""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
|
global _trusteeInterfaces
|
||||||
|
|
||||||
if not currentUser or not currentUser.id:
|
if not currentUser or not currentUser.id:
|
||||||
raise ValueError("Valid user context required")
|
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:
|
if cacheKey not in _trusteeInterfaces:
|
||||||
_trusteeInterfaces[cacheKey] = TrusteeObjects(currentUser)
|
_trusteeInterfaces[cacheKey] = TrusteeObjects(currentUser, mandateId=effectiveMandateId)
|
||||||
else:
|
else:
|
||||||
# Update user context if needed
|
# Update user context if needed
|
||||||
_trusteeInterfaces[cacheKey].setUserContext(currentUser)
|
_trusteeInterfaces[cacheKey].setUserContext(currentUser, mandateId=effectiveMandateId)
|
||||||
|
|
||||||
return _trusteeInterfaces[cacheKey]
|
return _trusteeInterfaces[cacheKey]
|
||||||
|
|
||||||
|
|
@ -56,11 +64,17 @@ class TrusteeObjects:
|
||||||
Manages trustee organisations, roles, access, contracts, documents, and positions.
|
Manages trustee organisations, roles, access, contracts, documents, and positions.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, currentUser: Optional[User] = None):
|
def __init__(self, currentUser: Optional[User] = None, mandateId: Optional[str] = None):
|
||||||
"""Initializes the Trustee Interface."""
|
"""Initializes the Trustee Interface.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
currentUser: The authenticated user
|
||||||
|
mandateId: The mandate ID from RequestContext (X-Mandate-Id header)
|
||||||
|
"""
|
||||||
self.currentUser = currentUser
|
self.currentUser = currentUser
|
||||||
self.userId = currentUser.id if currentUser else None
|
self.userId = currentUser.id if currentUser else None
|
||||||
self.mandateId = currentUser.mandateId if currentUser else None
|
# Use mandateId from parameter (Request-Context), not from user object
|
||||||
|
self.mandateId = mandateId
|
||||||
self.rbac = None
|
self.rbac = None
|
||||||
|
|
||||||
# Initialize database
|
# Initialize database
|
||||||
|
|
@ -68,20 +82,30 @@ class TrusteeObjects:
|
||||||
|
|
||||||
# Set user context if provided
|
# Set user context if provided
|
||||||
if currentUser:
|
if currentUser:
|
||||||
self.setUserContext(currentUser)
|
self.setUserContext(currentUser, mandateId=mandateId)
|
||||||
|
|
||||||
def setUserContext(self, currentUser: User):
|
def setUserContext(self, currentUser: User, mandateId: Optional[str] = None):
|
||||||
"""Sets the user context for the interface."""
|
"""Sets the user context for the interface.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
currentUser: The authenticated user
|
||||||
|
mandateId: The mandate ID from RequestContext (X-Mandate-Id header)
|
||||||
|
"""
|
||||||
if not currentUser:
|
if not currentUser:
|
||||||
logger.info("Initializing interface without user context")
|
logger.info("Initializing interface without user context")
|
||||||
return
|
return
|
||||||
|
|
||||||
self.currentUser = currentUser
|
self.currentUser = currentUser
|
||||||
self.userId = currentUser.id
|
self.userId = currentUser.id
|
||||||
self.mandateId = currentUser.mandateId
|
# Use mandateId from parameter (Request-Context), not from user object
|
||||||
|
self.mandateId = mandateId
|
||||||
|
|
||||||
if not self.userId or not self.mandateId:
|
if not self.userId:
|
||||||
raise ValueError("Invalid user context: id and mandateId are required")
|
raise ValueError("Invalid user context: id is required")
|
||||||
|
|
||||||
|
# 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
|
self.userLanguage = currentUser.language
|
||||||
|
|
||||||
|
|
@ -104,11 +128,11 @@ class TrusteeObjects:
|
||||||
def _initializeDatabase(self):
|
def _initializeDatabase(self):
|
||||||
"""Initializes the database connection directly."""
|
"""Initializes the database connection directly."""
|
||||||
try:
|
try:
|
||||||
dbHost = APP_CONFIG.get("DB_TRUSTEE_HOST", APP_CONFIG.get("DB_CHAT_HOST", "_no_config_default_data"))
|
dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data")
|
||||||
dbDatabase = APP_CONFIG.get("DB_TRUSTEE_DATABASE", "trustee")
|
dbDatabase = "poweron_trustee"
|
||||||
dbUser = APP_CONFIG.get("DB_TRUSTEE_USER", APP_CONFIG.get("DB_CHAT_USER"))
|
dbUser = APP_CONFIG.get("DB_USER")
|
||||||
dbPassword = APP_CONFIG.get("DB_TRUSTEE_PASSWORD_SECRET", APP_CONFIG.get("DB_CHAT_PASSWORD_SECRET"))
|
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
|
||||||
dbPort = int(APP_CONFIG.get("DB_TRUSTEE_PORT", APP_CONFIG.get("DB_CHAT_PORT", 5432)))
|
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
|
||||||
|
|
||||||
self.db = DatabaseConnector(
|
self.db = DatabaseConnector(
|
||||||
dbHost=dbHost,
|
dbHost=dbHost,
|
||||||
|
|
@ -174,7 +198,7 @@ class TrusteeObjects:
|
||||||
|
|
||||||
# ===== Organisation CRUD =====
|
# ===== 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."""
|
"""Create a new organisation."""
|
||||||
if not self.checkRbacPermission(TrusteeOrganisation, "create"):
|
if not self.checkRbacPermission(TrusteeOrganisation, "create"):
|
||||||
logger.warning(f"User {self.userId} lacks permission to create organisation")
|
logger.warning(f"User {self.userId} lacks permission to create organisation")
|
||||||
|
|
@ -196,13 +220,15 @@ class TrusteeObjects:
|
||||||
|
|
||||||
createdRecord = self.db.recordCreate(TrusteeOrganisation, data)
|
createdRecord = self.db.recordCreate(TrusteeOrganisation, data)
|
||||||
if createdRecord and createdRecord.get("id"):
|
if createdRecord and createdRecord.get("id"):
|
||||||
return createdRecord
|
return TrusteeOrganisation(**{k: v for k, v in createdRecord.items() if not k.startswith("_")})
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def getOrganisation(self, orgId: str) -> Optional[Dict[str, Any]]:
|
def getOrganisation(self, orgId: str) -> Optional[TrusteeOrganisation]:
|
||||||
"""Get a single organisation by ID."""
|
"""Get a single organisation by ID."""
|
||||||
records = self.db.getRecordset(TrusteeOrganisation, recordFilter={"id": orgId})
|
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:
|
def getAllOrganisations(self, params: Optional[PaginationParams] = None) -> PaginatedResult:
|
||||||
"""Get all organisations with RBAC filtering.
|
"""Get all organisations with RBAC filtering.
|
||||||
|
|
@ -214,7 +240,7 @@ class TrusteeObjects:
|
||||||
- New organisations wouldn't be visible without an access record
|
- New organisations wouldn't be visible without an access record
|
||||||
"""
|
"""
|
||||||
# Debug: Log user info and permissions
|
# 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)
|
# System RBAC filtering (filters by mandate for GROUP access level)
|
||||||
records = getRecordsetWithRBAC(
|
records = getRecordsetWithRBAC(
|
||||||
|
|
@ -247,7 +273,7 @@ class TrusteeObjects:
|
||||||
totalPages=totalPages
|
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."""
|
"""Update an organisation."""
|
||||||
if not self.checkRbacPermission(TrusteeOrganisation, "update"):
|
if not self.checkRbacPermission(TrusteeOrganisation, "update"):
|
||||||
logger.warning(f"User {self.userId} lacks permission to update organisation")
|
logger.warning(f"User {self.userId} lacks permission to update organisation")
|
||||||
|
|
@ -260,7 +286,9 @@ class TrusteeObjects:
|
||||||
|
|
||||||
data["id"] = orgId
|
data["id"] = orgId
|
||||||
updatedRecord = self.db.recordModify(TrusteeOrganisation, orgId, data)
|
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:
|
def deleteOrganisation(self, orgId: str) -> bool:
|
||||||
"""Delete an organisation."""
|
"""Delete an organisation."""
|
||||||
|
|
@ -272,7 +300,7 @@ class TrusteeObjects:
|
||||||
|
|
||||||
# ===== Role CRUD =====
|
# ===== 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)."""
|
"""Create a new role (sysadmin only)."""
|
||||||
if not self.checkRbacPermission(TrusteeRole, "create"):
|
if not self.checkRbacPermission(TrusteeRole, "create"):
|
||||||
logger.warning(f"User {self.userId} lacks permission to create role")
|
logger.warning(f"User {self.userId} lacks permission to create role")
|
||||||
|
|
@ -287,13 +315,15 @@ class TrusteeObjects:
|
||||||
|
|
||||||
createdRecord = self.db.recordCreate(TrusteeRole, data)
|
createdRecord = self.db.recordCreate(TrusteeRole, data)
|
||||||
if createdRecord and createdRecord.get("id"):
|
if createdRecord and createdRecord.get("id"):
|
||||||
return createdRecord
|
return TrusteeRole(**{k: v for k, v in createdRecord.items() if not k.startswith("_")})
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def getRole(self, roleId: str) -> Optional[Dict[str, Any]]:
|
def getRole(self, roleId: str) -> Optional[TrusteeRole]:
|
||||||
"""Get a single role by ID."""
|
"""Get a single role by ID."""
|
||||||
records = self.db.getRecordset(TrusteeRole, recordFilter={"id": roleId})
|
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:
|
def getAllRoles(self, params: Optional[PaginationParams] = None) -> PaginatedResult:
|
||||||
"""Get all roles with RBAC filtering.
|
"""Get all roles with RBAC filtering.
|
||||||
|
|
@ -338,7 +368,7 @@ class TrusteeObjects:
|
||||||
totalPages=totalPages
|
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)."""
|
"""Update a role (sysadmin only)."""
|
||||||
if not self.checkRbacPermission(TrusteeRole, "update"):
|
if not self.checkRbacPermission(TrusteeRole, "update"):
|
||||||
logger.warning(f"User {self.userId} lacks permission to update role")
|
logger.warning(f"User {self.userId} lacks permission to update role")
|
||||||
|
|
@ -346,7 +376,9 @@ class TrusteeObjects:
|
||||||
|
|
||||||
data["id"] = roleId
|
data["id"] = roleId
|
||||||
updatedRecord = self.db.recordModify(TrusteeRole, roleId, data)
|
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:
|
def deleteRole(self, roleId: str) -> bool:
|
||||||
"""Delete a role (sysadmin only, not if in use)."""
|
"""Delete a role (sysadmin only, not if in use)."""
|
||||||
|
|
@ -364,7 +396,7 @@ class TrusteeObjects:
|
||||||
|
|
||||||
# ===== Access CRUD =====
|
# ===== 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."""
|
"""Create a new access record. Requires admin role for the organisation or ALL access level."""
|
||||||
# Check system RBAC first
|
# Check system RBAC first
|
||||||
if not self.checkRbacPermission(TrusteeAccess, "create"):
|
if not self.checkRbacPermission(TrusteeAccess, "create"):
|
||||||
|
|
@ -389,13 +421,15 @@ class TrusteeObjects:
|
||||||
|
|
||||||
createdRecord = self.db.recordCreate(TrusteeAccess, data)
|
createdRecord = self.db.recordCreate(TrusteeAccess, data)
|
||||||
if createdRecord and createdRecord.get("id"):
|
if createdRecord and createdRecord.get("id"):
|
||||||
return createdRecord
|
return TrusteeAccess(**{k: v for k, v in createdRecord.items() if not k.startswith("_")})
|
||||||
return None
|
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."""
|
"""Get a single access record by ID."""
|
||||||
records = self.db.getRecordset(TrusteeAccess, recordFilter={"id": accessId})
|
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:
|
def getAllAccess(self, params: Optional[PaginationParams] = None) -> PaginatedResult:
|
||||||
"""Get all access records with RBAC filtering + feature-level filtering.
|
"""Get all access records with RBAC filtering + feature-level filtering.
|
||||||
|
|
@ -451,7 +485,7 @@ class TrusteeObjects:
|
||||||
totalPages=totalPages
|
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.
|
"""Get all access records for a specific organisation.
|
||||||
|
|
||||||
Requires admin role for the 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}")
|
logger.warning(f"User {self.userId} lacks admin role for organisation {organisationId}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
return getRecordsetWithRBAC(
|
records = getRecordsetWithRBAC(
|
||||||
connector=self.db,
|
connector=self.db,
|
||||||
modelClass=TrusteeAccess,
|
modelClass=TrusteeAccess,
|
||||||
currentUser=self.currentUser,
|
currentUser=self.currentUser,
|
||||||
recordFilter={"organisationId": organisationId},
|
recordFilter={"organisationId": organisationId},
|
||||||
orderBy="id"
|
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.
|
"""Get all access records for a specific user.
|
||||||
|
|
||||||
Users with ALL access level see all access records.
|
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
|
# Users with ALL access level (from system RBAC) see all records
|
||||||
accessLevel = self.getRbacAccessLevel(TrusteeAccess, "read")
|
accessLevel = self.getRbacAccessLevel(TrusteeAccess, "read")
|
||||||
if accessLevel == AccessLevel.ALL:
|
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
|
# Filter to only organisations where current user has admin role
|
||||||
userAccess = self.getAllUserAccess(self.userId)
|
userAccess = self.getAllUserAccess(self.userId)
|
||||||
|
|
@ -495,9 +530,10 @@ class TrusteeObjects:
|
||||||
if access.get("roleId") == "admin":
|
if access.get("roleId") == "admin":
|
||||||
adminOrgs.add(access.get("organisationId"))
|
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."""
|
"""Update an access record. Requires admin role for the organisation or ALL access level."""
|
||||||
# Check system RBAC first
|
# Check system RBAC first
|
||||||
if not self.checkRbacPermission(TrusteeAccess, "update"):
|
if not self.checkRbacPermission(TrusteeAccess, "update"):
|
||||||
|
|
@ -524,7 +560,9 @@ class TrusteeObjects:
|
||||||
|
|
||||||
data["id"] = accessId
|
data["id"] = accessId
|
||||||
updatedRecord = self.db.recordModify(TrusteeAccess, accessId, data)
|
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:
|
def deleteAccess(self, accessId: str) -> bool:
|
||||||
"""Delete an access record. Requires admin role for the organisation or ALL access level."""
|
"""Delete an access record. Requires admin role for the organisation or ALL access level."""
|
||||||
|
|
@ -555,7 +593,7 @@ class TrusteeObjects:
|
||||||
|
|
||||||
# ===== Contract CRUD =====
|
# ===== 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."""
|
"""Create a new contract."""
|
||||||
organisationId = data.get("organisationId")
|
organisationId = data.get("organisationId")
|
||||||
|
|
||||||
|
|
@ -572,13 +610,15 @@ class TrusteeObjects:
|
||||||
|
|
||||||
createdRecord = self.db.recordCreate(TrusteeContract, data)
|
createdRecord = self.db.recordCreate(TrusteeContract, data)
|
||||||
if createdRecord and createdRecord.get("id"):
|
if createdRecord and createdRecord.get("id"):
|
||||||
return createdRecord
|
return TrusteeContract(**{k: v for k, v in createdRecord.items() if not k.startswith("_")})
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def getContract(self, contractId: str) -> Optional[Dict[str, Any]]:
|
def getContract(self, contractId: str) -> Optional[TrusteeContract]:
|
||||||
"""Get a single contract by ID."""
|
"""Get a single contract by ID."""
|
||||||
records = self.db.getRecordset(TrusteeContract, recordFilter={"id": contractId})
|
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:
|
def getAllContracts(self, params: Optional[PaginationParams] = None) -> PaginatedResult:
|
||||||
"""Get all contracts with RBAC filtering + feature-level access filtering."""
|
"""Get all contracts with RBAC filtering + feature-level access filtering."""
|
||||||
|
|
@ -614,7 +654,7 @@ class TrusteeObjects:
|
||||||
totalPages=totalPages
|
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."""
|
"""Get all contracts for a specific organisation."""
|
||||||
# Step 1: System RBAC filtering
|
# Step 1: System RBAC filtering
|
||||||
records = getRecordsetWithRBAC(
|
records = getRecordsetWithRBAC(
|
||||||
|
|
@ -626,9 +666,10 @@ class TrusteeObjects:
|
||||||
)
|
)
|
||||||
|
|
||||||
# Step 2: Feature-level filtering based on trustee.access
|
# 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)."""
|
"""Update a contract (organisationId is immutable)."""
|
||||||
# Get existing contract to check organisation
|
# Get existing contract to check organisation
|
||||||
existingRecords = self.db.getRecordset(TrusteeContract, recordFilter={"id": contractId})
|
existingRecords = self.db.getRecordset(TrusteeContract, recordFilter={"id": contractId})
|
||||||
|
|
@ -652,7 +693,9 @@ class TrusteeObjects:
|
||||||
|
|
||||||
data["id"] = contractId
|
data["id"] = contractId
|
||||||
updatedRecord = self.db.recordModify(TrusteeContract, contractId, data)
|
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:
|
def deleteContract(self, contractId: str) -> bool:
|
||||||
"""Delete a contract."""
|
"""Delete a contract."""
|
||||||
|
|
@ -675,7 +718,7 @@ class TrusteeObjects:
|
||||||
|
|
||||||
# ===== Document CRUD =====
|
# ===== 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."""
|
"""Create a new document."""
|
||||||
organisationId = data.get("organisationId")
|
organisationId = data.get("organisationId")
|
||||||
contractId = data.get("contractId")
|
contractId = data.get("contractId")
|
||||||
|
|
@ -693,20 +736,19 @@ class TrusteeObjects:
|
||||||
|
|
||||||
createdRecord = self.db.recordCreate(TrusteeDocument, data)
|
createdRecord = self.db.recordCreate(TrusteeDocument, data)
|
||||||
if createdRecord and createdRecord.get("id"):
|
if createdRecord and createdRecord.get("id"):
|
||||||
# Remove binary data from response
|
# Remove binary data and metadata from Pydantic model
|
||||||
createdRecord.pop("documentData", None)
|
cleanedRecord = {k: v for k, v in createdRecord.items() if not k.startswith("_") and k != "documentData"}
|
||||||
return createdRecord
|
return TrusteeDocument(**cleanedRecord)
|
||||||
return None
|
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)."""
|
"""Get a single document by ID (metadata only)."""
|
||||||
records = self.db.getRecordset(TrusteeDocument, recordFilter={"id": documentId})
|
records = self.db.getRecordset(TrusteeDocument, recordFilter={"id": documentId})
|
||||||
if records:
|
if not records:
|
||||||
record = records[0]
|
|
||||||
# Remove binary data from response
|
|
||||||
record.pop("documentData", None)
|
|
||||||
return record
|
|
||||||
return None
|
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]:
|
def getDocumentData(self, documentId: str) -> Optional[bytes]:
|
||||||
"""Get document binary data."""
|
"""Get document binary data."""
|
||||||
|
|
@ -755,7 +797,7 @@ class TrusteeObjects:
|
||||||
totalPages=totalPages
|
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."""
|
"""Get all documents for a specific contract."""
|
||||||
# Step 1: System RBAC filtering
|
# Step 1: System RBAC filtering
|
||||||
records = getRecordsetWithRBAC(
|
records = getRecordsetWithRBAC(
|
||||||
|
|
@ -767,13 +809,15 @@ class TrusteeObjects:
|
||||||
)
|
)
|
||||||
|
|
||||||
# Step 2: Feature-level filtering based on trustee.access
|
# Step 2: Feature-level filtering based on trustee.access
|
||||||
records = self.filterRecordsByTrusteeAccess(records, TrusteeDocument)
|
filtered = self.filterRecordsByTrusteeAccess(records, TrusteeDocument)
|
||||||
|
|
||||||
for record in records:
|
result = []
|
||||||
record.pop("documentData", None)
|
for record in filtered:
|
||||||
return records
|
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."""
|
"""Update a document."""
|
||||||
# Get existing document to check organisation and creator
|
# Get existing document to check organisation and creator
|
||||||
existingRecords = self.db.getRecordset(TrusteeDocument, recordFilter={"id": documentId})
|
existingRecords = self.db.getRecordset(TrusteeDocument, recordFilter={"id": documentId})
|
||||||
|
|
@ -795,9 +839,10 @@ class TrusteeObjects:
|
||||||
|
|
||||||
data["id"] = documentId
|
data["id"] = documentId
|
||||||
updatedRecord = self.db.recordModify(TrusteeDocument, documentId, data)
|
updatedRecord = self.db.recordModify(TrusteeDocument, documentId, data)
|
||||||
if updatedRecord:
|
if not updatedRecord:
|
||||||
updatedRecord.pop("documentData", None)
|
return None
|
||||||
return updatedRecord
|
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:
|
def deleteDocument(self, documentId: str) -> bool:
|
||||||
"""Delete a document."""
|
"""Delete a document."""
|
||||||
|
|
@ -823,7 +868,7 @@ class TrusteeObjects:
|
||||||
|
|
||||||
# ===== Position CRUD =====
|
# ===== 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."""
|
"""Create a new position."""
|
||||||
organisationId = data.get("organisationId")
|
organisationId = data.get("organisationId")
|
||||||
contractId = data.get("contractId")
|
contractId = data.get("contractId")
|
||||||
|
|
@ -847,13 +892,15 @@ class TrusteeObjects:
|
||||||
|
|
||||||
createdRecord = self.db.recordCreate(TrusteePosition, data)
|
createdRecord = self.db.recordCreate(TrusteePosition, data)
|
||||||
if createdRecord and createdRecord.get("id"):
|
if createdRecord and createdRecord.get("id"):
|
||||||
return createdRecord
|
return TrusteePosition(**{k: v for k, v in createdRecord.items() if not k.startswith("_")})
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def getPosition(self, positionId: str) -> Optional[Dict[str, Any]]:
|
def getPosition(self, positionId: str) -> Optional[TrusteePosition]:
|
||||||
"""Get a single position by ID."""
|
"""Get a single position by ID."""
|
||||||
records = self.db.getRecordset(TrusteePosition, recordFilter={"id": positionId})
|
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:
|
def getAllPositions(self, params: Optional[PaginationParams] = None) -> PaginatedResult:
|
||||||
"""Get all positions with RBAC filtering + feature-level access filtering."""
|
"""Get all positions with RBAC filtering + feature-level access filtering."""
|
||||||
|
|
@ -890,7 +937,7 @@ class TrusteeObjects:
|
||||||
totalPages=totalPages
|
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."""
|
"""Get all positions for a specific contract."""
|
||||||
# Step 1: System RBAC filtering
|
# Step 1: System RBAC filtering
|
||||||
records = getRecordsetWithRBAC(
|
records = getRecordsetWithRBAC(
|
||||||
|
|
@ -902,9 +949,10 @@ class TrusteeObjects:
|
||||||
)
|
)
|
||||||
|
|
||||||
# Step 2: Feature-level filtering based on trustee.access
|
# 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."""
|
"""Get all positions for a specific organisation."""
|
||||||
# Step 1: System RBAC filtering
|
# Step 1: System RBAC filtering
|
||||||
records = getRecordsetWithRBAC(
|
records = getRecordsetWithRBAC(
|
||||||
|
|
@ -916,9 +964,10 @@ class TrusteeObjects:
|
||||||
)
|
)
|
||||||
|
|
||||||
# Step 2: Feature-level filtering based on trustee.access
|
# 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."""
|
"""Update a position."""
|
||||||
# Get existing position to check organisation and creator
|
# Get existing position to check organisation and creator
|
||||||
existingRecords = self.db.getRecordset(TrusteePosition, recordFilter={"id": positionId})
|
existingRecords = self.db.getRecordset(TrusteePosition, recordFilter={"id": positionId})
|
||||||
|
|
@ -940,7 +989,9 @@ class TrusteeObjects:
|
||||||
|
|
||||||
data["id"] = positionId
|
data["id"] = positionId
|
||||||
updatedRecord = self.db.recordModify(TrusteePosition, positionId, data)
|
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:
|
def deletePosition(self, positionId: str) -> bool:
|
||||||
"""Delete a position."""
|
"""Delete a position."""
|
||||||
|
|
@ -966,7 +1017,7 @@ class TrusteeObjects:
|
||||||
|
|
||||||
# ===== Position-Document Link CRUD =====
|
# ===== 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."""
|
"""Create a new position-document link."""
|
||||||
organisationId = data.get("organisationId")
|
organisationId = data.get("organisationId")
|
||||||
contractId = data.get("contractId")
|
contractId = data.get("contractId")
|
||||||
|
|
@ -984,13 +1035,15 @@ class TrusteeObjects:
|
||||||
|
|
||||||
createdRecord = self.db.recordCreate(TrusteePositionDocument, data)
|
createdRecord = self.db.recordCreate(TrusteePositionDocument, data)
|
||||||
if createdRecord and createdRecord.get("id"):
|
if createdRecord and createdRecord.get("id"):
|
||||||
return createdRecord
|
return TrusteePositionDocument(**{k: v for k, v in createdRecord.items() if not k.startswith("_")})
|
||||||
return None
|
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."""
|
"""Get a single position-document link by ID."""
|
||||||
records = self.db.getRecordset(TrusteePositionDocument, recordFilter={"id": linkId})
|
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:
|
def getAllPositionDocuments(self, params: Optional[PaginationParams] = None) -> PaginatedResult:
|
||||||
"""Get all position-document links with RBAC filtering + feature-level access filtering."""
|
"""Get all position-document links with RBAC filtering + feature-level access filtering."""
|
||||||
|
|
@ -1027,7 +1080,7 @@ class TrusteeObjects:
|
||||||
totalPages=totalPages
|
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."""
|
"""Get all documents linked to a position."""
|
||||||
# Step 1: System RBAC filtering
|
# Step 1: System RBAC filtering
|
||||||
links = getRecordsetWithRBAC(
|
links = getRecordsetWithRBAC(
|
||||||
|
|
@ -1039,9 +1092,10 @@ class TrusteeObjects:
|
||||||
)
|
)
|
||||||
|
|
||||||
# Step 2: Feature-level filtering based on trustee.access
|
# 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."""
|
"""Get all positions linked to a document."""
|
||||||
# Step 1: System RBAC filtering
|
# Step 1: System RBAC filtering
|
||||||
links = getRecordsetWithRBAC(
|
links = getRecordsetWithRBAC(
|
||||||
|
|
@ -1053,7 +1107,8 @@ class TrusteeObjects:
|
||||||
)
|
)
|
||||||
|
|
||||||
# Step 2: Feature-level filtering based on trustee.access
|
# 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:
|
def deletePositionDocument(self, linkId: str) -> bool:
|
||||||
"""Delete a position-document link."""
|
"""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.
|
RBAC helper functions for interfaces.
|
||||||
Provides RBAC filtering for database queries without connectors importing security.
|
Provides RBAC filtering for database queries without connectors importing security.
|
||||||
|
|
||||||
|
Multi-Tenant Design:
|
||||||
|
- mandateId kommt aus Request-Context (X-Mandate-Id Header)
|
||||||
|
- GROUP-Filter verwendet expliziten mandateId Parameter
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
@ -24,24 +28,33 @@ def getRecordsetWithRBAC(
|
||||||
recordFilter: Dict[str, Any] = None,
|
recordFilter: Dict[str, Any] = None,
|
||||||
orderBy: str = None,
|
orderBy: str = None,
|
||||||
limit: int = None,
|
limit: int = None,
|
||||||
|
mandateId: Optional[str] = None,
|
||||||
|
featureInstanceId: Optional[str] = None,
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Get records with RBAC filtering applied at database level.
|
Get records with RBAC filtering applied at database level.
|
||||||
This function wraps connector.getRecordset() with RBAC logic.
|
This function wraps connector.getRecordset() with RBAC logic.
|
||||||
|
|
||||||
|
Multi-Tenant Design:
|
||||||
|
- mandateId wird explizit übergeben (aus Request-Context / X-Mandate-Id Header)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
connector: DatabaseConnector instance
|
connector: DatabaseConnector instance
|
||||||
modelClass: Pydantic model class for the table
|
modelClass: Pydantic model class for the table
|
||||||
currentUser: User object with roleLabels
|
currentUser: User object
|
||||||
recordFilter: Additional record filters
|
recordFilter: Additional record filters
|
||||||
orderBy: Field to order by (defaults to "id")
|
orderBy: Field to order by (defaults to "id")
|
||||||
limit: Maximum number of records to return
|
limit: Maximum number of records to return
|
||||||
|
mandateId: Explicit mandate context (from request header). Required for GROUP access.
|
||||||
|
featureInstanceId: Explicit feature instance context
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of filtered records
|
List of filtered records
|
||||||
"""
|
"""
|
||||||
table = modelClass.__name__
|
table = modelClass.__name__
|
||||||
|
|
||||||
|
effectiveMandateId = mandateId
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if not connector._ensureTableExists(modelClass):
|
if not connector._ensureTableExists(modelClass):
|
||||||
return []
|
return []
|
||||||
|
|
@ -53,7 +66,9 @@ def getRecordsetWithRBAC(
|
||||||
permissions = rbacInstance.getUserPermissions(
|
permissions = rbacInstance.getUserPermissions(
|
||||||
currentUser,
|
currentUser,
|
||||||
AccessRuleContext.DATA,
|
AccessRuleContext.DATA,
|
||||||
table
|
table,
|
||||||
|
mandateId=effectiveMandateId,
|
||||||
|
featureInstanceId=featureInstanceId
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check view permission first
|
# Check view permission first
|
||||||
|
|
@ -66,7 +81,13 @@ def getRecordsetWithRBAC(
|
||||||
whereValues = []
|
whereValues = []
|
||||||
|
|
||||||
# Add RBAC WHERE clause based on read permission
|
# Add RBAC WHERE clause based on read permission
|
||||||
rbacWhereClause = buildRbacWhereClause(permissions, currentUser, table, connector)
|
rbacWhereClause = buildRbacWhereClause(
|
||||||
|
permissions,
|
||||||
|
currentUser,
|
||||||
|
table,
|
||||||
|
connector,
|
||||||
|
mandateId=effectiveMandateId
|
||||||
|
)
|
||||||
if rbacWhereClause:
|
if rbacWhereClause:
|
||||||
whereConditions.append(rbacWhereClause["condition"])
|
whereConditions.append(rbacWhereClause["condition"])
|
||||||
whereValues.extend(rbacWhereClause["values"])
|
whereValues.extend(rbacWhereClause["values"])
|
||||||
|
|
@ -155,17 +176,21 @@ def buildRbacWhereClause(
|
||||||
permissions: UserPermissions,
|
permissions: UserPermissions,
|
||||||
currentUser: User,
|
currentUser: User,
|
||||||
table: str,
|
table: str,
|
||||||
connector # DatabaseConnector instance for connection access
|
connector, # DatabaseConnector instance for connection access
|
||||||
|
mandateId: Optional[str] = None
|
||||||
) -> Optional[Dict[str, Any]]:
|
) -> Optional[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Build RBAC WHERE clause based on permissions and access level.
|
Build RBAC WHERE clause based on permissions and access level.
|
||||||
Moved from connector to interfaces.
|
|
||||||
|
Multi-Tenant Design:
|
||||||
|
- mandateId wird explizit übergeben (aus Request-Context / X-Mandate-Id Header)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
permissions: UserPermissions object
|
permissions: UserPermissions object
|
||||||
currentUser: User object
|
currentUser: User object
|
||||||
table: Table name
|
table: Table name
|
||||||
connector: DatabaseConnector instance (needed for GROUP queries)
|
connector: DatabaseConnector instance (needed for GROUP queries)
|
||||||
|
mandateId: Explicit mandate context (from request header). Required for GROUP access.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary with "condition" and "values" keys, or None if no filtering needed
|
Dictionary with "condition" and "values" keys, or None if no filtering needed
|
||||||
|
|
@ -201,7 +226,9 @@ def buildRbacWhereClause(
|
||||||
|
|
||||||
# Group records - filter by mandateId
|
# Group records - filter by mandateId
|
||||||
if readLevel == AccessLevel.GROUP:
|
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")
|
logger.warning(f"User {currentUser.id} has no mandateId for GROUP access")
|
||||||
return {"condition": "1 = 0", "values": []}
|
return {"condition": "1 = 0", "values": []}
|
||||||
|
|
||||||
|
|
@ -209,7 +236,7 @@ def buildRbacWhereClause(
|
||||||
if table == "UserInDB":
|
if table == "UserInDB":
|
||||||
return {
|
return {
|
||||||
"condition": '"mandateId" = %s',
|
"condition": '"mandateId" = %s',
|
||||||
"values": [currentUser.mandateId]
|
"values": [effectiveMandateId]
|
||||||
}
|
}
|
||||||
# For UserConnection, need to join with UserInDB or filter by mandateId in user
|
# For UserConnection, need to join with UserInDB or filter by mandateId in user
|
||||||
elif table == "UserConnection":
|
elif table == "UserConnection":
|
||||||
|
|
@ -218,7 +245,7 @@ def buildRbacWhereClause(
|
||||||
with connector.connection.cursor() as cursor:
|
with connector.connection.cursor() as cursor:
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
'SELECT "id" FROM "UserInDB" WHERE "mandateId" = %s',
|
'SELECT "id" FROM "UserInDB" WHERE "mandateId" = %s',
|
||||||
(currentUser.mandateId,)
|
(effectiveMandateId,)
|
||||||
)
|
)
|
||||||
users = cursor.fetchall()
|
users = cursor.fetchall()
|
||||||
userIds = [u["id"] for u in users]
|
userIds = [u["id"] for u in users]
|
||||||
|
|
@ -236,8 +263,7 @@ def buildRbacWhereClause(
|
||||||
else:
|
else:
|
||||||
return {
|
return {
|
||||||
"condition": '"mandateId" = %s',
|
"condition": '"mandateId" = %s',
|
||||||
"values": [currentUser.mandateId]
|
"values": [effectiveMandateId]
|
||||||
}
|
}
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,19 +31,26 @@ class VoiceObjects:
|
||||||
self.userId: Optional[str] = None
|
self.userId: Optional[str] = None
|
||||||
self._google_speech_connector: Optional[ConnectorGoogleSpeech] = None
|
self._google_speech_connector: Optional[ConnectorGoogleSpeech] = None
|
||||||
|
|
||||||
def setUserContext(self, currentUser: User):
|
def setUserContext(self, currentUser: User, mandateId: Optional[str] = None):
|
||||||
"""Set the user context for the interface."""
|
"""Set the user context for the interface.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
currentUser: The authenticated user
|
||||||
|
mandateId: The mandate ID from RequestContext (X-Mandate-Id header)
|
||||||
|
"""
|
||||||
if not currentUser:
|
if not currentUser:
|
||||||
logger.info("Initializing voice interface without user context")
|
logger.info("Initializing voice interface without user context")
|
||||||
return
|
return
|
||||||
|
|
||||||
self.currentUser = currentUser
|
self.currentUser = currentUser
|
||||||
self.userId = currentUser.id
|
self.userId = currentUser.id
|
||||||
|
# Use mandateId from parameter (Request-Context), not from user object
|
||||||
|
self.mandateId = mandateId
|
||||||
|
|
||||||
if not self.userId:
|
if not self.userId:
|
||||||
raise ValueError("Invalid user context: id is required")
|
raise ValueError("Invalid user context: id is required")
|
||||||
|
|
||||||
logger.debug(f"Voice interface user context set: userId={self.userId}")
|
logger.debug(f"Voice interface user context set: userId={self.userId}, mandateId={self.mandateId}")
|
||||||
|
|
||||||
def _getGoogleSpeechConnector(self) -> ConnectorGoogleSpeech:
|
def _getGoogleSpeechConnector(self) -> ConnectorGoogleSpeech:
|
||||||
"""Get or create Google Cloud Speech connector instance."""
|
"""Get or create Google Cloud Speech connector instance."""
|
||||||
|
|
@ -308,11 +315,11 @@ class VoiceObjects:
|
||||||
try:
|
try:
|
||||||
logger.info(f"Creating voice settings: {settingsData}")
|
logger.info(f"Creating voice settings: {settingsData}")
|
||||||
|
|
||||||
# Ensure mandateId is set from user context if not provided
|
# Ensure mandateId is set from context if not provided
|
||||||
if "mandateId" not in settingsData or not settingsData["mandateId"]:
|
if "mandateId" not in settingsData or not settingsData["mandateId"]:
|
||||||
if not self.currentUser or not self.currentUser.mandateId:
|
if not self.mandateId:
|
||||||
raise ValueError("mandateId is required but not provided and user context has no mandateId")
|
raise ValueError("mandateId is required but not provided and context has no mandateId")
|
||||||
settingsData["mandateId"] = self.currentUser.mandateId
|
settingsData["mandateId"] = self.mandateId
|
||||||
|
|
||||||
# Add timestamps
|
# Add timestamps
|
||||||
currentTime = getUtcTimestamp()
|
currentTime = getUtcTimestamp()
|
||||||
|
|
@ -376,7 +383,7 @@ class VoiceObjects:
|
||||||
# Create default settings if none exist
|
# Create default settings if none exist
|
||||||
defaultSettings = {
|
defaultSettings = {
|
||||||
"userId": userId,
|
"userId": userId,
|
||||||
"mandateId": self.currentUser.mandateId,
|
"mandateId": self.mandateId,
|
||||||
"sttLanguage": "de-DE",
|
"sttLanguage": "de-DE",
|
||||||
"ttsLanguage": "de-DE",
|
"ttsLanguage": "de-DE",
|
||||||
"ttsVoice": "de-DE-Wavenet-A",
|
"ttsVoice": "de-DE-Wavenet-A",
|
||||||
|
|
@ -524,21 +531,22 @@ class VoiceObjects:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def getVoiceInterface(currentUser: User = None) -> VoiceObjects:
|
def getVoiceInterface(currentUser: User = None, mandateId: Optional[str] = None) -> VoiceObjects:
|
||||||
"""
|
"""
|
||||||
Factory function to get or create Voice interface instance.
|
Factory function to get or create Voice interface instance.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
currentUser: User object for context (optional)
|
currentUser: User object for context (optional)
|
||||||
|
mandateId: The mandate ID from RequestContext (X-Mandate-Id header). Required.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
VoiceObjects instance
|
VoiceObjects instance
|
||||||
"""
|
"""
|
||||||
# For now, create a new instance each time
|
effectiveMandateId = str(mandateId) if mandateId else None
|
||||||
# In the future, this could be enhanced with singleton pattern per user
|
|
||||||
voiceInterface = VoiceObjects()
|
voiceInterface = VoiceObjects()
|
||||||
|
|
||||||
if currentUser:
|
if currentUser:
|
||||||
voiceInterface.setUserContext(currentUser)
|
voiceInterface.setUserContext(currentUser, mandateId=effectiveMandateId)
|
||||||
|
|
||||||
return voiceInterface
|
return voiceInterface
|
||||||
|
|
|
||||||
|
|
@ -3,20 +3,70 @@
|
||||||
"""
|
"""
|
||||||
Admin RBAC Roles Management routes.
|
Admin RBAC Roles Management routes.
|
||||||
Provides endpoints for managing roles and role assignments to users.
|
Provides endpoints for managing roles and role assignments to users.
|
||||||
|
|
||||||
|
MULTI-TENANT: These are SYSTEM-LEVEL operations requiring isSysAdmin=true.
|
||||||
|
Roles are global system resources, not mandate-specific.
|
||||||
|
Role assignments are managed via UserMandateRole (not User.roleLabels).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Depends, Query, Body, Path, Request
|
from fastapi import APIRouter, HTTPException, Depends, Query, Body, Path, Request
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional, Set
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from modules.auth import getCurrentUser, limiter
|
from modules.auth import limiter, requireSysAdmin, RequestContext
|
||||||
from modules.datamodels.datamodelUam import User, UserInDB
|
from modules.datamodels.datamodelUam import User, UserInDB
|
||||||
from modules.datamodels.datamodelRbac import Role
|
from modules.datamodels.datamodelRbac import Role
|
||||||
from modules.interfaces.interfaceDbAppObjects import getInterface
|
from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole
|
||||||
|
from modules.interfaces.interfaceDbAppObjects import getInterface, getRootInterface
|
||||||
|
|
||||||
# Configure logger
|
# Configure logger
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _getUserRoleLabels(interface, userId: str) -> List[str]:
|
||||||
|
"""
|
||||||
|
Get role labels for a user from UserMandateRole (across all mandates).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
interface: Database interface
|
||||||
|
userId: User ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of role labels
|
||||||
|
"""
|
||||||
|
roleLabels: Set[str] = set()
|
||||||
|
|
||||||
|
# Get all UserMandate records for this user
|
||||||
|
userMandates = interface.db.getRecordset(UserMandate, recordFilter={"userId": userId})
|
||||||
|
|
||||||
|
for um in userMandates:
|
||||||
|
userMandateId = um.get("id")
|
||||||
|
if not userMandateId:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get all UserMandateRole records for this membership
|
||||||
|
userMandateRoles = interface.db.getRecordset(
|
||||||
|
UserMandateRole,
|
||||||
|
recordFilter={"userMandateId": str(userMandateId)}
|
||||||
|
)
|
||||||
|
|
||||||
|
for umr in userMandateRoles:
|
||||||
|
roleId = umr.get("roleId")
|
||||||
|
if roleId:
|
||||||
|
# Get role by ID to get roleLabel
|
||||||
|
role = interface.getRole(str(roleId))
|
||||||
|
if role:
|
||||||
|
roleLabels.add(role.roleLabel)
|
||||||
|
|
||||||
|
return list(roleLabels)
|
||||||
|
|
||||||
|
|
||||||
|
def _hasRoleLabel(interface, userId: str, roleLabel: str) -> bool:
|
||||||
|
"""
|
||||||
|
Check if user has a specific role label (across all mandates).
|
||||||
|
"""
|
||||||
|
return roleLabel in _getUserRoleLabels(interface, userId)
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/api/admin/rbac/roles",
|
prefix="/api/admin/rbac/roles",
|
||||||
tags=["Admin RBAC Roles"],
|
tags=["Admin RBAC Roles"],
|
||||||
|
|
@ -24,51 +74,27 @@ router = APIRouter(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _ensureAdminAccess(currentUser: User) -> None:
|
|
||||||
"""Ensure current user has admin access to RBAC roles management."""
|
|
||||||
interface = getInterface(currentUser)
|
|
||||||
|
|
||||||
# Check if user has admin or sysadmin role
|
|
||||||
roleLabels = currentUser.roleLabels or []
|
|
||||||
if "sysadmin" not in roleLabels and "admin" not in roleLabels:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=403,
|
|
||||||
detail="Admin or sysadmin role required to manage RBAC roles"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Additional RBAC check: verify user has permission to update UserInDB
|
|
||||||
# This is already covered by admin/sysadmin role check above, but we can add explicit RBAC check if needed
|
|
||||||
# For now, admin/sysadmin role check is sufficient
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=List[Dict[str, Any]])
|
@router.get("/", response_model=List[Dict[str, Any]])
|
||||||
@limiter.limit("60/minute")
|
@limiter.limit("60/minute")
|
||||||
async def listRoles(
|
async def listRoles(
|
||||||
request: Request,
|
request: Request,
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(requireSysAdmin)
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Get list of all available roles with metadata.
|
Get list of all available roles with metadata.
|
||||||
|
MULTI-TENANT: SysAdmin-only (roles are system resources).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
- List of role dictionaries with role label, description, and user count
|
- List of role dictionaries with role label, description, and user count
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
_ensureAdminAccess(currentUser)
|
interface = getRootInterface()
|
||||||
|
|
||||||
interface = getInterface(currentUser)
|
|
||||||
|
|
||||||
# Get all roles from database
|
# Get all roles from database
|
||||||
dbRoles = interface.getAllRoles()
|
dbRoles = interface.getAllRoles()
|
||||||
|
|
||||||
# Get all users to count role assignments
|
# Count role assignments from UserMandateRole table
|
||||||
allUsers = interface.getUsers()
|
roleCounts = interface.countRoleAssignments()
|
||||||
|
|
||||||
# Count users per role
|
|
||||||
roleCounts: Dict[str, int] = {}
|
|
||||||
for user in allUsers:
|
|
||||||
for roleLabel in (user.roleLabels or []):
|
|
||||||
roleCounts[roleLabel] = roleCounts.get(roleLabel, 0) + 1
|
|
||||||
|
|
||||||
# Convert Role objects to dictionaries and add user counts
|
# Convert Role objects to dictionaries and add user counts
|
||||||
result = []
|
result = []
|
||||||
|
|
@ -77,22 +103,10 @@ async def listRoles(
|
||||||
"id": role.id,
|
"id": role.id,
|
||||||
"roleLabel": role.roleLabel,
|
"roleLabel": role.roleLabel,
|
||||||
"description": role.description,
|
"description": role.description,
|
||||||
"userCount": roleCounts.get(role.roleLabel, 0),
|
"userCount": roleCounts.get(str(role.id), 0),
|
||||||
"isSystemRole": role.isSystemRole
|
"isSystemRole": role.isSystemRole
|
||||||
})
|
})
|
||||||
|
|
||||||
# Add any roles found in user assignments that don't exist in database
|
|
||||||
dbRoleLabels = {role.roleLabel for role in dbRoles}
|
|
||||||
for roleLabel, count in roleCounts.items():
|
|
||||||
if roleLabel not in dbRoleLabels:
|
|
||||||
result.append({
|
|
||||||
"id": None,
|
|
||||||
"roleLabel": roleLabel,
|
|
||||||
"description": {"en": f"Custom role: {roleLabel}", "fr": f"Rôle personnalisé : {roleLabel}"},
|
|
||||||
"userCount": count,
|
|
||||||
"isSystemRole": False
|
|
||||||
})
|
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|
@ -109,19 +123,17 @@ async def listRoles(
|
||||||
@limiter.limit("60/minute")
|
@limiter.limit("60/minute")
|
||||||
async def getRoleOptions(
|
async def getRoleOptions(
|
||||||
request: Request,
|
request: Request,
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(requireSysAdmin)
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Get role options for select dropdowns.
|
Get role options for select dropdowns.
|
||||||
Returns roles in format suitable for frontend select components.
|
MULTI-TENANT: SysAdmin-only.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
- List of role option dictionaries with value and label
|
- List of role option dictionaries with value and label
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
_ensureAdminAccess(currentUser)
|
interface = getRootInterface()
|
||||||
|
|
||||||
interface = getInterface(currentUser)
|
|
||||||
|
|
||||||
# Get all roles from database
|
# Get all roles from database
|
||||||
dbRoles = interface.getAllRoles()
|
dbRoles = interface.getAllRoles()
|
||||||
|
|
@ -153,10 +165,11 @@ async def getRoleOptions(
|
||||||
async def createRole(
|
async def createRole(
|
||||||
request: Request,
|
request: Request,
|
||||||
role: Role = Body(...),
|
role: Role = Body(...),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(requireSysAdmin)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Create a new role.
|
Create a new role.
|
||||||
|
MULTI-TENANT: SysAdmin-only (roles are system resources).
|
||||||
|
|
||||||
Request Body:
|
Request Body:
|
||||||
- role: Role object to create
|
- role: Role object to create
|
||||||
|
|
@ -165,9 +178,7 @@ async def createRole(
|
||||||
- Created role dictionary
|
- Created role dictionary
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
_ensureAdminAccess(currentUser)
|
interface = getRootInterface()
|
||||||
|
|
||||||
interface = getInterface(currentUser)
|
|
||||||
|
|
||||||
createdRole = interface.createRole(role)
|
createdRole = interface.createRole(role)
|
||||||
|
|
||||||
|
|
@ -198,10 +209,11 @@ async def createRole(
|
||||||
async def getRole(
|
async def getRole(
|
||||||
request: Request,
|
request: Request,
|
||||||
roleId: str = Path(..., description="Role ID"),
|
roleId: str = Path(..., description="Role ID"),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(requireSysAdmin)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Get a role by ID.
|
Get a role by ID.
|
||||||
|
MULTI-TENANT: SysAdmin-only.
|
||||||
|
|
||||||
Path Parameters:
|
Path Parameters:
|
||||||
- roleId: Role ID
|
- roleId: Role ID
|
||||||
|
|
@ -210,9 +222,7 @@ async def getRole(
|
||||||
- Role dictionary
|
- Role dictionary
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
_ensureAdminAccess(currentUser)
|
interface = getRootInterface()
|
||||||
|
|
||||||
interface = getInterface(currentUser)
|
|
||||||
|
|
||||||
role = interface.getRole(roleId)
|
role = interface.getRole(roleId)
|
||||||
if not role:
|
if not role:
|
||||||
|
|
@ -244,10 +254,11 @@ async def updateRole(
|
||||||
request: Request,
|
request: Request,
|
||||||
roleId: str = Path(..., description="Role ID"),
|
roleId: str = Path(..., description="Role ID"),
|
||||||
role: Role = Body(...),
|
role: Role = Body(...),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(requireSysAdmin)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Update an existing role.
|
Update an existing role.
|
||||||
|
MULTI-TENANT: SysAdmin-only.
|
||||||
|
|
||||||
Path Parameters:
|
Path Parameters:
|
||||||
- roleId: Role ID
|
- roleId: Role ID
|
||||||
|
|
@ -259,9 +270,7 @@ async def updateRole(
|
||||||
- Updated role dictionary
|
- Updated role dictionary
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
_ensureAdminAccess(currentUser)
|
interface = getRootInterface()
|
||||||
|
|
||||||
interface = getInterface(currentUser)
|
|
||||||
|
|
||||||
updatedRole = interface.updateRole(roleId, role)
|
updatedRole = interface.updateRole(roleId, role)
|
||||||
|
|
||||||
|
|
@ -292,10 +301,11 @@ async def updateRole(
|
||||||
async def deleteRole(
|
async def deleteRole(
|
||||||
request: Request,
|
request: Request,
|
||||||
roleId: str = Path(..., description="Role ID"),
|
roleId: str = Path(..., description="Role ID"),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(requireSysAdmin)
|
||||||
) -> Dict[str, str]:
|
) -> Dict[str, str]:
|
||||||
"""
|
"""
|
||||||
Delete a role.
|
Delete a role.
|
||||||
|
MULTI-TENANT: SysAdmin-only.
|
||||||
|
|
||||||
Path Parameters:
|
Path Parameters:
|
||||||
- roleId: Role ID
|
- roleId: Role ID
|
||||||
|
|
@ -304,9 +314,7 @@ async def deleteRole(
|
||||||
- Success message
|
- Success message
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
_ensureAdminAccess(currentUser)
|
interface = getRootInterface()
|
||||||
|
|
||||||
interface = getInterface(currentUser)
|
|
||||||
|
|
||||||
success = interface.deleteRole(roleId)
|
success = interface.deleteRole(roleId)
|
||||||
if not success:
|
if not success:
|
||||||
|
|
@ -337,48 +345,50 @@ async def deleteRole(
|
||||||
async def listUsersWithRoles(
|
async def listUsersWithRoles(
|
||||||
request: Request,
|
request: Request,
|
||||||
roleLabel: Optional[str] = Query(None, description="Filter by role label"),
|
roleLabel: Optional[str] = Query(None, description="Filter by role label"),
|
||||||
mandateId: Optional[str] = Query(None, description="Filter by mandate ID"),
|
mandateId: Optional[str] = Query(None, description="Filter by mandate ID (via UserMandate)"),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(requireSysAdmin)
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Get list of users with their role assignments.
|
Get list of users with their role assignments.
|
||||||
|
MULTI-TENANT: SysAdmin-only, can see all users across mandates.
|
||||||
|
|
||||||
Query Parameters:
|
Query Parameters:
|
||||||
- roleLabel: Optional filter by role label
|
- roleLabel: Optional filter by role label
|
||||||
- mandateId: Optional filter by mandate ID
|
- mandateId: Optional filter by mandate ID (via UserMandate table)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
- List of user dictionaries with role assignments
|
- List of user dictionaries with role assignments
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
_ensureAdminAccess(currentUser)
|
interface = getRootInterface()
|
||||||
|
|
||||||
interface = getInterface(currentUser)
|
# Get all users (SysAdmin sees all)
|
||||||
|
users = interface.getUsers()
|
||||||
|
|
||||||
# Get users based on filters
|
# Filter by mandate if specified (via UserMandate table)
|
||||||
if mandateId:
|
if mandateId:
|
||||||
# Filter by mandate (if user has permission)
|
from modules.datamodels.datamodelMembership import UserMandate
|
||||||
users = interface.getUsers()
|
userMandates = interface.db.getRecordset(UserMandate, recordFilter={"mandateId": mandateId})
|
||||||
users = [u for u in users if u.mandateId == mandateId]
|
mandateUserIds = {str(um["userId"]) for um in userMandates}
|
||||||
else:
|
users = [u for u in users if str(u.id) in mandateUserIds]
|
||||||
users = interface.getUsers()
|
|
||||||
|
|
||||||
# Filter by role if specified
|
# Filter by role if specified (via UserMandateRole)
|
||||||
if roleLabel:
|
if roleLabel:
|
||||||
users = [u for u in users if roleLabel in (u.roleLabels or [])]
|
users = [u for u in users if _hasRoleLabel(interface, str(u.id), roleLabel)]
|
||||||
|
|
||||||
# Format response
|
# Format response
|
||||||
result = []
|
result = []
|
||||||
for user in users:
|
for user in users:
|
||||||
|
userRoleLabels = _getUserRoleLabels(interface, str(user.id))
|
||||||
result.append({
|
result.append({
|
||||||
"id": user.id,
|
"id": user.id,
|
||||||
"username": user.username,
|
"username": user.username,
|
||||||
"email": user.email,
|
"email": user.email,
|
||||||
"fullName": user.fullName,
|
"fullName": user.fullName,
|
||||||
"mandateId": user.mandateId,
|
"isSysAdmin": user.isSysAdmin,
|
||||||
"enabled": user.enabled,
|
"enabled": user.enabled,
|
||||||
"roleLabels": user.roleLabels or [],
|
"roleLabels": userRoleLabels,
|
||||||
"roleCount": len(user.roleLabels or [])
|
"roleCount": len(userRoleLabels)
|
||||||
})
|
})
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
@ -398,10 +408,11 @@ async def listUsersWithRoles(
|
||||||
async def getUserRoles(
|
async def getUserRoles(
|
||||||
request: Request,
|
request: Request,
|
||||||
userId: str = Path(..., description="User ID"),
|
userId: str = Path(..., description="User ID"),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(requireSysAdmin)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Get role assignments for a specific user.
|
Get role assignments for a specific user.
|
||||||
|
MULTI-TENANT: SysAdmin-only.
|
||||||
|
|
||||||
Path Parameters:
|
Path Parameters:
|
||||||
- userId: User ID
|
- userId: User ID
|
||||||
|
|
@ -410,9 +421,7 @@ async def getUserRoles(
|
||||||
- User dictionary with role assignments
|
- User dictionary with role assignments
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
_ensureAdminAccess(currentUser)
|
interface = getRootInterface()
|
||||||
|
|
||||||
interface = getInterface(currentUser)
|
|
||||||
|
|
||||||
# Get user
|
# Get user
|
||||||
user = interface.getUser(userId)
|
user = interface.getUser(userId)
|
||||||
|
|
@ -422,15 +431,16 @@ async def getUserRoles(
|
||||||
detail=f"User {userId} not found"
|
detail=f"User {userId} not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
userRoleLabels = _getUserRoleLabels(interface, str(user.id))
|
||||||
return {
|
return {
|
||||||
"id": user.id,
|
"id": user.id,
|
||||||
"username": user.username,
|
"username": user.username,
|
||||||
"email": user.email,
|
"email": user.email,
|
||||||
"fullName": user.fullName,
|
"fullName": user.fullName,
|
||||||
"mandateId": user.mandateId,
|
"isSysAdmin": user.isSysAdmin,
|
||||||
"enabled": user.enabled,
|
"enabled": user.enabled,
|
||||||
"roleLabels": user.roleLabels or [],
|
"roleLabels": userRoleLabels,
|
||||||
"roleCount": len(user.roleLabels or [])
|
"roleCount": len(userRoleLabels)
|
||||||
}
|
}
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|
@ -448,25 +458,24 @@ async def getUserRoles(
|
||||||
async def updateUserRoles(
|
async def updateUserRoles(
|
||||||
request: Request,
|
request: Request,
|
||||||
userId: str = Path(..., description="User ID"),
|
userId: str = Path(..., description="User ID"),
|
||||||
roleLabels: List[str] = Body(..., description="List of role labels to assign"),
|
newRoleLabels: List[str] = Body(..., description="List of role labels to assign"),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(requireSysAdmin)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Update role assignments for a specific user.
|
Update role assignments for a specific user.
|
||||||
|
MULTI-TENANT: SysAdmin-only. Updates roles in user's first mandate.
|
||||||
|
|
||||||
Path Parameters:
|
Path Parameters:
|
||||||
- userId: User ID
|
- userId: User ID
|
||||||
|
|
||||||
Request Body:
|
Request Body:
|
||||||
- roleLabels: List of role labels to assign (e.g., ["admin", "user"])
|
- newRoleLabels: List of role labels to assign (e.g., ["admin", "user"])
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
- Updated user dictionary with role assignments
|
- Updated user dictionary with role assignments
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
_ensureAdminAccess(currentUser)
|
interface = getRootInterface()
|
||||||
|
|
||||||
interface = getInterface(currentUser)
|
|
||||||
|
|
||||||
# Get user
|
# Get user
|
||||||
user = interface.getUser(userId)
|
user = interface.getUser(userId)
|
||||||
|
|
@ -478,28 +487,57 @@ async def updateUserRoles(
|
||||||
|
|
||||||
# Validate role labels (basic validation - check against standard roles)
|
# Validate role labels (basic validation - check against standard roles)
|
||||||
standardRoles = ["sysadmin", "admin", "user", "viewer"]
|
standardRoles = ["sysadmin", "admin", "user", "viewer"]
|
||||||
for roleLabel in roleLabels:
|
for roleLabel in newRoleLabels:
|
||||||
if roleLabel not in standardRoles:
|
if roleLabel not in standardRoles:
|
||||||
logger.warning(f"Non-standard role label assigned: {roleLabel}")
|
logger.warning(f"Non-standard role label assigned: {roleLabel}")
|
||||||
|
|
||||||
# Update user roles
|
# Get user's first mandate (for role assignment)
|
||||||
userData = {
|
userMandates = interface.db.getRecordset(UserMandate, recordFilter={"userId": userId})
|
||||||
"roleLabels": roleLabels
|
if not userMandates:
|
||||||
}
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"User {userId} has no mandate memberships. Add to mandate first."
|
||||||
|
)
|
||||||
|
|
||||||
updatedUser = interface.updateUser(userId, userData)
|
userMandateId = str(userMandates[0].get("id"))
|
||||||
|
|
||||||
logger.info(f"Updated roles for user {userId}: {roleLabels}")
|
# Get current roles for this mandate
|
||||||
|
existingRoles = interface.db.getRecordset(
|
||||||
|
UserMandateRole,
|
||||||
|
recordFilter={"userMandateId": userMandateId}
|
||||||
|
)
|
||||||
|
existingRoleIds = {str(r.get("roleId")) for r in existingRoles}
|
||||||
|
|
||||||
|
# Convert roleLabels to roleIds
|
||||||
|
newRoleIds = set()
|
||||||
|
for roleLabel in newRoleLabels:
|
||||||
|
role = interface.getRoleByLabel(roleLabel)
|
||||||
|
if role:
|
||||||
|
newRoleIds.add(str(role.id))
|
||||||
|
|
||||||
|
# Remove roles that are no longer needed
|
||||||
|
for existingRole in existingRoles:
|
||||||
|
if str(existingRole.get("roleId")) not in newRoleIds:
|
||||||
|
interface.db.recordDelete(UserMandateRole, str(existingRole.get("id")))
|
||||||
|
|
||||||
|
# Add new roles
|
||||||
|
for roleId in newRoleIds:
|
||||||
|
if roleId not in existingRoleIds:
|
||||||
|
newRole = UserMandateRole(userMandateId=userMandateId, roleId=roleId)
|
||||||
|
interface.db.recordCreate(UserMandateRole, newRole.model_dump())
|
||||||
|
|
||||||
|
logger.info(f"Updated roles for user {userId}: {newRoleLabels} by SysAdmin {context.user.id}")
|
||||||
|
|
||||||
|
userRoleLabels = _getUserRoleLabels(interface, userId)
|
||||||
return {
|
return {
|
||||||
"id": updatedUser.id,
|
"id": user.id,
|
||||||
"username": updatedUser.username,
|
"username": user.username,
|
||||||
"email": updatedUser.email,
|
"email": user.email,
|
||||||
"fullName": updatedUser.fullName,
|
"fullName": user.fullName,
|
||||||
"mandateId": updatedUser.mandateId,
|
"isSysAdmin": user.isSysAdmin,
|
||||||
"enabled": updatedUser.enabled,
|
"enabled": user.enabled,
|
||||||
"roleLabels": updatedUser.roleLabels or [],
|
"roleLabels": userRoleLabels,
|
||||||
"roleCount": len(updatedUser.roleLabels or [])
|
"roleCount": len(userRoleLabels)
|
||||||
}
|
}
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|
@ -518,10 +556,11 @@ async def addUserRole(
|
||||||
request: Request,
|
request: Request,
|
||||||
userId: str = Path(..., description="User ID"),
|
userId: str = Path(..., description="User ID"),
|
||||||
roleLabel: str = Path(..., description="Role label to add"),
|
roleLabel: str = Path(..., description="Role label to add"),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(requireSysAdmin)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Add a role to a user (if not already assigned).
|
Add a role to a user (if not already assigned).
|
||||||
|
MULTI-TENANT: SysAdmin-only. Adds role to user's first mandate.
|
||||||
|
|
||||||
Path Parameters:
|
Path Parameters:
|
||||||
- userId: User ID
|
- userId: User ID
|
||||||
|
|
@ -531,9 +570,7 @@ async def addUserRole(
|
||||||
- Updated user dictionary with role assignments
|
- Updated user dictionary with role assignments
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
_ensureAdminAccess(currentUser)
|
interface = getRootInterface()
|
||||||
|
|
||||||
interface = getInterface(currentUser)
|
|
||||||
|
|
||||||
# Get user
|
# Get user
|
||||||
user = interface.getUser(userId)
|
user = interface.getUser(userId)
|
||||||
|
|
@ -543,33 +580,46 @@ async def addUserRole(
|
||||||
detail=f"User {userId} not found"
|
detail=f"User {userId} not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get current roles
|
# Get role by label
|
||||||
currentRoles = list(user.roleLabels or [])
|
role = interface.getRoleByLabel(roleLabel)
|
||||||
|
if not role:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"Role '{roleLabel}' not found"
|
||||||
|
)
|
||||||
|
|
||||||
# Add role if not already present
|
# Get user's first mandate
|
||||||
if roleLabel not in currentRoles:
|
userMandates = interface.db.getRecordset(UserMandate, recordFilter={"userId": userId})
|
||||||
currentRoles.append(roleLabel)
|
if not userMandates:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"User {userId} has no mandate memberships. Add to mandate first."
|
||||||
|
)
|
||||||
|
|
||||||
# Update user roles
|
userMandateId = str(userMandates[0].get("id"))
|
||||||
userData = {
|
|
||||||
"roleLabels": currentRoles
|
|
||||||
}
|
|
||||||
|
|
||||||
updatedUser = interface.updateUser(userId, userData)
|
# Check if role is already assigned
|
||||||
|
existingAssignment = interface.db.getRecordset(
|
||||||
|
UserMandateRole,
|
||||||
|
recordFilter={"userMandateId": userMandateId, "roleId": str(role.id)}
|
||||||
|
)
|
||||||
|
|
||||||
logger.info(f"Added role {roleLabel} to user {userId}")
|
if not existingAssignment:
|
||||||
else:
|
# Add the role
|
||||||
updatedUser = user
|
newRole = UserMandateRole(userMandateId=userMandateId, roleId=str(role.id))
|
||||||
|
interface.db.recordCreate(UserMandateRole, newRole.model_dump())
|
||||||
|
logger.info(f"Added role {roleLabel} to user {userId} by SysAdmin {context.user.id}")
|
||||||
|
|
||||||
|
userRoleLabels = _getUserRoleLabels(interface, userId)
|
||||||
return {
|
return {
|
||||||
"id": updatedUser.id,
|
"id": user.id,
|
||||||
"username": updatedUser.username,
|
"username": user.username,
|
||||||
"email": updatedUser.email,
|
"email": user.email,
|
||||||
"fullName": updatedUser.fullName,
|
"fullName": user.fullName,
|
||||||
"mandateId": updatedUser.mandateId,
|
"isSysAdmin": user.isSysAdmin,
|
||||||
"enabled": updatedUser.enabled,
|
"enabled": user.enabled,
|
||||||
"roleLabels": updatedUser.roleLabels or [],
|
"roleLabels": userRoleLabels,
|
||||||
"roleCount": len(updatedUser.roleLabels or [])
|
"roleCount": len(userRoleLabels)
|
||||||
}
|
}
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|
@ -588,10 +638,11 @@ async def removeUserRole(
|
||||||
request: Request,
|
request: Request,
|
||||||
userId: str = Path(..., description="User ID"),
|
userId: str = Path(..., description="User ID"),
|
||||||
roleLabel: str = Path(..., description="Role label to remove"),
|
roleLabel: str = Path(..., description="Role label to remove"),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(requireSysAdmin)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Remove a role from a user.
|
Remove a role from a user.
|
||||||
|
MULTI-TENANT: SysAdmin-only. Removes role from all user's mandates.
|
||||||
|
|
||||||
Path Parameters:
|
Path Parameters:
|
||||||
- userId: User ID
|
- userId: User ID
|
||||||
|
|
@ -601,9 +652,7 @@ async def removeUserRole(
|
||||||
- Updated user dictionary with role assignments
|
- Updated user dictionary with role assignments
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
_ensureAdminAccess(currentUser)
|
interface = getRootInterface()
|
||||||
|
|
||||||
interface = getInterface(currentUser)
|
|
||||||
|
|
||||||
# Get user
|
# Get user
|
||||||
user = interface.getUser(userId)
|
user = interface.getUser(userId)
|
||||||
|
|
@ -613,38 +662,44 @@ async def removeUserRole(
|
||||||
detail=f"User {userId} not found"
|
detail=f"User {userId} not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get current roles
|
# Get role by label
|
||||||
currentRoles = list(user.roleLabels or [])
|
role = interface.getRoleByLabel(roleLabel)
|
||||||
|
if not role:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"Role '{roleLabel}' not found"
|
||||||
|
)
|
||||||
|
|
||||||
# Remove role if present
|
# Remove role from all user's mandates
|
||||||
if roleLabel in currentRoles:
|
userMandates = interface.db.getRecordset(UserMandate, recordFilter={"userId": userId})
|
||||||
currentRoles.remove(roleLabel)
|
roleRemoved = False
|
||||||
|
|
||||||
# Ensure user has at least one role (default to "user")
|
for um in userMandates:
|
||||||
if not currentRoles:
|
userMandateId = str(um.get("id"))
|
||||||
currentRoles = ["user"]
|
|
||||||
logger.warning(f"User {userId} had all roles removed, defaulting to 'user' role")
|
|
||||||
|
|
||||||
# Update user roles
|
# Find and delete the role assignment
|
||||||
userData = {
|
assignments = interface.db.getRecordset(
|
||||||
"roleLabels": currentRoles
|
UserMandateRole,
|
||||||
}
|
recordFilter={"userMandateId": userMandateId, "roleId": str(role.id)}
|
||||||
|
)
|
||||||
|
|
||||||
updatedUser = interface.updateUser(userId, userData)
|
for assignment in assignments:
|
||||||
|
interface.db.recordDelete(UserMandateRole, str(assignment.get("id")))
|
||||||
|
roleRemoved = True
|
||||||
|
|
||||||
logger.info(f"Removed role {roleLabel} from user {userId}")
|
if roleRemoved:
|
||||||
else:
|
logger.info(f"Removed role {roleLabel} from user {userId} by SysAdmin {context.user.id}")
|
||||||
updatedUser = user
|
|
||||||
|
|
||||||
|
userRoleLabels = _getUserRoleLabels(interface, userId)
|
||||||
return {
|
return {
|
||||||
"id": updatedUser.id,
|
"id": user.id,
|
||||||
"username": updatedUser.username,
|
"username": user.username,
|
||||||
"email": updatedUser.email,
|
"email": user.email,
|
||||||
"fullName": updatedUser.fullName,
|
"fullName": user.fullName,
|
||||||
"mandateId": updatedUser.mandateId,
|
"isSysAdmin": user.isSysAdmin,
|
||||||
"enabled": updatedUser.enabled,
|
"enabled": user.enabled,
|
||||||
"roleLabels": updatedUser.roleLabels or [],
|
"roleLabels": userRoleLabels,
|
||||||
"roleCount": len(updatedUser.roleLabels or [])
|
"roleCount": len(userRoleLabels)
|
||||||
}
|
}
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|
@ -662,48 +717,68 @@ async def removeUserRole(
|
||||||
async def getUsersWithRole(
|
async def getUsersWithRole(
|
||||||
request: Request,
|
request: Request,
|
||||||
roleLabel: str = Path(..., description="Role label"),
|
roleLabel: str = Path(..., description="Role label"),
|
||||||
mandateId: Optional[str] = Query(None, description="Filter by mandate ID"),
|
mandateId: Optional[str] = Query(None, description="Filter by mandate ID (via UserMandate)"),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(requireSysAdmin)
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Get all users with a specific role.
|
Get all users with a specific role.
|
||||||
|
MULTI-TENANT: SysAdmin-only.
|
||||||
|
|
||||||
Path Parameters:
|
Path Parameters:
|
||||||
- roleLabel: Role label
|
- roleLabel: Role label
|
||||||
|
|
||||||
Query Parameters:
|
Query Parameters:
|
||||||
- mandateId: Optional filter by mandate ID
|
- mandateId: Optional filter by mandate ID (via UserMandate table)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
- List of users with the specified role
|
- List of users with the specified role
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
_ensureAdminAccess(currentUser)
|
interface = getRootInterface()
|
||||||
|
|
||||||
interface = getInterface(currentUser)
|
# Get role by label
|
||||||
|
role = interface.getRoleByLabel(roleLabel)
|
||||||
|
if not role:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"Role '{roleLabel}' not found"
|
||||||
|
)
|
||||||
|
|
||||||
# Get all users
|
# Get all UserMandateRole assignments for this role
|
||||||
users = interface.getUsers()
|
roleAssignments = interface.db.getRecordset(
|
||||||
|
UserMandateRole,
|
||||||
|
recordFilter={"roleId": str(role.id)}
|
||||||
|
)
|
||||||
|
|
||||||
# Filter by role
|
# Get unique userMandateIds
|
||||||
users = [u for u in users if roleLabel in (u.roleLabels or [])]
|
userMandateIds = {str(ra.get("userMandateId")) for ra in roleAssignments}
|
||||||
|
|
||||||
|
# Get userIds from UserMandate records
|
||||||
|
userIds: Set[str] = set()
|
||||||
|
for userMandateId in userMandateIds:
|
||||||
|
umRecords = interface.db.getRecordset(UserMandate, recordFilter={"id": userMandateId})
|
||||||
|
if umRecords:
|
||||||
|
um = umRecords[0]
|
||||||
# Filter by mandate if specified
|
# Filter by mandate if specified
|
||||||
if mandateId:
|
if mandateId and str(um.get("mandateId")) != mandateId:
|
||||||
users = [u for u in users if u.mandateId == mandateId]
|
continue
|
||||||
|
userIds.add(str(um.get("userId")))
|
||||||
|
|
||||||
# Format response
|
# Get users and format response
|
||||||
result = []
|
result = []
|
||||||
for user in users:
|
for userId in userIds:
|
||||||
|
user = interface.getUser(userId)
|
||||||
|
if user:
|
||||||
|
userRoleLabels = _getUserRoleLabels(interface, userId)
|
||||||
result.append({
|
result.append({
|
||||||
"id": user.id,
|
"id": user.id,
|
||||||
"username": user.username,
|
"username": user.username,
|
||||||
"email": user.email,
|
"email": user.email,
|
||||||
"fullName": user.fullName,
|
"fullName": user.fullName,
|
||||||
"mandateId": user.mandateId,
|
"isSysAdmin": user.isSysAdmin,
|
||||||
"enabled": user.enabled,
|
"enabled": user.enabled,
|
||||||
"roleLabels": user.roleLabels or [],
|
"roleLabels": userRoleLabels,
|
||||||
"roleCount": len(user.roleLabels or [])
|
"roleCount": len(userRoleLabels)
|
||||||
})
|
})
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,7 @@ async def create_automation(
|
||||||
chatInterface = getChatInterface(currentUser)
|
chatInterface = getChatInterface(currentUser)
|
||||||
automationData = automation.model_dump()
|
automationData = automation.model_dump()
|
||||||
created = chatInterface.createAutomationDefinition(automationData)
|
created = chatInterface.createAutomationDefinition(automationData)
|
||||||
return AutomationDefinition(**created)
|
return created
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -171,7 +171,7 @@ async def get_automation(
|
||||||
detail=f"Automation {automationId} not found"
|
detail=f"Automation {automationId} not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
return AutomationDefinition(**automation)
|
return automation
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -194,7 +194,7 @@ async def update_automation(
|
||||||
chatInterface = getChatInterface(currentUser)
|
chatInterface = getChatInterface(currentUser)
|
||||||
automationData = automation.model_dump()
|
automationData = automation.model_dump()
|
||||||
updated = chatInterface.updateAutomationDefinition(automationId, automationData)
|
updated = chatInterface.updateAutomationDefinition(automationId, automationData)
|
||||||
return AutomationDefinition(**updated)
|
return updated
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except PermissionError as e:
|
except PermissionError as e:
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,10 @@
|
||||||
"""
|
"""
|
||||||
Mandate routes for the backend API.
|
Mandate routes for the backend API.
|
||||||
Implements the endpoints for mandate management.
|
Implements the endpoints for mandate management.
|
||||||
|
|
||||||
|
MULTI-TENANT:
|
||||||
|
- Mandate CRUD is SysAdmin-only (mandates are system resources)
|
||||||
|
- User management within mandates is Mandate-Admin (add/remove users)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Response, Query
|
from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Response, Query
|
||||||
|
|
@ -10,18 +14,53 @@ from typing import List, Dict, Any, Optional
|
||||||
from fastapi import status
|
from fastapi import status
|
||||||
import logging
|
import logging
|
||||||
import json
|
import json
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
# Import auth module
|
# Import auth module
|
||||||
from modules.auth import limiter, getCurrentUser
|
from modules.auth import limiter, requireSysAdmin, getRequestContext, RequestContext
|
||||||
|
|
||||||
# Import interfaces
|
# Import interfaces
|
||||||
import modules.interfaces.interfaceDbAppObjects as interfaceDbAppObjects
|
import modules.interfaces.interfaceDbAppObjects as interfaceDbAppObjects
|
||||||
from modules.shared.attributeUtils import getModelAttributeDefinitions
|
from modules.shared.attributeUtils import getModelAttributeDefinitions
|
||||||
|
from modules.shared.auditLogger import audit_logger
|
||||||
|
|
||||||
# Import the model classes
|
# Import the model classes
|
||||||
from modules.datamodels.datamodelUam import Mandate, User
|
from modules.datamodels.datamodelUam import Mandate, User
|
||||||
|
from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole
|
||||||
|
from modules.datamodels.datamodelRbac import Role
|
||||||
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
|
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Request/Response Models for User Management
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class UserMandateCreate(BaseModel):
|
||||||
|
"""Request model for adding a user to a mandate"""
|
||||||
|
targetUserId: str = Field(..., description="User ID to add to the mandate")
|
||||||
|
roleIds: List[str] = Field(..., description="Role IDs to assign to the user")
|
||||||
|
|
||||||
|
|
||||||
|
class UserMandateResponse(BaseModel):
|
||||||
|
"""Response model for user mandate membership"""
|
||||||
|
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
|
# Configure logger
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -40,10 +79,11 @@ router = APIRouter(
|
||||||
async def get_mandates(
|
async def get_mandates(
|
||||||
request: Request,
|
request: Request,
|
||||||
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(requireSysAdmin)
|
||||||
) -> PaginatedResponse[Mandate]:
|
) -> PaginatedResponse[Mandate]:
|
||||||
"""
|
"""
|
||||||
Get mandates with optional pagination, sorting, and filtering.
|
Get mandates with optional pagination, sorting, and filtering.
|
||||||
|
MULTI-TENANT: SysAdmin-only (mandates are system resources).
|
||||||
|
|
||||||
Query Parameters:
|
Query Parameters:
|
||||||
- pagination: JSON-encoded PaginationParams object, or None for no pagination
|
- pagination: JSON-encoded PaginationParams object, or None for no pagination
|
||||||
|
|
@ -67,7 +107,7 @@ async def get_mandates(
|
||||||
detail=f"Invalid pagination parameter: {str(e)}"
|
detail=f"Invalid pagination parameter: {str(e)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
appInterface = interfaceDbAppObjects.getInterface(currentUser)
|
appInterface = interfaceDbAppObjects.getRootInterface()
|
||||||
result = appInterface.getAllMandates(pagination=paginationParams)
|
result = appInterface.getAllMandates(pagination=paginationParams)
|
||||||
|
|
||||||
# If pagination was requested, result is PaginatedResult
|
# If pagination was requested, result is PaginatedResult
|
||||||
|
|
@ -103,11 +143,14 @@ async def get_mandates(
|
||||||
async def get_mandate(
|
async def get_mandate(
|
||||||
request: Request,
|
request: Request,
|
||||||
mandateId: str = Path(..., description="ID of the mandate"),
|
mandateId: str = Path(..., description="ID of the mandate"),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(requireSysAdmin)
|
||||||
) -> Mandate:
|
) -> Mandate:
|
||||||
"""Get a specific mandate by ID"""
|
"""
|
||||||
|
Get a specific mandate by ID.
|
||||||
|
MULTI-TENANT: SysAdmin-only.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
appInterface = interfaceDbAppObjects.getInterface(currentUser)
|
appInterface = interfaceDbAppObjects.getRootInterface()
|
||||||
mandate = appInterface.getMandate(mandateId)
|
mandate = appInterface.getMandate(mandateId)
|
||||||
|
|
||||||
if not mandate:
|
if not mandate:
|
||||||
|
|
@ -131,9 +174,12 @@ async def get_mandate(
|
||||||
async def create_mandate(
|
async def create_mandate(
|
||||||
request: Request,
|
request: Request,
|
||||||
mandateData: dict = Body(..., description="Mandate data with at least 'name' field"),
|
mandateData: dict = Body(..., description="Mandate data with at least 'name' field"),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(requireSysAdmin)
|
||||||
) -> Mandate:
|
) -> Mandate:
|
||||||
"""Create a new mandate"""
|
"""
|
||||||
|
Create a new mandate.
|
||||||
|
MULTI-TENANT: SysAdmin-only.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
logger.debug(f"Creating mandate with data: {mandateData}")
|
logger.debug(f"Creating mandate with data: {mandateData}")
|
||||||
|
|
||||||
|
|
@ -148,7 +194,7 @@ async def create_mandate(
|
||||||
# Get optional fields with defaults
|
# Get optional fields with defaults
|
||||||
language = mandateData.get('language', 'en')
|
language = mandateData.get('language', 'en')
|
||||||
|
|
||||||
appInterface = interfaceDbAppObjects.getInterface(currentUser)
|
appInterface = interfaceDbAppObjects.getRootInterface()
|
||||||
|
|
||||||
# Create mandate
|
# Create mandate
|
||||||
newMandate = appInterface.createMandate(
|
newMandate = appInterface.createMandate(
|
||||||
|
|
@ -162,6 +208,8 @@ async def create_mandate(
|
||||||
detail="Failed to create mandate"
|
detail="Failed to create mandate"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger.info(f"Mandate {newMandate.id} created by SysAdmin {context.user.id}")
|
||||||
|
|
||||||
return newMandate
|
return newMandate
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|
@ -178,13 +226,16 @@ async def update_mandate(
|
||||||
request: Request,
|
request: Request,
|
||||||
mandateId: str = Path(..., description="ID of the mandate to update"),
|
mandateId: str = Path(..., description="ID of the mandate to update"),
|
||||||
mandateData: dict = Body(..., description="Mandate update data"),
|
mandateData: dict = Body(..., description="Mandate update data"),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(requireSysAdmin)
|
||||||
) -> Mandate:
|
) -> Mandate:
|
||||||
"""Update an existing mandate"""
|
"""
|
||||||
|
Update an existing mandate.
|
||||||
|
MULTI-TENANT: SysAdmin-only.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
logger.debug(f"Updating mandate {mandateId} with data: {mandateData}")
|
logger.debug(f"Updating mandate {mandateId} with data: {mandateData}")
|
||||||
|
|
||||||
appInterface = interfaceDbAppObjects.getInterface(currentUser)
|
appInterface = interfaceDbAppObjects.getRootInterface()
|
||||||
|
|
||||||
# Check if mandate exists
|
# Check if mandate exists
|
||||||
existingMandate = appInterface.getMandate(mandateId)
|
existingMandate = appInterface.getMandate(mandateId)
|
||||||
|
|
@ -203,6 +254,8 @@ async def update_mandate(
|
||||||
detail="Failed to update mandate"
|
detail="Failed to update mandate"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger.info(f"Mandate {mandateId} updated by SysAdmin {context.user.id}")
|
||||||
|
|
||||||
return updatedMandate
|
return updatedMandate
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|
@ -218,11 +271,14 @@ async def update_mandate(
|
||||||
async def delete_mandate(
|
async def delete_mandate(
|
||||||
request: Request,
|
request: Request,
|
||||||
mandateId: str = Path(..., description="ID of the mandate to delete"),
|
mandateId: str = Path(..., description="ID of the mandate to delete"),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(requireSysAdmin)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Delete a mandate"""
|
"""
|
||||||
|
Delete a mandate.
|
||||||
|
MULTI-TENANT: SysAdmin-only.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
appInterface = interfaceDbAppObjects.getInterface(currentUser)
|
appInterface = interfaceDbAppObjects.getRootInterface()
|
||||||
|
|
||||||
# Check if mandate exists
|
# Check if mandate exists
|
||||||
existingMandate = appInterface.getMandate(mandateId)
|
existingMandate = appInterface.getMandate(mandateId)
|
||||||
|
|
@ -232,6 +288,13 @@ async def delete_mandate(
|
||||||
detail=f"Mandate {mandateId} not found"
|
detail=f"Mandate {mandateId} not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# MULTI-TENANT: Delete all UserMandate entries for this mandate first
|
||||||
|
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
|
# Delete mandate
|
||||||
try:
|
try:
|
||||||
appInterface.deleteMandate(mandateId)
|
appInterface.deleteMandate(mandateId)
|
||||||
|
|
@ -241,6 +304,8 @@ async def delete_mandate(
|
||||||
detail=str(e)
|
detail=str(e)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger.info(f"Mandate {mandateId} deleted by SysAdmin {context.user.id}")
|
||||||
|
|
||||||
return {"message": f"Mandate {mandateId} deleted successfully"}
|
return {"message": f"Mandate {mandateId} deleted successfully"}
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|
@ -250,3 +315,456 @@ async def delete_mandate(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
detail=f"Failed to delete mandate: {str(e)}"
|
detail=f"Failed to delete mandate: {str(e)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# User Management within Mandates (Mandate-Admin)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@router.get("/{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.
|
User routes for the backend API.
|
||||||
Implements the endpoints for user management.
|
Implements the endpoints for user management.
|
||||||
|
|
||||||
|
MULTI-TENANT: User management requires RequestContext.
|
||||||
|
- mandateId from X-Mandate-Id header determines which users are visible
|
||||||
|
- SysAdmin can see all users across mandates
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Response, Query
|
from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Response, Query
|
||||||
|
|
@ -13,7 +17,7 @@ import json
|
||||||
|
|
||||||
# Import interfaces and models
|
# Import interfaces and models
|
||||||
import modules.interfaces.interfaceDbAppObjects as interfaceDbAppObjects
|
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
|
# Import the attribute definition and helper functions
|
||||||
from modules.datamodels.datamodelUam import User
|
from modules.datamodels.datamodelUam import User
|
||||||
|
|
@ -32,19 +36,19 @@ router = APIRouter(
|
||||||
@limiter.limit("30/minute")
|
@limiter.limit("30/minute")
|
||||||
async def get_users(
|
async def get_users(
|
||||||
request: Request,
|
request: Request,
|
||||||
mandateId: Optional[str] = Query(None, description="Mandate ID to filter users"),
|
|
||||||
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> PaginatedResponse[User]:
|
) -> PaginatedResponse[User]:
|
||||||
"""
|
"""
|
||||||
Get users with optional pagination, sorting, and filtering.
|
Get users with optional pagination, sorting, and filtering.
|
||||||
|
MULTI-TENANT: mandateId from X-Mandate-Id header determines scope.
|
||||||
|
SysAdmin without mandateId sees all users.
|
||||||
|
|
||||||
Query Parameters:
|
Query Parameters:
|
||||||
- mandateId: Optional mandate ID to filter users
|
|
||||||
- pagination: JSON-encoded PaginationParams object, or None for no pagination
|
- pagination: JSON-encoded PaginationParams object, or None for no pagination
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
- GET /api/users/ (no pagination - returns all users)
|
- GET /api/users/ (no pagination - returns all users in mandate)
|
||||||
- GET /api/users/?pagination={"page":1,"pageSize":10,"sort":[]}
|
- GET /api/users/?pagination={"page":1,"pageSize":10,"sort":[]}
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
|
@ -62,22 +66,63 @@ async def get_users(
|
||||||
detail=f"Invalid pagination parameter: {str(e)}"
|
detail=f"Invalid pagination parameter: {str(e)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
appInterface = interfaceDbAppObjects.getInterface(currentUser)
|
appInterface = interfaceDbAppObjects.getInterface(context.user)
|
||||||
# If mandateId is provided, use it, otherwise use the current user's mandate
|
|
||||||
targetMandateId = mandateId or currentUser.mandateId
|
|
||||||
# Get users with optional pagination
|
|
||||||
result = appInterface.getUsersByMandate(targetMandateId, pagination=paginationParams)
|
|
||||||
|
|
||||||
# If pagination was requested, result is PaginatedResult
|
# MULTI-TENANT: Use mandateId from context (header)
|
||||||
# If no pagination, result is List[User]
|
# SysAdmin without mandateId can see all users
|
||||||
|
if 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:
|
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(
|
return PaginatedResponse(
|
||||||
items=result.items,
|
items=paginatedUsers,
|
||||||
pagination=PaginationMetadata(
|
pagination=PaginationMetadata(
|
||||||
currentPage=paginationParams.page,
|
currentPage=paginationParams.page,
|
||||||
pageSize=paginationParams.pageSize,
|
pageSize=paginationParams.pageSize,
|
||||||
totalItems=result.totalItems,
|
totalItems=totalItems,
|
||||||
totalPages=result.totalPages,
|
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,
|
sort=paginationParams.sort,
|
||||||
filters=paginationParams.filters
|
filters=paginationParams.filters
|
||||||
)
|
)
|
||||||
|
|
@ -87,6 +132,12 @@ async def get_users(
|
||||||
items=result,
|
items=result,
|
||||||
pagination=None
|
pagination=None
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
# Non-SysAdmin without mandateId - should not happen (getRequestContext enforces)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="X-Mandate-Id header is required"
|
||||||
|
)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -101,11 +152,14 @@ async def get_users(
|
||||||
async def get_user(
|
async def get_user(
|
||||||
request: Request,
|
request: Request,
|
||||||
userId: str = Path(..., description="ID of the user"),
|
userId: str = Path(..., description="ID of the user"),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> User:
|
) -> User:
|
||||||
"""Get a specific user by ID"""
|
"""
|
||||||
|
Get a specific user by ID.
|
||||||
|
MULTI-TENANT: User must be in the same mandate (via UserMandate) or caller is SysAdmin.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
appInterface = interfaceDbAppObjects.getInterface(currentUser)
|
appInterface = interfaceDbAppObjects.getInterface(context.user)
|
||||||
# Get user without filtering by enabled status
|
# Get user without filtering by enabled status
|
||||||
user = appInterface.getUser(userId)
|
user = appInterface.getUser(userId)
|
||||||
|
|
||||||
|
|
@ -115,6 +169,19 @@ async def get_user(
|
||||||
detail=f"User with ID {userId} not found"
|
detail=f"User with ID {userId} not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# MULTI-TENANT: Verify user is in the same mandate (unless SysAdmin)
|
||||||
|
if context.mandateId and not context.isSysAdmin:
|
||||||
|
from modules.datamodels.datamodelMembership import UserMandate
|
||||||
|
userMandate = appInterface.db.getRecordset(UserMandate, recordFilter={
|
||||||
|
"userId": userId,
|
||||||
|
"mandateId": str(context.mandateId)
|
||||||
|
})
|
||||||
|
if not userMandate:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="User not in your mandate"
|
||||||
|
)
|
||||||
|
|
||||||
return user
|
return user
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|
@ -131,10 +198,13 @@ async def create_user(
|
||||||
request: Request,
|
request: Request,
|
||||||
user_data: User = Body(...),
|
user_data: User = Body(...),
|
||||||
password: Optional[str] = Body(None, embed=True),
|
password: Optional[str] = Body(None, embed=True),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> User:
|
) -> User:
|
||||||
"""Create a new user"""
|
"""
|
||||||
appInterface = interfaceDbAppObjects.getInterface(currentUser)
|
Create a new user.
|
||||||
|
MULTI-TENANT: User is created and automatically added to the current mandate.
|
||||||
|
"""
|
||||||
|
appInterface = interfaceDbAppObjects.getInterface(context.user)
|
||||||
|
|
||||||
# Extract fields from User model and call createUser with individual parameters
|
# Extract fields from User model and call createUser with individual parameters
|
||||||
from modules.datamodels.datamodelUam import AuthAuthority
|
from modules.datamodels.datamodelUam import AuthAuthority
|
||||||
|
|
@ -145,10 +215,22 @@ async def create_user(
|
||||||
fullName=user_data.fullName,
|
fullName=user_data.fullName,
|
||||||
language=user_data.language,
|
language=user_data.language,
|
||||||
enabled=user_data.enabled,
|
enabled=user_data.enabled,
|
||||||
roleLabels=user_data.roleLabels if user_data.roleLabels else ["user"],
|
|
||||||
authenticationAuthority=user_data.authenticationAuthority
|
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
|
return newUser
|
||||||
|
|
||||||
@router.put("/{userId}", response_model=User)
|
@router.put("/{userId}", response_model=User)
|
||||||
|
|
@ -157,10 +239,13 @@ async def update_user(
|
||||||
request: Request,
|
request: Request,
|
||||||
userId: str = Path(..., description="ID of the user to update"),
|
userId: str = Path(..., description="ID of the user to update"),
|
||||||
userData: User = Body(...),
|
userData: User = Body(...),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> User:
|
) -> User:
|
||||||
"""Update an existing user"""
|
"""
|
||||||
appInterface = interfaceDbAppObjects.getInterface(currentUser)
|
Update an existing user.
|
||||||
|
MULTI-TENANT: Can only update users in the same mandate (unless SysAdmin).
|
||||||
|
"""
|
||||||
|
appInterface = interfaceDbAppObjects.getInterface(context.user)
|
||||||
|
|
||||||
# Check if the user exists
|
# Check if the user exists
|
||||||
existingUser = appInterface.getUser(userId)
|
existingUser = appInterface.getUser(userId)
|
||||||
|
|
@ -170,6 +255,19 @@ async def update_user(
|
||||||
detail=f"User with ID {userId} not found"
|
detail=f"User with ID {userId} not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# MULTI-TENANT: Verify user is in the same mandate (unless SysAdmin)
|
||||||
|
if context.mandateId and not context.isSysAdmin:
|
||||||
|
from modules.datamodels.datamodelMembership import UserMandate
|
||||||
|
userMandate = appInterface.db.getRecordset(UserMandate, recordFilter={
|
||||||
|
"userId": userId,
|
||||||
|
"mandateId": str(context.mandateId)
|
||||||
|
})
|
||||||
|
if not userMandate:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Cannot update user outside your mandate"
|
||||||
|
)
|
||||||
|
|
||||||
# Update user
|
# Update user
|
||||||
updatedUser = appInterface.updateUser(userId, userData)
|
updatedUser = appInterface.updateUser(userId, userData)
|
||||||
|
|
||||||
|
|
@ -187,19 +285,22 @@ async def reset_user_password(
|
||||||
request: Request,
|
request: Request,
|
||||||
userId: str = Path(..., description="ID of the user to reset password for"),
|
userId: str = Path(..., description="ID of the user to reset password for"),
|
||||||
newPassword: str = Body(..., embed=True),
|
newPassword: str = Body(..., embed=True),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Reset user password (Admin only)"""
|
"""
|
||||||
|
Reset user password (Admin only).
|
||||||
|
MULTI-TENANT: Can only reset passwords for users in the same mandate (unless SysAdmin).
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
# Check if current user is admin
|
# Check if current user is admin
|
||||||
if "admin" not in (currentUser.roleLabels or []) and "sysadmin" not in (currentUser.roleLabels or []):
|
if not context.isSysAdmin:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Only administrators can reset passwords"
|
detail="Only administrators can reset passwords"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get user interface
|
# Get user interface
|
||||||
appInterface = interfaceDbAppObjects.getInterface(currentUser)
|
appInterface = interfaceDbAppObjects.getInterface(context.user)
|
||||||
|
|
||||||
# Get target user
|
# Get target user
|
||||||
target_user = appInterface.getUserById(userId)
|
target_user = appInterface.getUserById(userId)
|
||||||
|
|
@ -209,6 +310,19 @@ async def reset_user_password(
|
||||||
detail="User not found"
|
detail="User not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# MULTI-TENANT: Verify user is in the same mandate (unless SysAdmin)
|
||||||
|
if context.mandateId and not context.isSysAdmin:
|
||||||
|
from modules.datamodels.datamodelMembership import UserMandate
|
||||||
|
userMandate = appInterface.db.getRecordset(UserMandate, recordFilter={
|
||||||
|
"userId": userId,
|
||||||
|
"mandateId": str(context.mandateId)
|
||||||
|
})
|
||||||
|
if not userMandate:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Cannot reset password for user outside your mandate"
|
||||||
|
)
|
||||||
|
|
||||||
# Validate password strength
|
# Validate password strength
|
||||||
if len(newPassword) < 8:
|
if len(newPassword) < 8:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -231,7 +345,7 @@ async def reset_user_password(
|
||||||
userId=userId,
|
userId=userId,
|
||||||
authority=None, # Revoke all authorities
|
authority=None, # Revoke all authorities
|
||||||
mandateId=None, # Revoke across all mandates
|
mandateId=None, # Revoke across all mandates
|
||||||
revokedBy=currentUser.id,
|
revokedBy=context.user.id,
|
||||||
reason="password_reset"
|
reason="password_reset"
|
||||||
)
|
)
|
||||||
logger.info(f"Revoked {revoked_count} tokens for user {userId} after password reset")
|
logger.info(f"Revoked {revoked_count} tokens for user {userId} after password reset")
|
||||||
|
|
@ -243,8 +357,8 @@ async def reset_user_password(
|
||||||
try:
|
try:
|
||||||
from modules.shared.auditLogger import audit_logger
|
from modules.shared.auditLogger import audit_logger
|
||||||
audit_logger.logSecurityEvent(
|
audit_logger.logSecurityEvent(
|
||||||
userId=str(currentUser.id),
|
userId=str(context.user.id),
|
||||||
mandateId=str(currentUser.mandateId),
|
mandateId=str(context.mandateId) if context.mandateId else "system",
|
||||||
action="password_reset",
|
action="password_reset",
|
||||||
details=f"Reset password for user {userId}"
|
details=f"Reset password for user {userId}"
|
||||||
)
|
)
|
||||||
|
|
@ -271,15 +385,18 @@ async def change_password(
|
||||||
request: Request,
|
request: Request,
|
||||||
currentPassword: str = Body(..., embed=True),
|
currentPassword: str = Body(..., embed=True),
|
||||||
newPassword: str = Body(..., embed=True),
|
newPassword: str = Body(..., embed=True),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Change current user's password"""
|
"""
|
||||||
|
Change current user's password.
|
||||||
|
MULTI-TENANT: User changes their own password (no mandate restriction).
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
# Get user interface
|
# Get user interface
|
||||||
appInterface = interfaceDbAppObjects.getInterface(currentUser)
|
appInterface = interfaceDbAppObjects.getInterface(context.user)
|
||||||
|
|
||||||
# Verify current password
|
# Verify current password
|
||||||
if not appInterface.verifyPassword(currentPassword, currentUser.passwordHash):
|
if not appInterface.verifyPassword(currentPassword, context.user.passwordHash):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Current password is incorrect"
|
detail="Current password is incorrect"
|
||||||
|
|
@ -293,7 +410,7 @@ async def change_password(
|
||||||
)
|
)
|
||||||
|
|
||||||
# Change password
|
# Change password
|
||||||
success = appInterface.resetUserPassword(str(currentUser.id), newPassword)
|
success = appInterface.resetUserPassword(str(context.user.id), newPassword)
|
||||||
if not success:
|
if not success:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
|
@ -304,23 +421,23 @@ async def change_password(
|
||||||
try:
|
try:
|
||||||
from modules.datamodels.datamodelUam import AuthAuthority
|
from modules.datamodels.datamodelUam import AuthAuthority
|
||||||
revoked_count = appInterface.revokeTokensByUser(
|
revoked_count = appInterface.revokeTokensByUser(
|
||||||
userId=str(currentUser.id),
|
userId=str(context.user.id),
|
||||||
authority=None, # Revoke all authorities
|
authority=None, # Revoke all authorities
|
||||||
mandateId=None, # Revoke across all mandates
|
mandateId=None, # Revoke across all mandates
|
||||||
revokedBy=currentUser.id,
|
revokedBy=context.user.id,
|
||||||
reason="password_change"
|
reason="password_change"
|
||||||
)
|
)
|
||||||
logger.info(f"Revoked {revoked_count} tokens for user {currentUser.id} after password change")
|
logger.info(f"Revoked {revoked_count} tokens for user {context.user.id} after password change")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to revoke tokens after password change for user {currentUser.id}: {str(e)}")
|
logger.error(f"Failed to revoke tokens after password change for user {context.user.id}: {str(e)}")
|
||||||
# Don't fail the password change if token revocation fails
|
# Don't fail the password change if token revocation fails
|
||||||
|
|
||||||
# Log password change
|
# Log password change
|
||||||
try:
|
try:
|
||||||
from modules.shared.auditLogger import audit_logger
|
from modules.shared.auditLogger import audit_logger
|
||||||
audit_logger.logSecurityEvent(
|
audit_logger.logSecurityEvent(
|
||||||
userId=str(currentUser.id),
|
userId=str(context.user.id),
|
||||||
mandateId=str(currentUser.mandateId),
|
mandateId=str(context.mandateId) if context.mandateId else "system",
|
||||||
action="password_change",
|
action="password_change",
|
||||||
details="User changed their own password"
|
details="User changed their own password"
|
||||||
)
|
)
|
||||||
|
|
@ -346,9 +463,11 @@ async def sendPasswordLink(
|
||||||
request: Request,
|
request: Request,
|
||||||
userId: str = Path(..., description="ID of the user to send password setup link"),
|
userId: str = Path(..., description="ID of the user to send password setup link"),
|
||||||
frontendUrl: str = Body(..., embed=True),
|
frontendUrl: str = Body(..., embed=True),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Send password setup/reset link to a user (admin function).
|
"""
|
||||||
|
Send password setup/reset link to a user (admin function).
|
||||||
|
MULTI-TENANT: Can only send to users in the same mandate (unless SysAdmin).
|
||||||
|
|
||||||
This allows admins to send a magic link to users to set or reset their password.
|
This allows admins to send a magic link to users to set or reset their password.
|
||||||
Used when creating users without password or to help users who forgot their password.
|
Used when creating users without password or to help users who forgot their password.
|
||||||
|
|
@ -362,7 +481,7 @@ async def sendPasswordLink(
|
||||||
from modules.interfaces.interfaceDbAppObjects import getRootInterface
|
from modules.interfaces.interfaceDbAppObjects import getRootInterface
|
||||||
|
|
||||||
# Get user interface
|
# Get user interface
|
||||||
appInterface = interfaceDbAppObjects.getInterface(currentUser)
|
appInterface = interfaceDbAppObjects.getInterface(context.user)
|
||||||
|
|
||||||
# Get target user
|
# Get target user
|
||||||
targetUser = appInterface.getUser(userId)
|
targetUser = appInterface.getUser(userId)
|
||||||
|
|
@ -372,6 +491,19 @@ async def sendPasswordLink(
|
||||||
detail="User not found"
|
detail="User not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# MULTI-TENANT: Verify user is in the same mandate (unless SysAdmin)
|
||||||
|
if context.mandateId and not context.isSysAdmin:
|
||||||
|
from modules.datamodels.datamodelMembership import UserMandate
|
||||||
|
userMandate = appInterface.db.getRecordset(UserMandate, recordFilter={
|
||||||
|
"userId": userId,
|
||||||
|
"mandateId": str(context.mandateId)
|
||||||
|
})
|
||||||
|
if not userMandate:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Cannot send password link to user outside your mandate"
|
||||||
|
)
|
||||||
|
|
||||||
# Check if user has an email
|
# Check if user has an email
|
||||||
if not targetUser.email:
|
if not targetUser.email:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -440,15 +572,15 @@ Falls Sie diese Anforderung nicht erwartet haben, kontaktieren Sie bitte Ihren A
|
||||||
try:
|
try:
|
||||||
from modules.shared.auditLogger import audit_logger
|
from modules.shared.auditLogger import audit_logger
|
||||||
audit_logger.logSecurityEvent(
|
audit_logger.logSecurityEvent(
|
||||||
userId=str(currentUser.id),
|
userId=str(context.user.id),
|
||||||
mandateId=str(currentUser.mandateId),
|
mandateId=str(context.mandateId) if context.mandateId else "system",
|
||||||
action="send_password_link",
|
action="send_password_link",
|
||||||
details=f"Sent password setup link to user {userId} ({targetUser.email})"
|
details=f"Sent password setup link to user {userId} ({targetUser.email})"
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
logger.info(f"Password setup link sent to {targetUser.email} for user {targetUser.username} by admin {currentUser.username}")
|
logger.info(f"Password setup link sent to {targetUser.email} for user {targetUser.username} by admin {context.user.username}")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"message": f"Password setup link sent to {targetUser.email}",
|
"message": f"Password setup link sent to {targetUser.email}",
|
||||||
|
|
@ -470,10 +602,13 @@ Falls Sie diese Anforderung nicht erwartet haben, kontaktieren Sie bitte Ihren A
|
||||||
async def delete_user(
|
async def delete_user(
|
||||||
request: Request,
|
request: Request,
|
||||||
userId: str = Path(..., description="ID of the user to delete"),
|
userId: str = Path(..., description="ID of the user to delete"),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Delete a user"""
|
"""
|
||||||
appInterface = interfaceDbAppObjects.getInterface(currentUser)
|
Delete a user.
|
||||||
|
MULTI-TENANT: Can only delete users in the same mandate (unless SysAdmin).
|
||||||
|
"""
|
||||||
|
appInterface = interfaceDbAppObjects.getInterface(context.user)
|
||||||
|
|
||||||
# Check if the user exists
|
# Check if the user exists
|
||||||
existingUser = appInterface.getUser(userId)
|
existingUser = appInterface.getUser(userId)
|
||||||
|
|
@ -483,6 +618,25 @@ async def delete_user(
|
||||||
detail=f"User with ID {userId} not found"
|
detail=f"User with ID {userId} not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# MULTI-TENANT: Verify user is in the same mandate (unless SysAdmin)
|
||||||
|
if context.mandateId and not context.isSysAdmin:
|
||||||
|
from modules.datamodels.datamodelMembership import UserMandate
|
||||||
|
userMandate = appInterface.db.getRecordset(UserMandate, recordFilter={
|
||||||
|
"userId": userId,
|
||||||
|
"mandateId": str(context.mandateId)
|
||||||
|
})
|
||||||
|
if not userMandate:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Cannot delete user outside your mandate"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Delete UserMandate entries for this user first
|
||||||
|
from modules.datamodels.datamodelMembership import UserMandate
|
||||||
|
userMandates = appInterface.db.getRecordset(UserMandate, recordFilter={"userId": userId})
|
||||||
|
for um in userMandates:
|
||||||
|
appInterface.db.deleteRecord(UserMandate, um["id"])
|
||||||
|
|
||||||
success = appInterface.deleteUser(userId)
|
success = appInterface.deleteUser(userId)
|
||||||
if not success:
|
if not success:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,7 @@ import logging
|
||||||
|
|
||||||
# Import interfaces and models
|
# Import interfaces and models
|
||||||
import modules.interfaces.interfaceDbChatObjects as interfaceDbChatObjects
|
import modules.interfaces.interfaceDbChatObjects as interfaceDbChatObjects
|
||||||
from modules.auth import getCurrentUser, limiter
|
from modules.auth import limiter, getRequestContext, requireSysAdmin, RequestContext
|
||||||
from modules.datamodels.datamodelUam import User
|
|
||||||
|
|
||||||
# Configure logger
|
# Configure logger
|
||||||
logger = logging.getLogger(__name__)
|
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("")
|
@router.get("")
|
||||||
@limiter.limit("30/minute")
|
@limiter.limit("30/minute")
|
||||||
async def get_all_automation_events(
|
async def get_all_automation_events(
|
||||||
request: Request,
|
request: Request,
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(requireSysAdmin)
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Get all automation events across all mandates (sysadmin only).
|
Get all automation events across all mandates (sysadmin only).
|
||||||
Returns list of all registered events with their automation IDs and schedules.
|
Returns list of all registered events with their automation IDs and schedules.
|
||||||
"""
|
"""
|
||||||
requireSysadmin(currentUser)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from modules.shared.eventManagement import eventManager
|
from modules.shared.eventManagement import eventManager
|
||||||
|
|
||||||
|
|
@ -79,20 +68,18 @@ async def get_all_automation_events(
|
||||||
@limiter.limit("5/minute")
|
@limiter.limit("5/minute")
|
||||||
async def sync_all_automation_events(
|
async def sync_all_automation_events(
|
||||||
request: Request,
|
request: Request,
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(requireSysAdmin)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Manually trigger sync for all automations (sysadmin only).
|
Manually trigger sync for all automations (sysadmin only).
|
||||||
This will register/remove events based on active flags.
|
This will register/remove events based on active flags.
|
||||||
"""
|
"""
|
||||||
requireSysadmin(currentUser)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from modules.interfaces.interfaceDbChatObjects import getInterface as getChatInterface
|
from modules.interfaces.interfaceDbChatObjects import getInterface as getChatInterface
|
||||||
from modules.interfaces.interfaceDbAppObjects import getRootInterface
|
from modules.interfaces.interfaceDbAppObjects import getRootInterface
|
||||||
from modules.features.workflow import syncAutomationEvents
|
from modules.features.workflow import syncAutomationEvents
|
||||||
|
|
||||||
chatInterface = getChatInterface(currentUser)
|
chatInterface = getChatInterface(context.user)
|
||||||
# Get event user for sync operation (routes can import from interfaces)
|
# Get event user for sync operation (routes can import from interfaces)
|
||||||
rootInterface = getRootInterface()
|
rootInterface = getRootInterface()
|
||||||
eventUser = rootInterface.getUserByUsername("event")
|
eventUser = rootInterface.getUserByUsername("event")
|
||||||
|
|
@ -103,7 +90,7 @@ async def sync_all_automation_events(
|
||||||
)
|
)
|
||||||
|
|
||||||
from modules.services import getInterface as getServices
|
from modules.services import getInterface as getServices
|
||||||
services = getServices(currentUser, None)
|
services = getServices(context.user, None)
|
||||||
result = await syncAutomationEvents(services, eventUser)
|
result = await syncAutomationEvents(services, eventUser)
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
|
|
@ -124,14 +111,12 @@ async def sync_all_automation_events(
|
||||||
async def remove_event(
|
async def remove_event(
|
||||||
request: Request,
|
request: Request,
|
||||||
eventId: str = Path(..., description="Event ID to remove"),
|
eventId: str = Path(..., description="Event ID to remove"),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(requireSysAdmin)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Manually remove a specific event from scheduler (sysadmin only).
|
Manually remove a specific event from scheduler (sysadmin only).
|
||||||
Used for debugging and manual event cleanup.
|
Used for debugging and manual event cleanup.
|
||||||
"""
|
"""
|
||||||
requireSysadmin(currentUser)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from modules.shared.eventManagement import eventManager
|
from modules.shared.eventManagement import eventManager
|
||||||
|
|
||||||
|
|
@ -141,9 +126,9 @@ async def remove_event(
|
||||||
# Update automation's eventId if it exists
|
# Update automation's eventId if it exists
|
||||||
if eventId.startswith("automation."):
|
if eventId.startswith("automation."):
|
||||||
automation_id = eventId.replace("automation.", "")
|
automation_id = eventId.replace("automation.", "")
|
||||||
chatInterface = interfaceDbChatObjects.getInterface(currentUser)
|
chatInterface = interfaceDbChatObjects.getInterface(context.user)
|
||||||
automation = chatInterface.getAutomationDefinition(automation_id)
|
automation = chatInterface.getAutomationDefinition(automation_id)
|
||||||
if automation and automation.get("eventId") == eventId:
|
if automation and getattr(automation, "eventId", None) == eventId:
|
||||||
chatInterface.updateAutomationDefinition(automation_id, {"eventId": None})
|
chatInterface.updateAutomationDefinition(automation_id, {"eventId": None})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -157,4 +142,3 @@ async def remove_event(
|
||||||
status_code=500,
|
status_code=500,
|
||||||
detail=f"Error removing event: {str(e)}"
|
detail=f"Error removing event: {str(e)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -10,14 +10,13 @@ from typing import Optional, Dict, Any
|
||||||
from fastapi import APIRouter, HTTPException, Depends, Body, Path, Query, Request
|
from fastapi import APIRouter, HTTPException, Depends, Body, Path, Query, Request
|
||||||
|
|
||||||
# Import auth modules
|
# Import auth modules
|
||||||
from modules.auth import limiter, getCurrentUser
|
from modules.auth import limiter, getRequestContext, RequestContext
|
||||||
|
|
||||||
# Import interfaces
|
# Import interfaces
|
||||||
import modules.interfaces.interfaceDbChatObjects as interfaceDbChatObjects
|
import modules.interfaces.interfaceDbChatObjects as interfaceDbChatObjects
|
||||||
|
|
||||||
# Import models
|
# Import models
|
||||||
from modules.datamodels.datamodelChat import ChatWorkflow, UserInputRequest, WorkflowModeEnum
|
from modules.datamodels.datamodelChat import ChatWorkflow, UserInputRequest, WorkflowModeEnum
|
||||||
from modules.datamodels.datamodelUam import User
|
|
||||||
|
|
||||||
# Import workflow control functions
|
# Import workflow control functions
|
||||||
from modules.features.workflow import chatStart, chatStop
|
from modules.features.workflow import chatStart, chatStop
|
||||||
|
|
@ -32,8 +31,8 @@ router = APIRouter(
|
||||||
responses={404: {"description": "Not found"}}
|
responses={404: {"description": "Not found"}}
|
||||||
)
|
)
|
||||||
|
|
||||||
def getServiceChat(currentUser: User):
|
def _getServiceChat(context: RequestContext):
|
||||||
return interfaceDbChatObjects.getInterface(currentUser)
|
return interfaceDbChatObjects.getInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None)
|
||||||
|
|
||||||
# Workflow start endpoint
|
# Workflow start endpoint
|
||||||
@router.post("/start", response_model=ChatWorkflow)
|
@router.post("/start", response_model=ChatWorkflow)
|
||||||
|
|
@ -43,7 +42,7 @@ async def start_workflow(
|
||||||
workflowId: Optional[str] = Query(None, description="Optional ID of the workflow to continue"),
|
workflowId: Optional[str] = Query(None, description="Optional ID of the workflow to continue"),
|
||||||
workflowMode: WorkflowModeEnum = Query(..., description="Workflow mode: 'Dynamic' or 'Automation' (mandatory)"),
|
workflowMode: WorkflowModeEnum = Query(..., description="Workflow mode: 'Dynamic' or 'Automation' (mandatory)"),
|
||||||
userInput: UserInputRequest = Body(...),
|
userInput: UserInputRequest = Body(...),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> ChatWorkflow:
|
) -> ChatWorkflow:
|
||||||
"""
|
"""
|
||||||
Starts a new workflow or continues an existing one.
|
Starts a new workflow or continues an existing one.
|
||||||
|
|
@ -54,7 +53,7 @@ async def start_workflow(
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Start or continue workflow using playground controller
|
# Start or continue workflow using playground controller
|
||||||
workflow = await chatStart(currentUser, userInput, workflowMode, workflowId)
|
workflow = await chatStart(context.user, userInput, workflowMode, workflowId)
|
||||||
|
|
||||||
return workflow
|
return workflow
|
||||||
|
|
||||||
|
|
@ -71,12 +70,12 @@ async def start_workflow(
|
||||||
async def stop_workflow(
|
async def stop_workflow(
|
||||||
request: Request,
|
request: Request,
|
||||||
workflowId: str = Path(..., description="ID of the workflow to stop"),
|
workflowId: str = Path(..., description="ID of the workflow to stop"),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> ChatWorkflow:
|
) -> ChatWorkflow:
|
||||||
"""Stops a running workflow."""
|
"""Stops a running workflow."""
|
||||||
try:
|
try:
|
||||||
# Stop workflow using playground controller
|
# Stop workflow using playground controller
|
||||||
workflow = await chatStop(currentUser, workflowId)
|
workflow = await chatStop(context.user, workflowId)
|
||||||
|
|
||||||
return workflow
|
return workflow
|
||||||
|
|
||||||
|
|
@ -94,7 +93,7 @@ async def get_workflow_chat_data(
|
||||||
request: Request,
|
request: Request,
|
||||||
workflowId: str = Path(..., description="ID of the workflow"),
|
workflowId: str = Path(..., description="ID of the workflow"),
|
||||||
afterTimestamp: Optional[float] = Query(None, description="Unix timestamp to get data after"),
|
afterTimestamp: Optional[float] = Query(None, description="Unix timestamp to get data after"),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Get unified chat data (messages, logs, stats) for a workflow with timestamp-based selective data transfer.
|
Get unified chat data (messages, logs, stats) for a workflow with timestamp-based selective data transfer.
|
||||||
|
|
@ -102,7 +101,7 @@ async def get_workflow_chat_data(
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Get service center
|
# Get service center
|
||||||
interfaceDbChat = getServiceChat(currentUser)
|
interfaceDbChat = _getServiceChat(context)
|
||||||
|
|
||||||
# Verify workflow exists
|
# Verify workflow exists
|
||||||
workflow = interfaceDbChat.getWorkflow(workflowId)
|
workflow = interfaceDbChat.getWorkflow(workflowId)
|
||||||
|
|
@ -15,7 +15,7 @@ from fastapi.responses import StreamingResponse
|
||||||
from modules.shared.timeUtils import parseTimestamp
|
from modules.shared.timeUtils import parseTimestamp
|
||||||
|
|
||||||
# Import auth modules
|
# Import auth modules
|
||||||
from modules.auth import limiter, getCurrentUser
|
from modules.auth import limiter, getRequestContext, RequestContext
|
||||||
|
|
||||||
# Import interfaces
|
# Import interfaces
|
||||||
import modules.interfaces.interfaceDbChatObjects as interfaceDbChatObjects
|
import modules.interfaces.interfaceDbChatObjects as interfaceDbChatObjects
|
||||||
|
|
@ -23,7 +23,6 @@ from modules.interfaces.interfaceRbac import getRecordsetWithRBAC
|
||||||
|
|
||||||
# Import models
|
# Import models
|
||||||
from modules.datamodels.datamodelChat import ChatWorkflow, UserInputRequest, WorkflowModeEnum
|
from modules.datamodels.datamodelChat import ChatWorkflow, UserInputRequest, WorkflowModeEnum
|
||||||
from modules.datamodels.datamodelUam import User
|
|
||||||
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse
|
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse
|
||||||
|
|
||||||
# Import chatbot feature
|
# Import chatbot feature
|
||||||
|
|
@ -43,8 +42,8 @@ router = APIRouter(
|
||||||
responses={404: {"description": "Not found"}}
|
responses={404: {"description": "Not found"}}
|
||||||
)
|
)
|
||||||
|
|
||||||
def getServiceChat(currentUser: User):
|
def _getServiceChat(context: RequestContext):
|
||||||
return interfaceDbChatObjects.getInterface(currentUser)
|
return interfaceDbChatObjects.getInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None)
|
||||||
|
|
||||||
# Chatbot streaming endpoint (SSE)
|
# Chatbot streaming endpoint (SSE)
|
||||||
@router.post("/start/stream")
|
@router.post("/start/stream")
|
||||||
|
|
@ -53,7 +52,7 @@ async def stream_chatbot_start(
|
||||||
request: Request,
|
request: Request,
|
||||||
workflowId: Optional[str] = Query(None, description="Optional ID of the workflow to continue (can also be in request body)"),
|
workflowId: Optional[str] = Query(None, description="Optional ID of the workflow to continue (can also be in request body)"),
|
||||||
userInput: UserInputRequest = Body(...),
|
userInput: UserInputRequest = Body(...),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> StreamingResponse:
|
) -> StreamingResponse:
|
||||||
"""
|
"""
|
||||||
Starts a new chatbot workflow or continues an existing one with SSE streaming.
|
Starts a new chatbot workflow or continues an existing one with SSE streaming.
|
||||||
|
|
@ -71,7 +70,7 @@ async def stream_chatbot_start(
|
||||||
final_workflow_id = workflowId or userInput.workflowId
|
final_workflow_id = workflowId or userInput.workflowId
|
||||||
|
|
||||||
# Start background processing (this will create the workflow and event queue)
|
# Start background processing (this will create the workflow and event queue)
|
||||||
workflow = await chatProcess(currentUser, userInput, final_workflow_id)
|
workflow = await chatProcess(context.user, str(context.mandateId), userInput, final_workflow_id)
|
||||||
|
|
||||||
# Get event queue for the workflow
|
# Get event queue for the workflow
|
||||||
queue = event_manager.get_queue(workflow.id)
|
queue = event_manager.get_queue(workflow.id)
|
||||||
|
|
@ -83,7 +82,7 @@ async def stream_chatbot_start(
|
||||||
"""Async generator for SSE events - pure event-driven streaming (no polling)."""
|
"""Async generator for SSE events - pure event-driven streaming (no polling)."""
|
||||||
try:
|
try:
|
||||||
# Get interface for initial data and status checks
|
# Get interface for initial data and status checks
|
||||||
interfaceDbChat = getServiceChat(currentUser)
|
interfaceDbChat = _getServiceChat(context)
|
||||||
|
|
||||||
# Get current workflow to check if resuming and get current round
|
# Get current workflow to check if resuming and get current round
|
||||||
current_workflow = interfaceDbChat.getWorkflow(workflow.id)
|
current_workflow = interfaceDbChat.getWorkflow(workflow.id)
|
||||||
|
|
@ -239,11 +238,11 @@ async def stream_chatbot_start(
|
||||||
async def stop_chatbot(
|
async def stop_chatbot(
|
||||||
request: Request,
|
request: Request,
|
||||||
workflowId: str = Path(..., description="ID of the workflow to stop"),
|
workflowId: str = Path(..., description="ID of the workflow to stop"),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> ChatWorkflow:
|
) -> ChatWorkflow:
|
||||||
"""Stops a running chatbot workflow."""
|
"""Stops a running chatbot workflow."""
|
||||||
try:
|
try:
|
||||||
workflow = await chatStop(currentUser, workflowId)
|
workflow = await chatStop(context.user, workflowId)
|
||||||
|
|
||||||
# Emit stopped event to active streams
|
# Emit stopped event to active streams
|
||||||
event_manager = get_event_manager()
|
event_manager = get_event_manager()
|
||||||
|
|
@ -272,18 +271,18 @@ async def stop_chatbot(
|
||||||
async def delete_chatbot(
|
async def delete_chatbot(
|
||||||
request: Request,
|
request: Request,
|
||||||
workflowId: str = Path(..., description="ID of the workflow to delete"),
|
workflowId: str = Path(..., description="ID of the workflow to delete"),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Deletes a chatbot workflow and its associated data."""
|
"""Deletes a chatbot workflow and its associated data."""
|
||||||
try:
|
try:
|
||||||
# Get service center
|
# Get service center
|
||||||
interfaceDbChat = getServiceChat(currentUser)
|
interfaceDbChat = _getServiceChat(context)
|
||||||
|
|
||||||
# Check workflow access and permission using RBAC
|
# Check workflow access and permission using RBAC
|
||||||
workflows = getRecordsetWithRBAC(
|
workflows = getRecordsetWithRBAC(
|
||||||
interfaceDbChat.db,
|
interfaceDbChat.db,
|
||||||
ChatWorkflow,
|
ChatWorkflow,
|
||||||
currentUser,
|
context.user,
|
||||||
recordFilter={"id": workflowId}
|
recordFilter={"id": workflowId}
|
||||||
)
|
)
|
||||||
if not workflows:
|
if not workflows:
|
||||||
|
|
@ -337,7 +336,7 @@ async def get_chatbot_threads(
|
||||||
request: Request,
|
request: Request,
|
||||||
workflowId: Optional[str] = Query(None, description="Optional workflow ID to get details and chat data for a specific thread"),
|
workflowId: Optional[str] = Query(None, description="Optional workflow ID to get details and chat data for a specific thread"),
|
||||||
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object (only used when workflowId is not provided)"),
|
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object (only used when workflowId is not provided)"),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> Union[PaginatedResponse[ChatWorkflow], Dict[str, Any]]:
|
) -> Union[PaginatedResponse[ChatWorkflow], Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
List all chatbot workflows (threads) for the current user, or get details and chat data for a specific thread.
|
List all chatbot workflows (threads) for the current user, or get details and chat data for a specific thread.
|
||||||
|
|
@ -346,7 +345,7 @@ async def get_chatbot_threads(
|
||||||
- If workflowId is not provided: Returns a paginated list of all workflows
|
- If workflowId is not provided: Returns a paginated list of all workflows
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
interfaceDbChat = getServiceChat(currentUser)
|
interfaceDbChat = _getServiceChat(context)
|
||||||
|
|
||||||
# If workflowId is provided, return single workflow with chat data
|
# If workflowId is provided, return single workflow with chat data
|
||||||
if workflowId:
|
if workflowId:
|
||||||
|
|
@ -456,4 +455,3 @@ async def get_chatbot_threads(
|
||||||
status_code=500,
|
status_code=500,
|
||||||
detail=f"Error getting chatbot threads: {str(e)}"
|
detail=f"Error getting chatbot threads: {str(e)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -5,10 +5,9 @@ from typing import List, Dict, Any, Optional
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
# Import auth module
|
# Import auth module
|
||||||
from modules.auth import limiter, getCurrentUser
|
from modules.auth import limiter, getRequestContext, RequestContext
|
||||||
|
|
||||||
# Import interfaces
|
# Import interfaces
|
||||||
from modules.datamodels.datamodelUam import User
|
|
||||||
from modules.datamodels.datamodelNeutralizer import DataNeutraliserConfig, DataNeutralizerAttributes
|
from modules.datamodels.datamodelNeutralizer import DataNeutraliserConfig, DataNeutralizerAttributes
|
||||||
from modules.features.neutralizePlayground.mainNeutralizePlayground import NeutralizationPlayground
|
from modules.features.neutralizePlayground.mainNeutralizePlayground import NeutralizationPlayground
|
||||||
|
|
||||||
|
|
@ -32,18 +31,18 @@ router = APIRouter(
|
||||||
@limiter.limit("30/minute")
|
@limiter.limit("30/minute")
|
||||||
async def get_neutralization_config(
|
async def get_neutralization_config(
|
||||||
request: Request,
|
request: Request,
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> DataNeutraliserConfig:
|
) -> DataNeutraliserConfig:
|
||||||
"""Get data neutralization configuration"""
|
"""Get data neutralization configuration"""
|
||||||
try:
|
try:
|
||||||
service = NeutralizationPlayground(currentUser)
|
service = NeutralizationPlayground(context.user, str(context.mandateId))
|
||||||
config = service.getConfig()
|
config = service.getConfig()
|
||||||
|
|
||||||
if not config:
|
if not config:
|
||||||
# Return default config instead of 404
|
# Return default config instead of 404
|
||||||
return DataNeutraliserConfig(
|
return DataNeutraliserConfig(
|
||||||
mandateId=currentUser.mandateId,
|
mandateId=context.mandateId,
|
||||||
userId=currentUser.id,
|
userId=context.user.id,
|
||||||
enabled=True,
|
enabled=True,
|
||||||
namesToParse="",
|
namesToParse="",
|
||||||
sharepointSourcePath="",
|
sharepointSourcePath="",
|
||||||
|
|
@ -66,11 +65,11 @@ async def get_neutralization_config(
|
||||||
async def save_neutralization_config(
|
async def save_neutralization_config(
|
||||||
request: Request,
|
request: Request,
|
||||||
config_data: Dict[str, Any] = Body(...),
|
config_data: Dict[str, Any] = Body(...),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> DataNeutraliserConfig:
|
) -> DataNeutraliserConfig:
|
||||||
"""Save or update data neutralization configuration"""
|
"""Save or update data neutralization configuration"""
|
||||||
try:
|
try:
|
||||||
service = NeutralizationPlayground(currentUser)
|
service = NeutralizationPlayground(context.user, str(context.mandateId))
|
||||||
config = service.saveConfig(config_data)
|
config = service.saveConfig(config_data)
|
||||||
|
|
||||||
return config
|
return config
|
||||||
|
|
@ -87,7 +86,7 @@ async def save_neutralization_config(
|
||||||
async def neutralize_text(
|
async def neutralize_text(
|
||||||
request: Request,
|
request: Request,
|
||||||
text_data: Dict[str, Any] = Body(...),
|
text_data: Dict[str, Any] = Body(...),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Neutralize text content"""
|
"""Neutralize text content"""
|
||||||
try:
|
try:
|
||||||
|
|
@ -100,7 +99,7 @@ async def neutralize_text(
|
||||||
detail="Text content is required"
|
detail="Text content is required"
|
||||||
)
|
)
|
||||||
|
|
||||||
service = NeutralizationPlayground(currentUser)
|
service = NeutralizationPlayground(context.user, str(context.mandateId))
|
||||||
result = service.neutralizeText(text, file_id)
|
result = service.neutralizeText(text, file_id)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
@ -119,7 +118,7 @@ async def neutralize_text(
|
||||||
async def resolve_text(
|
async def resolve_text(
|
||||||
request: Request,
|
request: Request,
|
||||||
text_data: Dict[str, str] = Body(...),
|
text_data: Dict[str, str] = Body(...),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> Dict[str, str]:
|
) -> Dict[str, str]:
|
||||||
"""Resolve UIDs in neutralized text back to original text"""
|
"""Resolve UIDs in neutralized text back to original text"""
|
||||||
try:
|
try:
|
||||||
|
|
@ -131,7 +130,7 @@ async def resolve_text(
|
||||||
detail="Text content is required"
|
detail="Text content is required"
|
||||||
)
|
)
|
||||||
|
|
||||||
service = NeutralizationPlayground(currentUser)
|
service = NeutralizationPlayground(context.user, str(context.mandateId))
|
||||||
resolved_text = service.resolveText(text)
|
resolved_text = service.resolveText(text)
|
||||||
|
|
||||||
return {"resolved_text": resolved_text}
|
return {"resolved_text": resolved_text}
|
||||||
|
|
@ -150,11 +149,11 @@ async def resolve_text(
|
||||||
async def get_neutralization_attributes(
|
async def get_neutralization_attributes(
|
||||||
request: Request,
|
request: Request,
|
||||||
fileId: Optional[str] = Query(None, description="Filter by file ID"),
|
fileId: Optional[str] = Query(None, description="Filter by file ID"),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> List[DataNeutralizerAttributes]:
|
) -> List[DataNeutralizerAttributes]:
|
||||||
"""Get neutralization attributes, optionally filtered by file ID"""
|
"""Get neutralization attributes, optionally filtered by file ID"""
|
||||||
try:
|
try:
|
||||||
service = NeutralizationPlayground(currentUser)
|
service = NeutralizationPlayground(context.user, str(context.mandateId))
|
||||||
attributes = service.getAttributes(fileId)
|
attributes = service.getAttributes(fileId)
|
||||||
|
|
||||||
return attributes
|
return attributes
|
||||||
|
|
@ -171,7 +170,7 @@ async def get_neutralization_attributes(
|
||||||
async def process_sharepoint_files(
|
async def process_sharepoint_files(
|
||||||
request: Request,
|
request: Request,
|
||||||
paths_data: Dict[str, str] = Body(...),
|
paths_data: Dict[str, str] = Body(...),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Process files from SharePoint source path and store neutralized files in target path"""
|
"""Process files from SharePoint source path and store neutralized files in target path"""
|
||||||
try:
|
try:
|
||||||
|
|
@ -184,7 +183,7 @@ async def process_sharepoint_files(
|
||||||
detail="Both source and target paths are required"
|
detail="Both source and target paths are required"
|
||||||
)
|
)
|
||||||
|
|
||||||
service = NeutralizationPlayground(currentUser)
|
service = NeutralizationPlayground(context.user, str(context.mandateId))
|
||||||
result = await service.processSharepointFiles(source_path, target_path)
|
result = await service.processSharepointFiles(source_path, target_path)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
@ -203,7 +202,7 @@ async def process_sharepoint_files(
|
||||||
async def batch_process_files(
|
async def batch_process_files(
|
||||||
request: Request,
|
request: Request,
|
||||||
files_data: List[Dict[str, Any]] = Body(...),
|
files_data: List[Dict[str, Any]] = Body(...),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Process multiple files for neutralization"""
|
"""Process multiple files for neutralization"""
|
||||||
try:
|
try:
|
||||||
|
|
@ -213,7 +212,7 @@ async def batch_process_files(
|
||||||
detail="Files data is required"
|
detail="Files data is required"
|
||||||
)
|
)
|
||||||
|
|
||||||
service = NeutralizationPlayground(currentUser)
|
service = NeutralizationPlayground(context.user, str(context.mandateId))
|
||||||
result = service.batchNeutralizeFiles(files_data)
|
result = service.batchNeutralizeFiles(files_data)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
@ -231,11 +230,11 @@ async def batch_process_files(
|
||||||
@limiter.limit("30/minute")
|
@limiter.limit("30/minute")
|
||||||
async def get_neutralization_stats(
|
async def get_neutralization_stats(
|
||||||
request: Request,
|
request: Request,
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Get neutralization processing statistics"""
|
"""Get neutralization processing statistics"""
|
||||||
try:
|
try:
|
||||||
service = NeutralizationPlayground(currentUser)
|
service = NeutralizationPlayground(context.user, str(context.mandateId))
|
||||||
stats = service.getProcessingStats()
|
stats = service.getProcessingStats()
|
||||||
|
|
||||||
return stats
|
return stats
|
||||||
|
|
@ -252,11 +251,11 @@ async def get_neutralization_stats(
|
||||||
async def cleanup_file_attributes(
|
async def cleanup_file_attributes(
|
||||||
request: Request,
|
request: Request,
|
||||||
fileId: str = Path(..., description="File ID to cleanup attributes for"),
|
fileId: str = Path(..., description="File ID to cleanup attributes for"),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> Dict[str, str]:
|
) -> Dict[str, str]:
|
||||||
"""Clean up neutralization attributes for a specific file"""
|
"""Clean up neutralization attributes for a specific file"""
|
||||||
try:
|
try:
|
||||||
service = NeutralizationPlayground(currentUser)
|
service = NeutralizationPlayground(context.user, str(context.mandateId))
|
||||||
success = service.cleanupFileAttributes(fileId)
|
success = service.cleanupFileAttributes(fileId)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
|
|
@ -10,10 +10,9 @@ from typing import Optional, Dict, Any, List, Union
|
||||||
from fastapi import APIRouter, HTTPException, Depends, Body, Request, Query, Path, status
|
from fastapi import APIRouter, HTTPException, Depends, Body, Request, Query, Path, status
|
||||||
|
|
||||||
# Import auth modules
|
# Import auth modules
|
||||||
from modules.auth import limiter, getCurrentUser
|
from modules.auth import limiter, getRequestContext, RequestContext
|
||||||
|
|
||||||
# Import models
|
# Import models
|
||||||
from modules.datamodels.datamodelUam import User
|
|
||||||
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata
|
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata
|
||||||
from modules.datamodels.datamodelRealEstate import (
|
from modules.datamodels.datamodelRealEstate import (
|
||||||
Projekt,
|
Projekt,
|
||||||
|
|
@ -63,7 +62,7 @@ router = APIRouter(
|
||||||
async def process_command(
|
async def process_command(
|
||||||
request: Request,
|
request: Request,
|
||||||
userInput: str = Body(..., embed=True, description="Natural language command"),
|
userInput: str = Body(..., embed=True, description="Natural language command"),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Process natural language command and execute corresponding CRUD operation.
|
Process natural language command and execute corresponding CRUD operation.
|
||||||
|
|
@ -73,9 +72,9 @@ async def process_command(
|
||||||
|
|
||||||
Example user inputs:
|
Example user inputs:
|
||||||
- "Erstelle ein neues Projekt namens 'Hauptstrasse 42'"
|
- "Erstelle ein neues Projekt namens 'Hauptstrasse 42'"
|
||||||
- "Zeige mir alle Projekte in Zürich"
|
- "Zeige mir alle Projekte in Zuerich"
|
||||||
- "Aktualisiere Projekt XYZ mit Status 'Planung'"
|
- "Aktualisiere Projekt XYZ mit Status 'Planung'"
|
||||||
- "Lösche Parzelle ABC"
|
- "Loesche Parzelle ABC"
|
||||||
- "SELECT * FROM Projekt WHERE plz = '8000'"
|
- "SELECT * FROM Projekt WHERE plz = '8000'"
|
||||||
|
|
||||||
Headers:
|
Headers:
|
||||||
|
|
@ -93,7 +92,7 @@ async def process_command(
|
||||||
# Validate CSRF token (middleware also checks, but explicit validation for better error messages)
|
# Validate CSRF token (middleware also checks, but explicit validation for better error messages)
|
||||||
csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token")
|
csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token")
|
||||||
if not csrf_token:
|
if not csrf_token:
|
||||||
logger.warning(f"CSRF token missing for POST /api/realestate/command from user {currentUser.id}")
|
logger.warning(f"CSRF token missing for POST /api/realestate/command from user {context.user.id}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="CSRF token missing. Please include X-CSRF-Token header."
|
detail="CSRF token missing. Please include X-CSRF-Token header."
|
||||||
|
|
@ -101,7 +100,7 @@ async def process_command(
|
||||||
|
|
||||||
# Basic CSRF token format validation
|
# Basic CSRF token format validation
|
||||||
if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64:
|
if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64:
|
||||||
logger.warning(f"Invalid CSRF token format for POST /api/realestate/command from user {currentUser.id}")
|
logger.warning(f"Invalid CSRF token format for POST /api/realestate/command from user {context.user.id}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Invalid CSRF token format"
|
detail="Invalid CSRF token format"
|
||||||
|
|
@ -111,18 +110,19 @@ async def process_command(
|
||||||
try:
|
try:
|
||||||
int(csrf_token, 16)
|
int(csrf_token, 16)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
logger.warning(f"CSRF token is not a valid hex string for POST /api/realestate/command from user {currentUser.id}")
|
logger.warning(f"CSRF token is not a valid hex string for POST /api/realestate/command from user {context.user.id}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Invalid CSRF token format"
|
detail="Invalid CSRF token format"
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"Processing command request from user {currentUser.id} (mandate: {currentUser.mandateId})")
|
logger.info(f"Processing command request from user {context.user.id} (mandate: {context.mandateId})")
|
||||||
logger.debug(f"User input: {userInput}")
|
logger.debug(f"User input: {userInput}")
|
||||||
|
|
||||||
# Process natural language command with AI
|
# Process natural language command with AI
|
||||||
result = await processNaturalLanguageCommand(
|
result = await processNaturalLanguageCommand(
|
||||||
currentUser=currentUser,
|
currentUser=context.user,
|
||||||
|
mandateId=str(context.mandateId),
|
||||||
userInput=userInput
|
userInput=userInput
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -147,7 +147,7 @@ async def process_command(
|
||||||
@limiter.limit("120/minute")
|
@limiter.limit("120/minute")
|
||||||
async def get_available_tables(
|
async def get_available_tables(
|
||||||
request: Request,
|
request: Request,
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Get all available real estate tables.
|
Get all available real estate tables.
|
||||||
|
|
@ -164,7 +164,7 @@ async def get_available_tables(
|
||||||
# Validate CSRF token if provided
|
# Validate CSRF token if provided
|
||||||
csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token")
|
csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token")
|
||||||
if not csrf_token:
|
if not csrf_token:
|
||||||
logger.warning(f"CSRF token missing for GET /api/realestate/tables from user {currentUser.id}")
|
logger.warning(f"CSRF token missing for GET /api/realestate/tables from user {context.user.id}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="CSRF token missing. Please include X-CSRF-Token header."
|
detail="CSRF token missing. Please include X-CSRF-Token header."
|
||||||
|
|
@ -172,7 +172,7 @@ async def get_available_tables(
|
||||||
|
|
||||||
# Basic CSRF token format validation
|
# Basic CSRF token format validation
|
||||||
if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64:
|
if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64:
|
||||||
logger.warning(f"Invalid CSRF token format for GET /api/realestate/tables from user {currentUser.id}")
|
logger.warning(f"Invalid CSRF token format for GET /api/realestate/tables from user {context.user.id}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Invalid CSRF token format"
|
detail="Invalid CSRF token format"
|
||||||
|
|
@ -182,13 +182,13 @@ async def get_available_tables(
|
||||||
try:
|
try:
|
||||||
int(csrf_token, 16)
|
int(csrf_token, 16)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
logger.warning(f"CSRF token is not a valid hex string for GET /api/realestate/tables from user {currentUser.id}")
|
logger.warning(f"CSRF token is not a valid hex string for GET /api/realestate/tables from user {context.user.id}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Invalid CSRF token format"
|
detail="Invalid CSRF token format"
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"Getting available tables for user {currentUser.id} (mandate: {currentUser.mandateId})")
|
logger.info(f"Getting available tables for user {context.user.id} (mandate: {context.mandateId})")
|
||||||
|
|
||||||
# Define available tables with descriptions
|
# Define available tables with descriptions
|
||||||
tables = [
|
tables = [
|
||||||
|
|
@ -245,7 +245,7 @@ async def get_table_data(
|
||||||
request: Request,
|
request: Request,
|
||||||
table: str = Path(..., description="Table name (Projekt, Parzelle, Dokument, Gemeinde, Kanton, Land)"),
|
table: str = Path(..., description="Table name (Projekt, Parzelle, Dokument, Gemeinde, Kanton, Land)"),
|
||||||
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> PaginatedResponse[Dict[str, Any]]:
|
) -> PaginatedResponse[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Get all data from a specific real estate table with optional pagination.
|
Get all data from a specific real estate table with optional pagination.
|
||||||
|
|
@ -273,7 +273,7 @@ async def get_table_data(
|
||||||
# Validate CSRF token if provided
|
# Validate CSRF token if provided
|
||||||
csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token")
|
csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token")
|
||||||
if not csrf_token:
|
if not csrf_token:
|
||||||
logger.warning(f"CSRF token missing for GET /api/realestate/table/{table} from user {currentUser.id}")
|
logger.warning(f"CSRF token missing for GET /api/realestate/table/{table} from user {context.user.id}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="CSRF token missing. Please include X-CSRF-Token header."
|
detail="CSRF token missing. Please include X-CSRF-Token header."
|
||||||
|
|
@ -281,7 +281,7 @@ async def get_table_data(
|
||||||
|
|
||||||
# Basic CSRF token format validation
|
# Basic CSRF token format validation
|
||||||
if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64:
|
if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64:
|
||||||
logger.warning(f"Invalid CSRF token format for GET /api/realestate/table/{table} from user {currentUser.id}")
|
logger.warning(f"Invalid CSRF token format for GET /api/realestate/table/{table} from user {context.user.id}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Invalid CSRF token format"
|
detail="Invalid CSRF token format"
|
||||||
|
|
@ -291,13 +291,13 @@ async def get_table_data(
|
||||||
try:
|
try:
|
||||||
int(csrf_token, 16)
|
int(csrf_token, 16)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
logger.warning(f"CSRF token is not a valid hex string for GET /api/realestate/table/{table} from user {currentUser.id}")
|
logger.warning(f"CSRF token is not a valid hex string for GET /api/realestate/table/{table} from user {context.user.id}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Invalid CSRF token format"
|
detail="Invalid CSRF token format"
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"Getting table data for '{table}' from user {currentUser.id} (mandate: {currentUser.mandateId})")
|
logger.info(f"Getting table data for '{table}' from user {context.user.id} (mandate: {context.mandateId})")
|
||||||
|
|
||||||
# Map table names to model classes and getter methods
|
# Map table names to model classes and getter methods
|
||||||
table_mapping = {
|
table_mapping = {
|
||||||
|
|
@ -317,7 +317,7 @@ async def get_table_data(
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get interface and fetch data
|
# Get interface and fetch data
|
||||||
realEstateInterface = getRealEstateInterface(currentUser)
|
realEstateInterface = getRealEstateInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None)
|
||||||
model_class, method_name = table_mapping[table]
|
model_class, method_name = table_mapping[table]
|
||||||
getter_method = getattr(realEstateInterface, method_name)
|
getter_method = getattr(realEstateInterface, method_name)
|
||||||
|
|
||||||
|
|
@ -399,7 +399,7 @@ async def create_table_record(
|
||||||
request: Request,
|
request: Request,
|
||||||
table: str = Path(..., description="Table name (Projekt, Parzelle, Dokument, Gemeinde, Kanton, Land)"),
|
table: str = Path(..., description="Table name (Projekt, Parzelle, Dokument, Gemeinde, Kanton, Land)"),
|
||||||
data: Dict[str, Any] = Body(..., description="Record data to create"),
|
data: Dict[str, Any] = Body(..., description="Record data to create"),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Create a new record in a specific real estate table.
|
Create a new record in a specific real estate table.
|
||||||
|
|
@ -442,7 +442,7 @@ async def create_table_record(
|
||||||
# Validate CSRF token
|
# Validate CSRF token
|
||||||
csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token")
|
csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token")
|
||||||
if not csrf_token:
|
if not csrf_token:
|
||||||
logger.warning(f"CSRF token missing for POST /api/realestate/table/{table} from user {currentUser.id}")
|
logger.warning(f"CSRF token missing for POST /api/realestate/table/{table} from user {context.user.id}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="CSRF token missing. Please include X-CSRF-Token header."
|
detail="CSRF token missing. Please include X-CSRF-Token header."
|
||||||
|
|
@ -450,7 +450,7 @@ async def create_table_record(
|
||||||
|
|
||||||
# Basic CSRF token format validation
|
# Basic CSRF token format validation
|
||||||
if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64:
|
if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64:
|
||||||
logger.warning(f"Invalid CSRF token format for POST /api/realestate/table/{table} from user {currentUser.id}")
|
logger.warning(f"Invalid CSRF token format for POST /api/realestate/table/{table} from user {context.user.id}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Invalid CSRF token format"
|
detail="Invalid CSRF token format"
|
||||||
|
|
@ -460,7 +460,7 @@ async def create_table_record(
|
||||||
try:
|
try:
|
||||||
int(csrf_token, 16)
|
int(csrf_token, 16)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
logger.warning(f"CSRF token is not a valid hex string for POST /api/realestate/table/{table} from user {currentUser.id}")
|
logger.warning(f"CSRF token is not a valid hex string for POST /api/realestate/table/{table} from user {context.user.id}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Invalid CSRF token format"
|
detail="Invalid CSRF token format"
|
||||||
|
|
@ -468,7 +468,7 @@ async def create_table_record(
|
||||||
|
|
||||||
# Special handling for Projekt with parcel data
|
# Special handling for Projekt with parcel data
|
||||||
if table == "Projekt" and ("parzelle" in data or "parzellen" in data):
|
if table == "Projekt" and ("parzelle" in data or "parzellen" in data):
|
||||||
logger.info(f"Creating Projekt with parcel data for user {currentUser.id} (mandate: {currentUser.mandateId})")
|
logger.info(f"Creating Projekt with parcel data for user {context.user.id} (mandate: {context.mandateId})")
|
||||||
|
|
||||||
# Extract fields
|
# Extract fields
|
||||||
label = data.get("label")
|
label = data.get("label")
|
||||||
|
|
@ -491,7 +491,7 @@ async def create_table_record(
|
||||||
detail="parzellen must be an array"
|
detail="parzellen must be an array"
|
||||||
)
|
)
|
||||||
elif "parzelle" in data:
|
elif "parzelle" in data:
|
||||||
# Single parcel (backward compatibility)
|
# Single parcel
|
||||||
parzelle_data = data.get("parzelle")
|
parzelle_data = data.get("parzelle")
|
||||||
if parzelle_data:
|
if parzelle_data:
|
||||||
parzellen_data = [parzelle_data]
|
parzellen_data = [parzelle_data]
|
||||||
|
|
@ -505,7 +505,8 @@ async def create_table_record(
|
||||||
# Use helper function to create project with parcel data
|
# Use helper function to create project with parcel data
|
||||||
try:
|
try:
|
||||||
result = await create_project_with_parcel_data(
|
result = await create_project_with_parcel_data(
|
||||||
currentUser=currentUser,
|
currentUser=context.user,
|
||||||
|
mandateId=str(context.mandateId),
|
||||||
projekt_label=label,
|
projekt_label=label,
|
||||||
parzellen_data=parzellen_data,
|
parzellen_data=parzellen_data,
|
||||||
status_prozess=status_prozess,
|
status_prozess=status_prozess,
|
||||||
|
|
@ -524,7 +525,7 @@ async def create_table_record(
|
||||||
)
|
)
|
||||||
|
|
||||||
# Standard handling for other tables or Projekt without parcel data
|
# Standard handling for other tables or Projekt without parcel data
|
||||||
logger.info(f"Creating record in table '{table}' for user {currentUser.id} (mandate: {currentUser.mandateId})")
|
logger.info(f"Creating record in table '{table}' for user {context.user.id} (mandate: {context.mandateId})")
|
||||||
logger.debug(f"Record data: {data}")
|
logger.debug(f"Record data: {data}")
|
||||||
|
|
||||||
# Map table names to model classes and create methods
|
# Map table names to model classes and create methods
|
||||||
|
|
@ -545,13 +546,13 @@ async def create_table_record(
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get interface
|
# Get interface
|
||||||
realEstateInterface = getRealEstateInterface(currentUser)
|
realEstateInterface = getRealEstateInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None)
|
||||||
model_class, method_name = table_mapping[table]
|
model_class, method_name = table_mapping[table]
|
||||||
create_method = getattr(realEstateInterface, method_name)
|
create_method = getattr(realEstateInterface, method_name)
|
||||||
|
|
||||||
# Ensure mandateId is set (will be set by interface if missing)
|
# Ensure mandateId is set from context
|
||||||
if "mandateId" not in data:
|
if "mandateId" not in data:
|
||||||
data["mandateId"] = currentUser.mandateId
|
data["mandateId"] = str(context.mandateId) if context.mandateId else None
|
||||||
|
|
||||||
# Create model instance from data
|
# Create model instance from data
|
||||||
try:
|
try:
|
||||||
|
|
@ -596,7 +597,7 @@ async def search_parcel(
|
||||||
request: Request,
|
request: Request,
|
||||||
location: str = Query(..., description="Either coordinates as 'x,y' (LV95) or address string"),
|
location: str = Query(..., description="Either coordinates as 'x,y' (LV95) or address string"),
|
||||||
include_adjacent: bool = Query(False, description="Include adjacent parcels information"),
|
include_adjacent: bool = Query(False, description="Include adjacent parcels information"),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Search for parcel information by address or coordinates.
|
Search for parcel information by address or coordinates.
|
||||||
|
|
@ -614,50 +615,18 @@ async def search_parcel(
|
||||||
|
|
||||||
Headers:
|
Headers:
|
||||||
- X-CSRF-Token: CSRF token (required for security)
|
- X-CSRF-Token: CSRF token (required for security)
|
||||||
|
|
||||||
Examples:
|
|
||||||
- GET /api/realestate/parcel/search?location=2600000,1200000
|
|
||||||
- GET /api/realestate/parcel/search?location=Bundesplatz 3, 3003 Bern
|
|
||||||
- GET /api/realestate/parcel/search?location=Bundesplatz 3, 3003 Bern&include_adjacent=true
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
{
|
|
||||||
"parcel": {
|
|
||||||
"id": "823",
|
|
||||||
"egrid": "CH294676423526",
|
|
||||||
"number": "823",
|
|
||||||
"name": "823",
|
|
||||||
"identnd": "BE0200000042",
|
|
||||||
"canton": "BE",
|
|
||||||
"municipality_code": 351,
|
|
||||||
"municipality_name": "Bern",
|
|
||||||
"address": "Bundesplatz 3 3011 Bern",
|
|
||||||
"plz": "3011",
|
|
||||||
"perimeter": {...},
|
|
||||||
"area_m2": 1234.56,
|
|
||||||
"centroid": {"x": 2600000, "y": 1200000},
|
|
||||||
"geoportal_url": "https://...",
|
|
||||||
"realestate_type": null
|
|
||||||
},
|
|
||||||
"map_view": {
|
|
||||||
"center": {"x": 2600000, "y": 1200000},
|
|
||||||
"zoom_bounds": {"min_x": ..., "max_x": ..., "min_y": ..., "max_y": ...},
|
|
||||||
"geometry_geojson": {...}
|
|
||||||
},
|
|
||||||
"adjacent_parcels": [...] // Optional (only if include_adjacent=true)
|
|
||||||
}
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Validate CSRF token
|
# Validate CSRF token
|
||||||
csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token")
|
csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token")
|
||||||
if not csrf_token:
|
if not csrf_token:
|
||||||
logger.warning(f"CSRF token missing for GET /api/realestate/parcel/search from user {currentUser.id}")
|
logger.warning(f"CSRF token missing for GET /api/realestate/parcel/search from user {context.user.id}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="CSRF token missing. Please include X-CSRF-Token header."
|
detail="CSRF token missing. Please include X-CSRF-Token header."
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"Searching parcel for user {currentUser.id} (mandate: {currentUser.mandateId}) with location: {location}")
|
logger.info(f"Searching parcel for user {context.user.id} (mandate: {context.mandateId}) with location: {location}")
|
||||||
|
|
||||||
# Initialize connector
|
# Initialize connector
|
||||||
connector = SwissTopoMapServerConnector()
|
connector = SwissTopoMapServerConnector()
|
||||||
|
|
@ -762,15 +731,14 @@ async def search_parcel(
|
||||||
# Basic municipality lookup for common codes
|
# Basic municipality lookup for common codes
|
||||||
common_municipalities = {
|
common_municipalities = {
|
||||||
351: "Bern",
|
351: "Bern",
|
||||||
261: "Zürich",
|
261: "Zuerich",
|
||||||
6621: "Genève",
|
6621: "Geneve",
|
||||||
2701: "Basel",
|
2701: "Basel",
|
||||||
5586: "Lausanne",
|
5586: "Lausanne",
|
||||||
1061: "Luzern",
|
1061: "Luzern",
|
||||||
3203: "Winterthur",
|
3203: "Winterthur",
|
||||||
230: "St. Gallen",
|
230: "St. Gallen",
|
||||||
5192: "Lugano",
|
5192: "Lugano",
|
||||||
351: "Bern",
|
|
||||||
1367: "Schwyz"
|
1367: "Schwyz"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -944,7 +912,7 @@ async def add_parcel_to_project(
|
||||||
request: Request,
|
request: Request,
|
||||||
projekt_id: str = Path(..., description="Projekt ID"),
|
projekt_id: str = Path(..., description="Projekt ID"),
|
||||||
body: Dict[str, Any] = Body(...),
|
body: Dict[str, Any] = Body(...),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Add a parcel to an existing project.
|
Add a parcel to an existing project.
|
||||||
|
|
@ -961,7 +929,7 @@ async def add_parcel_to_project(
|
||||||
|
|
||||||
Option 2 - Create new parcel from location:
|
Option 2 - Create new parcel from location:
|
||||||
{
|
{
|
||||||
"location": "Hauptstrasse 42, 8000 Zürich"
|
"location": "Hauptstrasse 42, 8000 Zuerich"
|
||||||
}
|
}
|
||||||
|
|
||||||
Option 3 - Create new parcel with custom data:
|
Option 3 - Create new parcel with custom data:
|
||||||
|
|
@ -988,7 +956,7 @@ async def add_parcel_to_project(
|
||||||
# Validate CSRF token
|
# Validate CSRF token
|
||||||
csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token")
|
csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token")
|
||||||
if not csrf_token:
|
if not csrf_token:
|
||||||
logger.warning(f"CSRF token missing for POST /api/realestate/projekt/{projekt_id}/add-parcel from user {currentUser.id}")
|
logger.warning(f"CSRF token missing for POST /api/realestate/projekt/{projekt_id}/add-parcel from user {context.user.id}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="CSRF token missing. Please include X-CSRF-Token header."
|
detail="CSRF token missing. Please include X-CSRF-Token header."
|
||||||
|
|
@ -1008,15 +976,16 @@ async def add_parcel_to_project(
|
||||||
detail="Invalid CSRF token format"
|
detail="Invalid CSRF token format"
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"Adding parcel to project {projekt_id} for user {currentUser.id} (mandate: {currentUser.mandateId})")
|
logger.info(f"Adding parcel to project {projekt_id} for user {context.user.id} (mandate: {context.mandateId})")
|
||||||
|
|
||||||
# Get interface
|
# Get interface
|
||||||
realEstateInterface = getRealEstateInterface(currentUser)
|
realEstateInterface = getRealEstateInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None)
|
||||||
|
|
||||||
# Fetch existing Projekt
|
# Fetch existing Projekt - use mandateId from context
|
||||||
projekte = realEstateInterface.getProjekte(
|
recordFilter = {"id": projekt_id}
|
||||||
recordFilter={"id": projekt_id, "mandateId": currentUser.mandateId}
|
if context.mandateId:
|
||||||
)
|
recordFilter["mandateId"] = str(context.mandateId)
|
||||||
|
projekte = realEstateInterface.getProjekte(recordFilter=recordFilter)
|
||||||
if not projekte:
|
if not projekte:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
|
@ -1034,9 +1003,10 @@ async def add_parcel_to_project(
|
||||||
# Option 1: Link existing parcel
|
# Option 1: Link existing parcel
|
||||||
if parcel_id:
|
if parcel_id:
|
||||||
logger.info(f"Linking existing parcel {parcel_id}")
|
logger.info(f"Linking existing parcel {parcel_id}")
|
||||||
parcels = realEstateInterface.getParzellen(
|
parcelFilter = {"id": parcel_id}
|
||||||
recordFilter={"id": parcel_id, "mandateId": currentUser.mandateId}
|
if context.mandateId:
|
||||||
)
|
parcelFilter["mandateId"] = str(context.mandateId)
|
||||||
|
parcels = realEstateInterface.getParzellen(recordFilter=parcelFilter)
|
||||||
if not parcels:
|
if not parcels:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
|
@ -1062,9 +1032,9 @@ async def add_parcel_to_project(
|
||||||
extracted_attributes = connector.extract_parcel_attributes(parcel_data)
|
extracted_attributes = connector.extract_parcel_attributes(parcel_data)
|
||||||
attributes = parcel_data.get("attributes", {})
|
attributes = parcel_data.get("attributes", {})
|
||||||
|
|
||||||
# Create Parzelle
|
# Create Parzelle with mandateId from context
|
||||||
parzelle_create_data = {
|
parzelle_create_data = {
|
||||||
"mandateId": currentUser.mandateId,
|
"mandateId": str(context.mandateId) if context.mandateId else None,
|
||||||
"label": extracted_attributes.get("label") or attributes.get("number") or "Unknown",
|
"label": extracted_attributes.get("label") or attributes.get("number") or "Unknown",
|
||||||
"parzellenAliasTags": [attributes.get("egris_egrid")] if attributes.get("egris_egrid") else [],
|
"parzellenAliasTags": [attributes.get("egris_egrid")] if attributes.get("egris_egrid") else [],
|
||||||
"eigentuemerschaft": None,
|
"eigentuemerschaft": None,
|
||||||
|
|
@ -1111,7 +1081,7 @@ async def add_parcel_to_project(
|
||||||
# Option 3: Create from custom data
|
# Option 3: Create from custom data
|
||||||
elif parcel_data_dict:
|
elif parcel_data_dict:
|
||||||
logger.info(f"Creating parcel from custom data")
|
logger.info(f"Creating parcel from custom data")
|
||||||
parcel_data_dict["mandateId"] = currentUser.mandateId
|
parcel_data_dict["mandateId"] = str(context.mandateId) if context.mandateId else None
|
||||||
parzelle_instance = Parzelle(**parcel_data_dict)
|
parzelle_instance = Parzelle(**parcel_data_dict)
|
||||||
parzelle = realEstateInterface.createParzelle(parzelle_instance)
|
parzelle = realEstateInterface.createParzelle(parzelle_instance)
|
||||||
|
|
||||||
|
|
@ -1150,4 +1120,3 @@ async def add_parcel_to_project(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
detail=f"Error adding parcel to project: {str(e)}"
|
detail=f"Error adding parcel to project: {str(e)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -13,7 +13,7 @@ import logging
|
||||||
import json
|
import json
|
||||||
import io
|
import io
|
||||||
|
|
||||||
from modules.auth import limiter, getCurrentUser
|
from modules.auth import limiter, getRequestContext, RequestContext
|
||||||
from modules.interfaces.interfaceDbTrusteeObjects import getInterface
|
from modules.interfaces.interfaceDbTrusteeObjects import getInterface
|
||||||
from modules.datamodels.datamodelTrustee import (
|
from modules.datamodels.datamodelTrustee import (
|
||||||
TrusteeOrganisation,
|
TrusteeOrganisation,
|
||||||
|
|
@ -24,7 +24,6 @@ from modules.datamodels.datamodelTrustee import (
|
||||||
TrusteePosition,
|
TrusteePosition,
|
||||||
TrusteePositionDocument,
|
TrusteePositionDocument,
|
||||||
)
|
)
|
||||||
from modules.datamodels.datamodelUam import User
|
|
||||||
from modules.datamodels.datamodelPagination import (
|
from modules.datamodels.datamodelPagination import (
|
||||||
PaginationParams,
|
PaginationParams,
|
||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
|
|
@ -67,13 +66,13 @@ def _parsePagination(pagination: Optional[str]) -> Optional[PaginationParams]:
|
||||||
async def getOrganisations(
|
async def getOrganisations(
|
||||||
request: Request,
|
request: Request,
|
||||||
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams"),
|
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams"),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> PaginatedResponse[TrusteeOrganisation]:
|
) -> PaginatedResponse[TrusteeOrganisation]:
|
||||||
"""Get all organisations with optional pagination."""
|
"""Get all organisations with optional pagination."""
|
||||||
logger = logging.getLogger(__name__)
|
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)
|
paginationParams = _parsePagination(pagination)
|
||||||
interface = getInterface(currentUser)
|
interface = getInterface(context.user, mandateId=context.mandateId)
|
||||||
result = interface.getAllOrganisations(paginationParams)
|
result = interface.getAllOrganisations(paginationParams)
|
||||||
logger.debug(f"getOrganisations returned {len(result.items)} items")
|
logger.debug(f"getOrganisations returned {len(result.items)} items")
|
||||||
|
|
||||||
|
|
@ -97,14 +96,14 @@ async def getOrganisations(
|
||||||
async def getOrganisation(
|
async def getOrganisation(
|
||||||
request: Request,
|
request: Request,
|
||||||
orgId: str = Path(..., description="Organisation ID"),
|
orgId: str = Path(..., description="Organisation ID"),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> TrusteeOrganisation:
|
) -> TrusteeOrganisation:
|
||||||
"""Get a single organisation by ID."""
|
"""Get a single organisation by ID."""
|
||||||
interface = getInterface(currentUser)
|
interface = getInterface(context.user, mandateId=context.mandateId)
|
||||||
org = interface.getOrganisation(orgId)
|
org = interface.getOrganisation(orgId)
|
||||||
if not org:
|
if not org:
|
||||||
raise HTTPException(status_code=404, detail=f"Organisation {orgId} not found")
|
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)
|
@router.post("/organisations", response_model=TrusteeOrganisation, status_code=201)
|
||||||
|
|
@ -112,14 +111,14 @@ async def getOrganisation(
|
||||||
async def createOrganisation(
|
async def createOrganisation(
|
||||||
request: Request,
|
request: Request,
|
||||||
data: TrusteeOrganisation = Body(...),
|
data: TrusteeOrganisation = Body(...),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> TrusteeOrganisation:
|
) -> TrusteeOrganisation:
|
||||||
"""Create a new organisation."""
|
"""Create a new organisation."""
|
||||||
interface = getInterface(currentUser)
|
interface = getInterface(context.user, mandateId=context.mandateId)
|
||||||
result = interface.createOrganisation(data.model_dump())
|
result = interface.createOrganisation(data.model_dump())
|
||||||
if not result:
|
if not result:
|
||||||
raise HTTPException(status_code=400, detail="Failed to create organisation")
|
raise HTTPException(status_code=400, detail="Failed to create organisation")
|
||||||
return TrusteeOrganisation(**result)
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.put("/organisations/{orgId}", response_model=TrusteeOrganisation)
|
@router.put("/organisations/{orgId}", response_model=TrusteeOrganisation)
|
||||||
|
|
@ -128,10 +127,10 @@ async def updateOrganisation(
|
||||||
request: Request,
|
request: Request,
|
||||||
orgId: str = Path(..., description="Organisation ID"),
|
orgId: str = Path(..., description="Organisation ID"),
|
||||||
data: TrusteeOrganisation = Body(...),
|
data: TrusteeOrganisation = Body(...),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> TrusteeOrganisation:
|
) -> TrusteeOrganisation:
|
||||||
"""Update an organisation."""
|
"""Update an organisation."""
|
||||||
interface = getInterface(currentUser)
|
interface = getInterface(context.user, mandateId=context.mandateId)
|
||||||
existing = interface.getOrganisation(orgId)
|
existing = interface.getOrganisation(orgId)
|
||||||
if not existing:
|
if not existing:
|
||||||
raise HTTPException(status_code=404, detail=f"Organisation {orgId} not found")
|
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"}))
|
result = interface.updateOrganisation(orgId, data.model_dump(exclude={"id"}))
|
||||||
if not result:
|
if not result:
|
||||||
raise HTTPException(status_code=400, detail="Failed to update organisation")
|
raise HTTPException(status_code=400, detail="Failed to update organisation")
|
||||||
return TrusteeOrganisation(**result)
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/organisations/{orgId}")
|
@router.delete("/organisations/{orgId}")
|
||||||
|
|
@ -147,10 +146,10 @@ async def updateOrganisation(
|
||||||
async def deleteOrganisation(
|
async def deleteOrganisation(
|
||||||
request: Request,
|
request: Request,
|
||||||
orgId: str = Path(..., description="Organisation ID"),
|
orgId: str = Path(..., description="Organisation ID"),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Delete an organisation."""
|
"""Delete an organisation."""
|
||||||
interface = getInterface(currentUser)
|
interface = getInterface(context.user, mandateId=context.mandateId)
|
||||||
existing = interface.getOrganisation(orgId)
|
existing = interface.getOrganisation(orgId)
|
||||||
if not existing:
|
if not existing:
|
||||||
raise HTTPException(status_code=404, detail=f"Organisation {orgId} not found")
|
raise HTTPException(status_code=404, detail=f"Organisation {orgId} not found")
|
||||||
|
|
@ -168,11 +167,11 @@ async def deleteOrganisation(
|
||||||
async def getRoles(
|
async def getRoles(
|
||||||
request: Request,
|
request: Request,
|
||||||
pagination: Optional[str] = Query(None),
|
pagination: Optional[str] = Query(None),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> PaginatedResponse[TrusteeRole]:
|
) -> PaginatedResponse[TrusteeRole]:
|
||||||
"""Get all roles with optional pagination."""
|
"""Get all roles with optional pagination."""
|
||||||
paginationParams = _parsePagination(pagination)
|
paginationParams = _parsePagination(pagination)
|
||||||
interface = getInterface(currentUser)
|
interface = getInterface(context.user, mandateId=context.mandateId)
|
||||||
result = interface.getAllRoles(paginationParams)
|
result = interface.getAllRoles(paginationParams)
|
||||||
|
|
||||||
if paginationParams:
|
if paginationParams:
|
||||||
|
|
@ -195,14 +194,14 @@ async def getRoles(
|
||||||
async def getRole(
|
async def getRole(
|
||||||
request: Request,
|
request: Request,
|
||||||
roleId: str = Path(..., description="Role ID"),
|
roleId: str = Path(..., description="Role ID"),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> TrusteeRole:
|
) -> TrusteeRole:
|
||||||
"""Get a single role by ID."""
|
"""Get a single role by ID."""
|
||||||
interface = getInterface(currentUser)
|
interface = getInterface(context.user, mandateId=context.mandateId)
|
||||||
role = interface.getRole(roleId)
|
role = interface.getRole(roleId)
|
||||||
if not role:
|
if not role:
|
||||||
raise HTTPException(status_code=404, detail=f"Role {roleId} not found")
|
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)
|
@router.post("/roles", response_model=TrusteeRole, status_code=201)
|
||||||
|
|
@ -210,14 +209,14 @@ async def getRole(
|
||||||
async def createRole(
|
async def createRole(
|
||||||
request: Request,
|
request: Request,
|
||||||
data: TrusteeRole = Body(...),
|
data: TrusteeRole = Body(...),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> TrusteeRole:
|
) -> TrusteeRole:
|
||||||
"""Create a new role (sysadmin only)."""
|
"""Create a new role (sysadmin only)."""
|
||||||
interface = getInterface(currentUser)
|
interface = getInterface(context.user, mandateId=context.mandateId)
|
||||||
result = interface.createRole(data.model_dump())
|
result = interface.createRole(data.model_dump())
|
||||||
if not result:
|
if not result:
|
||||||
raise HTTPException(status_code=400, detail="Failed to create role")
|
raise HTTPException(status_code=400, detail="Failed to create role")
|
||||||
return TrusteeRole(**result)
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.put("/roles/{roleId}", response_model=TrusteeRole)
|
@router.put("/roles/{roleId}", response_model=TrusteeRole)
|
||||||
|
|
@ -226,10 +225,10 @@ async def updateRole(
|
||||||
request: Request,
|
request: Request,
|
||||||
roleId: str = Path(...),
|
roleId: str = Path(...),
|
||||||
data: TrusteeRole = Body(...),
|
data: TrusteeRole = Body(...),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> TrusteeRole:
|
) -> TrusteeRole:
|
||||||
"""Update a role (sysadmin only)."""
|
"""Update a role (sysadmin only)."""
|
||||||
interface = getInterface(currentUser)
|
interface = getInterface(context.user, mandateId=context.mandateId)
|
||||||
existing = interface.getRole(roleId)
|
existing = interface.getRole(roleId)
|
||||||
if not existing:
|
if not existing:
|
||||||
raise HTTPException(status_code=404, detail=f"Role {roleId} not found")
|
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"}))
|
result = interface.updateRole(roleId, data.model_dump(exclude={"id"}))
|
||||||
if not result:
|
if not result:
|
||||||
raise HTTPException(status_code=400, detail="Failed to update role")
|
raise HTTPException(status_code=400, detail="Failed to update role")
|
||||||
return TrusteeRole(**result)
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/roles/{roleId}")
|
@router.delete("/roles/{roleId}")
|
||||||
|
|
@ -245,10 +244,10 @@ async def updateRole(
|
||||||
async def deleteRole(
|
async def deleteRole(
|
||||||
request: Request,
|
request: Request,
|
||||||
roleId: str = Path(...),
|
roleId: str = Path(...),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Delete a role (sysadmin only, fails if in use)."""
|
"""Delete a role (sysadmin only, fails if in use)."""
|
||||||
interface = getInterface(currentUser)
|
interface = getInterface(context.user, mandateId=context.mandateId)
|
||||||
existing = interface.getRole(roleId)
|
existing = interface.getRole(roleId)
|
||||||
if not existing:
|
if not existing:
|
||||||
raise HTTPException(status_code=404, detail=f"Role {roleId} not found")
|
raise HTTPException(status_code=404, detail=f"Role {roleId} not found")
|
||||||
|
|
@ -266,11 +265,11 @@ async def deleteRole(
|
||||||
async def getAllAccess(
|
async def getAllAccess(
|
||||||
request: Request,
|
request: Request,
|
||||||
pagination: Optional[str] = Query(None),
|
pagination: Optional[str] = Query(None),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> PaginatedResponse[TrusteeAccess]:
|
) -> PaginatedResponse[TrusteeAccess]:
|
||||||
"""Get all access records with optional pagination."""
|
"""Get all access records with optional pagination."""
|
||||||
paginationParams = _parsePagination(pagination)
|
paginationParams = _parsePagination(pagination)
|
||||||
interface = getInterface(currentUser)
|
interface = getInterface(context.user, mandateId=context.mandateId)
|
||||||
result = interface.getAllAccess(paginationParams)
|
result = interface.getAllAccess(paginationParams)
|
||||||
|
|
||||||
if paginationParams:
|
if paginationParams:
|
||||||
|
|
@ -293,14 +292,14 @@ async def getAllAccess(
|
||||||
async def getAccess(
|
async def getAccess(
|
||||||
request: Request,
|
request: Request,
|
||||||
accessId: str = Path(...),
|
accessId: str = Path(...),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> TrusteeAccess:
|
) -> TrusteeAccess:
|
||||||
"""Get a single access record by ID."""
|
"""Get a single access record by ID."""
|
||||||
interface = getInterface(currentUser)
|
interface = getInterface(context.user, mandateId=context.mandateId)
|
||||||
access = interface.getAccess(accessId)
|
access = interface.getAccess(accessId)
|
||||||
if not access:
|
if not access:
|
||||||
raise HTTPException(status_code=404, detail=f"Access {accessId} not found")
|
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])
|
@router.get("/access/organisation/{orgId}", response_model=List[TrusteeAccess])
|
||||||
|
|
@ -308,11 +307,11 @@ async def getAccess(
|
||||||
async def getAccessByOrganisation(
|
async def getAccessByOrganisation(
|
||||||
request: Request,
|
request: Request,
|
||||||
orgId: str = Path(...),
|
orgId: str = Path(...),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> List[TrusteeAccess]:
|
) -> List[TrusteeAccess]:
|
||||||
"""Get all access records for an organisation."""
|
"""Get all access records for an organisation."""
|
||||||
interface = getInterface(currentUser)
|
interface = getInterface(context.user, mandateId=context.mandateId)
|
||||||
return [TrusteeAccess(**a) for a in interface.getAccessByOrganisation(orgId)]
|
return interface.getAccessByOrganisation(orgId)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/access/user/{userId}", response_model=List[TrusteeAccess])
|
@router.get("/access/user/{userId}", response_model=List[TrusteeAccess])
|
||||||
|
|
@ -320,11 +319,11 @@ async def getAccessByOrganisation(
|
||||||
async def getAccessByUser(
|
async def getAccessByUser(
|
||||||
request: Request,
|
request: Request,
|
||||||
userId: str = Path(...),
|
userId: str = Path(...),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> List[TrusteeAccess]:
|
) -> List[TrusteeAccess]:
|
||||||
"""Get all access records for a user."""
|
"""Get all access records for a user."""
|
||||||
interface = getInterface(currentUser)
|
interface = getInterface(context.user, mandateId=context.mandateId)
|
||||||
return [TrusteeAccess(**a) for a in interface.getAccessByUser(userId)]
|
return interface.getAccessByUser(userId)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/access", response_model=TrusteeAccess, status_code=201)
|
@router.post("/access", response_model=TrusteeAccess, status_code=201)
|
||||||
|
|
@ -332,14 +331,14 @@ async def getAccessByUser(
|
||||||
async def createAccess(
|
async def createAccess(
|
||||||
request: Request,
|
request: Request,
|
||||||
data: TrusteeAccess = Body(...),
|
data: TrusteeAccess = Body(...),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> TrusteeAccess:
|
) -> TrusteeAccess:
|
||||||
"""Create a new access record."""
|
"""Create a new access record."""
|
||||||
interface = getInterface(currentUser)
|
interface = getInterface(context.user, mandateId=context.mandateId)
|
||||||
result = interface.createAccess(data.model_dump())
|
result = interface.createAccess(data.model_dump())
|
||||||
if not result:
|
if not result:
|
||||||
raise HTTPException(status_code=400, detail="Failed to create access")
|
raise HTTPException(status_code=400, detail="Failed to create access")
|
||||||
return TrusteeAccess(**result)
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.put("/access/{accessId}", response_model=TrusteeAccess)
|
@router.put("/access/{accessId}", response_model=TrusteeAccess)
|
||||||
|
|
@ -348,10 +347,10 @@ async def updateAccess(
|
||||||
request: Request,
|
request: Request,
|
||||||
accessId: str = Path(...),
|
accessId: str = Path(...),
|
||||||
data: TrusteeAccess = Body(...),
|
data: TrusteeAccess = Body(...),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> TrusteeAccess:
|
) -> TrusteeAccess:
|
||||||
"""Update an access record."""
|
"""Update an access record."""
|
||||||
interface = getInterface(currentUser)
|
interface = getInterface(context.user, mandateId=context.mandateId)
|
||||||
existing = interface.getAccess(accessId)
|
existing = interface.getAccess(accessId)
|
||||||
if not existing:
|
if not existing:
|
||||||
raise HTTPException(status_code=404, detail=f"Access {accessId} not found")
|
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"}))
|
result = interface.updateAccess(accessId, data.model_dump(exclude={"id"}))
|
||||||
if not result:
|
if not result:
|
||||||
raise HTTPException(status_code=400, detail="Failed to update access")
|
raise HTTPException(status_code=400, detail="Failed to update access")
|
||||||
return TrusteeAccess(**result)
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/access/{accessId}")
|
@router.delete("/access/{accessId}")
|
||||||
|
|
@ -367,10 +366,10 @@ async def updateAccess(
|
||||||
async def deleteAccess(
|
async def deleteAccess(
|
||||||
request: Request,
|
request: Request,
|
||||||
accessId: str = Path(...),
|
accessId: str = Path(...),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Delete an access record."""
|
"""Delete an access record."""
|
||||||
interface = getInterface(currentUser)
|
interface = getInterface(context.user, mandateId=context.mandateId)
|
||||||
existing = interface.getAccess(accessId)
|
existing = interface.getAccess(accessId)
|
||||||
if not existing:
|
if not existing:
|
||||||
raise HTTPException(status_code=404, detail=f"Access {accessId} not found")
|
raise HTTPException(status_code=404, detail=f"Access {accessId} not found")
|
||||||
|
|
@ -388,11 +387,11 @@ async def deleteAccess(
|
||||||
async def getContracts(
|
async def getContracts(
|
||||||
request: Request,
|
request: Request,
|
||||||
pagination: Optional[str] = Query(None),
|
pagination: Optional[str] = Query(None),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> PaginatedResponse[TrusteeContract]:
|
) -> PaginatedResponse[TrusteeContract]:
|
||||||
"""Get all contracts with optional pagination."""
|
"""Get all contracts with optional pagination."""
|
||||||
paginationParams = _parsePagination(pagination)
|
paginationParams = _parsePagination(pagination)
|
||||||
interface = getInterface(currentUser)
|
interface = getInterface(context.user, mandateId=context.mandateId)
|
||||||
result = interface.getAllContracts(paginationParams)
|
result = interface.getAllContracts(paginationParams)
|
||||||
|
|
||||||
if paginationParams:
|
if paginationParams:
|
||||||
|
|
@ -415,14 +414,14 @@ async def getContracts(
|
||||||
async def getContract(
|
async def getContract(
|
||||||
request: Request,
|
request: Request,
|
||||||
contractId: str = Path(...),
|
contractId: str = Path(...),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> TrusteeContract:
|
) -> TrusteeContract:
|
||||||
"""Get a single contract by ID."""
|
"""Get a single contract by ID."""
|
||||||
interface = getInterface(currentUser)
|
interface = getInterface(context.user, mandateId=context.mandateId)
|
||||||
contract = interface.getContract(contractId)
|
contract = interface.getContract(contractId)
|
||||||
if not contract:
|
if not contract:
|
||||||
raise HTTPException(status_code=404, detail=f"Contract {contractId} not found")
|
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])
|
@router.get("/contracts/organisation/{orgId}", response_model=List[TrusteeContract])
|
||||||
|
|
@ -430,11 +429,11 @@ async def getContract(
|
||||||
async def getContractsByOrganisation(
|
async def getContractsByOrganisation(
|
||||||
request: Request,
|
request: Request,
|
||||||
orgId: str = Path(...),
|
orgId: str = Path(...),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> List[TrusteeContract]:
|
) -> List[TrusteeContract]:
|
||||||
"""Get all contracts for an organisation."""
|
"""Get all contracts for an organisation."""
|
||||||
interface = getInterface(currentUser)
|
interface = getInterface(context.user, mandateId=context.mandateId)
|
||||||
return [TrusteeContract(**c) for c in interface.getContractsByOrganisation(orgId)]
|
return interface.getContractsByOrganisation(orgId)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/contracts", response_model=TrusteeContract, status_code=201)
|
@router.post("/contracts", response_model=TrusteeContract, status_code=201)
|
||||||
|
|
@ -442,14 +441,14 @@ async def getContractsByOrganisation(
|
||||||
async def createContract(
|
async def createContract(
|
||||||
request: Request,
|
request: Request,
|
||||||
data: TrusteeContract = Body(...),
|
data: TrusteeContract = Body(...),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> TrusteeContract:
|
) -> TrusteeContract:
|
||||||
"""Create a new contract."""
|
"""Create a new contract."""
|
||||||
interface = getInterface(currentUser)
|
interface = getInterface(context.user, mandateId=context.mandateId)
|
||||||
result = interface.createContract(data.model_dump())
|
result = interface.createContract(data.model_dump())
|
||||||
if not result:
|
if not result:
|
||||||
raise HTTPException(status_code=400, detail="Failed to create contract")
|
raise HTTPException(status_code=400, detail="Failed to create contract")
|
||||||
return TrusteeContract(**result)
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.put("/contracts/{contractId}", response_model=TrusteeContract)
|
@router.put("/contracts/{contractId}", response_model=TrusteeContract)
|
||||||
|
|
@ -458,10 +457,10 @@ async def updateContract(
|
||||||
request: Request,
|
request: Request,
|
||||||
contractId: str = Path(...),
|
contractId: str = Path(...),
|
||||||
data: TrusteeContract = Body(...),
|
data: TrusteeContract = Body(...),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> TrusteeContract:
|
) -> TrusteeContract:
|
||||||
"""Update a contract (organisationId is immutable)."""
|
"""Update a contract (organisationId is immutable)."""
|
||||||
interface = getInterface(currentUser)
|
interface = getInterface(context.user, mandateId=context.mandateId)
|
||||||
existing = interface.getContract(contractId)
|
existing = interface.getContract(contractId)
|
||||||
if not existing:
|
if not existing:
|
||||||
raise HTTPException(status_code=404, detail=f"Contract {contractId} not found")
|
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"}))
|
result = interface.updateContract(contractId, data.model_dump(exclude={"id"}))
|
||||||
if not result:
|
if not result:
|
||||||
raise HTTPException(status_code=400, detail="Failed to update contract (organisationId cannot be changed)")
|
raise HTTPException(status_code=400, detail="Failed to update contract (organisationId cannot be changed)")
|
||||||
return TrusteeContract(**result)
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/contracts/{contractId}")
|
@router.delete("/contracts/{contractId}")
|
||||||
|
|
@ -477,10 +476,10 @@ async def updateContract(
|
||||||
async def deleteContract(
|
async def deleteContract(
|
||||||
request: Request,
|
request: Request,
|
||||||
contractId: str = Path(...),
|
contractId: str = Path(...),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Delete a contract."""
|
"""Delete a contract."""
|
||||||
interface = getInterface(currentUser)
|
interface = getInterface(context.user, mandateId=context.mandateId)
|
||||||
existing = interface.getContract(contractId)
|
existing = interface.getContract(contractId)
|
||||||
if not existing:
|
if not existing:
|
||||||
raise HTTPException(status_code=404, detail=f"Contract {contractId} not found")
|
raise HTTPException(status_code=404, detail=f"Contract {contractId} not found")
|
||||||
|
|
@ -498,11 +497,11 @@ async def deleteContract(
|
||||||
async def getDocuments(
|
async def getDocuments(
|
||||||
request: Request,
|
request: Request,
|
||||||
pagination: Optional[str] = Query(None),
|
pagination: Optional[str] = Query(None),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> PaginatedResponse[TrusteeDocument]:
|
) -> PaginatedResponse[TrusteeDocument]:
|
||||||
"""Get all documents (metadata only) with optional pagination."""
|
"""Get all documents (metadata only) with optional pagination."""
|
||||||
paginationParams = _parsePagination(pagination)
|
paginationParams = _parsePagination(pagination)
|
||||||
interface = getInterface(currentUser)
|
interface = getInterface(context.user, mandateId=context.mandateId)
|
||||||
result = interface.getAllDocuments(paginationParams)
|
result = interface.getAllDocuments(paginationParams)
|
||||||
|
|
||||||
if paginationParams:
|
if paginationParams:
|
||||||
|
|
@ -525,14 +524,14 @@ async def getDocuments(
|
||||||
async def getDocument(
|
async def getDocument(
|
||||||
request: Request,
|
request: Request,
|
||||||
documentId: str = Path(...),
|
documentId: str = Path(...),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> TrusteeDocument:
|
) -> TrusteeDocument:
|
||||||
"""Get document metadata by ID."""
|
"""Get document metadata by ID."""
|
||||||
interface = getInterface(currentUser)
|
interface = getInterface(context.user, mandateId=context.mandateId)
|
||||||
doc = interface.getDocument(documentId)
|
doc = interface.getDocument(documentId)
|
||||||
if not doc:
|
if not doc:
|
||||||
raise HTTPException(status_code=404, detail=f"Document {documentId} not found")
|
raise HTTPException(status_code=404, detail=f"Document {documentId} not found")
|
||||||
return TrusteeDocument(**doc)
|
return doc
|
||||||
|
|
||||||
|
|
||||||
@router.get("/documents/{documentId}/data")
|
@router.get("/documents/{documentId}/data")
|
||||||
|
|
@ -540,10 +539,10 @@ async def getDocument(
|
||||||
async def getDocumentData(
|
async def getDocumentData(
|
||||||
request: Request,
|
request: Request,
|
||||||
documentId: str = Path(...),
|
documentId: str = Path(...),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
):
|
):
|
||||||
"""Download document binary data."""
|
"""Download document binary data."""
|
||||||
interface = getInterface(currentUser)
|
interface = getInterface(context.user, mandateId=context.mandateId)
|
||||||
doc = interface.getDocument(documentId)
|
doc = interface.getDocument(documentId)
|
||||||
if not doc:
|
if not doc:
|
||||||
raise HTTPException(status_code=404, detail=f"Document {documentId} not found")
|
raise HTTPException(status_code=404, detail=f"Document {documentId} not found")
|
||||||
|
|
@ -554,8 +553,8 @@ async def getDocumentData(
|
||||||
|
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
io.BytesIO(data),
|
io.BytesIO(data),
|
||||||
media_type=doc.get("documentMimeType", "application/octet-stream"),
|
media_type=doc.documentMimeType or "application/octet-stream",
|
||||||
headers={"Content-Disposition": f"attachment; filename={doc.get('documentName', 'document')}"}
|
headers={"Content-Disposition": f"attachment; filename={doc.documentName or 'document'}"}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -564,11 +563,11 @@ async def getDocumentData(
|
||||||
async def getDocumentsByContract(
|
async def getDocumentsByContract(
|
||||||
request: Request,
|
request: Request,
|
||||||
contractId: str = Path(...),
|
contractId: str = Path(...),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> List[TrusteeDocument]:
|
) -> List[TrusteeDocument]:
|
||||||
"""Get all documents for a contract."""
|
"""Get all documents for a contract."""
|
||||||
interface = getInterface(currentUser)
|
interface = getInterface(context.user, mandateId=context.mandateId)
|
||||||
return [TrusteeDocument(**d) for d in interface.getDocumentsByContract(contractId)]
|
return interface.getDocumentsByContract(contractId)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/documents", response_model=TrusteeDocument, status_code=201)
|
@router.post("/documents", response_model=TrusteeDocument, status_code=201)
|
||||||
|
|
@ -576,14 +575,14 @@ async def getDocumentsByContract(
|
||||||
async def createDocument(
|
async def createDocument(
|
||||||
request: Request,
|
request: Request,
|
||||||
data: TrusteeDocument = Body(...),
|
data: TrusteeDocument = Body(...),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> TrusteeDocument:
|
) -> TrusteeDocument:
|
||||||
"""Create a new document."""
|
"""Create a new document."""
|
||||||
interface = getInterface(currentUser)
|
interface = getInterface(context.user, mandateId=context.mandateId)
|
||||||
result = interface.createDocument(data.model_dump())
|
result = interface.createDocument(data.model_dump())
|
||||||
if not result:
|
if not result:
|
||||||
raise HTTPException(status_code=400, detail="Failed to create document")
|
raise HTTPException(status_code=400, detail="Failed to create document")
|
||||||
return TrusteeDocument(**result)
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.put("/documents/{documentId}", response_model=TrusteeDocument)
|
@router.put("/documents/{documentId}", response_model=TrusteeDocument)
|
||||||
|
|
@ -592,10 +591,10 @@ async def updateDocument(
|
||||||
request: Request,
|
request: Request,
|
||||||
documentId: str = Path(...),
|
documentId: str = Path(...),
|
||||||
data: TrusteeDocument = Body(...),
|
data: TrusteeDocument = Body(...),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> TrusteeDocument:
|
) -> TrusteeDocument:
|
||||||
"""Update document metadata."""
|
"""Update document metadata."""
|
||||||
interface = getInterface(currentUser)
|
interface = getInterface(context.user, mandateId=context.mandateId)
|
||||||
existing = interface.getDocument(documentId)
|
existing = interface.getDocument(documentId)
|
||||||
if not existing:
|
if not existing:
|
||||||
raise HTTPException(status_code=404, detail=f"Document {documentId} not found")
|
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"}))
|
result = interface.updateDocument(documentId, data.model_dump(exclude={"id"}))
|
||||||
if not result:
|
if not result:
|
||||||
raise HTTPException(status_code=400, detail="Failed to update document")
|
raise HTTPException(status_code=400, detail="Failed to update document")
|
||||||
return TrusteeDocument(**result)
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/documents/{documentId}")
|
@router.delete("/documents/{documentId}")
|
||||||
|
|
@ -611,10 +610,10 @@ async def updateDocument(
|
||||||
async def deleteDocument(
|
async def deleteDocument(
|
||||||
request: Request,
|
request: Request,
|
||||||
documentId: str = Path(...),
|
documentId: str = Path(...),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Delete a document."""
|
"""Delete a document."""
|
||||||
interface = getInterface(currentUser)
|
interface = getInterface(context.user, mandateId=context.mandateId)
|
||||||
existing = interface.getDocument(documentId)
|
existing = interface.getDocument(documentId)
|
||||||
if not existing:
|
if not existing:
|
||||||
raise HTTPException(status_code=404, detail=f"Document {documentId} not found")
|
raise HTTPException(status_code=404, detail=f"Document {documentId} not found")
|
||||||
|
|
@ -632,11 +631,11 @@ async def deleteDocument(
|
||||||
async def getPositions(
|
async def getPositions(
|
||||||
request: Request,
|
request: Request,
|
||||||
pagination: Optional[str] = Query(None),
|
pagination: Optional[str] = Query(None),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> PaginatedResponse[TrusteePosition]:
|
) -> PaginatedResponse[TrusteePosition]:
|
||||||
"""Get all positions with optional pagination."""
|
"""Get all positions with optional pagination."""
|
||||||
paginationParams = _parsePagination(pagination)
|
paginationParams = _parsePagination(pagination)
|
||||||
interface = getInterface(currentUser)
|
interface = getInterface(context.user, mandateId=context.mandateId)
|
||||||
result = interface.getAllPositions(paginationParams)
|
result = interface.getAllPositions(paginationParams)
|
||||||
|
|
||||||
if paginationParams:
|
if paginationParams:
|
||||||
|
|
@ -659,14 +658,14 @@ async def getPositions(
|
||||||
async def getPosition(
|
async def getPosition(
|
||||||
request: Request,
|
request: Request,
|
||||||
positionId: str = Path(...),
|
positionId: str = Path(...),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> TrusteePosition:
|
) -> TrusteePosition:
|
||||||
"""Get a single position by ID."""
|
"""Get a single position by ID."""
|
||||||
interface = getInterface(currentUser)
|
interface = getInterface(context.user, mandateId=context.mandateId)
|
||||||
position = interface.getPosition(positionId)
|
position = interface.getPosition(positionId)
|
||||||
if not position:
|
if not position:
|
||||||
raise HTTPException(status_code=404, detail=f"Position {positionId} not found")
|
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])
|
@router.get("/positions/contract/{contractId}", response_model=List[TrusteePosition])
|
||||||
|
|
@ -674,11 +673,11 @@ async def getPosition(
|
||||||
async def getPositionsByContract(
|
async def getPositionsByContract(
|
||||||
request: Request,
|
request: Request,
|
||||||
contractId: str = Path(...),
|
contractId: str = Path(...),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> List[TrusteePosition]:
|
) -> List[TrusteePosition]:
|
||||||
"""Get all positions for a contract."""
|
"""Get all positions for a contract."""
|
||||||
interface = getInterface(currentUser)
|
interface = getInterface(context.user, mandateId=context.mandateId)
|
||||||
return [TrusteePosition(**p) for p in interface.getPositionsByContract(contractId)]
|
return interface.getPositionsByContract(contractId)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/positions/organisation/{orgId}", response_model=List[TrusteePosition])
|
@router.get("/positions/organisation/{orgId}", response_model=List[TrusteePosition])
|
||||||
|
|
@ -686,11 +685,11 @@ async def getPositionsByContract(
|
||||||
async def getPositionsByOrganisation(
|
async def getPositionsByOrganisation(
|
||||||
request: Request,
|
request: Request,
|
||||||
orgId: str = Path(...),
|
orgId: str = Path(...),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> List[TrusteePosition]:
|
) -> List[TrusteePosition]:
|
||||||
"""Get all positions for an organisation."""
|
"""Get all positions for an organisation."""
|
||||||
interface = getInterface(currentUser)
|
interface = getInterface(context.user, mandateId=context.mandateId)
|
||||||
return [TrusteePosition(**p) for p in interface.getPositionsByOrganisation(orgId)]
|
return interface.getPositionsByOrganisation(orgId)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/positions", response_model=TrusteePosition, status_code=201)
|
@router.post("/positions", response_model=TrusteePosition, status_code=201)
|
||||||
|
|
@ -698,14 +697,14 @@ async def getPositionsByOrganisation(
|
||||||
async def createPosition(
|
async def createPosition(
|
||||||
request: Request,
|
request: Request,
|
||||||
data: TrusteePosition = Body(...),
|
data: TrusteePosition = Body(...),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> TrusteePosition:
|
) -> TrusteePosition:
|
||||||
"""Create a new position."""
|
"""Create a new position."""
|
||||||
interface = getInterface(currentUser)
|
interface = getInterface(context.user, mandateId=context.mandateId)
|
||||||
result = interface.createPosition(data.model_dump())
|
result = interface.createPosition(data.model_dump())
|
||||||
if not result:
|
if not result:
|
||||||
raise HTTPException(status_code=400, detail="Failed to create position")
|
raise HTTPException(status_code=400, detail="Failed to create position")
|
||||||
return TrusteePosition(**result)
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.put("/positions/{positionId}", response_model=TrusteePosition)
|
@router.put("/positions/{positionId}", response_model=TrusteePosition)
|
||||||
|
|
@ -714,10 +713,10 @@ async def updatePosition(
|
||||||
request: Request,
|
request: Request,
|
||||||
positionId: str = Path(...),
|
positionId: str = Path(...),
|
||||||
data: TrusteePosition = Body(...),
|
data: TrusteePosition = Body(...),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> TrusteePosition:
|
) -> TrusteePosition:
|
||||||
"""Update a position."""
|
"""Update a position."""
|
||||||
interface = getInterface(currentUser)
|
interface = getInterface(context.user, mandateId=context.mandateId)
|
||||||
existing = interface.getPosition(positionId)
|
existing = interface.getPosition(positionId)
|
||||||
if not existing:
|
if not existing:
|
||||||
raise HTTPException(status_code=404, detail=f"Position {positionId} not found")
|
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"}))
|
result = interface.updatePosition(positionId, data.model_dump(exclude={"id"}))
|
||||||
if not result:
|
if not result:
|
||||||
raise HTTPException(status_code=400, detail="Failed to update position")
|
raise HTTPException(status_code=400, detail="Failed to update position")
|
||||||
return TrusteePosition(**result)
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/positions/{positionId}")
|
@router.delete("/positions/{positionId}")
|
||||||
|
|
@ -733,10 +732,10 @@ async def updatePosition(
|
||||||
async def deletePosition(
|
async def deletePosition(
|
||||||
request: Request,
|
request: Request,
|
||||||
positionId: str = Path(...),
|
positionId: str = Path(...),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Delete a position."""
|
"""Delete a position."""
|
||||||
interface = getInterface(currentUser)
|
interface = getInterface(context.user, mandateId=context.mandateId)
|
||||||
existing = interface.getPosition(positionId)
|
existing = interface.getPosition(positionId)
|
||||||
if not existing:
|
if not existing:
|
||||||
raise HTTPException(status_code=404, detail=f"Position {positionId} not found")
|
raise HTTPException(status_code=404, detail=f"Position {positionId} not found")
|
||||||
|
|
@ -754,11 +753,11 @@ async def deletePosition(
|
||||||
async def getPositionDocuments(
|
async def getPositionDocuments(
|
||||||
request: Request,
|
request: Request,
|
||||||
pagination: Optional[str] = Query(None),
|
pagination: Optional[str] = Query(None),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> PaginatedResponse[TrusteePositionDocument]:
|
) -> PaginatedResponse[TrusteePositionDocument]:
|
||||||
"""Get all position-document links with optional pagination."""
|
"""Get all position-document links with optional pagination."""
|
||||||
paginationParams = _parsePagination(pagination)
|
paginationParams = _parsePagination(pagination)
|
||||||
interface = getInterface(currentUser)
|
interface = getInterface(context.user, mandateId=context.mandateId)
|
||||||
result = interface.getAllPositionDocuments(paginationParams)
|
result = interface.getAllPositionDocuments(paginationParams)
|
||||||
|
|
||||||
if paginationParams:
|
if paginationParams:
|
||||||
|
|
@ -781,14 +780,14 @@ async def getPositionDocuments(
|
||||||
async def getPositionDocument(
|
async def getPositionDocument(
|
||||||
request: Request,
|
request: Request,
|
||||||
linkId: str = Path(...),
|
linkId: str = Path(...),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> TrusteePositionDocument:
|
) -> TrusteePositionDocument:
|
||||||
"""Get a single position-document link by ID."""
|
"""Get a single position-document link by ID."""
|
||||||
interface = getInterface(currentUser)
|
interface = getInterface(context.user, mandateId=context.mandateId)
|
||||||
link = interface.getPositionDocument(linkId)
|
link = interface.getPositionDocument(linkId)
|
||||||
if not link:
|
if not link:
|
||||||
raise HTTPException(status_code=404, detail=f"Link {linkId} not found")
|
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])
|
@router.get("/position-documents/position/{positionId}", response_model=List[TrusteePositionDocument])
|
||||||
|
|
@ -796,11 +795,11 @@ async def getPositionDocument(
|
||||||
async def getDocumentsForPosition(
|
async def getDocumentsForPosition(
|
||||||
request: Request,
|
request: Request,
|
||||||
positionId: str = Path(...),
|
positionId: str = Path(...),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> List[TrusteePositionDocument]:
|
) -> List[TrusteePositionDocument]:
|
||||||
"""Get all document links for a position."""
|
"""Get all document links for a position."""
|
||||||
interface = getInterface(currentUser)
|
interface = getInterface(context.user, mandateId=context.mandateId)
|
||||||
return [TrusteePositionDocument(**l) for l in interface.getDocumentsForPosition(positionId)]
|
return interface.getDocumentsForPosition(positionId)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/position-documents/document/{documentId}", response_model=List[TrusteePositionDocument])
|
@router.get("/position-documents/document/{documentId}", response_model=List[TrusteePositionDocument])
|
||||||
|
|
@ -808,11 +807,11 @@ async def getDocumentsForPosition(
|
||||||
async def getPositionsForDocument(
|
async def getPositionsForDocument(
|
||||||
request: Request,
|
request: Request,
|
||||||
documentId: str = Path(...),
|
documentId: str = Path(...),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> List[TrusteePositionDocument]:
|
) -> List[TrusteePositionDocument]:
|
||||||
"""Get all position links for a document."""
|
"""Get all position links for a document."""
|
||||||
interface = getInterface(currentUser)
|
interface = getInterface(context.user, mandateId=context.mandateId)
|
||||||
return [TrusteePositionDocument(**l) for l in interface.getPositionsForDocument(documentId)]
|
return interface.getPositionsForDocument(documentId)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/position-documents", response_model=TrusteePositionDocument, status_code=201)
|
@router.post("/position-documents", response_model=TrusteePositionDocument, status_code=201)
|
||||||
|
|
@ -820,14 +819,14 @@ async def getPositionsForDocument(
|
||||||
async def createPositionDocument(
|
async def createPositionDocument(
|
||||||
request: Request,
|
request: Request,
|
||||||
data: TrusteePositionDocument = Body(...),
|
data: TrusteePositionDocument = Body(...),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> TrusteePositionDocument:
|
) -> TrusteePositionDocument:
|
||||||
"""Create a new position-document link."""
|
"""Create a new position-document link."""
|
||||||
interface = getInterface(currentUser)
|
interface = getInterface(context.user, mandateId=context.mandateId)
|
||||||
result = interface.createPositionDocument(data.model_dump())
|
result = interface.createPositionDocument(data.model_dump())
|
||||||
if not result:
|
if not result:
|
||||||
raise HTTPException(status_code=400, detail="Failed to create link")
|
raise HTTPException(status_code=400, detail="Failed to create link")
|
||||||
return TrusteePositionDocument(**result)
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/position-documents/{linkId}")
|
@router.delete("/position-documents/{linkId}")
|
||||||
|
|
@ -835,10 +834,10 @@ async def createPositionDocument(
|
||||||
async def deletePositionDocument(
|
async def deletePositionDocument(
|
||||||
request: Request,
|
request: Request,
|
||||||
linkId: str = Path(...),
|
linkId: str = Path(...),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Delete a position-document link."""
|
"""Delete a position-document link."""
|
||||||
interface = getInterface(currentUser)
|
interface = getInterface(context.user, mandateId=context.mandateId)
|
||||||
existing = interface.getPositionDocument(linkId)
|
existing = interface.getPositionDocument(linkId)
|
||||||
if not existing:
|
if not existing:
|
||||||
raise HTTPException(status_code=404, detail=f"Link {linkId} not found")
|
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 json
|
||||||
|
|
||||||
# Import auth module
|
# Import auth module
|
||||||
from modules.auth import limiter, getCurrentUser
|
from modules.auth import limiter, getCurrentUser, getRequestContext, RequestContext
|
||||||
|
from modules.datamodels.datamodelRbac import Role
|
||||||
|
|
||||||
# Import interfaces
|
# Import interfaces
|
||||||
import modules.interfaces.interfaceDbComponentObjects as interfaceDbComponentObjects
|
import modules.interfaces.interfaceDbComponentObjects as interfaceDbComponentObjects
|
||||||
|
|
@ -379,16 +380,23 @@ async def triggerSubscription(
|
||||||
request: Request,
|
request: Request,
|
||||||
subscriptionId: str = Path(..., description="ID of the subscription to trigger"),
|
subscriptionId: str = Path(..., description="ID of the subscription to trigger"),
|
||||||
eventParameters: Dict[str, Any] = Body(...),
|
eventParameters: Dict[str, Any] = Body(...),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> MessagingSubscriptionExecutionResult:
|
) -> MessagingSubscriptionExecutionResult:
|
||||||
"""Trigger a subscription with event parameters"""
|
"""
|
||||||
# RBAC-Check: Nur Admin/Mandate-Admin kann triggern
|
Trigger a subscription with event parameters.
|
||||||
# TODO: Add proper RBAC check here
|
|
||||||
|
Requires Mandate-Admin role or SysAdmin.
|
||||||
|
"""
|
||||||
|
# RBAC-Check: Admin or Mandate-Admin can trigger
|
||||||
|
if not _hasTriggerPermission(context):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Admin or Mandate-Admin role required to trigger subscriptions"
|
||||||
|
)
|
||||||
|
|
||||||
# Get messaging service from request app state
|
# Get messaging service from request app state
|
||||||
# We need to access services through the request
|
|
||||||
from modules.services import getInterface as getServicesInterface
|
from modules.services import getInterface as getServicesInterface
|
||||||
services = getServicesInterface(currentUser, None)
|
services = getServicesInterface(context.user, None, mandateId=str(context.mandateId))
|
||||||
|
|
||||||
# Konvertiere Dict zu Pydantic Model
|
# Konvertiere Dict zu Pydantic Model
|
||||||
eventParams = MessagingEventParameters(triggerData=eventParameters)
|
eventParams = MessagingEventParameters(triggerData=eventParameters)
|
||||||
|
|
@ -397,6 +405,37 @@ async def triggerSubscription(
|
||||||
return executionResult
|
return executionResult
|
||||||
|
|
||||||
|
|
||||||
|
def _hasTriggerPermission(context: RequestContext) -> bool:
|
||||||
|
"""
|
||||||
|
Check if user has permission to trigger subscriptions.
|
||||||
|
Requires admin or mandate-admin role.
|
||||||
|
"""
|
||||||
|
if context.isSysAdmin:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if not context.roleIds:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
from modules.interfaces.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
|
# Delivery Endpoints
|
||||||
|
|
||||||
@router.get("/deliveries", response_model=PaginatedResponse[MessagingDelivery])
|
@router.get("/deliveries", response_model=PaginatedResponse[MessagingDelivery])
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,11 @@
|
||||||
"""
|
"""
|
||||||
RBAC routes for the backend API.
|
RBAC routes for the backend API.
|
||||||
Implements endpoints for role-based access control permissions.
|
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
|
from fastapi import APIRouter, HTTPException, Depends, Query, Body, Path, Request
|
||||||
|
|
@ -11,11 +16,11 @@ import logging
|
||||||
import json
|
import json
|
||||||
import math
|
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.datamodelUam import User, UserPermissions, AccessLevel
|
||||||
from modules.datamodels.datamodelRbac import AccessRuleContext, AccessRule, Role
|
from modules.datamodels.datamodelRbac import AccessRuleContext, AccessRule, Role
|
||||||
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
|
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
|
||||||
from modules.interfaces.interfaceDbAppObjects import getInterface
|
from modules.interfaces.interfaceDbAppObjects import getInterface, getRootInterface
|
||||||
|
|
||||||
# Configure logger
|
# Configure logger
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -33,10 +38,11 @@ async def getPermissions(
|
||||||
request: Request,
|
request: Request,
|
||||||
context: str = Query(..., description="Context type: DATA, UI, or RESOURCE"),
|
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)"),
|
item: Optional[str] = Query(None, description="Item identifier (table name, UI path, or resource path)"),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
reqContext: RequestContext = Depends(getRequestContext)
|
||||||
) -> UserPermissions:
|
) -> UserPermissions:
|
||||||
"""
|
"""
|
||||||
Get RBAC permissions for the current user for a specific context and item.
|
Get RBAC permissions for the current user for a specific context and item.
|
||||||
|
MULTI-TENANT: Uses RequestContext (mandateId/featureInstanceId from headers).
|
||||||
|
|
||||||
Query Parameters:
|
Query Parameters:
|
||||||
- context: Context type (DATA, UI, or RESOURCE)
|
- context: Context type (DATA, UI, or RESOURCE)
|
||||||
|
|
@ -63,16 +69,17 @@ async def getPermissions(
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get interface and RBAC permissions
|
# Get interface and RBAC permissions
|
||||||
interface = getInterface(currentUser)
|
interface = getInterface(reqContext.user)
|
||||||
if not interface.rbac:
|
if not interface.rbac:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
status_code=500,
|
||||||
detail="RBAC interface not available"
|
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(
|
permissions = interface.rbac.getUserPermissions(
|
||||||
currentUser,
|
reqContext.user,
|
||||||
accessContext,
|
accessContext,
|
||||||
item or ""
|
item or ""
|
||||||
)
|
)
|
||||||
|
|
@ -94,10 +101,11 @@ async def getPermissions(
|
||||||
async def getAllPermissions(
|
async def getAllPermissions(
|
||||||
request: Request,
|
request: Request,
|
||||||
context: Optional[str] = Query(None, description="Context type: UI or RESOURCE (if not provided, returns both)"),
|
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]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Get all RBAC permissions for the current user for UI and/or RESOURCE contexts.
|
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.
|
This endpoint is optimized for UI initialization to avoid multiple API calls.
|
||||||
|
|
||||||
Query Parameters:
|
Query Parameters:
|
||||||
|
|
@ -128,7 +136,7 @@ async def getAllPermissions(
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Get interface and RBAC permissions
|
# Get interface and RBAC permissions
|
||||||
interface = getInterface(currentUser)
|
interface = getInterface(reqContext.user)
|
||||||
if not interface.rbac:
|
if not interface.rbac:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
status_code=500,
|
||||||
|
|
@ -158,9 +166,9 @@ async def getAllPermissions(
|
||||||
|
|
||||||
result: Dict[str, Any] = {}
|
result: Dict[str, Any] = {}
|
||||||
|
|
||||||
# Get all access rules for user's roles
|
# MULTI-TENANT: Get role IDs from context (computed from mandateId/featureInstanceId)
|
||||||
roleLabels = currentUser.roleLabels or []
|
roleIds = reqContext.roleIds or []
|
||||||
if not roleLabels:
|
if not roleIds and not reqContext.isSysAdmin:
|
||||||
# User has no roles, return empty permissions
|
# User has no roles, return empty permissions
|
||||||
for ctx in contextsToFetch:
|
for ctx in contextsToFetch:
|
||||||
result[ctx.value.lower()] = {}
|
result[ctx.value.lower()] = {}
|
||||||
|
|
@ -171,9 +179,9 @@ async def getAllPermissions(
|
||||||
for ctx in contextsToFetch:
|
for ctx in contextsToFetch:
|
||||||
allRules[ctx] = []
|
allRules[ctx] = []
|
||||||
# Get all rules for user's roles in this context
|
# Get all rules for user's roles in this context
|
||||||
for roleLabel in roleLabels:
|
for roleId in roleIds:
|
||||||
rules = interface.getAccessRules(
|
rules = interface.getAccessRules(
|
||||||
roleLabel=roleLabel,
|
roleId=str(roleId),
|
||||||
context=ctx,
|
context=ctx,
|
||||||
pagination=None
|
pagination=None
|
||||||
)
|
)
|
||||||
|
|
@ -191,7 +199,7 @@ async def getAllPermissions(
|
||||||
|
|
||||||
# For each item, calculate user permissions
|
# For each item, calculate user permissions
|
||||||
for item in sorted(items):
|
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
|
# Only include if user has view permission
|
||||||
if permissions.view:
|
if permissions.view:
|
||||||
result[ctx.value.lower()][item] = {
|
result[ctx.value.lower()][item] = {
|
||||||
|
|
@ -222,11 +230,11 @@ async def getAccessRules(
|
||||||
context: Optional[str] = Query(None, description="Filter by context (DATA, UI, RESOURCE)"),
|
context: Optional[str] = Query(None, description="Filter by context (DATA, UI, RESOURCE)"),
|
||||||
item: Optional[str] = Query(None, description="Filter by item identifier"),
|
item: Optional[str] = Query(None, description="Filter by item identifier"),
|
||||||
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
reqContext: RequestContext = Depends(requireSysAdmin)
|
||||||
) -> PaginatedResponse:
|
) -> PaginatedResponse:
|
||||||
"""
|
"""
|
||||||
Get access rules with optional filters.
|
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:
|
Query Parameters:
|
||||||
- roleLabel: Optional role label filter
|
- roleLabel: Optional role label filter
|
||||||
|
|
@ -237,29 +245,8 @@ async def getAccessRules(
|
||||||
- List of AccessRule objects
|
- List of AccessRule objects
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Get interface
|
# Get interface - SysAdmin uses root interface
|
||||||
interface = getInterface(currentUser)
|
interface = getRootInterface()
|
||||||
|
|
||||||
# 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"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Parse context if provided
|
# Parse context if provided
|
||||||
accessContext = None
|
accessContext = None
|
||||||
|
|
@ -329,11 +316,11 @@ async def getAccessRules(
|
||||||
async def getAccessRule(
|
async def getAccessRule(
|
||||||
request: Request,
|
request: Request,
|
||||||
ruleId: str = Path(..., description="Access rule ID"),
|
ruleId: str = Path(..., description="Access rule ID"),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
reqContext: RequestContext = Depends(requireSysAdmin)
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Get a specific access rule by ID.
|
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:
|
Path Parameters:
|
||||||
- ruleId: Access rule ID
|
- ruleId: Access rule ID
|
||||||
|
|
@ -342,28 +329,8 @@ async def getAccessRule(
|
||||||
- AccessRule object
|
- AccessRule object
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Get interface
|
# Get interface - SysAdmin uses root interface
|
||||||
interface = getInterface(currentUser)
|
interface = getRootInterface()
|
||||||
|
|
||||||
# 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 rule
|
# Get rule
|
||||||
rule = interface.getAccessRule(ruleId)
|
rule = interface.getAccessRule(ruleId)
|
||||||
|
|
@ -391,11 +358,11 @@ async def getAccessRule(
|
||||||
async def createAccessRule(
|
async def createAccessRule(
|
||||||
request: Request,
|
request: Request,
|
||||||
accessRuleData: dict = Body(..., description="Access rule data"),
|
accessRuleData: dict = Body(..., description="Access rule data"),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
reqContext: RequestContext = Depends(requireSysAdmin)
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Create a new access rule.
|
Create a new access rule.
|
||||||
Only sysadmin can create access rules.
|
MULTI-TENANT: SysAdmin-only.
|
||||||
|
|
||||||
Request Body:
|
Request Body:
|
||||||
- AccessRule object data (roleLabel, context, item, view, read, create, update, delete)
|
- AccessRule object data (roleLabel, context, item, view, read, create, update, delete)
|
||||||
|
|
@ -404,28 +371,8 @@ async def createAccessRule(
|
||||||
- Created AccessRule object
|
- Created AccessRule object
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Get interface
|
# Get interface - SysAdmin uses root interface
|
||||||
interface = getInterface(currentUser)
|
interface = getRootInterface()
|
||||||
|
|
||||||
# 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"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validate and parse access rule data
|
# Validate and parse access rule data
|
||||||
try:
|
try:
|
||||||
|
|
@ -457,7 +404,7 @@ async def createAccessRule(
|
||||||
# Create rule
|
# Create rule
|
||||||
createdRule = interface.createAccessRule(accessRule)
|
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
|
# Convert to dict for JSON serialization
|
||||||
return createdRule.model_dump()
|
return createdRule.model_dump()
|
||||||
|
|
@ -478,11 +425,11 @@ async def updateAccessRule(
|
||||||
request: Request,
|
request: Request,
|
||||||
ruleId: str = Path(..., description="Access rule ID"),
|
ruleId: str = Path(..., description="Access rule ID"),
|
||||||
accessRuleData: dict = Body(..., description="Updated access rule data"),
|
accessRuleData: dict = Body(..., description="Updated access rule data"),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
reqContext: RequestContext = Depends(requireSysAdmin)
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Update an existing access rule.
|
Update an existing access rule.
|
||||||
Only sysadmin can update access rules.
|
MULTI-TENANT: SysAdmin-only.
|
||||||
|
|
||||||
Path Parameters:
|
Path Parameters:
|
||||||
- ruleId: Access rule ID
|
- ruleId: Access rule ID
|
||||||
|
|
@ -494,28 +441,8 @@ async def updateAccessRule(
|
||||||
- Updated AccessRule object
|
- Updated AccessRule object
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Get interface
|
# Get interface - SysAdmin uses root interface
|
||||||
interface = getInterface(currentUser)
|
interface = getRootInterface()
|
||||||
|
|
||||||
# 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 existing rule to ensure it exists
|
# Get existing rule to ensure it exists
|
||||||
existingRule = interface.getAccessRule(ruleId)
|
existingRule = interface.getAccessRule(ruleId)
|
||||||
|
|
@ -560,7 +487,7 @@ async def updateAccessRule(
|
||||||
# Update rule
|
# Update rule
|
||||||
updatedRule = interface.updateAccessRule(ruleId, accessRule)
|
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
|
# Convert to dict for JSON serialization
|
||||||
return updatedRule.model_dump()
|
return updatedRule.model_dump()
|
||||||
|
|
@ -580,11 +507,11 @@ async def updateAccessRule(
|
||||||
async def deleteAccessRule(
|
async def deleteAccessRule(
|
||||||
request: Request,
|
request: Request,
|
||||||
ruleId: str = Path(..., description="Access rule ID"),
|
ruleId: str = Path(..., description="Access rule ID"),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
reqContext: RequestContext = Depends(requireSysAdmin)
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Delete an access rule.
|
Delete an access rule.
|
||||||
Only sysadmin can delete access rules.
|
MULTI-TENANT: SysAdmin-only.
|
||||||
|
|
||||||
Path Parameters:
|
Path Parameters:
|
||||||
- ruleId: Access rule ID
|
- ruleId: Access rule ID
|
||||||
|
|
@ -593,28 +520,8 @@ async def deleteAccessRule(
|
||||||
- Success message
|
- Success message
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Get interface
|
# Get interface - SysAdmin uses root interface
|
||||||
interface = getInterface(currentUser)
|
interface = getRootInterface()
|
||||||
|
|
||||||
# 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 existing rule to ensure it exists
|
# Get existing rule to ensure it exists
|
||||||
existingRule = interface.getAccessRule(ruleId)
|
existingRule = interface.getAccessRule(ruleId)
|
||||||
|
|
@ -633,7 +540,7 @@ async def deleteAccessRule(
|
||||||
detail=f"Failed to delete access rule {ruleId}"
|
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"}
|
return {"success": True, "message": f"Access rule {ruleId} deleted successfully"}
|
||||||
|
|
||||||
|
|
@ -649,38 +556,26 @@ async def deleteAccessRule(
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Role Management Endpoints
|
# 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)
|
@router.get("/roles", response_model=PaginatedResponse)
|
||||||
@limiter.limit("60/minute")
|
@limiter.limit("60/minute")
|
||||||
async def listRoles(
|
async def listRoles(
|
||||||
request: Request,
|
request: Request,
|
||||||
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
reqContext: RequestContext = Depends(requireSysAdmin)
|
||||||
) -> PaginatedResponse:
|
) -> PaginatedResponse:
|
||||||
"""
|
"""
|
||||||
Get list of all available roles with metadata.
|
Get list of all available roles with metadata.
|
||||||
|
MULTI-TENANT: SysAdmin-only (roles are system resources).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
- List of role dictionaries with role label, description, and user count
|
- List of role dictionaries with role label, description, and user count
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
_ensureAdminAccess(currentUser)
|
interface = getRootInterface()
|
||||||
|
|
||||||
interface = getInterface(currentUser)
|
|
||||||
|
|
||||||
# Parse pagination parameter
|
# Parse pagination parameter
|
||||||
paginationParams = None
|
paginationParams = None
|
||||||
|
|
@ -696,21 +591,11 @@ async def listRoles(
|
||||||
detail=f"Invalid pagination parameter: {str(e)}"
|
detail=f"Invalid pagination parameter: {str(e)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get all roles from database (without pagination) to enrich with user counts and add custom roles
|
# Get all roles from database
|
||||||
# Note: We get all roles first because we need to add custom roles before pagination
|
|
||||||
dbRoles = interface.getAllRoles(pagination=None)
|
dbRoles = interface.getAllRoles(pagination=None)
|
||||||
|
|
||||||
# Get all users to count role assignments
|
# Count role assignments from UserMandateRole table
|
||||||
# Since _ensureAdminAccess ensures user is sysadmin or admin,
|
roleCounts = interface.countRoleAssignments()
|
||||||
# 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
|
|
||||||
|
|
||||||
# Convert Role objects to dictionaries and add user counts
|
# Convert Role objects to dictionaries and add user counts
|
||||||
result = []
|
result = []
|
||||||
|
|
@ -719,22 +604,10 @@ async def listRoles(
|
||||||
"id": role.id,
|
"id": role.id,
|
||||||
"roleLabel": role.roleLabel,
|
"roleLabel": role.roleLabel,
|
||||||
"description": role.description,
|
"description": role.description,
|
||||||
"userCount": roleCounts.get(role.roleLabel, 0),
|
"userCount": roleCounts.get(str(role.id), 0),
|
||||||
"isSystemRole": role.isSystemRole
|
"isSystemRole": role.isSystemRole
|
||||||
})
|
})
|
||||||
|
|
||||||
# Add any roles found in user assignments that don't exist in database
|
|
||||||
dbRoleLabels = {role.roleLabel for role in dbRoles}
|
|
||||||
for roleLabel, count in roleCounts.items():
|
|
||||||
if roleLabel not in dbRoleLabels:
|
|
||||||
result.append({
|
|
||||||
"id": None,
|
|
||||||
"roleLabel": roleLabel,
|
|
||||||
"description": {"en": f"Custom role: {roleLabel}", "fr": f"Rôle personnalisé : {roleLabel}"},
|
|
||||||
"userCount": count,
|
|
||||||
"isSystemRole": False
|
|
||||||
})
|
|
||||||
|
|
||||||
# Apply filtering and sorting if pagination requested
|
# Apply filtering and sorting if pagination requested
|
||||||
if paginationParams:
|
if paginationParams:
|
||||||
# Apply filtering (if filters provided)
|
# Apply filtering (if filters provided)
|
||||||
|
|
@ -789,19 +662,17 @@ async def listRoles(
|
||||||
@limiter.limit("60/minute")
|
@limiter.limit("60/minute")
|
||||||
async def getRoleOptions(
|
async def getRoleOptions(
|
||||||
request: Request,
|
request: Request,
|
||||||
currentUser: User = Depends(getCurrentUser)
|
reqContext: RequestContext = Depends(requireSysAdmin)
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Get role options for select dropdowns.
|
Get role options for select dropdowns.
|
||||||
Returns roles in format suitable for frontend select components.
|
MULTI-TENANT: SysAdmin-only.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
- List of role option dictionaries with value and label
|
- List of role option dictionaries with value and label
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
_ensureAdminAccess(currentUser)
|
interface = getRootInterface()
|
||||||
|
|
||||||
interface = getInterface(currentUser)
|
|
||||||
|
|
||||||
# Get all roles from database
|
# Get all roles from database
|
||||||
dbRoles = interface.getAllRoles()
|
dbRoles = interface.getAllRoles()
|
||||||
|
|
@ -833,10 +704,11 @@ async def getRoleOptions(
|
||||||
async def createRole(
|
async def createRole(
|
||||||
request: Request,
|
request: Request,
|
||||||
role: Role = Body(...),
|
role: Role = Body(...),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
reqContext: RequestContext = Depends(requireSysAdmin)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Create a new role.
|
Create a new role.
|
||||||
|
MULTI-TENANT: SysAdmin-only.
|
||||||
|
|
||||||
Request Body:
|
Request Body:
|
||||||
- role: Role object to create
|
- role: Role object to create
|
||||||
|
|
@ -845,12 +717,12 @@ async def createRole(
|
||||||
- Created role dictionary
|
- Created role dictionary
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
_ensureAdminAccess(currentUser)
|
interface = getRootInterface()
|
||||||
|
|
||||||
interface = getInterface(currentUser)
|
|
||||||
|
|
||||||
createdRole = interface.createRole(role)
|
createdRole = interface.createRole(role)
|
||||||
|
|
||||||
|
logger.info(f"Created role {createdRole.roleLabel} by SysAdmin {reqContext.user.id}")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": createdRole.id,
|
"id": createdRole.id,
|
||||||
"roleLabel": createdRole.roleLabel,
|
"roleLabel": createdRole.roleLabel,
|
||||||
|
|
@ -878,10 +750,11 @@ async def createRole(
|
||||||
async def getRole(
|
async def getRole(
|
||||||
request: Request,
|
request: Request,
|
||||||
roleId: str = Path(..., description="Role ID"),
|
roleId: str = Path(..., description="Role ID"),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
reqContext: RequestContext = Depends(requireSysAdmin)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Get a role by ID.
|
Get a role by ID.
|
||||||
|
MULTI-TENANT: SysAdmin-only.
|
||||||
|
|
||||||
Path Parameters:
|
Path Parameters:
|
||||||
- roleId: Role ID
|
- roleId: Role ID
|
||||||
|
|
@ -890,9 +763,7 @@ async def getRole(
|
||||||
- Role dictionary
|
- Role dictionary
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
_ensureAdminAccess(currentUser)
|
interface = getRootInterface()
|
||||||
|
|
||||||
interface = getInterface(currentUser)
|
|
||||||
|
|
||||||
role = interface.getRole(roleId)
|
role = interface.getRole(roleId)
|
||||||
if not role:
|
if not role:
|
||||||
|
|
@ -924,10 +795,11 @@ async def updateRole(
|
||||||
request: Request,
|
request: Request,
|
||||||
roleId: str = Path(..., description="Role ID"),
|
roleId: str = Path(..., description="Role ID"),
|
||||||
role: Role = Body(...),
|
role: Role = Body(...),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
reqContext: RequestContext = Depends(requireSysAdmin)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Update an existing role.
|
Update an existing role.
|
||||||
|
MULTI-TENANT: SysAdmin-only.
|
||||||
|
|
||||||
Path Parameters:
|
Path Parameters:
|
||||||
- roleId: Role ID
|
- roleId: Role ID
|
||||||
|
|
@ -939,12 +811,12 @@ async def updateRole(
|
||||||
- Updated role dictionary
|
- Updated role dictionary
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
_ensureAdminAccess(currentUser)
|
interface = getRootInterface()
|
||||||
|
|
||||||
interface = getInterface(currentUser)
|
|
||||||
|
|
||||||
updatedRole = interface.updateRole(roleId, role)
|
updatedRole = interface.updateRole(roleId, role)
|
||||||
|
|
||||||
|
logger.info(f"Updated role {roleId} by SysAdmin {reqContext.user.id}")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": updatedRole.id,
|
"id": updatedRole.id,
|
||||||
"roleLabel": updatedRole.roleLabel,
|
"roleLabel": updatedRole.roleLabel,
|
||||||
|
|
@ -972,10 +844,11 @@ async def updateRole(
|
||||||
async def deleteRole(
|
async def deleteRole(
|
||||||
request: Request,
|
request: Request,
|
||||||
roleId: str = Path(..., description="Role ID"),
|
roleId: str = Path(..., description="Role ID"),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
reqContext: RequestContext = Depends(requireSysAdmin)
|
||||||
) -> Dict[str, str]:
|
) -> Dict[str, str]:
|
||||||
"""
|
"""
|
||||||
Delete a role.
|
Delete a role.
|
||||||
|
MULTI-TENANT: SysAdmin-only.
|
||||||
|
|
||||||
Path Parameters:
|
Path Parameters:
|
||||||
- roleId: Role ID
|
- roleId: Role ID
|
||||||
|
|
@ -984,9 +857,7 @@ async def deleteRole(
|
||||||
- Success message
|
- Success message
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
_ensureAdminAccess(currentUser)
|
interface = getRootInterface()
|
||||||
|
|
||||||
interface = getInterface(currentUser)
|
|
||||||
|
|
||||||
success = interface.deleteRole(roleId)
|
success = interface.deleteRole(roleId)
|
||||||
if not success:
|
if not success:
|
||||||
|
|
@ -995,6 +866,8 @@ async def deleteRole(
|
||||||
detail=f"Role {roleId} not found"
|
detail=f"Role {roleId} not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger.info(f"Deleted role {roleId} by SysAdmin {reqContext.user.id}")
|
||||||
|
|
||||||
return {"message": f"Role {roleId} deleted successfully"}
|
return {"message": f"Role {roleId} deleted successfully"}
|
||||||
|
|
||||||
except HTTPException:
|
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
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
|
"""
|
||||||
|
Security Administration routes.
|
||||||
|
MULTI-TENANT: These are SYSTEM-LEVEL operations requiring isSysAdmin=true.
|
||||||
|
No mandate context - SysAdmin manages infrastructure, not data.
|
||||||
|
"""
|
||||||
from fastapi import APIRouter, HTTPException, Depends, status, Request, Body
|
from fastapi import APIRouter, HTTPException, Depends, status, Request, Body
|
||||||
from fastapi.responses import FileResponse, JSONResponse
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
from typing import Optional, Dict, Any, List
|
from typing import Optional, Dict, Any, List
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from modules.auth import getCurrentUser, limiter
|
from modules.auth import getCurrentUser, limiter, requireSysAdmin
|
||||||
from modules.interfaces.interfaceDbAppObjects import getInterface, getRootInterface
|
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
||||||
|
from modules.interfaces.interfaceDbAppObjects import getRootInterface
|
||||||
from modules.datamodels.datamodelUam import User, UserInDB, AuthAuthority
|
from modules.datamodels.datamodelUam import User, UserInDB, AuthAuthority
|
||||||
from modules.datamodels.datamodelSecurity import Token
|
from modules.datamodels.datamodelSecurity import Token
|
||||||
from modules.shared.configuration import APP_CONFIG
|
from modules.shared.configuration import APP_CONFIG
|
||||||
|
|
@ -26,13 +32,63 @@ router = APIRouter(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
def _ensure_admin_scope(current_user: User, target_mandate_id: Optional[str] = None) -> None:
|
|
||||||
roleLabels = current_user.roleLabels or []
|
def _getPoweronDatabases() -> List[str]:
|
||||||
if "admin" not in roleLabels and "sysadmin" not in roleLabels:
|
"""Load databases from PostgreSQL host matching poweron_%."""
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin privileges required")
|
dbHost = APP_CONFIG.get("DB_HOST")
|
||||||
if "admin" in roleLabels and "sysadmin" not in roleLabels:
|
dbUser = APP_CONFIG.get("DB_USER")
|
||||||
if target_mandate_id and str(target_mandate_id) != str(current_user.mandateId):
|
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden for target mandate")
|
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
|
||||||
|
|
||||||
|
# Connect to 'postgres' system database to query all databases
|
||||||
|
connector = DatabaseConnector(
|
||||||
|
dbHost=dbHost,
|
||||||
|
dbDatabase="postgres",
|
||||||
|
dbUser=dbUser,
|
||||||
|
dbPassword=dbPassword,
|
||||||
|
dbPort=dbPort,
|
||||||
|
userId=None
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with connector.connection.cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT datname
|
||||||
|
FROM pg_database
|
||||||
|
WHERE datname LIKE 'poweron_%'
|
||||||
|
AND datistemplate = false
|
||||||
|
ORDER BY datname
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
return [row["datname"] for row in rows if row.get("datname")]
|
||||||
|
finally:
|
||||||
|
connector.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _getDatabaseConnector(databaseName: str, userId: str = None) -> DatabaseConnector:
|
||||||
|
"""
|
||||||
|
Create a generic DatabaseConnector for any poweron_* database.
|
||||||
|
Fully dynamic - no interface mapping needed.
|
||||||
|
"""
|
||||||
|
if not databaseName.startswith("poweron_"):
|
||||||
|
raise ValueError(f"Invalid database name: {databaseName}")
|
||||||
|
|
||||||
|
dbHost = APP_CONFIG.get("DB_HOST")
|
||||||
|
dbUser = APP_CONFIG.get("DB_USER")
|
||||||
|
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
|
||||||
|
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
|
||||||
|
|
||||||
|
connector = DatabaseConnector(
|
||||||
|
dbHost=dbHost,
|
||||||
|
dbDatabase=databaseName,
|
||||||
|
dbUser=dbUser,
|
||||||
|
dbPassword=dbPassword,
|
||||||
|
dbPort=dbPort,
|
||||||
|
userId=userId
|
||||||
|
)
|
||||||
|
return connector
|
||||||
|
|
||||||
|
|
||||||
# ----------------------
|
# ----------------------
|
||||||
|
|
@ -43,17 +99,19 @@ def _ensure_admin_scope(current_user: User, target_mandate_id: Optional[str] = N
|
||||||
@limiter.limit("30/minute")
|
@limiter.limit("30/minute")
|
||||||
async def list_tokens(
|
async def list_tokens(
|
||||||
request: Request,
|
request: Request,
|
||||||
currentUser: User = Depends(getCurrentUser),
|
currentUser: User = Depends(requireSysAdmin),
|
||||||
userId: Optional[str] = None,
|
userId: Optional[str] = None,
|
||||||
authority: Optional[str] = None,
|
authority: Optional[str] = None,
|
||||||
sessionId: Optional[str] = None,
|
sessionId: Optional[str] = None,
|
||||||
statusFilter: Optional[str] = None,
|
statusFilter: Optional[str] = None,
|
||||||
connectionId: Optional[str] = None,
|
connectionId: Optional[str] = None,
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
List all tokens in the system.
|
||||||
|
MULTI-TENANT: SysAdmin-only, no mandate filter (system-level view).
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
appInterface = getRootInterface()
|
appInterface = getRootInterface()
|
||||||
target_mandate = currentUser.mandateId
|
|
||||||
_ensure_admin_scope(currentUser, target_mandate)
|
|
||||||
|
|
||||||
recordFilter: Dict[str, Any] = {}
|
recordFilter: Dict[str, Any] = {}
|
||||||
if userId:
|
if userId:
|
||||||
|
|
@ -66,9 +124,7 @@ async def list_tokens(
|
||||||
recordFilter["connectionId"] = connectionId
|
recordFilter["connectionId"] = connectionId
|
||||||
if statusFilter:
|
if statusFilter:
|
||||||
recordFilter["status"] = statusFilter
|
recordFilter["status"] = statusFilter
|
||||||
roleLabels = currentUser.roleLabels or []
|
# MULTI-TENANT: SysAdmin sees ALL tokens (no mandate filter)
|
||||||
if "admin" in roleLabels and "sysadmin" not in roleLabels:
|
|
||||||
recordFilter["mandateId"] = str(currentUser.mandateId)
|
|
||||||
|
|
||||||
tokens = appInterface.db.getRecordset(Token, recordFilter=recordFilter)
|
tokens = appInterface.db.getRecordset(Token, recordFilter=recordFilter)
|
||||||
return tokens
|
return tokens
|
||||||
|
|
@ -83,27 +139,26 @@ async def list_tokens(
|
||||||
@limiter.limit("30/minute")
|
@limiter.limit("30/minute")
|
||||||
async def revoke_tokens_by_user(
|
async def revoke_tokens_by_user(
|
||||||
request: Request,
|
request: Request,
|
||||||
currentUser: User = Depends(getCurrentUser),
|
currentUser: User = Depends(requireSysAdmin),
|
||||||
payload: Dict[str, Any] = Body(...)
|
payload: Dict[str, Any] = Body(...)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Revoke all tokens for a user.
|
||||||
|
MULTI-TENANT: SysAdmin-only, can revoke across all mandates.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
userId = payload.get("userId")
|
userId = payload.get("userId")
|
||||||
authority = payload.get("authority")
|
authority = payload.get("authority")
|
||||||
reason = payload.get("reason", "admin revoke")
|
reason = payload.get("reason", "sysadmin revoke")
|
||||||
if not userId:
|
if not userId:
|
||||||
raise HTTPException(status_code=400, detail="userId is required")
|
raise HTTPException(status_code=400, detail="userId is required")
|
||||||
|
|
||||||
appInterface = getRootInterface()
|
appInterface = getRootInterface()
|
||||||
# Tenant scope check
|
# MULTI-TENANT: SysAdmin can revoke any user's tokens (no mandate restriction)
|
||||||
target_user = appInterface.db.getRecordset(User, recordFilter={"id": userId})
|
|
||||||
target_mandate = target_user[0].get("mandateId") if target_user else None
|
|
||||||
_ensure_admin_scope(currentUser, target_mandate)
|
|
||||||
|
|
||||||
roleLabels = currentUser.roleLabels or []
|
|
||||||
count = appInterface.revokeTokensByUser(
|
count = appInterface.revokeTokensByUser(
|
||||||
userId=userId,
|
userId=userId,
|
||||||
authority=AuthAuthority(authority) if authority else None,
|
authority=AuthAuthority(authority) if authority else None,
|
||||||
mandateId=None if "sysadmin" in roleLabels else str(currentUser.mandateId),
|
mandateId=None, # SysAdmin: no mandate filter
|
||||||
revokedBy=currentUser.id,
|
revokedBy=currentUser.id,
|
||||||
reason=reason
|
reason=reason
|
||||||
)
|
)
|
||||||
|
|
@ -119,22 +174,23 @@ async def revoke_tokens_by_user(
|
||||||
@limiter.limit("30/minute")
|
@limiter.limit("30/minute")
|
||||||
async def revoke_tokens_by_session(
|
async def revoke_tokens_by_session(
|
||||||
request: Request,
|
request: Request,
|
||||||
currentUser: User = Depends(getCurrentUser),
|
currentUser: User = Depends(requireSysAdmin),
|
||||||
payload: Dict[str, Any] = Body(...)
|
payload: Dict[str, Any] = Body(...)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Revoke all tokens for a specific session.
|
||||||
|
MULTI-TENANT: SysAdmin-only.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
userId = payload.get("userId")
|
userId = payload.get("userId")
|
||||||
sessionId = payload.get("sessionId")
|
sessionId = payload.get("sessionId")
|
||||||
authority = payload.get("authority", "local")
|
authority = payload.get("authority", "local")
|
||||||
reason = payload.get("reason", "admin session revoke")
|
reason = payload.get("reason", "sysadmin session revoke")
|
||||||
if not userId or not sessionId:
|
if not userId or not sessionId:
|
||||||
raise HTTPException(status_code=400, detail="userId and sessionId are required")
|
raise HTTPException(status_code=400, detail="userId and sessionId are required")
|
||||||
|
|
||||||
appInterface = getRootInterface()
|
appInterface = getRootInterface()
|
||||||
target_user = appInterface.db.getRecordset(User, recordFilter={"id": userId})
|
# MULTI-TENANT: SysAdmin can revoke any session (no mandate check)
|
||||||
target_mandate = target_user[0].get("mandateId") if target_user else None
|
|
||||||
_ensure_admin_scope(currentUser, target_mandate)
|
|
||||||
|
|
||||||
count = appInterface.revokeTokensBySessionId(
|
count = appInterface.revokeTokensBySessionId(
|
||||||
sessionId=sessionId,
|
sessionId=sessionId,
|
||||||
userId=userId,
|
userId=userId,
|
||||||
|
|
@ -154,22 +210,20 @@ async def revoke_tokens_by_session(
|
||||||
@limiter.limit("30/minute")
|
@limiter.limit("30/minute")
|
||||||
async def revoke_token_by_id(
|
async def revoke_token_by_id(
|
||||||
request: Request,
|
request: Request,
|
||||||
currentUser: User = Depends(getCurrentUser),
|
currentUser: User = Depends(requireSysAdmin),
|
||||||
payload: Dict[str, Any] = Body(...)
|
payload: Dict[str, Any] = Body(...)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Revoke a specific token by ID.
|
||||||
|
MULTI-TENANT: SysAdmin-only.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
tokenId = payload.get("tokenId")
|
tokenId = payload.get("tokenId")
|
||||||
reason = payload.get("reason", "admin revoke")
|
reason = payload.get("reason", "sysadmin revoke")
|
||||||
if not tokenId:
|
if not tokenId:
|
||||||
raise HTTPException(status_code=400, detail="tokenId is required")
|
raise HTTPException(status_code=400, detail="tokenId is required")
|
||||||
appInterface = getRootInterface()
|
appInterface = getRootInterface()
|
||||||
# Load token to check tenant scope for admins
|
# MULTI-TENANT: SysAdmin can revoke any token (no mandate check)
|
||||||
tokens = appInterface.db.getRecordset(Token, recordFilter={"id": tokenId})
|
|
||||||
if not tokens:
|
|
||||||
return {"revoked": 0}
|
|
||||||
target_mandate = tokens[0].get("mandateId")
|
|
||||||
_ensure_admin_scope(currentUser, target_mandate)
|
|
||||||
|
|
||||||
ok = appInterface.revokeTokenById(tokenId, revokedBy=currentUser.id, reason=reason)
|
ok = appInterface.revokeTokenById(tokenId, revokedBy=currentUser.id, reason=reason)
|
||||||
return {"revoked": 1 if ok else 0}
|
return {"revoked": 1 if ok else 0}
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|
@ -183,29 +237,34 @@ async def revoke_token_by_id(
|
||||||
@limiter.limit("10/minute")
|
@limiter.limit("10/minute")
|
||||||
async def revoke_tokens_by_mandate(
|
async def revoke_tokens_by_mandate(
|
||||||
request: Request,
|
request: Request,
|
||||||
currentUser: User = Depends(getCurrentUser),
|
currentUser: User = Depends(requireSysAdmin),
|
||||||
payload: Dict[str, Any] = Body(...)
|
payload: Dict[str, Any] = Body(...)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Revoke all tokens for users in a mandate.
|
||||||
|
MULTI-TENANT: SysAdmin-only, can revoke tokens for any mandate.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
mandateId = payload.get("mandateId")
|
mandateId = payload.get("mandateId")
|
||||||
authority = payload.get("authority", "local")
|
authority = payload.get("authority", "local")
|
||||||
reason = payload.get("reason", "admin mandate revoke")
|
reason = payload.get("reason", "sysadmin mandate revoke")
|
||||||
if not mandateId:
|
if not mandateId:
|
||||||
raise HTTPException(status_code=400, detail="mandateId is required")
|
raise HTTPException(status_code=400, detail="mandateId is required")
|
||||||
|
|
||||||
_ensure_admin_scope(currentUser, mandateId)
|
# MULTI-TENANT: SysAdmin can revoke tokens for any mandate
|
||||||
|
|
||||||
# Revoke for all users in mandate
|
|
||||||
appInterface = getRootInterface()
|
appInterface = getRootInterface()
|
||||||
# IMPORTANT: user rows are stored as UserInDB in the database
|
|
||||||
users = appInterface.db.getRecordset(UserInDB, recordFilter={"mandateId": mandateId})
|
# Get all UserMandate entries for this mandate to find users
|
||||||
|
# Note: In new model, users are linked via UserMandate, not User.mandateId
|
||||||
|
from modules.datamodels.datamodelMembership import UserMandate
|
||||||
|
userMandates = appInterface.db.getRecordset(UserMandate, recordFilter={"mandateId": mandateId})
|
||||||
|
|
||||||
total = 0
|
total = 0
|
||||||
for u in users:
|
for um in userMandates:
|
||||||
# Revoke regardless of token.mandateId to also catch legacy tokens without mandateId
|
|
||||||
total += appInterface.revokeTokensByUser(
|
total += appInterface.revokeTokensByUser(
|
||||||
userId=u["id"],
|
userId=um["userId"],
|
||||||
authority=AuthAuthority(authority),
|
authority=AuthAuthority(authority) if authority else None,
|
||||||
mandateId=None,
|
mandateId=None, # Revoke all tokens for user
|
||||||
revokedBy=currentUser.id,
|
revokedBy=currentUser.id,
|
||||||
reason=reason
|
reason=reason
|
||||||
)
|
)
|
||||||
|
|
@ -225,10 +284,13 @@ async def revoke_tokens_by_mandate(
|
||||||
@limiter.limit("60/minute")
|
@limiter.limit("60/minute")
|
||||||
async def download_log(
|
async def download_log(
|
||||||
request: Request,
|
request: Request,
|
||||||
currentUser: User = Depends(getCurrentUser),
|
currentUser: User = Depends(requireSysAdmin),
|
||||||
log_name: str = "poweron"
|
log_name: str = "poweron"
|
||||||
):
|
):
|
||||||
_ensure_admin_scope(currentUser)
|
"""
|
||||||
|
Download server logs.
|
||||||
|
MULTI-TENANT: SysAdmin-only (infrastructure management).
|
||||||
|
"""
|
||||||
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
# base_dir -> gateway
|
# base_dir -> gateway
|
||||||
if log_name == "poweron":
|
if log_name == "poweron":
|
||||||
|
|
@ -251,33 +313,18 @@ async def download_log(
|
||||||
@limiter.limit("10/minute")
|
@limiter.limit("10/minute")
|
||||||
async def list_databases(
|
async def list_databases(
|
||||||
request: Request,
|
request: Request,
|
||||||
currentUser: User = Depends(getCurrentUser)
|
currentUser: User = Depends(requireSysAdmin)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
_ensure_admin_scope(currentUser)
|
"""
|
||||||
|
List all poweron_* databases.
|
||||||
# Get database names from configuration for each interface
|
MULTI-TENANT: SysAdmin-only (infrastructure management).
|
||||||
databases = []
|
"""
|
||||||
|
try:
|
||||||
# App database (interfaceDbAppObjects.py)
|
databases = _getPoweronDatabases()
|
||||||
app_db = APP_CONFIG.get("DB_APP_DATABASE")
|
|
||||||
if app_db:
|
|
||||||
databases.append(app_db)
|
|
||||||
|
|
||||||
# Chat database (interfaceDbChatObjects.py)
|
|
||||||
chat_db = APP_CONFIG.get("DB_CHAT_DATABASE")
|
|
||||||
if chat_db:
|
|
||||||
databases.append(chat_db)
|
|
||||||
|
|
||||||
# Management database (interfaceDbComponentObjects.py)
|
|
||||||
management_db = APP_CONFIG.get("DB_MANAGEMENT_DATABASE")
|
|
||||||
if management_db:
|
|
||||||
databases.append(management_db)
|
|
||||||
|
|
||||||
# Fallback to default if no databases configured
|
|
||||||
if not databases:
|
|
||||||
databases = ["poweron"]
|
|
||||||
|
|
||||||
return {"databases": databases}
|
return {"databases": databases}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to load databases from host: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to load databases from host")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/databases/{database_name}/tables")
|
@router.get("/databases/{database_name}/tables")
|
||||||
|
|
@ -285,48 +332,28 @@ async def list_databases(
|
||||||
async def get_database_tables(
|
async def get_database_tables(
|
||||||
request: Request,
|
request: Request,
|
||||||
database_name: str,
|
database_name: str,
|
||||||
currentUser: User = Depends(getCurrentUser)
|
currentUser: User = Depends(requireSysAdmin)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
_ensure_admin_scope(currentUser)
|
"""
|
||||||
|
List tables in a database.
|
||||||
# Get all configured database names
|
MULTI-TENANT: SysAdmin-only (infrastructure management).
|
||||||
configured_dbs = []
|
"""
|
||||||
app_db = APP_CONFIG.get("DB_APP_DATABASE")
|
if not database_name.startswith("poweron_"):
|
||||||
if app_db:
|
raise HTTPException(status_code=400, detail="Invalid database name format")
|
||||||
configured_dbs.append(app_db)
|
|
||||||
chat_db = APP_CONFIG.get("DB_CHAT_DATABASE")
|
|
||||||
if chat_db:
|
|
||||||
configured_dbs.append(chat_db)
|
|
||||||
management_db = APP_CONFIG.get("DB_MANAGEMENT_DATABASE")
|
|
||||||
if management_db:
|
|
||||||
configured_dbs.append(management_db)
|
|
||||||
|
|
||||||
if not configured_dbs:
|
|
||||||
configured_dbs = ["poweron"]
|
|
||||||
|
|
||||||
if database_name not in configured_dbs:
|
|
||||||
raise HTTPException(status_code=400, detail=f"Invalid database name. Available databases: {configured_dbs}")
|
|
||||||
|
|
||||||
|
connector = None
|
||||||
try:
|
try:
|
||||||
# Use the appropriate interface based on database name
|
connector = _getDatabaseConnector(database_name, currentUser.id)
|
||||||
if database_name == app_db:
|
tables = connector.getTables()
|
||||||
appInterface = getRootInterface()
|
|
||||||
tables = appInterface.db.getTables()
|
|
||||||
elif database_name == chat_db:
|
|
||||||
from modules.interfaces.interfaceDbChatObjects import getInterface as getChatInterface
|
|
||||||
chatInterface = getChatInterface(currentUser)
|
|
||||||
tables = chatInterface.db.getTables()
|
|
||||||
elif database_name == management_db:
|
|
||||||
from modules.interfaces.interfaceDbComponentObjects import getInterface as getComponentInterface
|
|
||||||
componentInterface = getComponentInterface(currentUser)
|
|
||||||
tables = componentInterface.db.getTables()
|
|
||||||
else:
|
|
||||||
raise HTTPException(status_code=400, detail="Database not found")
|
|
||||||
|
|
||||||
return {"tables": tables}
|
return {"tables": tables}
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting database tables: {str(e)}")
|
logger.error(f"Error getting database tables: {str(e)}")
|
||||||
raise HTTPException(status_code=500, detail="Failed to get database tables")
|
raise HTTPException(status_code=500, detail=f"Failed to get database tables: {str(e)}")
|
||||||
|
finally:
|
||||||
|
if connector:
|
||||||
|
connector.close()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/databases/{database_name}/tables/{table_name}/drop")
|
@router.post("/databases/{database_name}/tables/{table_name}/drop")
|
||||||
|
|
@ -335,43 +362,20 @@ async def drop_table(
|
||||||
request: Request,
|
request: Request,
|
||||||
database_name: str,
|
database_name: str,
|
||||||
table_name: str,
|
table_name: str,
|
||||||
currentUser: User = Depends(getCurrentUser),
|
currentUser: User = Depends(requireSysAdmin),
|
||||||
payload: Dict[str, Any] = Body(...)
|
payload: Dict[str, Any] = Body(...)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
_ensure_admin_scope(currentUser)
|
"""
|
||||||
|
Drop a table from a database.
|
||||||
# Get all configured database names
|
MULTI-TENANT: SysAdmin-only (infrastructure management).
|
||||||
configured_dbs = []
|
"""
|
||||||
app_db = APP_CONFIG.get("DB_APP_DATABASE")
|
if not database_name.startswith("poweron_"):
|
||||||
if app_db:
|
raise HTTPException(status_code=400, detail="Invalid database name format")
|
||||||
configured_dbs.append(app_db)
|
|
||||||
chat_db = APP_CONFIG.get("DB_CHAT_DATABASE")
|
|
||||||
if chat_db:
|
|
||||||
configured_dbs.append(chat_db)
|
|
||||||
management_db = APP_CONFIG.get("DB_MANAGEMENT_DATABASE")
|
|
||||||
if management_db:
|
|
||||||
configured_dbs.append(management_db)
|
|
||||||
|
|
||||||
if not configured_dbs:
|
|
||||||
configured_dbs = ["poweron"]
|
|
||||||
|
|
||||||
if database_name not in configured_dbs:
|
|
||||||
raise HTTPException(status_code=400, detail=f"Invalid database name. Available databases: {configured_dbs}")
|
|
||||||
|
|
||||||
|
connector = None
|
||||||
try:
|
try:
|
||||||
# Use the appropriate interface based on database name
|
connector = _getDatabaseConnector(database_name, currentUser.id)
|
||||||
if database_name == app_db:
|
conn = connector.connection
|
||||||
interface = getRootInterface()
|
|
||||||
elif database_name == chat_db:
|
|
||||||
from modules.interfaces.interfaceDbChatObjects import getInterface as getChatInterface
|
|
||||||
interface = getChatInterface(currentUser)
|
|
||||||
elif database_name == management_db:
|
|
||||||
from modules.interfaces.interfaceDbComponentObjects import getInterface as getComponentInterface
|
|
||||||
interface = getComponentInterface(currentUser)
|
|
||||||
else:
|
|
||||||
raise HTTPException(status_code=400, detail="Database not found")
|
|
||||||
|
|
||||||
conn = interface.db.connection
|
|
||||||
with conn.cursor() as cursor:
|
with conn.cursor() as cursor:
|
||||||
# Check if table exists
|
# Check if table exists
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
|
|
@ -388,57 +392,50 @@ async def drop_table(
|
||||||
return {"message": f"Table '{table_name}' dropped successfully from database '{database_name}'"}
|
return {"message": f"Table '{table_name}' dropped successfully from database '{database_name}'"}
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error dropping table: {str(e)}")
|
logger.error(f"Error dropping table: {str(e)}")
|
||||||
if 'interface' in locals() and interface and interface.db and interface.db.connection:
|
if connector and connector.connection:
|
||||||
interface.db.connection.rollback()
|
connector.connection.rollback()
|
||||||
raise HTTPException(status_code=500, detail="Failed to drop table")
|
raise HTTPException(status_code=500, detail="Failed to drop table")
|
||||||
|
finally:
|
||||||
|
if connector:
|
||||||
|
connector.close()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/databases/drop")
|
@router.post("/databases/drop")
|
||||||
@limiter.limit("5/minute")
|
@limiter.limit("5/minute")
|
||||||
async def drop_database(
|
async def drop_database(
|
||||||
request: Request,
|
request: Request,
|
||||||
currentUser: User = Depends(getCurrentUser),
|
currentUser: User = Depends(requireSysAdmin),
|
||||||
payload: Dict[str, Any] = Body(...)
|
payload: Dict[str, Any] = Body(...)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
_ensure_admin_scope(currentUser)
|
"""
|
||||||
db_name = payload.get("database")
|
Drop all tables in a database.
|
||||||
|
MULTI-TENANT: SysAdmin-only (infrastructure management).
|
||||||
|
"""
|
||||||
|
dbName = payload.get("database")
|
||||||
|
|
||||||
# Get all configured database names
|
if not dbName or not dbName.startswith("poweron_"):
|
||||||
configured_dbs = []
|
raise HTTPException(status_code=400, detail="Invalid database name")
|
||||||
app_db = APP_CONFIG.get("DB_APP_DATABASE")
|
|
||||||
if app_db:
|
|
||||||
configured_dbs.append(app_db)
|
|
||||||
chat_db = APP_CONFIG.get("DB_CHAT_DATABASE")
|
|
||||||
if chat_db:
|
|
||||||
configured_dbs.append(chat_db)
|
|
||||||
management_db = APP_CONFIG.get("DB_MANAGEMENT_DATABASE")
|
|
||||||
if management_db:
|
|
||||||
configured_dbs.append(management_db)
|
|
||||||
|
|
||||||
if not configured_dbs:
|
|
||||||
configured_dbs = ["poweron"]
|
|
||||||
|
|
||||||
if not db_name or db_name not in configured_dbs:
|
|
||||||
raise HTTPException(status_code=400, detail=f"Invalid database name. Available databases: {configured_dbs}")
|
|
||||||
|
|
||||||
|
# Validate database exists
|
||||||
try:
|
try:
|
||||||
# Use the appropriate interface based on database name
|
configuredDbs = _getPoweronDatabases()
|
||||||
if db_name == app_db:
|
except Exception as e:
|
||||||
interface = getRootInterface()
|
logger.warning(f"Failed to load databases from host: {e}")
|
||||||
elif db_name == chat_db:
|
configuredDbs = []
|
||||||
from modules.interfaces.interfaceDbChatObjects import getInterface as getChatInterface
|
|
||||||
interface = getChatInterface(currentUser)
|
|
||||||
elif db_name == management_db:
|
|
||||||
from modules.interfaces.interfaceDbComponentObjects import getInterface as getComponentInterface
|
|
||||||
interface = getComponentInterface(currentUser)
|
|
||||||
else:
|
|
||||||
raise HTTPException(status_code=400, detail="Database not found")
|
|
||||||
|
|
||||||
conn = interface.db.connection
|
if configuredDbs and dbName not in configuredDbs:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Database not found. Available: {configuredDbs}")
|
||||||
|
|
||||||
|
connector = None
|
||||||
|
try:
|
||||||
|
connector = _getDatabaseConnector(dbName, currentUser.id)
|
||||||
|
conn = connector.connection
|
||||||
with conn.cursor() as cursor:
|
with conn.cursor() as cursor:
|
||||||
# Drop all user tables (public schema) except system table
|
# Drop all user tables (public schema)
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
SELECT table_name FROM information_schema.tables
|
SELECT table_name FROM information_schema.tables
|
||||||
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
|
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
|
||||||
|
|
@ -449,12 +446,17 @@ async def drop_database(
|
||||||
cursor.execute(f'DROP TABLE IF EXISTS "{tbl}" CASCADE')
|
cursor.execute(f'DROP TABLE IF EXISTS "{tbl}" CASCADE')
|
||||||
dropped.append(tbl)
|
dropped.append(tbl)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
logger.warning(f"Admin drop_database executed by {currentUser.id}: dropped tables from '{db_name}': {dropped}")
|
logger.warning(f"Admin drop_database executed by {currentUser.id}: dropped tables from '{dbName}': {dropped}")
|
||||||
return {"droppedTables": dropped}
|
return {"droppedTables": dropped}
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error dropping database tables: {str(e)}")
|
logger.error(f"Error dropping database tables: {str(e)}")
|
||||||
if 'interface' in locals() and interface and interface.db and interface.db.connection:
|
if connector and connector.connection:
|
||||||
interface.db.connection.rollback()
|
connector.connection.rollback()
|
||||||
raise HTTPException(status_code=500, detail="Failed to drop database tables")
|
raise HTTPException(status_code=500, detail="Failed to drop database tables")
|
||||||
|
finally:
|
||||||
|
if connector:
|
||||||
|
connector.close()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -340,11 +340,12 @@ async def auth_callback(code: str, state: str, request: Request, response: Respo
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create JWT token data (like Microsoft does)
|
# Create JWT token data (like Microsoft does)
|
||||||
|
# MULTI-TENANT: Token does NOT contain mandateId anymore
|
||||||
jwt_token_data = {
|
jwt_token_data = {
|
||||||
"sub": user.username,
|
"sub": user.username,
|
||||||
"mandateId": str(user.mandateId),
|
|
||||||
"userId": str(user.id),
|
"userId": str(user.id),
|
||||||
"authenticationAuthority": AuthAuthority.GOOGLE.value
|
"authenticationAuthority": AuthAuthority.GOOGLE.value
|
||||||
|
# NO mandateId in token - stateless multi-tenant design
|
||||||
}
|
}
|
||||||
|
|
||||||
# Create JWT access token
|
# Create JWT access token
|
||||||
|
|
@ -360,6 +361,7 @@ async def auth_callback(code: str, state: str, request: Request, response: Respo
|
||||||
jti = payload.get("jti")
|
jti = payload.get("jti")
|
||||||
|
|
||||||
# Create JWT token with matching id
|
# Create JWT token with matching id
|
||||||
|
# MULTI-TENANT: Token model no longer has mandateId field
|
||||||
token = Token(
|
token = Token(
|
||||||
id=jti,
|
id=jti,
|
||||||
userId=user.id, # Use local user's ID
|
userId=user.id, # Use local user's ID
|
||||||
|
|
@ -368,8 +370,8 @@ async def auth_callback(code: str, state: str, request: Request, response: Respo
|
||||||
tokenRefresh=token_response.get("refresh_token", ""),
|
tokenRefresh=token_response.get("refresh_token", ""),
|
||||||
tokenType="bearer",
|
tokenType="bearer",
|
||||||
expiresAt=jwt_expires_at.timestamp(),
|
expiresAt=jwt_expires_at.timestamp(),
|
||||||
createdAt=getUtcTimestamp(),
|
createdAt=getUtcTimestamp()
|
||||||
mandateId=str(user.mandateId)
|
# NO mandateId - Token is not mandate-bound
|
||||||
)
|
)
|
||||||
|
|
||||||
# Save access token (no connectionId)
|
# Save access token (no connectionId)
|
||||||
|
|
@ -615,11 +617,12 @@ async def logout(
|
||||||
appInterface.logout()
|
appInterface.logout()
|
||||||
|
|
||||||
# Log successful logout
|
# Log successful logout
|
||||||
|
# MULTI-TENANT: Logout is a system-level function, no mandate context
|
||||||
try:
|
try:
|
||||||
from modules.shared.auditLogger import audit_logger
|
from modules.shared.auditLogger import audit_logger
|
||||||
audit_logger.logUserAccess(
|
audit_logger.logUserAccess(
|
||||||
userId=str(currentUser.id),
|
userId=str(currentUser.id),
|
||||||
mandateId=str(currentUser.mandateId),
|
mandateId="system",
|
||||||
action="logout",
|
action="logout",
|
||||||
successInfo="google_auth_logout"
|
successInfo="google_auth_logout"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -83,11 +83,13 @@ async def login(
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create token data
|
# Create token data
|
||||||
|
# MULTI-TENANT: Token does NOT contain mandateId anymore
|
||||||
|
# Mandate context is determined per request via X-Mandate-Id header
|
||||||
token_data = {
|
token_data = {
|
||||||
"sub": user.username,
|
"sub": user.username,
|
||||||
"mandateId": str(user.mandateId),
|
|
||||||
"userId": str(user.id),
|
"userId": str(user.id),
|
||||||
"authenticationAuthority": AuthAuthority.LOCAL
|
"authenticationAuthority": AuthAuthority.LOCAL
|
||||||
|
# NO mandateId in token - stateless multi-tenant design
|
||||||
}
|
}
|
||||||
|
|
||||||
# Create session id and include in token claims for session-scoped logout
|
# Create session id and include in token claims for session-scoped logout
|
||||||
|
|
@ -116,7 +118,8 @@ async def login(
|
||||||
# Get jti from already decoded payload
|
# Get jti from already decoded payload
|
||||||
jti = payload.get("jti")
|
jti = payload.get("jti")
|
||||||
|
|
||||||
# Create token
|
# Create token record in database
|
||||||
|
# MULTI-TENANT: Token model no longer has mandateId field
|
||||||
token = Token(
|
token = Token(
|
||||||
id=jti,
|
id=jti,
|
||||||
userId=user.id,
|
userId=user.id,
|
||||||
|
|
@ -124,19 +127,20 @@ async def login(
|
||||||
tokenAccess=access_token,
|
tokenAccess=access_token,
|
||||||
tokenType="bearer",
|
tokenType="bearer",
|
||||||
expiresAt=expires_at.timestamp(),
|
expiresAt=expires_at.timestamp(),
|
||||||
sessionId=session_id,
|
sessionId=session_id
|
||||||
mandateId=str(user.mandateId)
|
# NO mandateId - Token is not mandate-bound
|
||||||
)
|
)
|
||||||
|
|
||||||
# Save access token
|
# Save access token
|
||||||
userInterface.saveAccessToken(token)
|
userInterface.saveAccessToken(token)
|
||||||
|
|
||||||
# Log successful login
|
# Log successful login
|
||||||
|
# MULTI-TENANT: Login is a system-level function, no mandate context
|
||||||
try:
|
try:
|
||||||
from modules.shared.auditLogger import audit_logger
|
from modules.shared.auditLogger import audit_logger
|
||||||
audit_logger.logUserAccess(
|
audit_logger.logUserAccess(
|
||||||
userId=str(user.id),
|
userId=str(user.id),
|
||||||
mandateId=str(user.mandateId),
|
mandateId="system",
|
||||||
action="login",
|
action="login",
|
||||||
successInfo="local_auth_success"
|
successInfo="local_auth_success"
|
||||||
)
|
)
|
||||||
|
|
@ -236,7 +240,6 @@ async def register_user(
|
||||||
fullName=userData.fullName,
|
fullName=userData.fullName,
|
||||||
language=userData.language,
|
language=userData.language,
|
||||||
enabled=True, # Users are enabled by default (can login after setting password)
|
enabled=True, # Users are enabled by default (can login after setting password)
|
||||||
roleLabels=["user"], # Default role for new registrations
|
|
||||||
authenticationAuthority=AuthAuthority.LOCAL
|
authenticationAuthority=AuthAuthority.LOCAL
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -358,11 +361,12 @@ async def refresh_token(
|
||||||
raise HTTPException(status_code=500, detail="Failed to validate user")
|
raise HTTPException(status_code=500, detail="Failed to validate user")
|
||||||
|
|
||||||
# Create new token data
|
# Create new token data
|
||||||
|
# MULTI-TENANT: Token does NOT contain mandateId anymore
|
||||||
token_data = {
|
token_data = {
|
||||||
"sub": current_user.username,
|
"sub": current_user.username,
|
||||||
"mandateId": str(current_user.mandateId),
|
|
||||||
"userId": str(current_user.id),
|
"userId": str(current_user.id),
|
||||||
"authenticationAuthority": current_user.authenticationAuthority
|
"authenticationAuthority": current_user.authenticationAuthority
|
||||||
|
# NO mandateId in token
|
||||||
}
|
}
|
||||||
|
|
||||||
# Create new access token + set cookie
|
# Create new access token + set cookie
|
||||||
|
|
@ -427,11 +431,12 @@ async def logout(request: Request, response: Response, currentUser: User = Depen
|
||||||
revoked = 1
|
revoked = 1
|
||||||
|
|
||||||
# Log successful logout
|
# Log successful logout
|
||||||
|
# MULTI-TENANT: Logout is a system-level function, no mandate context
|
||||||
try:
|
try:
|
||||||
from modules.shared.auditLogger import audit_logger
|
from modules.shared.auditLogger import audit_logger
|
||||||
audit_logger.logUserAccess(
|
audit_logger.logUserAccess(
|
||||||
userId=str(currentUser.id),
|
userId=str(currentUser.id),
|
||||||
mandateId=str(currentUser.mandateId),
|
mandateId="system",
|
||||||
action="logout",
|
action="logout",
|
||||||
successInfo=f"revoked_tokens: {revoked}"
|
successInfo=f"revoked_tokens: {revoked}"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -348,11 +348,12 @@ async def auth_callback(code: str, state: str, request: Request, response: Respo
|
||||||
appInterface.saveAccessToken(token)
|
appInterface.saveAccessToken(token)
|
||||||
|
|
||||||
# Create JWT token data
|
# Create JWT token data
|
||||||
|
# MULTI-TENANT: Token does NOT contain mandateId anymore
|
||||||
jwt_token_data = {
|
jwt_token_data = {
|
||||||
"sub": user.username,
|
"sub": user.username,
|
||||||
"mandateId": str(user.mandateId),
|
|
||||||
"userId": str(user.id),
|
"userId": str(user.id),
|
||||||
"authenticationAuthority": AuthAuthority.MSFT.value
|
"authenticationAuthority": AuthAuthority.MSFT.value
|
||||||
|
# NO mandateId in token - stateless multi-tenant design
|
||||||
}
|
}
|
||||||
|
|
||||||
# Create JWT access token
|
# Create JWT access token
|
||||||
|
|
@ -368,6 +369,7 @@ async def auth_callback(code: str, state: str, request: Request, response: Respo
|
||||||
jti = payload.get("jti")
|
jti = payload.get("jti")
|
||||||
|
|
||||||
# Create JWT token with matching id
|
# Create JWT token with matching id
|
||||||
|
# MULTI-TENANT: Token model no longer has mandateId field
|
||||||
jwt_token_obj = Token(
|
jwt_token_obj = Token(
|
||||||
id=jti,
|
id=jti,
|
||||||
userId=user.id,
|
userId=user.id,
|
||||||
|
|
@ -375,8 +377,8 @@ async def auth_callback(code: str, state: str, request: Request, response: Respo
|
||||||
tokenAccess=jwt_token,
|
tokenAccess=jwt_token,
|
||||||
tokenType="bearer",
|
tokenType="bearer",
|
||||||
expiresAt=jwt_expires_at.timestamp(),
|
expiresAt=jwt_expires_at.timestamp(),
|
||||||
createdAt=getUtcTimestamp(),
|
createdAt=getUtcTimestamp()
|
||||||
mandateId=str(user.mandateId)
|
# NO mandateId - Token is not mandate-bound
|
||||||
)
|
)
|
||||||
|
|
||||||
# Save JWT access token
|
# Save JWT access token
|
||||||
|
|
@ -625,11 +627,12 @@ async def logout(
|
||||||
appInterface.logout()
|
appInterface.logout()
|
||||||
|
|
||||||
# Log successful logout
|
# Log successful logout
|
||||||
|
# MULTI-TENANT: Logout is a system-level function, no mandate context
|
||||||
try:
|
try:
|
||||||
from modules.shared.auditLogger import audit_logger
|
from modules.shared.auditLogger import audit_logger
|
||||||
audit_logger.logUserAccess(
|
audit_logger.logUserAccess(
|
||||||
userId=str(currentUser.id),
|
userId=str(currentUser.id),
|
||||||
mandateId=str(currentUser.mandateId),
|
mandateId="system",
|
||||||
action="logout",
|
action="logout",
|
||||||
successInfo="microsoft_auth_logout"
|
successInfo="microsoft_auth_logout"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,24 @@
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""
|
"""
|
||||||
RBAC interface: Core RBAC logic and permission resolution.
|
RBAC interface: Core RBAC logic and permission resolution.
|
||||||
Moved from interfaces to security module to maintain proper architectural layering.
|
|
||||||
Connectors can import from security, but not from interfaces.
|
Multi-Tenant Design:
|
||||||
|
- AccessRules referenzieren roleId (FK), nicht roleLabel
|
||||||
|
- Rollen werden über UserMandate + UserMandateRole geladen
|
||||||
|
- Priorisierung: Instance > Mandate > Global
|
||||||
|
- Stateless Design: Kein Cache, direkt aus DB
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import List, Optional, Dict, Any, TYPE_CHECKING
|
from typing import List, Optional, TYPE_CHECKING
|
||||||
from modules.datamodels.datamodelRbac import AccessRule, AccessRuleContext
|
from modules.datamodels.datamodelRbac import AccessRule, AccessRuleContext, Role
|
||||||
from modules.datamodels.datamodelUam import User, UserPermissions, AccessLevel
|
from modules.datamodels.datamodelUam import User, UserPermissions, AccessLevel
|
||||||
|
from modules.datamodels.datamodelMembership import (
|
||||||
|
UserMandate,
|
||||||
|
UserMandateRole,
|
||||||
|
FeatureAccess,
|
||||||
|
FeatureAccessRole
|
||||||
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
||||||
|
|
@ -20,6 +30,11 @@ logger = logging.getLogger(__name__)
|
||||||
class RbacClass:
|
class RbacClass:
|
||||||
"""
|
"""
|
||||||
RBAC interface for permission resolution and rule validation.
|
RBAC interface for permission resolution and rule validation.
|
||||||
|
|
||||||
|
Multi-Tenant Design:
|
||||||
|
- Lädt Rollen über UserMandate + UserMandateRole
|
||||||
|
- AccessRules werden über roleId gefunden
|
||||||
|
- isSysAdmin für System-Level Operationen (ohne Mandant)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, db: "DatabaseConnector", dbApp: "DatabaseConnector"):
|
def __init__(self, db: "DatabaseConnector", dbApp: "DatabaseConnector"):
|
||||||
|
|
@ -34,14 +49,27 @@ class RbacClass:
|
||||||
self.db = db
|
self.db = db
|
||||||
self.dbApp = dbApp
|
self.dbApp = dbApp
|
||||||
|
|
||||||
def getUserPermissions(self, user: User, context: AccessRuleContext, item: str) -> UserPermissions:
|
def getUserPermissions(
|
||||||
|
self,
|
||||||
|
user: User,
|
||||||
|
context: AccessRuleContext,
|
||||||
|
item: str,
|
||||||
|
mandateId: Optional[str] = None,
|
||||||
|
featureInstanceId: Optional[str] = None
|
||||||
|
) -> UserPermissions:
|
||||||
"""
|
"""
|
||||||
Get combined permissions for a user across all their roles.
|
Get combined permissions for a user across all their roles.
|
||||||
|
|
||||||
|
Multi-Tenant Design:
|
||||||
|
- Lädt Rollen aus UserMandate + UserMandateRole wenn mandateId gegeben
|
||||||
|
- isSysAdmin gibt vollen Zugriff auf System-Level (kein mandateId)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user: User object with roleLabels
|
user: User object
|
||||||
context: Access rule context (DATA, UI, RESOURCE)
|
context: Access rule context (DATA, UI, RESOURCE)
|
||||||
item: Item identifier (table name, UI path, resource path)
|
item: Item identifier (table name, UI path, resource path)
|
||||||
|
mandateId: Optional mandate context for role lookup
|
||||||
|
featureInstanceId: Optional feature instance context
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
UserPermissions object with combined permissions
|
UserPermissions object with combined permissions
|
||||||
|
|
@ -54,23 +82,37 @@ class RbacClass:
|
||||||
delete=AccessLevel.NONE
|
delete=AccessLevel.NONE
|
||||||
)
|
)
|
||||||
|
|
||||||
if not hasattr(user, 'roleLabels') or not user.roleLabels:
|
# SysAdmin auf System-Level (kein Mandant) hat vollen Zugriff
|
||||||
|
if hasattr(user, 'isSysAdmin') and user.isSysAdmin and not mandateId:
|
||||||
|
return UserPermissions(
|
||||||
|
view=True,
|
||||||
|
read=AccessLevel.ALL,
|
||||||
|
create=AccessLevel.ALL,
|
||||||
|
update=AccessLevel.ALL,
|
||||||
|
delete=AccessLevel.ALL
|
||||||
|
)
|
||||||
|
|
||||||
|
# Lade Role-IDs für den User via UserMandate + UserMandateRole
|
||||||
|
roleIds = self._getRoleIdsForUser(user, mandateId, featureInstanceId)
|
||||||
|
|
||||||
|
if not roleIds:
|
||||||
return permissions
|
return permissions
|
||||||
|
|
||||||
# Step 1: For each role, find the most specific matching rule (most specific wins within role)
|
# Lade alle relevanten Regeln für alle Rollen
|
||||||
|
allRulesWithPriority = self._getRulesForRoleIds(roleIds, context, mandateId, featureInstanceId)
|
||||||
|
|
||||||
|
# Für jede Rolle die spezifischste Regel finden
|
||||||
rolePermissions = {}
|
rolePermissions = {}
|
||||||
for roleLabel in user.roleLabels:
|
for priority, rule in allRulesWithPriority:
|
||||||
# Get all rules for this role and context
|
# Find most specific rule for this item
|
||||||
allRules = self._getRulesForRole(roleLabel, context)
|
if self._ruleMatchesItem(rule, item):
|
||||||
|
roleId = rule.roleId
|
||||||
|
# Speichere mit Priorität (höhere Priorität überschreibt)
|
||||||
|
if roleId not in rolePermissions or priority > rolePermissions[roleId][0]:
|
||||||
|
rolePermissions[roleId] = (priority, rule)
|
||||||
|
|
||||||
# Find most specific rule for this item (longest matching prefix)
|
# Combine permissions across roles using opening (union) logic
|
||||||
mostSpecificRule = self.findMostSpecificRule(allRules, item)
|
for roleId, (priority, rule) in rolePermissions.items():
|
||||||
|
|
||||||
if mostSpecificRule:
|
|
||||||
rolePermissions[roleLabel] = mostSpecificRule
|
|
||||||
|
|
||||||
# Step 2: Combine permissions across roles using opening (union) logic
|
|
||||||
for roleLabel, rule in rolePermissions.items():
|
|
||||||
# View: union logic - if ANY role has view=true, then view=true
|
# View: union logic - if ANY role has view=true, then view=true
|
||||||
if rule.view:
|
if rule.view:
|
||||||
permissions.view = True
|
permissions.view = True
|
||||||
|
|
@ -88,6 +130,274 @@ class RbacClass:
|
||||||
|
|
||||||
return permissions
|
return permissions
|
||||||
|
|
||||||
|
def _getRoleIdsForUser(
|
||||||
|
self,
|
||||||
|
user: User,
|
||||||
|
mandateId: Optional[str],
|
||||||
|
featureInstanceId: Optional[str]
|
||||||
|
) -> List[str]:
|
||||||
|
"""
|
||||||
|
Get all role IDs for a user in the given context.
|
||||||
|
Uses UserMandate + UserMandateRole for the new multi-tenant model.
|
||||||
|
|
||||||
|
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]:
|
def findMostSpecificRule(self, rules: List[AccessRule], item: str) -> Optional[AccessRule]:
|
||||||
"""
|
"""
|
||||||
Find the most specific rule for an item (longest matching prefix wins).
|
Find the most specific rule for an item (longest matching prefix wins).
|
||||||
|
|
@ -105,7 +415,6 @@ class RbacClass:
|
||||||
return genericRules[0] if genericRules else None
|
return genericRules[0] if genericRules else None
|
||||||
|
|
||||||
# Find longest matching prefix
|
# Find longest matching prefix
|
||||||
itemParts = item.split(".")
|
|
||||||
bestMatch = None
|
bestMatch = None
|
||||||
bestMatchLength = -1
|
bestMatchLength = -1
|
||||||
|
|
||||||
|
|
@ -176,39 +485,3 @@ class RbacClass:
|
||||||
AccessLevel.ALL: 3
|
AccessLevel.ALL: 3
|
||||||
}
|
}
|
||||||
return hierarchy.get(level1, 0) > hierarchy.get(level2, 0)
|
return hierarchy.get(level1, 0) > hierarchy.get(level2, 0)
|
||||||
|
|
||||||
def _getRulesForRole(self, roleLabel: str, context: AccessRuleContext) -> List[AccessRule]:
|
|
||||||
"""
|
|
||||||
Get all access rules for a specific role and context.
|
|
||||||
Always queries from DbApp database, not the current database.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
roleLabel: Role label to get rules for
|
|
||||||
context: Context type
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of AccessRule objects
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Always use DbApp database for AccessRule queries
|
|
||||||
rules = self.dbApp.getRecordset(
|
|
||||||
AccessRule,
|
|
||||||
recordFilter={
|
|
||||||
"roleLabel": roleLabel,
|
|
||||||
"context": context.value
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Convert dict records to AccessRule objects
|
|
||||||
accessRules = []
|
|
||||||
for record in rules:
|
|
||||||
try:
|
|
||||||
accessRule = AccessRule(**record)
|
|
||||||
accessRules.append(accessRule)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error converting rule record to AccessRule: {e}, record={record}")
|
|
||||||
|
|
||||||
return accessRules
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting rules for role {roleLabel} and context {context.value}: {e}", exc_info=True)
|
|
||||||
return []
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@
|
||||||
"""
|
"""
|
||||||
Root access management for system-level operations.
|
Root access management for system-level operations.
|
||||||
Provides secure access to root user and DbApp database connector.
|
Provides secure access to root user and DbApp database connector.
|
||||||
|
|
||||||
|
Bei leerer Datenbank wird automatisch Bootstrap ausgeführt.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
@ -14,6 +16,7 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_rootDbAppConnector = None
|
_rootDbAppConnector = None
|
||||||
_rootUser = None
|
_rootUser = None
|
||||||
|
_bootstrapExecuted = False
|
||||||
|
|
||||||
def getRootDbAppConnector() -> DatabaseConnector:
|
def getRootDbAppConnector() -> DatabaseConnector:
|
||||||
"""
|
"""
|
||||||
|
|
@ -24,29 +27,62 @@ def getRootDbAppConnector() -> DatabaseConnector:
|
||||||
|
|
||||||
if _rootDbAppConnector is None:
|
if _rootDbAppConnector is None:
|
||||||
_rootDbAppConnector = DatabaseConnector(
|
_rootDbAppConnector = DatabaseConnector(
|
||||||
dbHost=APP_CONFIG.get("DB_APP_HOST"),
|
dbHost=APP_CONFIG.get("DB_HOST"),
|
||||||
dbDatabase=APP_CONFIG.get("DB_APP_DATABASE", "app"),
|
dbDatabase="poweron_app",
|
||||||
dbUser=APP_CONFIG.get("DB_APP_USER"),
|
dbUser=APP_CONFIG.get("DB_USER"),
|
||||||
dbPassword=APP_CONFIG.get("DB_APP_PASSWORD_SECRET"),
|
dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET"),
|
||||||
dbPort=int(APP_CONFIG.get("DB_APP_PORT", 5432)),
|
dbPort=int(APP_CONFIG.get("DB_PORT", 5432)),
|
||||||
userId=None # No user context for root connector
|
userId=None # No user context for root connector
|
||||||
)
|
)
|
||||||
_rootDbAppConnector.initDbSystem()
|
_rootDbAppConnector.initDbSystem()
|
||||||
|
|
||||||
return _rootDbAppConnector
|
return _rootDbAppConnector
|
||||||
|
|
||||||
|
|
||||||
|
def _ensureBootstrap():
|
||||||
|
"""
|
||||||
|
Führt Bootstrap aus, falls noch nicht geschehen.
|
||||||
|
Wird automatisch aufgerufen, wenn getRootUser() keinen User findet.
|
||||||
|
"""
|
||||||
|
global _bootstrapExecuted
|
||||||
|
|
||||||
|
if _bootstrapExecuted:
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info("Running bootstrap to initialize database")
|
||||||
|
|
||||||
|
# Import here to avoid circular imports
|
||||||
|
from modules.interfaces.interfaceBootstrap import initBootstrap
|
||||||
|
|
||||||
|
dbApp = getRootDbAppConnector()
|
||||||
|
initBootstrap(dbApp)
|
||||||
|
|
||||||
|
_bootstrapExecuted = True
|
||||||
|
logger.info("Bootstrap completed")
|
||||||
|
|
||||||
|
|
||||||
def getRootUser() -> User:
|
def getRootUser() -> User:
|
||||||
"""
|
"""
|
||||||
Returns the root user (initial user from database).
|
Returns the root user (initial user from database).
|
||||||
Used for system-level operations that require root privileges.
|
Used for system-level operations that require root privileges.
|
||||||
|
|
||||||
|
Falls kein User existiert, wird Bootstrap automatisch ausgeführt.
|
||||||
"""
|
"""
|
||||||
global _rootUser
|
global _rootUser
|
||||||
|
|
||||||
if _rootUser is None:
|
if _rootUser is None:
|
||||||
dbApp = getRootDbAppConnector()
|
dbApp = getRootDbAppConnector()
|
||||||
initialUserId = dbApp.getInitialId(UserInDB)
|
initialUserId = dbApp.getInitialId(UserInDB)
|
||||||
|
|
||||||
|
# Wenn kein User existiert, Bootstrap ausführen
|
||||||
if not initialUserId:
|
if not initialUserId:
|
||||||
raise ValueError("No initial user ID found in database")
|
logger.info("No initial user found, running bootstrap")
|
||||||
|
_ensureBootstrap()
|
||||||
|
|
||||||
|
# Nochmal versuchen nach Bootstrap
|
||||||
|
initialUserId = dbApp.getInitialId(UserInDB)
|
||||||
|
if not initialUserId:
|
||||||
|
raise ValueError("No initial user ID found in database after bootstrap")
|
||||||
|
|
||||||
users = dbApp.getRecordset(UserInDB, recordFilter={"id": initialUserId})
|
users = dbApp.getRecordset(UserInDB, recordFilter={"id": initialUserId})
|
||||||
if not users:
|
if not users:
|
||||||
|
|
@ -56,4 +92,3 @@ def getRootUser() -> User:
|
||||||
_rootUser = User(**user_data)
|
_rootUser = User(**user_data)
|
||||||
|
|
||||||
return _rootUser
|
return _rootUser
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
from typing import Any
|
from typing import Any, Optional
|
||||||
|
|
||||||
from modules.datamodels.datamodelUam import User
|
from modules.datamodels.datamodelUam import User
|
||||||
from modules.datamodels.datamodelChat import ChatWorkflow
|
from modules.datamodels.datamodelChat import ChatWorkflow
|
||||||
|
|
@ -40,25 +40,26 @@ class PublicService:
|
||||||
|
|
||||||
class Services:
|
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.user: User = user
|
||||||
self.workflow: ChatWorkflow = workflow
|
self.workflow: ChatWorkflow = workflow
|
||||||
|
self.mandateId: Optional[str] = mandateId
|
||||||
self.currentUserPrompt: str = "" # Cleaned/normalized user intent for the current round
|
self.currentUserPrompt: str = "" # Cleaned/normalized user intent for the current round
|
||||||
self.rawUserPrompt: str = "" # Original raw user message 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
|
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
|
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
|
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
|
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
|
# Expose RBAC directly on services for convenience
|
||||||
self.rbac = self.interfaceDbApp.rbac if self.interfaceDbApp else None
|
self.rbac = self.interfaceDbApp.rbac if self.interfaceDbApp else None
|
||||||
|
|
@ -99,7 +100,15 @@ class Services:
|
||||||
self.messaging = PublicService(MessagingService(self))
|
self.messaging = PublicService(MessagingService(self))
|
||||||
|
|
||||||
|
|
||||||
def getInterface(user: User, workflow: ChatWorkflow) -> Services:
|
def getInterface(user: User, workflow: ChatWorkflow = None, mandateId: Optional[str] = None) -> Services:
|
||||||
return Services(user, workflow)
|
"""
|
||||||
|
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,
|
"currentAction": 0,
|
||||||
"totalTasks": 0,
|
"totalTasks": 0,
|
||||||
"totalActions": 0,
|
"totalActions": 0,
|
||||||
"mandateId": self.services.user.mandateId,
|
"mandateId": self.services.mandateId,
|
||||||
"messageIds": [],
|
"messageIds": [],
|
||||||
"workflowMode": workflowMode,
|
"workflowMode": workflowMode,
|
||||||
"maxSteps": 10 , # Set maxSteps
|
"maxSteps": 10 , # Set maxSteps
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue