From 0a0973d41bcbe249ab3d78b4b64ab018c1412080 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Sat, 21 Mar 2026 01:34:40 +0100 Subject: [PATCH 1/2] hotfix msft/google login tokens end to end separated from connection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat(billing): Nutzerhinweise bei leerem Budget + Mandats-Mail (402/SSE) Gateway - InsufficientBalanceException: billingModel, userAction (TOP_UP_SELF / CONTACT_MANDATE_ADMIN), DE/EN-Texte, toClientDict(), fromBalanceCheck() - HTTP 402 + JSON detail für globale API-Fehlerbehandlung - AI/Chatbot: vor Raise ggf. E-Mail an BillingSettings.notifyEmails (PREPAY_MANDATE, Throttle 1h/Mandat) via billingExhaustedNotify - Agent-Loop & Workspace-Route: SSE-ERROR mit strukturiertem Billing-Payload - datamodelBilling: notifyEmails-Doku für Pool-Alerts frontend_nyla - useWorkspace: SSE onError für INSUFFICIENT_BALANCE mit messageDe/En und Hinweis auf Billing-Pfad bei TOP_UP_SELF --- app.py | 16 +- env_dev.env | 24 +- env_int.env | 24 +- env_prod.env | 24 +- modules/auth/authentication.py | 42 +- modules/auth/csrf.py | 17 +- modules/auth/oauthProviderConfig.py | 42 + modules/auth/tokenManager.py | 38 +- modules/datamodels/datamodelBilling.py | 76 +- modules/datamodels/datamodelSecurity.py | 32 +- modules/features/chatbot/service.py | 26 +- modules/features/workspace/mainWorkspace.py | 9 +- .../workspace/routeFeatureWorkspace.py | 24 +- modules/interfaces/interfaceBootstrap.py | 28 +- modules/interfaces/interfaceDbApp.py | 104 +- modules/interfaces/interfaceDbBilling.py | 341 ++--- modules/interfaces/interfaceRbac.py | 24 +- modules/routes/routeAdminFeatures.py | 36 + modules/routes/routeBilling.py | 62 +- modules/routes/routeDataConnections.py | 19 +- modules/routes/routeDataMandates.py | 61 + modules/routes/routeNotifications.py | 25 + modules/routes/routeSecurityGoogle.py | 1105 ++++++++-------- modules/routes/routeSecurityLocal.py | 3 +- modules/routes/routeSecurityMsft.py | 1137 ++++++++--------- modules/routes/routeStore.py | 61 +- .../serviceSecurity/mainServiceSecurity.py | 12 +- .../services/serviceAgent/agentLoop.py | 15 + .../services/serviceAi/mainServiceAi.py | 21 +- .../serviceBilling/billingExhaustedNotify.py | 138 ++ .../serviceBilling/mainServiceBilling.py | 127 +- modules/system/mainSystem.py | 5 + 32 files changed, 2179 insertions(+), 1539 deletions(-) create mode 100644 modules/auth/oauthProviderConfig.py create mode 100644 modules/serviceCenter/services/serviceBilling/billingExhaustedNotify.py diff --git a/app.py b/app.py index 41873e7d..0c769a2a 100644 --- a/app.py +++ b/app.py @@ -8,7 +8,8 @@ from urllib.parse import quote_plus os.environ["NUMEXPR_MAX_THREADS"] = "12" -from fastapi import FastAPI +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse from fastapi.middleware.cors import CORSMiddleware from fastapi.security import HTTPBearer from contextlib import asynccontextmanager @@ -493,6 +494,19 @@ from slowapi import _rate_limit_exceeded_handler app.state.limiter = limiter app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) + +async def _insufficientBalanceHandler(request: Request, exc: Exception): + """HTTP 402 with structured billing hint (PREPAY_USER vs PREPAY_MANDATE).""" + payload = exc.toClientDict() if hasattr(exc, "toClientDict") else {"error": "INSUFFICIENT_BALANCE", "message": str(exc)} + return JSONResponse(status_code=402, content={"detail": payload}) + + +from modules.serviceCenter.services.serviceBilling.mainServiceBilling import ( + InsufficientBalanceException, +) + +app.add_exception_handler(InsufficientBalanceException, _insufficientBalanceHandler) + # CSRF protection middleware from modules.auth import CSRFMiddleware from modules.auth import ( diff --git a/env_dev.env b/env_dev.env index 7dfa75e4..e6643ca9 100644 --- a/env_dev.env +++ b/env_dev.env @@ -31,9 +31,20 @@ APP_LOGGING_FILE_ENABLED = True APP_LOGGING_ROTATION_SIZE = 10485760 APP_LOGGING_BACKUP_COUNT = 5 -# Service Redirects -Service_MSFT_REDIRECT_URI = http://localhost:8000/api/msft/auth/callback -Service_GOOGLE_REDIRECT_URI = http://localhost:8000/api/google/auth/callback +# OAuth: Auth app (login/JWT) vs Data app (Microsoft Graph / Google APIs). Same IDs until you split apps in Azure / GCP. +Service_MSFT_AUTH_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c +Service_MSFT_AUTH_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQm83T29rV1pQelMtc1p1MXR4NTFpa19CTEhHQ0xfNmdPUmZqcWp5UHBMS0hYTGl4c1pPdmhTNTJVWUl5WnlnUUZhV0VTRzVCb0d5YjR1NnZPZk5CZ0dGazNGdUJVbjkxeVdrYlNiVjJUYzF2aVFtQnVxTHFqTTJqZlF0RTFGNmE1OGN1TEk= +Service_MSFT_AUTH_REDIRECT_URI = http://localhost:8000/api/msft/auth/login/callback +Service_MSFT_DATA_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c +Service_MSFT_DATA_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQm83T29rV1pQelMtc1p1MXR4NTFpa19CTEhHQ0xfNmdPUmZqcWp5UHBMS0hYTGl4c1pPdmhTNTJVWUl5WnlnUUZhV0VTRzVCb0d5YjR1NnZPZk5CZ0dGazNGdUJVbjkxeVdrYlNiVjJUYzF2aVFtQnVxTHFqTTJqZlF0RTFGNmE1OGN1TEk= +Service_MSFT_DATA_REDIRECT_URI = http://localhost:8000/api/msft/auth/connect/callback + +Service_GOOGLE_AUTH_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com +Service_GOOGLE_AUTH_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpETDJhbGVQMHlFQzNPVFI1ZzBMa3pNMGlQUHhaQm10eVl1bFlSeTBybzlTOWE2MURXQ0hkRlo0NlNGbHQxWEl1OVkxQnVKYlhhOXR1cUF4T3k0WDdscktkY1oyYllRTmdDTWpfbUdwWGtSd1JvNlYxeTBJdEtaaS1vYnItcW0yaFM= +Service_GOOGLE_AUTH_REDIRECT_URI = http://localhost:8000/api/google/auth/login/callback +Service_GOOGLE_DATA_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com +Service_GOOGLE_DATA_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpETDJhbGVQMHlFQzNPVFI1ZzBMa3pNMGlQUHhaQm10eVl1bFlSeTBybzlTOWE2MURXQ0hkRlo0NlNGbHQxWEl1OVkxQnVKYlhhOXR1cUF4T3k0WDdscktkY1oyYllRTmdDTWpfbUdwWGtSd1JvNlYxeTBJdEtaaS1vYnItcW0yaFM= +Service_GOOGLE_DATA_REDIRECT_URI = http://localhost:8000/api/google/auth/connect/callback # Stripe Billing (both end with _SECRET for encryption script) STRIPE_SECRET_KEY_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGWDkxSldfM0NCZ3dmbHY5cS1nQlI3UWZ4ZWRrNVdUdEFKa25RckRiQWY0c1E5MjVsZzlfRkZEU0VFU2tNQ01qZnRNQ0pZVU9hVFN6OEU0RXhwdTl3algzLWJlSXRhYmZlMHltSC1XejlGWEU5TDF1LUlYNEh1aG9tRFI4YmlCYzUyei02U1dabWoyb0N2dVFSb1RhWTNnQjBCZkFjV0FfOWdYdDVpX1k5R2pYM1R6SHRiaE10V1l1dnQybjVHWDRiQUJLM0UxRDZnczhJZGFsc3JhOU82QT09 @@ -48,15 +59,8 @@ Connector_AiTavily_API_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEQTdnUHMwd2pIaXNtMmtCTFRE Connector_AiPrivateLlm_API_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGRHM5eFdUVmVZU1R1cHBwN1RlMUx4T0NlLTJLUFFVX3J2OElDWFpuZmJHVmp4Z3BNNWMwZUVVZUd2TFhRSjVmVkVlcFlVRWtybXh0ZHloZ01ZcnVvX195YjdlWVdEcjZSWFFTTlNBWUlaTlNoLWhqVFBIb0thVlBiaWhjYjFQOFY= Connector_AiMistral_API_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGeEQxYUIxOHhia0JlQWpWQ2dWQWZzY3l6SWwyUnJoR1hRQWloX2lxb2lGNkc4UnA4U2tWNjJaYzB1d1hvNG9fWUp1N3V4OW9FMGhaWVhjSlVwWEc1X2loVDBSZDEtdHdfcTA5QkcxQTR4OHc4RkRzclJrU2d1RFZpNDJkRDRURlE= -# Microsoft Service Configuration -Service_MSFT_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c -Service_MSFT_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQm83T29rV1pQelMtc1p1MXR4NTFpa19CTEhHQ0xfNmdPUmZqcWp5UHBMS0hYTGl4c1pPdmhTNTJVWUl5WnlnUUZhV0VTRzVCb0d5YjR1NnZPZk5CZ0dGazNGdUJVbjkxeVdrYlNiVjJUYzF2aVFtQnVxTHFqTTJqZlF0RTFGNmE1OGN1TEk= Service_MSFT_TENANT_ID = common -# Google Service configuration -Service_GOOGLE_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com -Service_GOOGLE_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpETDJhbGVQMHlFQzNPVFI1ZzBMa3pNMGlQUHhaQm10eVl1bFlSeTBybzlTOWE2MURXQ0hkRlo0NlNGbHQxWEl1OVkxQnVKYlhhOXR1cUF4T3k0WDdscktkY1oyYllRTmdDTWpfbUdwWGtSd1JvNlYxeTBJdEtaaS1vYnItcW0yaFM= - # Google Cloud Speech Services configuration Connector_GoogleSpeech_API_KEY_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpETk5FWWM3Q0JKMzhIYTlyMkhuNjA4NlF4dk82U2NScHhTVGY3UG83NkhfX3RrcWVtWWcyLXRjU1dTT21zWEl6YWRMMUFndXpsUnJOeHh3QThsNDZKRXROTzdXRUdsT0JZajZJNVlfb0gtMXkwWm9DOERPVnpjU0pyUEZfOGJsUnprT3ltMVVhalUyUm9hMUFtZEtHUnJqOGZ4dEZjZm5SWVVTckVCWnY1UkdVSHVmUlgwbnAyc0xDQW84R3ViSko5OHVCVWZRUVNiaG1pVFB6X3EwS0FPd2dUYjhiSmRjcXh2WEZiXzI4SFZqT21tbDduUWRyVWdFZXpmcVM5ZDR0VWtzZnF5UER6cGwwS2JlLV9CSTZ0Z0IyQ1h0YW9TcmhRTXZEckp4bWhmTkt6UTNYMk4zVkpnbUJmaDIxZnoyR2dWTEYwTUFEV0w2eUdUUGpoZk9XRkt4RVF1Z1NPdUpBeTcyWV9PY1Ffd2s0ZEdVekxGekhoeEl4TmNqaXYtbUJuSVdycFducERWdWtZajZnX011Q2w4eE9VMTBqQ1ZxRmdScWhXY1E3WWhzX1JZcHhxam9FbDVPN3Q1MWtrMUZuTUg3LVFQVHp1T1hpQWNDMzEzekVJWk9ybl91YUVjSkFob1VaMi1ONEtuMnRSOEg1S3QybUMwbVZDejItajBLTjM2Zy1hNzZQMW5LLVVDVGdFWm5BZUxNeEFnUkZzU3dxV0lCUlc0LWo4b05GczVpOGZSV2ZxbFBwUml6OU5tYjdnTks3Y3hrVEZVTHlmc1NPdFh4WE5pWldEZklOQUxBbjBpMTlkX3FFQVJ6c2NSZGdzTThycE92VW82enZKamhiRGFnU25aZGlHZHhZd2lUUmhuTVptNjhoWVlJQkxIOEkzbzJNMjZCZFJyM25tdXBnQ2ZWaHV3b2p6UWJpdk9xUEhBc1dyTlNmeF9wbm5yYUhHV01UZnVXWDFlNzBkdXlWUWhvcmJpSmljbmE3LUpUZEg4VzRwZ2JVSjdYUm1sODViQXVxUzdGTmZFbVpiN2V1YW5XV3U4b2VRWmxldGVGVHZsSldoekhVLU9wZ2V0cGZIYkNqM2pXVGctQVAyUm4xTHhpd1VVLXFhcnVEV21Rby1hbTlqTl84TjVveHdYTExUVkhHQ0ltaTB2WXJnY1NQVE5PbWg3ejgySElYc1JSTlQ3NDlFUWR6STZVUjVqaXFRN200NF9LY1ljQ0R2UldlWUtKY1NQVnJ4QXRyYTBGSWVuenhyM0Z0cWtndTd1eG8xRzY5a2dNZ1hkQm5MV3BHVzA2N1QwUkd6WlRGYTZQOUhnVWQ2S0Y5U0s1dXFNVXh5Q2pLWVUxSUQ2MlR1ak52NmRIZ2hlYTk1SGZGWS1RV3hWVU9rR3d1Rk9MLS11REZXbzhqMHpsSm1HYW1jMUNLT29YOHZsRWNaLTVvOFpmT3l3MHVwaERTT0dNLWFjcGRYZ25qT2szTkVFUnRFR3JWYS1aNXFIRnMyalozTlQzNFF2NXJLVHVPVF9zdTF6ZjlkbzJ4RFc2ZENmNFFxZDZzTzhfMUl0bW96V0lPZkh1dXFYZlEteFBlSG84Si1FNS1TTi1OMkFnX2pOYW8xY3MxMVJnVC02MDUyaXZfMEVHWDQtVlRpcENmV0h3V0dCWEFRS2prQXdNRlQ5dnRFVHU0Q1dNTmh0SlBCaU55bFMydWM1TTFFLW96ODBnV3dNZHFZTWZhRURYSHlrdzF3RlRuWDBoQUhSOUJWemtRM3pxcDJFbGJoaTJ3ZktRTlJxbXltaHBoZXVJVDlxS3cxNWo2c0ZBV0NzaUstRWdsMW1xLXFkanZGYUFiU0tSLXFQa0tkcDFoMV9kak41ZjQ0R214UmtOR1ZBanRuemY3Mmw1SkZ5aDZodGIzT3N2aV85MW9kcld6c0g0ZDgtTWo3b3Y3VjJCRnR2U2tMVm9rUXNVRnVHbzZXVTZ6RmI2RkNmajBfMWVnODVFbnpkT0oyci15czJHU0p1cUowTGZJMzVnd3hIRjQyTVhKOGRkcFRKdVpyQ3Yzd01Jb1lSajFmV0paeEV0cjk1SmpmdWpDVFJMUmMtUFctOGhaTmlKQXNRVlVUNlhJemxudHZCR056SVlBb3NOTEYxRTRLaFlVd2d3TWtxVlB6ZEtQLTkxOGMyY3N0a2pYRFUweDBNaGhja2xSSklPOUZla1dKTWRNbG8tUGdSNEV5cW90OWlOZFlIUExBd3U2b2hyS1owbXVMM3p0Qm41cUtzWUxYNzB1N3JpUTNBSGdsT0NuamNTb1lIbXR4MG1sakNPVkxBUXRLVE1xX0YxWDhOcERIY1lTQVFqS01CaXZKNllFaXlIR0JsM1pKMmV1OUo3TGI1WkRaVnYxUTl1LTM0SU1qN1V1b0RCT0x0VHNLTmNLZnk1S0MxYnBBcm03WnVua0xqaEhGUzhOU253ZkppRzdudXBSVlMxeFVOSWxtZ1o2RVBSQUhEUEFuQ1hxSVZMME4yWUtaU3VyRGo3RkUyRUNjT0pNcE1BdE1ZRzdXVl8ydUtXZjdMdHdEVW4teHUtTi1HSGliLUxud21TX0NtcGVkRFBHNkZ1WTlNczR4OUJfUVluc1BoV09oWS1scUdsNnB5d1U5M1huX3k4QzAyNldtb2hybktYN2xKZ1NTNWFsaWwzV3pCRVhkaGR5eTNlV1d6ZzFfaFZTT0E4UjRpQ3pKdEZxUlJ6UFZXM3laUndyWEk2NlBXLUpoajVhZzVwQXpWVzUtVjVNZFBwdWdQa3AxZC1KdGdqNnhibjN4dmFYb2cxcEVwc1g5R09zRUdINUZtOE5QRjVUU0dpZy1QVl9odnFtVDNuWFZLSURtMXlSMlhRNTBWSVFJbEdOOWpfVWV0SmdRWDdlUXZZWE8xRUxDN1I0aEN6MHYwNzM1cmpJS0ZpMnBYWkxfb3FsbEV1VnlqWGxqdVJ6SHlwSjAzRlMycTBaQ295NXNnZERpUnJQcjhrUUd3bkI4bDVzRmxQblhkaFJPTTdISnVUQmhET3BOMTM4bjVvUEc2VmZhb2lrR1FyTUl2RWNEeGg0U0dsNnV6eU5zOUxiNDY5SXBxR0hBS00wOTgyWTFnWkQyaEtLVUloT3ZxZGh0RWVGRmJzenFsaUtfZENQM0JzdkVVeTdXR3hUSmJST1NBMUI1NkVFWncwNW5JZVVLX1p1RXdqVnFfQWpvQ08yQjZhN1NkTkpTSnUxOVRXZXE0WFEtZWxhZW1NNXYtQ2sya0VGLURmS01lMkctNVY3c2ZhN0ZGRFgwWHlabTFkeS1hcUZ1dDZ3cnpPQ3hha2IzVE11M0pqbklmU0diczBqTFBNZC1QZGp6VzNTSnJVSjJoWkJUQjVORG4tYUJmMEJtSUNUdVpEaGt6OTM3TjFOdVhXUHItZjRtZ25nU3NhZC1sVTVXNTRDTmxZbnlfeHNsdkpuMXhUYnE1MnpVQ0ZOclRWM1M4eHdXTzRXbFRZZVQtTS1iRVdXVWZMSGotcWg3MUxUYTFnSEEtanBCRHlZRUNIdGdpUFhsYjdYUndCZnRITzhMZVJ1dHFoVlVNb0duVjlxd0U4OGRuQVV3MG90R0hiYW5MWkxWVklzbWFRNzBfSUNrdzc5bVdtTXg0dExEYnRCaDI3c1I4TWFwLXZKR0wxSjRZYjZIV3ZqZjNqTWhFT0RGSDVMc1A1UzY2bDBiMGFSUy1fNVRQRzRJWDVydUpqb1ZfSHNVbldVeUN2YlAxSW5WVDdxVzJ1WHpLeUdmb0xWMDNHN05oQzY3YnhvUUdhS2xaOHNidkVvbTZtSHFlblhOYmwyR3NQdVJDRUdxREhWdF9ZcXhwUWxHc2hyLW5vUGhIUVhJNUNhY0hFU0ptVnI0TFVhZDE1TFBBUEstSkRoZWJ5MHJhUmZrR1ZrRlFtRGpxS1pOMmFMQjBsdjluY3FiYUU4eGJVVXlZVEpuNWdHVVhJMGtwaTdZR2NDbXd2eHpOQ09SeTV6N1BaVUpsR1pQVDBZcElJUUt6VnVpQmxSYnE4Y1BCWV9IRWdVV0p3enBGVHItdnBGN3NyNWFBWmkySnByWThsbDliSlExQmp3LVlBaDIyZXp6UnR6cU9rTzJmTDBlSVpON0tiWllMdm1oME1zTFl2S2ZYYllhQlY2VHNZRGtHUDY4U1lIVExLZTU4VzZxSTZrZHl1ZTBDc0g4SjI4WGYyZHV1bm9wQ3R2Z09ld1ZmUkN5alJGeHZKSHl1bWhQVXpNMzdjblpLcUhfSm02Qlh5S1FVN3lIcHl0NnlRPT0= diff --git a/env_int.env b/env_int.env index 783c7461..d7105469 100644 --- a/env_int.env +++ b/env_int.env @@ -31,9 +31,20 @@ APP_LOGGING_FILE_ENABLED = True APP_LOGGING_ROTATION_SIZE = 10485760 APP_LOGGING_BACKUP_COUNT = 5 -# Service Redirects -Service_MSFT_REDIRECT_URI = https://gateway-int.poweron-center.net/api/msft/auth/callback -Service_GOOGLE_REDIRECT_URI = https://gateway-int.poweron-center.net/api/google/auth/callback +# OAuth: Auth app (login/JWT) vs Data app (Graph / Google APIs) +Service_MSFT_AUTH_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c +Service_MSFT_AUTH_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm83T29rMDZvcV9qTG5xb1FzUkdqS1llbzRxSEJXbmpONFFtcUtfZXdtZjQybmJSMjBjMEpnRVhiOGRuczZvVFBFdVVTQV80SG9PSnRQTEpLdVViNm5wc2E5aGRLWjZ4TGF1QjVkNmdRSzBpNWNkYXVublFYclVEdEM5TVBBZWVVMW5RVWk= +Service_MSFT_AUTH_REDIRECT_URI = https://gateway-int.poweron-center.net/api/msft/auth/login/callback +Service_MSFT_DATA_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c +Service_MSFT_DATA_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm83T29rMDZvcV9qTG5xb1FzUkdqS1llbzRxSEJXbmpONFFtcUtfZXdtZjQybmJSMjBjMEpnRVhiOGRuczZvVFBFdVVTQV80SG9PSnRQTEpLdVViNm5wc2E5aGRLWjZ4TGF1QjVkNmdRSzBpNWNkYXVublFYclVEdEM5TVBBZWVVMW5RVWk= +Service_MSFT_DATA_REDIRECT_URI = https://gateway-int.poweron-center.net/api/msft/auth/connect/callback + +Service_GOOGLE_AUTH_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com +Service_GOOGLE_AUTH_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNThGeVRNd3hacThtRnE0bzlDa0JPUWQyaEd6QjlFckdsMGZjRlRfUks2bXV3aDdVRTF3LVRlZVY5WjVzSXV4ZGNnX002RDl3dkNYdGFzZkxVUW01My1wTHRCanVCLUozZEx4TlduQlB5MnpvNTR2SGlvbFl1YkhzTEtsSi1SOEo= +Service_GOOGLE_AUTH_REDIRECT_URI = https://gateway-int.poweron-center.net/api/google/auth/login/callback +Service_GOOGLE_DATA_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com +Service_GOOGLE_DATA_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNThGeVRNd3hacThtRnE0bzlDa0JPUWQyaEd6QjlFckdsMGZjRlRfUks2bXV3aDdVRTF3LVRlZVY5WjVzSXV4ZGNnX002RDl3dkNYdGFzZkxVUW01My1wTHRCanVCLUozZEx4TlduQlB5MnpvNTR2SGlvbFl1YkhzTEtsSi1SOEo= +Service_GOOGLE_DATA_REDIRECT_URI = https://gateway-int.poweron-center.net/api/google/auth/connect/callback # Stripe Billing (both end with _SECRET for encryption script) STRIPE_SECRET_KEY_SECRET = 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 @@ -48,15 +59,8 @@ Connector_AiTavily_API_SECRET = INT_ENC:Z0FBQUFBQm8xSVRkdkJMTDY0akhXNzZDWHVYSEt1 Connector_AiPrivateLlm_API_SECRET = INT_ENC:Z0FBQUFBQnBudkpGSjZ1NWh0aWc1R3Z4MHNaeS1HamtUbndhcUZFZDlqUDhjSmg5eHFfdlVkU0RsVkJ2UVRaMWs3aWhraG5jSlc0YkxNWHVmR2JoSW5ENFFCdkJBM0VienlKSnhzNnBKbTJOUTFKczRfWlQ3bWpmUkRTT1I1OGNUSTlQdExacGRpeXg= Connector_AiMistral_API_SECRET = INT_ENC:Z0FBQUFBQnBudkpGZTNtZ1E4TWIxSEU1OUlreUpxZkJIR0Vxcm9xRHRUbnBxbTQ1cXlkbnltWkJVdTdMYWZ4c3Fsam42TERWUTVhNzZFMU9xVjdyRGFCYml6bmZsZFd2YmJzemlrSWN6Q3o3X0NXX2xXNUQteTNONHdKYzJ5YVpLLWdhU2JhSTJQZnI= -# Microsoft Service Configuration -Service_MSFT_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c -Service_MSFT_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm83T29rMDZvcV9qTG5xb1FzUkdqS1llbzRxSEJXbmpONFFtcUtfZXdtZjQybmJSMjBjMEpnRVhiOGRuczZvVFBFdVVTQV80SG9PSnRQTEpLdVViNm5wc2E5aGRLWjZ4TGF1QjVkNmdRSzBpNWNkYXVublFYclVEdEM5TVBBZWVVMW5RVWk= Service_MSFT_TENANT_ID = common -# Google Service configuration -Service_GOOGLE_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com -Service_GOOGLE_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNThGeVRNd3hacThtRnE0bzlDa0JPUWQyaEd6QjlFckdsMGZjRlRfUks2bXV3aDdVRTF3LVRlZVY5WjVzSXV4ZGNnX002RDl3dkNYdGFzZkxVUW01My1wTHRCanVCLUozZEx4TlduQlB5MnpvNTR2SGlvbFl1YkhzTEtsSi1SOEo= - # Google Cloud Speech Services configuration Connector_GoogleSpeech_API_KEY_SECRET = INT_ENC:Z0FBQUFBQm8xSVRkNmVXZ1pWcHcydTF2MXF0ZGJoWHBydF85bTczTktiaEJ3Wk1vMW1mZVhDSG1yd0ZxR2ZuSGJTX0N3MWptWXFJTkNTWjh1SUVVTXI4UDVzcGdLMkU5SHJ2TUpkRlRoRWdnSldtYjNTQkh4UDJHY2xmdTdZQ1ZiMTZZcGZxS3RzaHdjV3dtVkZUcEpJcWx0b2xuQVR6ZmpoVFZPY1hNMTV2SnhDaC1IZEh4UUpLTy1ILXA4RG1zamJTbUJ4X0t2M2NkdzJPbEJxSmFpRzV3WC0wZThoVzlxcmpHZ3ZkLVlVY3REZk1vV19WQ05BOWN6cnJ4MWNYYnNiQ0FQSUVnUlpfM3BhMnlsVlZUOG5wM3pzM1lSN1UzWlZKUXRLczlHbjI1LTFvSUJ4SlVXMy1BNk43bE5Hb0RfTTVlWk9oZnFIaVg0SW5pbm9EcXRTTzU1RFlYY3dTcnpKWWNyNjN5T1BGZ0FmX253cEFncmhvZVRuM05KYzhkOEhFMFJsc2NBSEwzZVZ1R0JMOGxsekVwUE55alZaRXFrdzNWWVNGWXNmbnhKeWhQSFo2VXBTUlRPeHdvdVdncEFuOWgydEtsSUFneUN6cGVaTnBSdjNCdVJseGJFdmlMc203UFhLVlYyTENkaGg2dVN6Z2xwT1ZmTmN5bVZGUkM3ZWcyVkt2ckFUVVd3WFFwYnJjNVRobEh2SkVJbXRwUUpEOFJKQ1NUc0Q4NHNqUFhPSDh5cTV6MEcwSDEwRUJCQ2JiTTJlOE5nd3pMMkJaQ1dVYjMwZVVWWnlETmp2dkZ3aXEtQ29WNkxZTFkzYUkxdTlQUU1OTnhWWU12YU9MVnJQa1d2ZjRtUlhneTNubEMxTmp1eUNPOThSMlB3Y1F0T2tCdFNsNFlKalZPV25yR2QycVBUb096RmZ1V0FTaGsxLV9FWDBmenBIOXpMdGpLcUc0TWRoY2hlMFhYTzlET1ZRekw0ZHNwUVBQdVJBX2h6Q2ZzWVZJWTNybTJiekp3WmhmWF9SUFBXQzlqUjctcVlHWWVMZWVQallzR0JGTVF0WmtnWlg1aTM1bFprNVExZXY5dnNvWF93UjhwbkJ3RzNXaVJ2d2RRU3JJVlBvaVh4eTlBRUtqWkJia3dJQVVBV2Nqdm9FUTRUVW1TaHp2ZUwxT0N2ZndxQ2Nka1RYWXF0LWxIWFE0dTFQcVhncFFPM0hFdUUtYlFnemx3WkF4bjA1aDFULUdrZlVZbEJtRGRCdjJyVkdJSXozd0I0dF9zbWhOeHFqRDA4T1NVaWR5cjBwSVgwbllPU294NjZGTnM1bFhIdGpNQUxFOENWd3FCbGpSRFRmRXotQnU0N2lCVEU5RGF6Qi10S2U2NGdadDlrRjZtVE5oZkw5ZWFjXzhCTmxXQzNFTFgxRXVYY3J3YkxnbnlBSm9PY3h4MlM1NVFQbVNDRW5Ld1dvNWMxSmdoTXJuaE1pT2VFeXYwWXBHZ29MZDVlN2lwUUNIeGNCVVdQVi1rRXdJMWFncUlPTXR0MmZVQ1l0d09mZTdzWGFBWUJMUFd3b0RSOU8zeER2UWpNdzAxS0ZJWnB5S3FJdU9wUDJnTTNwMWw3VFVqVXQ3ZGZnU1RkUktkc0NhUHJ0SGFxZ0lVWDEzYjNtU2JfMGNWM1Y0dHlCTzNESEdENC1jUWF5MVppRzR1QlBNSUJySjFfRi1ENHEwcmJ4S3hQUFpXVHA0TG9DZWdoUlo5WnNSM1lCZm1KbEs2ak1yUUU4Wk9JcVJGUkJwc0NvUkMyTjhoTWxtZmVQeDREZVRKZkhYN2duLVNTeGZzdFdBVnhEandJSXB5QjM0azF0ckI3Tk1wSzFhNGVOUVRrNjU0cG9JQ29pN09xOFkwR1lMTlktaGp4TktxdTVtTnNEcldsV2pEZm5nQWpJc2hxY0hjQnVSWUR5VVdaUXBHWUloTzFZUC1oNzJ4UjZ1dnpLcDJxWEZtQlNIMWkzZ0hXWXdKeC1iLXdZWVJhcU04VFlpMU5pd2ZIdTdCdkVWVFVBdmJuRk16bEFFQTh4alBrcTV2RzliT2hGdTVPOXlRMjFuZktiRTZIamQ1VFVqS0hRTXhxcU1mdkgyQ1NjQmZfcjl4c3NJd0RIeDVMZUFBbHJqdEJxWWl3aWdGUEQxR3ZnMkNGdVB4RUxkZi1xOVlFQXh1NjRfbkFEaEJ5TVZlUGFrWVhSTVRPeGxqNlJDTHNsRWRrei1pYjhnUmZrb3BvWkQ2QXBzYjFHNXZoWU1LSExhLWtlYlJTZlJmYUM5Y1Rhb1pkMVYyWTByM3NTS0VXMG1ybm1BTVN2QXRYaXZqX2dKSkZrajZSS2cyVlNOQnd5Y29zMlVyaWlNbTJEb3FuUFFtbWNTNVpZTktUenFZSl91cVFXZjRkQUZyYmtPczU2S1RKQ19ONGFOTHlwX2hOOEE1UHZEVjhnT0xxRjMxTEE4SHhRbmlmTkZwVXJBdlJDbU5oZS05SzI4QVhEWDZaN2ZiSlFwUGRXSnB5TE9MZV9ia3pYcmZVa1dicG5FMHRXUFZXMWJQVDAwOEdDQzJmZEl0ZDhUOEFpZXZWWXl5Q2xwSmFienNCMldlb2NKb2ZRYV9KbUdHRzNUcjU1VUFhMzk1a2J6dDVuNTl6NTdpM0hGa3k0UWVtbF9pdDVsQVp2cndDLUU5dnNYOF9CLS0ySXhBSFdCSnpqV010bllBb3U0cEZZYVF5R2tSNFM5NlRhdS1fb1NqbDBKMkw0V2N0VEZhNExtQlR3ckZ3cVlCeHVXdXJ6X0s4cEtsaG5rVUxCN2RRbHQxTmcyVFBqYUxyOHJzeFBXVUJaRHpXbUoxdHZzMFBzQk1UTUFvX1pGNFNMNDFvZWdTdEUtMUNKMXNIeVlvQk1CeEdpZVdmN0tsSDVZZHJXSGt5c2o2MHdwSTZIMVBhRzM1eU43Q2FtcVNidExxczNJeUx5U2RuUG5EeHpCTlg2SV9WNk1ET3BRNXFuc0pNWlVvZUYtY21oRGtJSmwxQ09QbHBUV3BuS3B5NE9RVkhfellqZjJUQ0diSV94QlhQWmdaaC1TRWxsMUVWSXB0aE1McFZDZDNwQUVKZ2t5cXRTXzlRZVJwN0pZSnJSV21XMlh0TzFRVEl0c2I4QjBxOGRCYkNxek04a011X1lrb2poQ3h2LUhKTGJiUlhneHp5QWFBcE5nMElkNTVzM3JGOWtUQ19wNVBTaVVHUHFDNFJnNXJaWDNBSkMwbi1WbTdtSnFySkhNQl9ZQjZrR2xDcXhTRExhMmNHcGlyWjR3ZU9SSjRZd1l4ZjVPeHNiYk53SW5SYnZPTzNkd1lnZmFseV9tQ3BxM3lNYVBHT0J0elJnMTByZ3VHemxta0tVQzZZRllmQ2VLZ1ZCNDhUUTc3LWNCZXBMekFwWW1fQkQ1NktzNGFMYUdYTU0xbXprY1FONUNlUHNMY3h2NFJMMmhNa3VNdzF4TVFWQk9odnJUMjFJMVd3Z2N6Sms5aEM2SWlWZFViZ0JWTEpUWWM5NmIzOS1oQmRqdkt1NUUycFlVcUxERUZGbnZqTUxIYnJmMDBHZDEzbnJsWEEzSUo3UmNPUDg1dnRUU1FzcWtjTWZwUG9zM0JTY3RqMDdST2UxcXFTM0d0bGkwdFhnMk5LaUlxNWx3V1pLaVlLUFJXZzBzVl9Ia1V1OHdYUEFWOU50UndycGtCdzM0Q0NQamp2VTNqbFBLaGhsbUk5dUI5MjU5OHVySk1oY0drUWtXUloyVVRvOWJmbUVYRzFVeWNQczh2NXJCeVppRlZiWDNJaDhOSmRmX2lURTNVS3NXQXFZT1QtUmdvMWJoVWYxU3lqUUJhbzEyX3I3TXhwbm9wc1FoQ1ZUTlNBRjMyQTBTY2tzbHZ3RFUtTjVxQ0o1QXRTVks2WENwMGZCRGstNU1jN3FhUFJCQThyaFhhMVRsbnlSRXNGRmt3Yk01X21ldmV3bTItWm1JaGpZQWZROEFtT1d1UUtPQlhYVVFqT2NxLUxQenJHX3JfMEdscDRiMXcyZ1ZmU3NFMzVoelZJaDlvT0ZoRGQ2bmtlM0M5ZHlCd2ZMbnRZRkZUWHVBUEx4czNfTmtMckh5eXZrZFBzOEItOGRYOEhsMzBhZ0xlOWFjZzgteVBsdnpPT1pYdUxnbFNXYnhKaVB6QUxVdUJCOFpvU2x2c1FHZV94MDBOVWJhYkxISkswc0U5UmdPWFJLXzZNYklHTjN1QzRKaldKdEVHb0pOU284N3c2LXZGMGVleEZ5NGZ6OGV1dm1tM0J0aTQ3VFlNOEJrdEh3PT0= diff --git a/env_prod.env b/env_prod.env index e2d1189d..f10b996e 100644 --- a/env_prod.env +++ b/env_prod.env @@ -31,9 +31,20 @@ APP_LOGGING_FILE_ENABLED = True APP_LOGGING_ROTATION_SIZE = 10485760 APP_LOGGING_BACKUP_COUNT = 5 -# Service Redirects -Service_MSFT_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/msft/auth/callback -Service_GOOGLE_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/google/auth/callback +# OAuth: Auth app (login/JWT) vs Data app (Graph / Google APIs) +Service_MSFT_AUTH_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c +Service_MSFT_AUTH_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBESkk2T25scFU1T1pNd2FENTFRM3kzcEpSXy1HT0trQkR2Wnl3U3RYbExzRy1YUTkxd3lPZE84U2lhX3FZanp5TjhYRGluLXVjU3hjaWRBUnZLbVhtRDItZ3FxNXJ3MUxicUZTXzJWZVNrR0VKN3ZlNEtET1ppOFk0MzNmbkwyRmROUk4= +Service_MSFT_AUTH_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/msft/auth/login/callback +Service_MSFT_DATA_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c +Service_MSFT_DATA_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBESkk2T25scFU1T1pNd2FENTFRM3kzcEpSXy1HT0trQkR2Wnl3U3RYbExzRy1YUTkxd3lPZE84U2lhX3FZanp5TjhYRGluLXVjU3hjaWRBUnZLbVhtRDItZ3FxNXJ3MUxicUZTXzJWZVNrR0VKN3ZlNEtET1ppOFk0MzNmbkwyRmROUk4= +Service_MSFT_DATA_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/msft/auth/connect/callback + +Service_GOOGLE_AUTH_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com +Service_GOOGLE_AUTH_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3eWFwSEZ4YnRJcjU1OW5kcXZKdkt1Z3gzWDFhVW5Eelh3VnpnNlppcWxweHY5UUQzeDIyVk83cW1XNVE4bllVWnR2MjlSQzFrV1UyUVV6OUt5b3Vqa3QzMUIwNFBqc2FVSXRxTlQ1OHVJZVFibnhBQ2puXzBwSXp5NUZhZjM1d1o= +Service_GOOGLE_AUTH_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/google/auth/login/callback +Service_GOOGLE_DATA_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com +Service_GOOGLE_DATA_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3eWFwSEZ4YnRJcjU1OW5kcXZKdkt1Z3gzWDFhVW5Eelh3VnpnNlppcWxweHY5UUQzeDIyVk83cW1XNVE4bllVWnR2MjlSQzFrV1UyUVV6OUt5b3Vqa3QzMUIwNFBqc2FVSXRxTlQ1OHVJZVFibnhBQ2puXzBwSXp5NUZhZjM1d1o= +Service_GOOGLE_DATA_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/google/auth/connect/callback # Stripe Billing (both end with _SECRET for encryption script) STRIPE_SECRET_KEY_SECRET = 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 @@ -48,15 +59,8 @@ Connector_AiTavily_API_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3NmItcDh6V0JpcE5Jc0NlUWZ Connector_AiPrivateLlm_API_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGanZ6U3pzZWkwXzVPWGtIQ040XzFrTXc5QWRnazdEeEktaUJ0akJmNnEzbWUzNHczLTJfc2dIdzBDY0FTaXZYcDhxNFdNbTNtbEJTb2VRZ0ZYd05hdlNLR1h6SUFzVml2Z1FLY1BjTl90UWozUGxtak1URnhhZmNDRWFTb0dKVUo= Connector_AiMistral_API_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGc2tQc2lvMk1YZk01Q1dob1U5cnR0dG03WWE3WkpoOWo0SEpvLU9Rc2lCNDExdy1wZExaN3lpT2FEQkxnaHRmWmZUUUZUUUJmblZreGlpaFpOdnFhbzlEd1RsVVJtX216cmhxTm5BcTN2eUZ2T054cDE5bmlEamJ3NGR6MVpFQnA= -# Microsoft Service Configuration -Service_MSFT_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c -Service_MSFT_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBESkk2T25scFU1T1pNd2FENTFRM3kzcEpSXy1HT0trQkR2Wnl3U3RYbExzRy1YUTkxd3lPZE84U2lhX3FZanp5TjhYRGluLXVjU3hjaWRBUnZLbVhtRDItZ3FxNXJ3MUxicUZTXzJWZVNrR0VKN3ZlNEtET1ppOFk0MzNmbkwyRmROUk4= Service_MSFT_TENANT_ID = common -# Google Service configuration -Service_GOOGLE_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com -Service_GOOGLE_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3eWFwSEZ4YnRJcjU1OW5kcXZKdkt1Z3gzWDFhVW5Eelh3VnpnNlppcWxweHY5UUQzeDIyVk83cW1XNVE4bllVWnR2MjlSQzFrV1UyUVV6OUt5b3Vqa3QzMUIwNFBqc2FVSXRxTlQ1OHVJZVFibnhBQ2puXzBwSXp5NUZhZjM1d1o= - # Google Cloud Speech Services configuration Connector_GoogleSpeech_API_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z4NFQxaF9uN3h1cVB6dnZid1c1R1VfNDlSQ1NHMEVDZWtKanpMQ29CLXc1MXBqRm1hQ0YtWVhaejBMY1ZTOEFEVlpWQ3hrYkFza1E2RDNsYkdMMndNR0VGNTMwVDRGdURJY3hyaVFxVjEtSEYwNHJzeWM3WmlpZW9jU2E3NTgycEV2allqQ3dJRTNyRFAzaDJ6dklKeXpNRkJhYjFzUkptN2dpbkNpMklrcGxuZl9vTkt3T0JvNm1YTXd5UlkwZWptUXdWVFpnV2J4X3J2WUhIUlFkSElFVnlqMnlJRnNHTnlpMWs2R1dZc2ROWjNYZG85cndmd1E5cUZnVmZRYnVjTG43dXFmSWd2bGFfVWFWSmtpWkpndWNlSUNwcnFNU2NqZXFaV0xsY3l3SElLRkVHcHZGZERKV1ltcGhTS0dhTko1VTJLYzNoZjRkSGVEX3dTMWVVTmdDczV5cE1JQUdSbUJGUm11eFhTVjJHbkt0SzB4UG1Dc2xmbnp1Y041Y2RTeWRuWGdmQy1sTGx0MGtnM2VJQ3EyLXViRlNhTU9ybzZkR1N1bXE5SXhlZENWRFpWSGlYOWx4SUQ3UlR0ZEVxQkxNakRUVFRiUmFnbklOalphLUZkRFVVaXBRUk5NZW5PaUZydTFmQkNPSTdTVTNZd0plWXllNVFJdmN4MVcyTGlwMGFtVjBzOGRxR1FjbzhfYW5zdTB0ZEZBTTJhakltazh1dktNMUZsOUItdFdTb1pIaUxySllXNkdlY20zUS0wTnpFNTB2SU5acG1VcXhyaHBmME8takw3RDh5T043T2VGOV92TzNya2pWSlpYVjZDdXlZcjM3a0hPTlhkaW9oQmxqQlpGRFYyTTY4WmZmT3k4Tk1tdXRuSGdTUVpNT2NKenhXb05PdXBfSEdhMTNxNjdpNXlKUUI2YUgydFFPX1VvXzVJb0UxWTU2YVNiNDQ0QndZanhMMHR1cGdHWGhvcEg1QXEtSXZJdTdZUE12ZEVVWkF4QmtsQS1GYnY3SFIxSHlsOGVfcEpGS1A4QUVEQWNEOFZYYlljQ3ByTU03YU16Y0UzUnJQZEprSWNjT1ZXVEtDWi03Y3ZzRVdYUTlabXJISEo5THRHVXVuM0xqbzA4bGVlZVpOMk1QMmptb21tV0pTMlVoOXdWVU95UW1iQmttc2w1RG9mMWwxXzg1T2IxYUVmTUJEZkpUdTFDTzZ3RlBFeUFiX01iRTZNWkNaSG45TkFOM2pzbUJRZ2N0VFpoejJUTG1RODY3TzZpSzVkYUQzaEpfY2pSTkRzU0VpanlkdXVQQmJ2WU5peno4QWNLTDVxZTlhSHI3NnNiM0k0Y3JkQ0xaOU05bGtsQl8zQklvaktWSDZ4aVp2MHlYelJuUDJyTU9CZC1OZjJxNFc1dDcwSUlxaVh1LTMyWWFwU0IwUU9kOUFpMWpnOERtLTh1VmJiNGVwcXBMbU5fMjVZc0hFbmxQT2puSFd1ZGpyTkphLU5sVlBZWWxrWEZrWGJQWmVkN19tZFZfZ1l1V3pSWlA0V0ZxM2lrWnl2NU9WeTdCbDROSmhfeENKTFhMVXk1d195S2JMUFJoRXZjcVo4V2g0MTNKRnZhUE1wRkNPM3FZOGdVazJPeW5PSGpuZnFGTTdJMkRnam5rUlV6NFlqODlIelRYaEN5VjdJNnVwbllNODNCTFRHMWlXbmM1VlRxbXB3Wm9LRjVrQUpjYzRNMThUMWwwSVhBMUlyamtPZnE4R0o4bEdHay1zMjR5RDJkZ1lYRHZaNHVHU2otR3ZpN25LZlEySEU0UmdTNzJGVHNWQXMyb0dVMV9WUE13ODhZWUFaakxGOWZieGNXZkNYRnV5djEyWTZLcmdrajRBLU1rS1Z0VVRkOWlDMU9fMGVmYXFhZXJGMUhpNkdmb2hkbzZ1OWV6VlNmVzNISjVYTFh6SjJNdWR5MWZidE8yVEo2dnRrZXhMRXBPczUwTG13OGhNUVpIQm0zQmRKRnJ0Nl8wNW1Ob0dHRDVpU0NWREV3TkY2SjktdVBkMFU1ZXBmSFpHQ3FHNTRZdTJvaExpZVEtLTU4YTVyeFBpNDdEajZtWUc4c1dBeUJqQ3NIY1NLS0FIMUxGZzZxNFNkOG9ORGNHWWJCVnZuNnJVTEtoQi1mRTZyUl81ZWJJMi1KOGdERzBhNVRZeHRYUUlqY2JvMFlaNHhWMU9pWFFiZjdaLUhkaG15TTBPZVlkS2R5UVdENTI4QVFiY1RJV0ZNZnlpVWxfZmlnN1BXbGdrbjFGUkhzYl9qeHBxVVJacUE4bjZETENHVFpSamh0NVpOM2hMYTZjYzBuS3J0a3hhZGxSM1V5UHd2OTU3ZHY0Yy1xWDBkWUk0Ymp0MWVrS3YzSktKODhQZnY3QTZ1Wm1VZkZJbS1jamdreks1ZlhpQjFOUDFiOHJ2Nm9NcmdTdU5LQXV2RkZWZEFNZnVKUjVwcVY3dDdhQnpmRVJ6SmlvVXpDM0ZiYXh5bGE2X04tTE9qZ3BiTnN3TF9ZaFRxSUpjNjB1dXZBcy1TZHRHTjFjSUR3WUl4cE9VNzB5Rkk4U3Z1SVZYTl9sYXlZVk83UnFrMlVmcnBpam9lRUlCY19DdVJwOXl2TVVDV1pMRFZTZk9MY3Z1eXA0MnhGazc5YllQaWtOeTc4NjlOa2lGY05RRzY1cG9nbGpYelc4c3FicWxWRkg0YzRSamFlQ19zOU14YWJreU9pNDREZVJ3a0REMUxGTzF1XzI1bEF3VXVZRjlBeWFiLXJsOXgza3VZem1WckhWSnVNbDBNcldadU8xQ3RwOTl5NGgtVlR0QklCLWl5WkE4V1FlQTBCOVU1RE9sQlRrYUNZOGdfUmEwbEZvUTFGUEFWVmQ4V1FhOU9VNjZqemRpZm1sUDhZQTJ0YVBRbWZldkF5THV4QXpfdUtNZ0tlcGdSRFM3c0lDOTNQbnBxdmxYYWNpTmI3MW9BMlZIdTQ5RldudHpNQWQ5NDNPLVVTLXVVNzdHZXh4UXpZa3dVa2J4dTFDV1RkYjRnWXU2M3lJekRYWGNMcWU5OVh6U2xZWDh6MmpqcnpiOHlnMjA5S3RFQm1NZjNSM21adkVnTUpSYVhkTzNkNnJCTmljY0x1cl9kMkx3UHhySjZEdHREanZERzNEUTFlTkR0NWlBczAtdmFGTjdZNVpTMlkxV2czYW5RN2lqemg4eUViZDV6RjdKNXdFcUlvcVhoNkJ6eVJkR1pua1hnNzQwOEs2TXJYSlpGcW9qRDU2QjBOWFFtdXBJRkRKbmdZUF9ZSmRPVEtvUjVhLTV1NjdXQjRhS0duaEtJb2FrQnNjUTRvdFMxdkdTNk1NYlFHUFhhYTJ1eUN3WHN4UlJ4UjdrZjY0SzFGYWVFN1k0cGJnc1RjNmFUenR4NHljbVhablZSWHZmUVN3cXRHNjhsX1BSZWEzdTJUZFA0S2pTaU9YMnZIQ1ZPcGhWMFJqZkVEMWRMR1h3SnU0Z2FzZ3VGM3puNzdhVjhaQXNIWHFsbjB0TDVYSFdSNV9rdWhUUUhSZHBGYkJIVDB5SDdlMC13QTVnS0g5Qkg5RGNxSGJlelVndUhPcEQ0QkRKMTJTZUM1OXJhVm0zYjU0OVY2dk9MQVBheklIQXpVNW9Yc0ROVjEzaFZTWmVxYlBWMlNlSzladzJ6TmNuMG5FVVZkN1VZN1pfS2ZHa0lQcE80S24wSnQtVlJVV09OVWJ3M09YMkZpV2ktVF9ENHhKU2dfYUQ2aUVyamk0VHJHQmVfVHU4clpUTFoteW5aSWRPV1M0RDRMTms4NGRoYmJfVE82aUl2X3VieVJOdDhBQmRwdzdnRTVBNzZwaW93dUlZb3ZRYUtOeG9ULWxvNVp5a0haSjdkcUhRb3d6UGIxRUpCVkVYX2d6TkRqQVozUWxkNGFoc1FXYVd2YWNkME9Qclo0bjYxMFRWTy1nbnI5NTBJNzRMMDluUXRKYTFqQUN4d0d5aHVlamN3Tkk3NWJXeXR0TW9BeUg5Vnp4Q2RnZUY3b3AtMDlrNmlrSGR0eGRtbUdUd2lFRWg4MklEeWJHN2wwZEpVSXMxNDNOWjRFS0tPdWxhMmFCckhfRENIY184aEFDZXNrRDl2dHQtQW12UnRuQXJjaDJoTUpiYkNWQUtfRG9GMUZoNWM4UnBYZ29RWWs2NHcyUm5kdTF3Vk1GeFpiRUJLaVZ2UGFjbi1jV3lMV0N2ZDl4VERPN295X01NNG56ZjZkRzZoYUtmY1E5NlVXemx2SnVfb19iSXg0R2M3Mjd1a2JRPT0= diff --git a/modules/auth/authentication.py b/modules/auth/authentication.py index 07c0e5d0..d15ff2fd 100644 --- a/modules/auth/authentication.py +++ b/modules/auth/authentication.py @@ -23,7 +23,7 @@ from modules.shared.configuration import APP_CONFIG from modules.security.rootAccess import getRootDbAppConnector, getRootUser from modules.interfaces.interfaceDbApp import getInterface, getRootInterface from modules.datamodels.datamodelUam import User, AuthAuthority, AccessLevel -from modules.datamodels.datamodelSecurity import Token +from modules.datamodels.datamodelSecurity import Token, TokenPurpose from modules.datamodels.datamodelRbac import AccessRule # Get Config Data @@ -189,10 +189,46 @@ def _getUserBase(token: str = Depends(cookieAuth)) -> User: f"Local JWT db record not active/valid: jti={tokenId}, userId={user.id}, sessionId={sessionId}" ) raise credentialsException + elif token_authority == str(AuthAuthority.GOOGLE.value): + active_token = appInterface.findActiveTokenById( + tokenId=tokenId, + userId=user.id, + authority=AuthAuthority.GOOGLE, + sessionId=sessionId, + mandateId=None, + tokenPurpose=TokenPurpose.AUTH_SESSION.value, + ) + if not active_token: + logger.info( + f"Google JWT db record not active/valid: jti={tokenId}, userId={user.id}" + ) + raise credentialsException + elif token_authority == str(AuthAuthority.MSFT.value): + active_token = appInterface.findActiveTokenById( + tokenId=tokenId, + userId=user.id, + authority=AuthAuthority.MSFT, + sessionId=sessionId, + mandateId=None, + tokenPurpose=TokenPurpose.AUTH_SESSION.value, + ) + if not active_token: + logger.info( + f"Microsoft JWT db record not active/valid: jti={tokenId}, userId={user.id}" + ) + raise credentialsException else: # No DB record for this token. If the claim says local (or missing/unknown), require DB record. - if normalized_authority in (None, "", str(AuthAuthority.LOCAL.value)): - logger.info("Local JWT without server record or missing authority claim") + if normalized_authority in ( + None, + "", + str(AuthAuthority.LOCAL.value), + str(AuthAuthority.GOOGLE.value), + str(AuthAuthority.MSFT.value), + ): + logger.info( + "JWT without server record or missing authority claim (local/google/msft require DB row)" + ) raise credentialsException except HTTPException: raise diff --git a/modules/auth/csrf.py b/modules/auth/csrf.py index 0266db3e..ba21435b 100644 --- a/modules/auth/csrf.py +++ b/modules/auth/csrf.py @@ -23,11 +23,18 @@ class CSRFMiddleware(BaseHTTPMiddleware): # Paths that are exempt from CSRF protection self.exempt_paths = exempt_paths or { "/api/local/login", - "/api/local/register", - "/api/msft/login", - "/api/google/login", - "/api/msft/callback", - "/api/google/callback", + "/api/local/register", + # OAuth Auth app + Data app (GET redirects / callbacks) + "/api/msft/auth/login", + "/api/msft/auth/login/callback", + "/api/msft/auth/connect", + "/api/msft/auth/connect/callback", + "/api/msft/adminconsent", + "/api/msft/adminconsent/callback", + "/api/google/auth/login", + "/api/google/auth/login/callback", + "/api/google/auth/connect", + "/api/google/auth/connect/callback", "/api/billing/webhook/stripe", # Stripe webhook (auth via Stripe-Signature) } diff --git a/modules/auth/oauthProviderConfig.py b/modules/auth/oauthProviderConfig.py new file mode 100644 index 00000000..f66ba2e9 --- /dev/null +++ b/modules/auth/oauthProviderConfig.py @@ -0,0 +1,42 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +"""OAuth scope sets for split Auth- vs Data-apps (Google / Microsoft).""" + +# Google — Auth app only (no Gmail/Drive API scopes) +googleAuthScopes = [ + "openid", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", +] + +# Google — Data app (Gmail + Drive + identity for token responses) +googleDataScopes = [ + "openid", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + "https://www.googleapis.com/auth/gmail.readonly", + "https://www.googleapis.com/auth/drive.readonly", +] + +# Microsoft — Auth app: Graph profile only (MSAL adds openid, profile, offline_access, …) +msftAuthScopes = [ + "User.Read", +] + +# Microsoft — Data app (delegated; requires admin consent for several) +msftDataScopes = [ + "User.Read", + "Mail.ReadWrite", + "Mail.Send", + "Files.ReadWrite.All", + "Sites.ReadWrite.All", + "Team.ReadBasic.All", + "OnlineMeetings.Read", + "Chat.ReadWrite", + "ChatMessage.Send", +] + + +def msftDataScopesForRefresh() -> str: + """Space-separated scope string identical to authorization request (Token v2 refresh).""" + return " ".join(msftDataScopes) diff --git a/modules/auth/tokenManager.py b/modules/auth/tokenManager.py index 0fd76092..5740a2ac 100644 --- a/modules/auth/tokenManager.py +++ b/modules/auth/tokenManager.py @@ -9,10 +9,11 @@ import logging import httpx from typing import Optional, Dict, Any, Callable -from modules.datamodels.datamodelSecurity import Token +from modules.datamodels.datamodelSecurity import Token, TokenPurpose from modules.datamodels.datamodelUam import AuthAuthority from modules.shared.configuration import APP_CONFIG from modules.shared.timeUtils import getUtcTimestamp, createExpirationTimestamp, parseTimestamp +from modules.auth.oauthProviderConfig import msftDataScopesForRefresh logger = logging.getLogger(__name__) @@ -20,14 +21,14 @@ class TokenManager: """Centralized token management service""" def __init__(self): - # Microsoft OAuth configuration - self.msft_client_id = APP_CONFIG.get("Service_MSFT_CLIENT_ID") - self.msft_client_secret = APP_CONFIG.get("Service_MSFT_CLIENT_SECRET") + # Microsoft Data-app OAuth (refresh + token exchange for connections) + self.msft_client_id = APP_CONFIG.get("Service_MSFT_DATA_CLIENT_ID") + self.msft_client_secret = APP_CONFIG.get("Service_MSFT_DATA_CLIENT_SECRET") self.msft_tenant_id = APP_CONFIG.get("Service_MSFT_TENANT_ID", "common") - # Google OAuth configuration - self.google_client_id = APP_CONFIG.get("Service_GOOGLE_CLIENT_ID") - self.google_client_secret = APP_CONFIG.get("Service_GOOGLE_CLIENT_SECRET") + # Google Data-app OAuth + self.google_client_id = APP_CONFIG.get("Service_GOOGLE_DATA_CLIENT_ID") + self.google_client_secret = APP_CONFIG.get("Service_GOOGLE_DATA_CLIENT_SECRET") def refreshMicrosoftToken(self, refreshToken: str, userId: str, oldToken: Token) -> Optional[Token]: """Refresh Microsoft OAuth token using refresh token""" @@ -49,7 +50,7 @@ class TokenManager: "client_secret": self.msft_client_secret, "grant_type": "refresh_token", "refresh_token": refreshToken, - "scope": "Mail.ReadWrite Mail.Send Mail.ReadWrite.Shared User.Read" + "scope": msftDataScopesForRefresh(), } logger.debug(f"refreshMicrosoftToken: Refresh request data prepared (refreshToken length: {len(refreshToken) if refreshToken else 0})") @@ -68,6 +69,7 @@ class TokenManager: userId=userId, authority=AuthAuthority.MSFT, connectionId=oldToken.connectionId, # Preserve connection ID + tokenPurpose=TokenPurpose.DATA_CONNECTION, tokenAccess=tokenData["access_token"], tokenRefresh=tokenData.get("refresh_token", refreshToken), # Keep old refresh token if new one not provided tokenType=tokenData.get("token_type", "bearer"), @@ -128,6 +130,7 @@ class TokenManager: userId=userId, authority=AuthAuthority.GOOGLE, connectionId=oldToken.connectionId, # Preserve connection ID + tokenPurpose=TokenPurpose.DATA_CONNECTION, tokenAccess=tokenData["access_token"], tokenRefresh=tokenData.get("refresh_token", refreshToken), # Use new refresh token if provided tokenType=tokenData.get("token_type", "bearer"), @@ -164,6 +167,15 @@ class TokenManager: try: logger.debug(f"refreshToken: Starting refresh for token {oldToken.id}, authority: {oldToken.authority}") logger.debug(f"refreshToken: Token details: userId={oldToken.userId}, connectionId={oldToken.connectionId}, hasRefreshToken={bool(oldToken.tokenRefresh)}") + + _tp = ( + oldToken.tokenPurpose.value + if isinstance(oldToken.tokenPurpose, TokenPurpose) + else oldToken.tokenPurpose + ) + if _tp != TokenPurpose.DATA_CONNECTION.value: + logger.warning("refreshToken: skipped — token is not dataConnection") + return None # Cooldown: avoid refreshing too frequently if a workflow triggers refresh repeatedly # Only allow a new refresh if at least 10 minutes passed since the token was created/refreshed @@ -266,6 +278,16 @@ class TokenManager: token = interface.getConnectionToken(connectionId) if not token: return None + _tp = ( + token.tokenPurpose.value + if isinstance(token.tokenPurpose, TokenPurpose) + else token.tokenPurpose + ) + if _tp != TokenPurpose.DATA_CONNECTION.value: + logger.warning( + f"getFreshToken: connection {connectionId} tokenPurpose is {_tp}, expected dataConnection" + ) + return None return self.ensureFreshToken( token, secondsBeforeExpiry=secondsBeforeExpiry, diff --git a/modules/datamodels/datamodelBilling.py b/modules/datamodels/datamodelBilling.py index 42d3da21..8ffbdef1 100644 --- a/modules/datamodels/datamodelBilling.py +++ b/modules/datamodels/datamodelBilling.py @@ -11,11 +11,13 @@ import uuid class BillingModelEnum(str, Enum): - """Billing model types.""" + """Billing model types (prepaid only; legacy UNLIMITED in DB maps to PREPAY_MANDATE).""" PREPAY_MANDATE = "PREPAY_MANDATE" # Prepaid budget shared by all users in mandate PREPAY_USER = "PREPAY_USER" # Prepaid budget per user within mandate - CREDIT_POSTPAY = "CREDIT_POSTPAY" # Credit with monthly invoice (requires billing address) - UNLIMITED = "UNLIMITED" # No cost limitation (internal mandates only) + + +# Nur fuer initRootMandateBilling (Root-Mandant PREPAY_USER + Startguthaben in Settings). +DEFAULT_USER_CREDIT_CHF = 5.0 class AccountTypeEnum(str, Enum): @@ -46,30 +48,6 @@ class PeriodTypeEnum(str, Enum): YEAR = "YEAR" -class BillingAddress(BaseModel): - """Billing address for CREDIT_POSTPAY mandates.""" - company: str = Field(..., description="Company name") - street: str = Field(..., description="Street and number") - zip: str = Field(..., description="Postal code") - city: str = Field(..., description="City") - country: str = Field(default="CH", description="Country code") - vatNumber: Optional[str] = Field(None, description="VAT number (optional)") - - -registerModelLabels( - "BillingAddress", - {"en": "Billing Address", "de": "Rechnungsadresse"}, - { - "company": {"en": "Company", "de": "Firma"}, - "street": {"en": "Street", "de": "Strasse"}, - "zip": {"en": "ZIP", "de": "PLZ"}, - "city": {"en": "City", "de": "Ort"}, - "country": {"en": "Country", "de": "Land"}, - "vatNumber": {"en": "VAT Number", "de": "MwSt-Nummer"}, - }, -) - - class BillingAccount(BaseModel): """Billing account for mandate or user-mandate combination.""" id: str = Field( @@ -79,7 +57,6 @@ class BillingAccount(BaseModel): userId: Optional[str] = Field(None, description="Foreign key to User (only for PREPAY_USER)") accountType: AccountTypeEnum = Field(..., description="Account type: MANDATE or USER") balance: float = Field(default=0.0, description="Current balance in CHF") - creditLimit: Optional[float] = Field(None, description="Credit limit in CHF (only for CREDIT_POSTPAY)") warningThreshold: float = Field(default=0.0, description="Warning threshold in CHF") lastWarningAt: Optional[datetime] = Field(None, description="Last warning sent timestamp") enabled: bool = Field(default=True, description="Account is active") @@ -94,7 +71,6 @@ registerModelLabels( "userId": {"en": "User ID", "de": "Benutzer-ID"}, "accountType": {"en": "Account Type", "de": "Kontotyp"}, "balance": {"en": "Balance (CHF)", "de": "Guthaben (CHF)"}, - "creditLimit": {"en": "Credit Limit (CHF)", "de": "Kreditlimit (CHF)"}, "warningThreshold": {"en": "Warning Threshold (CHF)", "de": "Warnschwelle (CHF)"}, "lastWarningAt": {"en": "Last Warning", "de": "Letzte Warnung"}, "enabled": {"en": "Enabled", "de": "Aktiv"}, @@ -161,15 +137,17 @@ class BillingSettings(BaseModel): billingModel: BillingModelEnum = Field(..., description="Billing model") # Configuration - defaultUserCredit: float = Field(default=10.0, description="Initial credit in CHF for new users (PREPAY_USER)") + defaultUserCredit: float = Field( + default=0.0, + description="Automatic initial credit (CHF) for PREPAY_USER only when a user is newly added to the root mandate; other mandates use 0 on join.", + ) warningThresholdPercent: float = Field(default=10.0, description="Warning threshold as percentage") - blockOnZeroBalance: bool = Field(default=True, description="Block AI features when balance is zero") - # Billing address (required for CREDIT_POSTPAY) - billingAddress: Optional[BillingAddress] = Field(None, description="Billing address") - - # Notifications - notifyEmails: List[str] = Field(default_factory=list, description="Email addresses for billing notifications") + # Notifications (e.g. mandate owner / finance — also used when PREPAY_MANDATE pool is exhausted) + notifyEmails: List[str] = Field( + default_factory=list, + description="Email addresses for billing alerts (mandate pool exhausted, warnings, etc.)", + ) notifyOnWarning: bool = Field(default=True, description="Send email when warning threshold is reached") @@ -180,11 +158,15 @@ registerModelLabels( "id": {"en": "ID", "de": "ID"}, "mandateId": {"en": "Mandate ID", "de": "Mandanten-ID"}, "billingModel": {"en": "Billing Model", "de": "Abrechnungsmodell"}, - "defaultUserCredit": {"en": "Default User Credit (CHF)", "de": "Standard-Startguthaben (CHF)"}, + "defaultUserCredit": { + "en": "Root start credit (CHF)", + "de": "Startguthaben nur Root-Mandant (CHF)", + }, "warningThresholdPercent": {"en": "Warning Threshold (%)", "de": "Warnschwelle (%)"}, - "blockOnZeroBalance": {"en": "Block on Zero Balance", "de": "Bei 0 blockieren"}, - "billingAddress": {"en": "Billing Address", "de": "Rechnungsadresse"}, - "notifyEmails": {"en": "Notification Emails", "de": "Benachrichtigungs-Emails"}, + "notifyEmails": { + "en": "Billing notification emails (owner / admin)", + "de": "E-Mails für Billing-Alerts (Inhaber/Admin)", + }, "notifyOnWarning": {"en": "Notify on Warning", "de": "Bei Warnung benachrichtigen"}, }, ) @@ -257,7 +239,6 @@ class BillingBalanceResponse(BaseModel): currency: str = "CHF" warningThreshold: float isWarning: bool - creditLimit: Optional[float] = None class BillingStatisticsChartData(BaseModel): @@ -285,3 +266,16 @@ class BillingCheckResult(BaseModel): currentBalance: Optional[float] = None requiredAmount: Optional[float] = None billingModel: Optional[BillingModelEnum] = None + + +def parseBillingModelFromStoredValue(raw: Optional[str]) -> BillingModelEnum: + """Map DB string to enum. Legacy UNLIMITED / unknown values become PREPAY_MANDATE.""" + if raw is None or (isinstance(raw, str) and raw.strip() == ""): + return BillingModelEnum.PREPAY_MANDATE + s = str(raw).strip().upper() + if s == "UNLIMITED": + return BillingModelEnum.PREPAY_MANDATE + try: + return BillingModelEnum(raw) + except ValueError: + return BillingModelEnum.PREPAY_MANDATE diff --git a/modules/datamodels/datamodelSecurity.py b/modules/datamodels/datamodelSecurity.py index 9fed9fa4..5caafe1b 100644 --- a/modules/datamodels/datamodelSecurity.py +++ b/modules/datamodels/datamodelSecurity.py @@ -9,8 +9,8 @@ Multi-Tenant Design: - Mandant-Kontext wird per Request-Header (X-Mandate-Id) bestimmt """ -from typing import Optional -from pydantic import BaseModel, Field, ConfigDict +from typing import Optional, Any +from pydantic import BaseModel, Field, ConfigDict, model_validator from modules.shared.attributeUtils import registerModelLabels from modules.shared.timeUtils import getUtcTimestamp from .datamodelUam import AuthAuthority @@ -23,6 +23,13 @@ class TokenStatus(str, Enum): REVOKED = "revoked" +class TokenPurpose(str, Enum): + """Login/session token vs provider token bound to a UserConnection.""" + + AUTH_SESSION = "authSession" + DATA_CONNECTION = "dataConnection" + + class Token(BaseModel): """ Authentication Token model. @@ -38,6 +45,10 @@ class Token(BaseModel): connectionId: Optional[str] = Field( None, description="ID of the connection this token belongs to" ) + tokenPurpose: Optional[TokenPurpose] = Field( + default=None, + description="authSession = gateway login JWT; dataConnection = provider OAuth for a connection", + ) tokenAccess: str tokenType: str = "bearer" expiresAt: float = Field( @@ -65,6 +76,22 @@ class Token(BaseModel): model_config = ConfigDict(use_enum_values=True) + @model_validator(mode="before") + @classmethod + def _defaultTokenPurposeFromDb(cls, data: Any) -> Any: + """Missing tokenPurpose: connection rows → dataConnection; session rows → authSession.""" + if isinstance(data, dict): + tp = data.get("tokenPurpose") + if tp is None or tp == "": + cid = data.get("connectionId") + purpose = ( + TokenPurpose.DATA_CONNECTION.value + if cid + else TokenPurpose.AUTH_SESSION.value + ) + data = {**data, "tokenPurpose": purpose} + return data + registerModelLabels( "Token", @@ -74,6 +101,7 @@ registerModelLabels( "userId": {"en": "User ID", "de": "Benutzer-ID", "fr": "ID utilisateur"}, "authority": {"en": "Authority", "de": "Autorität", "fr": "Autorité"}, "connectionId": {"en": "Connection ID", "de": "Verbindungs-ID", "fr": "ID de connexion"}, + "tokenPurpose": {"en": "Token purpose", "de": "Token-Verwendung", "fr": "Usage du jeton"}, "tokenAccess": {"en": "Access Token", "de": "Zugriffstoken", "fr": "Jeton d'accès"}, "tokenType": {"en": "Token Type", "de": "Token-Typ", "fr": "Type de jeton"}, "expiresAt": {"en": "Expires At", "de": "Läuft ab am", "fr": "Expire le"}, diff --git a/modules/features/chatbot/service.py b/modules/features/chatbot/service.py index 08ae88d0..121ca29b 100644 --- a/modules/features/chatbot/service.py +++ b/modules/features/chatbot/service.py @@ -1221,10 +1221,28 @@ def _preflight_billing_check(services, mandateId: str, featureInstanceId: Option try: balanceCheck = billingService.checkBalance(0.01) if not balanceCheck.allowed: - raise BillingService.InsufficientBalanceException( - currentBalance=balanceCheck.currentBalance or 0.0, - requiredAmount=0.01, - message=f"Ungenuegendes Guthaben. Aktuell: CHF {balanceCheck.currentBalance:.2f}" + mid = str(getattr(services, "mandateId", None) or mandateId or "") + from modules.datamodels.datamodelBilling import BillingModelEnum + from modules.serviceCenter.services.serviceBilling.billingExhaustedNotify import ( + maybeEmailMandatePoolExhausted, + ) + if balanceCheck.billingModel == BillingModelEnum.PREPAY_MANDATE: + u = getattr(services, "user", None) + ulabel = ( + (getattr(u, "email", None) or getattr(u, "username", None) or str(getattr(u, "id", ""))) + if u is not None else "" + ) + maybeEmailMandatePoolExhausted( + mid, + str(getattr(u, "id", "") if u is not None else ""), + ulabel, + float(balanceCheck.currentBalance or 0.0), + 0.01, + ) + raise BillingService.InsufficientBalanceException.fromBalanceCheck( + balanceCheck, + mid, + 0.01, ) rbacAllowedProviders = billingService.getallowedProviders() if not rbacAllowedProviders: diff --git a/modules/features/workspace/mainWorkspace.py b/modules/features/workspace/mainWorkspace.py index 5da67a45..81526414 100644 --- a/modules/features/workspace/mainWorkspace.py +++ b/modules/features/workspace/mainWorkspace.py @@ -110,14 +110,15 @@ TEMPLATE_ROLES = [ { "roleLabel": "workspace-admin", "description": { - "en": "Workspace Admin - Full access to AI workspace", - "de": "Workspace Admin - Vollzugriff auf AI Workspace", - "fr": "Administrateur Workspace - Acces complet au workspace AI" + "en": "Workspace Admin - All UI and API actions; data is always scoped to own records (same privacy as users)", + "de": "Workspace Admin - Alle UI- und API-Aktionen; Daten immer nur eigene Datensätze (gleiche Privatsphäre wie User)", + "fr": "Administrateur Workspace - Toute l'UI et les API; donnees limitees a ses propres enregistrements" }, "accessRules": [ {"context": "UI", "item": None, "view": True}, {"context": "RESOURCE", "item": None, "view": True}, - {"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"}, + # DATA: never ALL in shared instances — every role (including admin) sees only _createdBy = self + {"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "m"}, ] }, ] diff --git a/modules/features/workspace/routeFeatureWorkspace.py b/modules/features/workspace/routeFeatureWorkspace.py index 7397beb7..6f317e2b 100644 --- a/modules/features/workspace/routeFeatureWorkspace.py +++ b/modules/features/workspace/routeFeatureWorkspace.py @@ -15,6 +15,9 @@ from fastapi.responses import StreamingResponse, JSONResponse from pydantic import BaseModel, Field from modules.auth import limiter, getRequestContext, RequestContext +from modules.serviceCenter.services.serviceBilling.mainServiceBilling import ( + InsufficientBalanceException, +) from modules.interfaces import interfaceDbChat, interfaceDbManagement from modules.interfaces.interfaceAiObjects import AiObjects from modules.serviceCenter.core.serviceStreaming import get_event_manager @@ -581,12 +584,21 @@ async def _runWorkspaceAgent( }) except Exception as e: - logger.error(f"Workspace agent error: {e}", exc_info=True) - await eventManager.emit_event(queueId, "error", { - "type": "error", - "content": str(e), - "workflowId": workflowId, - }) + if isinstance(e, InsufficientBalanceException): + logger.warning(f"Workspace blocked by billing: {e.message}") + await eventManager.emit_event(queueId, "error", { + "type": "error", + "content": e.message, + "workflowId": workflowId, + "item": e.toClientDict(), + }) + else: + logger.error(f"Workspace agent error: {e}", exc_info=True) + await eventManager.emit_event(queueId, "error", { + "type": "error", + "content": str(e), + "workflowId": workflowId, + }) finally: eventManager._unregister_agent_task(queueId) diff --git a/modules/interfaces/interfaceBootstrap.py b/modules/interfaces/interfaceBootstrap.py index 63978165..c1cac9ef 100644 --- a/modules/interfaces/interfaceBootstrap.py +++ b/modules/interfaces/interfaceBootstrap.py @@ -1962,6 +1962,8 @@ def _createStoreResourceRules(db: DatabaseConnector) -> None: storeResources = [ "resource.store.automation", "resource.store.teamsbot", + "resource.store.workspace", + "resource.store.commcoach", ] storeRules = [] @@ -1998,7 +2000,7 @@ def _createStoreResourceRules(db: DatabaseConnector) -> None: def initRootMandateBilling(mandateId: str) -> None: """ Initialize billing settings for root mandate. - Root mandate uses PREPAY_USER model with 10 CHF initial credit per user. + Root mandate uses PREPAY_USER model with default initial credit per user in settings (DEFAULT_USER_CREDIT_CHF at bootstrap only). Creates billing accounts for ALL users regardless of billing model (for audit trail). Args: @@ -2007,7 +2009,12 @@ def initRootMandateBilling(mandateId: str) -> None: try: from modules.interfaces.interfaceDbBilling import _getRootInterface from modules.interfaces.interfaceDbApp import getRootInterface as getAppRootInterface - from modules.datamodels.datamodelBilling import BillingSettings, BillingModelEnum + from modules.datamodels.datamodelBilling import ( + BillingSettings, + BillingModelEnum, + DEFAULT_USER_CREDIT_CHF, + parseBillingModelFromStoredValue, + ) billingInterface = _getRootInterface() appInterface = getAppRootInterface() @@ -2020,27 +2027,28 @@ def initRootMandateBilling(mandateId: str) -> None: settings = BillingSettings( mandateId=mandateId, billingModel=BillingModelEnum.PREPAY_USER, - defaultUserCredit=10.0, + defaultUserCredit=DEFAULT_USER_CREDIT_CHF, warningThresholdPercent=10.0, - blockOnZeroBalance=True, notifyOnWarning=True ) billingInterface.createSettings(settings) - logger.info(f"Created billing settings for root mandate: PREPAY_USER with 10 CHF default credit") + logger.info( + f"Created billing settings for root mandate: PREPAY_USER with {DEFAULT_USER_CREDIT_CHF} CHF default credit" + ) existingSettings = billingInterface.getSettings(mandateId) # Always create user accounts for all users (audit trail) if existingSettings: - billingModel = existingSettings.get("billingModel", "UNLIMITED") - if billingModel == BillingModelEnum.UNLIMITED.value: - return # No accounts needed for UNLIMITED + billingModel = parseBillingModelFromStoredValue( + existingSettings.get("billingModel") + ).value # Initial balance depends on billing model if billingModel == BillingModelEnum.PREPAY_USER.value: - initialBalance = existingSettings.get("defaultUserCredit", 10.0) + initialBalance = float(existingSettings.get("defaultUserCredit", 0.0)) else: - initialBalance = 0.0 # PREPAY_MANDATE / CREDIT_POSTPAY: budget on pool + initialBalance = 0.0 # PREPAY_MANDATE: budget on pool account userMandates = appInterface.getUserMandatesByMandate(mandateId) accountsCreated = 0 diff --git a/modules/interfaces/interfaceDbApp.py b/modules/interfaces/interfaceDbApp.py index 3283c577..2fec872d 100644 --- a/modules/interfaces/interfaceDbApp.py +++ b/modules/interfaces/interfaceDbApp.py @@ -35,7 +35,7 @@ from modules.datamodels.datamodelRbac import ( Role, ) from modules.datamodels.datamodelUam import AccessLevel -from modules.datamodels.datamodelSecurity import Token, AuthEvent, TokenStatus +from modules.datamodels.datamodelSecurity import Token, AuthEvent, TokenStatus, TokenPurpose from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResult from modules.datamodels.datamodelMembership import ( UserMandate, @@ -687,11 +687,17 @@ class AppObjects: externalUsername: str = None, externalEmail: str = None, isSysAdmin: bool = False, + addExternalIdentityConnection: bool = True, ) -> User: """ Create a new user. Note: Role assignment is done via createUserMandate(), not via User fields. + + Args: + addExternalIdentityConnection: If True (default) and externalId/externalUsername are set, + creates a UserConnection row. OAuth login-only flows should pass False (data connection + is created separately via /auth/connect). """ try: # Ensure username is a string @@ -727,8 +733,9 @@ class AppObjects: if not createdRecord or not createdRecord.get("id"): raise ValueError("Failed to create user record") - # Add external connection if provided - if externalId and externalUsername: + # Optional: mirror external IdP identity into UserConnections (data/API OAuth). + # Auth-only login (Google/MSFT JWT) must NOT create a connection — see OAuth split. + if addExternalIdentityConnection and externalId and externalUsername: self.addUserConnection( createdRecord["id"], authenticationAuthority, @@ -746,7 +753,7 @@ class AppObjects: # Clear cache to ensure fresh data (already done above) - # Assign new user to the root mandate with system 'viewer' role + # Assign new user to the root mandate with mandate-instance 'user' role (no feature instances) userId = createdUser[0]["id"] self._assignUserToRootMandate(userId) @@ -815,7 +822,7 @@ class AppObjects: def _assignUserToRootMandate(self, userId: str) -> None: """ - Assign a new user to the root mandate with the mandate-instance 'viewer' role. + Assign a new user to the root mandate with the mandate-instance 'user' role. This ensures every user has a base membership in the system mandate. Uses the mandate-instance role (mandateId=rootMandateId), not the global template. @@ -839,17 +846,17 @@ class AppObjects: logger.debug(f"User {userId} already assigned to root mandate") return - # Find the mandate-instance 'viewer' role (bound to this mandate, not a global template) - mandateViewerRoles = self.db.getRecordset( + # Mandate-instance 'user' role (bound to this mandate, not a global template) + mandateUserRoles = self.db.getRecordset( Role, - recordFilter={"roleLabel": "viewer", "mandateId": rootMandateId, "featureInstanceId": None} + recordFilter={"roleLabel": "user", "mandateId": rootMandateId, "featureInstanceId": None} ) - viewerRoleId = mandateViewerRoles[0].get("id") if mandateViewerRoles else None + userRoleId = mandateUserRoles[0].get("id") if mandateUserRoles else None - roleIds = [viewerRoleId] if viewerRoleId else [] + roleIds = [userRoleId] if userRoleId else [] self.createUserMandate(userId, rootMandateId, roleIds) - logger.info(f"Assigned user {userId} to root mandate with viewer role") + logger.info(f"Assigned user {userId} to root mandate with user role") except Exception as e: # Log but don't fail user creation @@ -1641,8 +1648,9 @@ class AppObjects: Ensure a user has a billing account for the mandate if billing is configured. User accounts are always created for all billing models (for audit trail). Initial balance depends on billing model: - - PREPAY_USER: defaultUserCredit from settings - - PREPAY_MANDATE / CREDIT_POSTPAY: 0.0 (budget is on mandate pool) + - PREPAY_USER: defaultUserCredit from mandate BillingSettings when joining the root mandate (missing key => 0.0); + other mandates get 0.0. + - PREPAY_MANDATE: 0.0 on the user account (shared pool — no per-user start credit) Args: userId: User ID @@ -1650,7 +1658,7 @@ class AppObjects: """ try: from modules.interfaces.interfaceDbBilling import _getRootInterface as getBillingRootInterface - from modules.datamodels.datamodelBilling import BillingModelEnum + from modules.datamodels.datamodelBilling import BillingModelEnum, parseBillingModelFromStoredValue billingInterface = getBillingRootInterface() settings = billingInterface.getSettings(mandateId) @@ -1658,18 +1666,22 @@ class AppObjects: if not settings: return # No billing configured for this mandate - billingModel = settings.get("billingModel", "UNLIMITED") - if billingModel == BillingModelEnum.UNLIMITED.value: - return # No accounts needed for UNLIMITED + billingModel = parseBillingModelFromStoredValue(settings.get("billingModel")) - # Initial balance depends on billing model - if billingModel == BillingModelEnum.PREPAY_USER.value: - initialBalance = settings.get("defaultUserCredit", 10.0) + # Initial balance depends on billing model (start credit only on root mandate for PREPAY_USER) + rootMandateId = self._getRootMandateId() + isRootMandate = rootMandateId is not None and str(mandateId) == str(rootMandateId) + if billingModel == BillingModelEnum.PREPAY_USER: + initialBalance = ( + float(settings.get("defaultUserCredit", 0.0)) + if isRootMandate + else 0.0 + ) else: - initialBalance = 0.0 # PREPAY_MANDATE / CREDIT_POSTPAY: budget is on pool + initialBalance = 0.0 # PREPAY_MANDATE: budget is on pool billingInterface.getOrCreateUserAccount(mandateId, userId, initialBalance=initialBalance) - logger.info(f"Ensured billing account for user {userId} in mandate {mandateId} (model={billingModel}, initial={initialBalance} CHF)") + logger.info(f"Ensured billing account for user {userId} in mandate {mandateId} (model={billingModel.value}, initial={initialBalance} CHF)") except Exception as e: logger.warning(f"Failed to create billing account for user {userId} (non-critical): {e}") @@ -1678,6 +1690,8 @@ class AppObjects: """ Delete a UserMandate record (remove user from mandate). CASCADE will delete UserMandateRole entries. + Also removes FeatureAccess rows for any feature instances that belong to this mandate + (FeatureAccessRole rows cascade from FeatureAccess). Args: userId: User ID @@ -1690,6 +1704,24 @@ class AppObjects: existing = self.getUserMandate(userId, mandateId) if not existing: return False + + # Drop feature-instance memberships for instances under this mandate + instanceRows = self.db.getRecordset( + FeatureInstance, + recordFilter={"mandateId": mandateId} + ) + for row in instanceRows: + instId = row.get("id") + if not instId: + continue + accessRows = self.db.getRecordset( + FeatureAccess, + recordFilter={"userId": userId, "featureInstanceId": instId} + ) + for acc in accessRows: + accId = acc.get("id") + if accId: + self.db.recordDelete(FeatureAccess, accId) return self.db.recordDelete(UserMandate, existing.id) except Exception as e: @@ -2544,6 +2576,16 @@ class AppObjects: "Access tokens cannot have connectionId - use saveConnectionToken instead" ) + _tp = ( + token.tokenPurpose.value + if isinstance(token.tokenPurpose, TokenPurpose) + else token.tokenPurpose + ) + if _tp != TokenPurpose.AUTH_SESSION.value: + raise ValueError( + "saveAccessToken requires tokenPurpose=authSession (gateway session JWT)" + ) + # Validate user context if not self.currentUser or not self.currentUser.id: raise ValueError("No valid user context available for token storage") @@ -2566,6 +2608,7 @@ class AppObjects: "userId": self.currentUser.id, "authority": token.authority, "connectionId": None, # Ensure we only delete access tokens + "tokenPurpose": TokenPurpose.AUTH_SESSION.value, }, ) deleted_count = 0 @@ -2611,6 +2654,16 @@ class AppObjects: "Connection tokens must have connectionId - use saveAccessToken instead" ) + _tp = ( + token.tokenPurpose.value + if isinstance(token.tokenPurpose, TokenPurpose) + else token.tokenPurpose + ) + if _tp != TokenPurpose.DATA_CONNECTION.value: + raise ValueError( + "saveConnectionToken requires tokenPurpose=dataConnection (provider OAuth)" + ) + # Validate user context if not self.currentUser or not self.currentUser.id: raise ValueError("No valid user context available for token storage") @@ -2748,6 +2801,7 @@ class AppObjects: authority: AuthAuthority, sessionId: str = None, mandateId: str = None, + tokenPurpose: str = None, ) -> Optional[Token]: """Find an active access token by its id (jti) with optional session/tenant scoping.""" try: @@ -2763,6 +2817,8 @@ class AppObjects: recordFilter["sessionId"] = sessionId if mandateId is not None: recordFilter["mandateId"] = mandateId + if tokenPurpose is not None: + recordFilter["tokenPurpose"] = tokenPurpose tokens = self.db.getRecordset(Token, recordFilter=recordFilter) if not tokens: return None @@ -3405,6 +3461,10 @@ def getInterface(currentUser: User, mandateId: Optional[str] = None) -> AppObjec instance = AppObjects(currentUser) instance.setUserContext(currentUser, mandateId=effectiveMandateId) _gatewayInterfaces[contextKey] = instance + else: + # Re-apply user on every resolve: a prior code path (e.g. legacy logout) may have + # cleared currentUser on this cached singleton; OAuth/login must not see a stale context. + _gatewayInterfaces[contextKey].setUserContext(currentUser, mandateId=effectiveMandateId) return _gatewayInterfaces[contextKey] diff --git a/modules/interfaces/interfaceDbBilling.py b/modules/interfaces/interfaceDbBilling.py index 3075966a..58d56895 100644 --- a/modules/interfaces/interfaceDbBilling.py +++ b/modules/interfaces/interfaceDbBilling.py @@ -23,7 +23,6 @@ from modules.datamodels.datamodelBilling import ( BillingSettings, StripeWebhookEvent, UsageStatistics, - BillingAddress, BillingModelEnum, AccountTypeEnum, TransactionTypeEnum, @@ -31,10 +30,49 @@ from modules.datamodels.datamodelBilling import ( PeriodTypeEnum, BillingBalanceResponse, BillingCheckResult, + parseBillingModelFromStoredValue, ) logger = logging.getLogger(__name__) + +def _getAppDatabaseConnector() -> DatabaseConnector: + """App DB connector (same config as UserMandate reads in this module).""" + return DatabaseConnector( + dbDatabase=APP_CONFIG.get("DB_DATABASE", "poweron_app"), + dbHost=APP_CONFIG.get("DB_HOST", "localhost"), + dbPort=int(APP_CONFIG.get("DB_PORT", "5432")), + dbUser=APP_CONFIG.get("DB_USER"), + dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET"), + ) + + +def _getRootMandateIdFromAppDb(appDb: DatabaseConnector) -> Optional[str]: + """Resolve root mandate id (name='root', isSystem=True) from app database.""" + try: + rows = appDb.getRecordset(Mandate, recordFilter={"name": "root", "isSystem": True}) + if rows: + rid = rows[0].get("id") + return str(rid) if rid is not None else None + except Exception as e: + logger.warning("Could not resolve root mandate id from app DB: %s", e) + return None + + +_cachedRootMandateId: Optional[str] = None +_rootMandateIdCacheResolved: bool = False + + +def _getCachedRootMandateId() -> Optional[str]: + """Lazy-cached root mandate id (name=root, isSystem=True) for hot paths.""" + global _cachedRootMandateId, _rootMandateIdCacheResolved + if not _rootMandateIdCacheResolved: + appDb = _getAppDatabaseConnector() + _cachedRootMandateId = _getRootMandateIdFromAppDb(appDb) + _rootMandateIdCacheResolved = True + return _cachedRootMandateId + + # Singleton factory for BillingObjects instances _billingInterfaces: Dict[str, "BillingObjects"] = {} @@ -121,6 +159,8 @@ class BillingObjects: """ Get billing settings for a mandate. + Normalizes billingModel for API (legacy UNLIMITED → PREPAY_MANDATE) and persists once. + Args: mandateId: Mandate ID @@ -132,7 +172,29 @@ class BillingObjects: BillingSettings, recordFilter={"mandateId": mandateId} ) - return results[0] if results else None + if not results: + return None + row = dict(results[0]) + raw_bm = row.get("billingModel") + parsed = parseBillingModelFromStoredValue(raw_bm) + if str(raw_bm or "").strip().upper() == "UNLIMITED": + try: + self.updateSettings( + row["id"], + {"billingModel": BillingModelEnum.PREPAY_MANDATE.value}, + ) + logger.info( + "Migrated billing settings for mandate %s: UNLIMITED → PREPAY_MANDATE", + mandateId, + ) + except Exception as mig_err: + logger.warning( + "Could not persist billing model migration for mandate %s: %s", + mandateId, + mig_err, + ) + row["billingModel"] = parsed.value + return row except Exception as e: logger.error(f"Error getting billing settings: {e}") return None @@ -148,11 +210,6 @@ class BillingObjects: Created settings dict """ settingsDict = settings.model_dump(exclude_none=True) - - # Handle nested BillingAddress - if settings.billingAddress: - settingsDict["billingAddress"] = settings.billingAddress.model_dump() - return self.db.recordCreate(BillingSettings, settingsDict) def updateSettings(self, settingsId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]: @@ -168,7 +225,7 @@ class BillingObjects: """ return self.db.recordModify(BillingSettings, settingsId, updates) - def getOrCreateSettings(self, mandateId: str, defaultModel: BillingModelEnum = BillingModelEnum.UNLIMITED) -> Dict[str, Any]: + def getOrCreateSettings(self, mandateId: str, defaultModel: BillingModelEnum = BillingModelEnum.PREPAY_MANDATE) -> Dict[str, Any]: """ Get or create billing settings for a mandate. @@ -186,10 +243,9 @@ class BillingObjects: settings = BillingSettings( mandateId=mandateId, billingModel=defaultModel, - defaultUserCredit=10.0, + defaultUserCredit=0.0, warningThresholdPercent=10.0, - blockOnZeroBalance=True, - notifyOnWarning=True + notifyOnWarning=True, ) return self.createSettings(settings) @@ -365,7 +421,7 @@ class BillingObjects: def ensureAllMandateSettingsExist(self) -> int: """ Efficiently ensure all mandates have billing settings. - Creates default settings (PREPAY_USER) for mandates without settings. + Creates default settings (PREPAY_MANDATE, 0 CHF) for mandates without settings. Uses bulk queries to minimize database connections. Returns: @@ -397,11 +453,10 @@ class BillingObjects: # Create default billing settings settings = BillingSettings( mandateId=mandateId, - billingModel=BillingModelEnum.PREPAY_USER, - defaultUserCredit=10.0, + billingModel=BillingModelEnum.PREPAY_MANDATE, + defaultUserCredit=0.0, warningThresholdPercent=10.0, - blockOnZeroBalance=True, - notifyOnWarning=True + notifyOnWarning=True, ) self.createSettings(settings) existingMandateIds.add(mandateId) # Track newly created @@ -421,8 +476,8 @@ class BillingObjects: Ensure all users across all mandates have billing accounts. User accounts are always created regardless of billing model (for audit trail). Initial balance depends on billing model: - - PREPAY_USER: defaultUserCredit from settings - - PREPAY_MANDATE / CREDIT_POSTPAY: 0.0 (budget is on pool) + - PREPAY_USER: defaultUserCredit from settings only for the root mandate; other mandates get 0.0 + - PREPAY_MANDATE: 0.0 (budget is on pool) Uses bulk queries to minimize database connections. @@ -431,16 +486,23 @@ class BillingObjects: """ try: accountsCreated = 0 - - # Step 1: Get all billing settings (all models except UNLIMITED need user accounts) + appDb = _getAppDatabaseConnector() + rootMandateId = _getCachedRootMandateId() + + # Step 1: Get all billing settings (all mandates with settings get user accounts) allSettings = self.db.getRecordset(BillingSettings) billingMandates = {} # mandateId -> (billingModel, defaultCredit) for s in allSettings: - billingModel = s.get("billingModel", BillingModelEnum.UNLIMITED.value) - if billingModel == BillingModelEnum.UNLIMITED.value: - continue - defaultCredit = s.get("defaultUserCredit", 10.0) if billingModel == BillingModelEnum.PREPAY_USER.value else 0.0 - billingMandates[s.get("mandateId")] = (billingModel, defaultCredit) + billingModel = parseBillingModelFromStoredValue(s.get("billingModel")).value + mid = s.get("mandateId") + isRoot = rootMandateId is not None and str(mid) == str(rootMandateId) + if billingModel == BillingModelEnum.PREPAY_USER.value: + defaultCredit = ( + float(s.get("defaultUserCredit", 0.0) or 0.0) if isRoot else 0.0 + ) + else: + defaultCredit = 0.0 + billingMandates[mid] = (billingModel, defaultCredit) if not billingMandates: logger.debug("No billable mandates found, skipping account check") @@ -457,13 +519,6 @@ class BillingObjects: existingAccountKeys.add(key) # Step 3: Get all user-mandate combinations from APP database - appDb = DatabaseConnector( - dbDatabase=APP_CONFIG.get('DB_DATABASE', 'poweron_app'), - dbHost=APP_CONFIG.get('DB_HOST', 'localhost'), - dbPort=int(APP_CONFIG.get('DB_PORT', '5432')), - dbUser=APP_CONFIG.get('DB_USER'), - dbPassword=APP_CONFIG.get('DB_PASSWORD_SECRET') - ) allUserMandates = appDb.getRecordset( UserMandate, recordFilter={"enabled": True} @@ -711,69 +766,44 @@ class BillingObjects: """ Check if there's sufficient balance for an operation. - Budget logic: - - PREPAY_USER: check user's own account balance - - PREPAY_MANDATE: check mandate pool balance (shared by all users) - - CREDIT_POSTPAY: check mandate pool credit limit - - UNLIMITED: always allowed + - PREPAY_USER: user.balance >= estimatedCost + - PREPAY_MANDATE: mandate pool balance >= estimatedCost User accounts are always ensured to exist (for audit trail). - - Args: - mandateId: Mandate ID - userId: User ID - estimatedCost: Estimated cost of the operation - - Returns: - BillingCheckResult + Root mandate + PREPAY_USER: initial credit from settings.defaultUserCredit on first create. + Missing settings: treated as PREPAY_MANDATE with empty pool (strict). """ settings = self.getSettings(mandateId) if not settings: - return BillingCheckResult(allowed=True, billingModel=BillingModelEnum.UNLIMITED) + billingModel = BillingModelEnum.PREPAY_MANDATE + defaultCredit = 0.0 + else: + billingModel = parseBillingModelFromStoredValue(settings.get("billingModel")) + defaultCredit = float(settings.get("defaultUserCredit", 0.0) or 0.0) - billingModel = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value)) - - if billingModel == BillingModelEnum.UNLIMITED: - return BillingCheckResult(allowed=True, billingModel=billingModel) - - # Always ensure user account exists (for audit trail) - defaultCredit = settings.get("defaultUserCredit", 10.0) - initialBalance = defaultCredit if billingModel == BillingModelEnum.PREPAY_USER else 0.0 + rootMandateId = _getCachedRootMandateId() + isRootMandate = rootMandateId is not None and str(mandateId) == str(rootMandateId) + if billingModel == BillingModelEnum.PREPAY_USER: + initialBalance = defaultCredit if isRootMandate else 0.0 + else: + initialBalance = 0.0 self.getOrCreateUserAccount(mandateId, userId, initialBalance=initialBalance) - # Determine which balance to check based on billing model if billingModel == BillingModelEnum.PREPAY_USER: account = self.getUserAccount(mandateId, userId) currentBalance = account.get("balance", 0.0) if account else 0.0 - elif billingModel == BillingModelEnum.PREPAY_MANDATE: - poolAccount = self.getOrCreateMandateAccount(mandateId) - currentBalance = poolAccount.get("balance", 0.0) - elif billingModel == BillingModelEnum.CREDIT_POSTPAY: - poolAccount = self.getOrCreateMandateAccount(mandateId) - currentBalance = poolAccount.get("balance", 0.0) - creditLimit = poolAccount.get("creditLimit") - if creditLimit and abs(currentBalance) + estimatedCost > creditLimit: - return BillingCheckResult( - allowed=False, - reason="CREDIT_LIMIT_EXCEEDED", - currentBalance=currentBalance, - requiredAmount=estimatedCost, - billingModel=billingModel - ) - return BillingCheckResult(allowed=True, currentBalance=currentBalance, billingModel=billingModel) else: - return BillingCheckResult(allowed=True, billingModel=billingModel) + poolAccount = self.getOrCreateMandateAccount(mandateId) + currentBalance = poolAccount.get("balance", 0.0) - # PREPAY models - check balance if currentBalance < estimatedCost: - if settings.get("blockOnZeroBalance", True): - return BillingCheckResult( - allowed=False, - reason="INSUFFICIENT_BALANCE", - currentBalance=currentBalance, - requiredAmount=estimatedCost, - billingModel=billingModel - ) + return BillingCheckResult( + allowed=False, + reason="INSUFFICIENT_BALANCE", + currentBalance=currentBalance, + requiredAmount=estimatedCost, + billingModel=billingModel, + ) return BillingCheckResult(allowed=True, currentBalance=currentBalance, billingModel=billingModel) @@ -800,7 +830,6 @@ class BillingObjects: Balance is deducted from the appropriate account based on billing model: - PREPAY_USER: deduct from user's own balance - PREPAY_MANDATE: deduct from mandate pool balance - - CREDIT_POSTPAY: deduct from mandate pool balance """ if priceCHF <= 0: return None @@ -810,10 +839,7 @@ class BillingObjects: logger.debug(f"No billing settings for mandate {mandateId}, skipping usage recording") return None - billingModel = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value)) - - if billingModel == BillingModelEnum.UNLIMITED: - return None + billingModel = parseBillingModelFromStoredValue(settings.get("billingModel")) # Transaction is ALWAYS on the user's account (audit trail) userAccount = self.getOrCreateUserAccount(mandateId, userId) @@ -838,12 +864,11 @@ class BillingObjects: # Determine where to deduct balance if billingModel == BillingModelEnum.PREPAY_USER: - # Deduct from user's own balance return self.createTransaction(transaction) - else: - # PREPAY_MANDATE / CREDIT_POSTPAY: deduct from mandate pool + if billingModel == BillingModelEnum.PREPAY_MANDATE: poolAccount = self.getOrCreateMandateAccount(mandateId) return self.createTransaction(transaction, balanceAccountId=poolAccount["id"]) + return None # ========================================================================= # Workflow Cost Query @@ -865,18 +890,10 @@ class BillingObjects: def switchBillingModel(self, mandateId: str, oldModel: BillingModelEnum, newModel: BillingModelEnum) -> Dict[str, Any]: """ - Switch billing model with automatic budget migration. + Switch billing model with budget migration logged as BillingTransactions. - MANDATE -> USER: pool balance is distributed equally to all user accounts. - USER -> MANDATE: all user balances are consolidated into the pool, user balances set to 0. - - Args: - mandateId: Mandate ID - oldModel: Current billing model - newModel: New billing model - - Returns: - Migration result dict with details + PREPAY_MANDATE -> PREPAY_USER: pool debited, equal shares credited to user accounts. + PREPAY_USER -> PREPAY_MANDATE: user wallets debited, pool credited with sum. """ result = {"oldModel": oldModel.value, "newModel": newModel.value, "migratedAmount": 0.0, "userCount": 0} @@ -884,47 +901,91 @@ class BillingObjects: return result if oldModel == BillingModelEnum.PREPAY_MANDATE and newModel == BillingModelEnum.PREPAY_USER: - # Pool -> distribute equally to users poolAccount = self.getMandateAccount(mandateId) - if poolAccount and poolAccount.get("balance", 0.0) > 0: - poolBalance = poolAccount["balance"] - userAccounts = self.db.getRecordset( - BillingAccount, - recordFilter={"mandateId": mandateId, "accountType": AccountTypeEnum.USER.value} + userAccounts = self.db.getRecordset( + BillingAccount, + recordFilter={"mandateId": mandateId, "accountType": AccountTypeEnum.USER.value} + ) + poolBalance = poolAccount.get("balance", 0.0) if poolAccount else 0.0 + n = len(userAccounts) + if poolAccount and poolBalance > 0: + self.createTransaction( + BillingTransaction( + accountId=poolAccount["id"], + transactionType=TransactionTypeEnum.DEBIT, + amount=poolBalance, + description="Model switch: distributed from mandate pool to user wallets", + referenceType=ReferenceTypeEnum.SYSTEM, + ) ) - if userAccounts: - perUser = poolBalance / len(userAccounts) - for acc in userAccounts: - newBalance = acc.get("balance", 0.0) + perUser - self.updateAccountBalance(acc["id"], newBalance) - self.updateAccountBalance(poolAccount["id"], 0.0) - result["migratedAmount"] = poolBalance - result["userCount"] = len(userAccounts) - - logger.info(f"Switched {mandateId} MANDATE->USER: distributed {result['migratedAmount']} CHF to {result['userCount']} users") + result["migratedAmount"] = poolBalance + if n > 0: + remaining = poolBalance + for i, acc in enumerate(userAccounts): + if i == n - 1: + share = round(remaining, 4) + else: + share = round(poolBalance / n, 4) + remaining -= share + if share > 0: + self.createTransaction( + BillingTransaction( + accountId=acc["id"], + transactionType=TransactionTypeEnum.CREDIT, + amount=share, + description="Model switch: share from mandate pool", + referenceType=ReferenceTypeEnum.SYSTEM, + ) + ) + result["userCount"] = n + logger.info( + "Switched %s MANDATE->USER: migrated %.4f CHF to %d user account(s) (transactions logged)", + mandateId, + result["migratedAmount"], + result["userCount"], + ) + return result - elif oldModel == BillingModelEnum.PREPAY_USER and newModel == BillingModelEnum.PREPAY_MANDATE: - # Users -> consolidate into pool + if oldModel == BillingModelEnum.PREPAY_USER and newModel == BillingModelEnum.PREPAY_MANDATE: userAccounts = self.db.getRecordset( BillingAccount, recordFilter={"mandateId": mandateId, "accountType": AccountTypeEnum.USER.value} ) totalUserBalance = sum(acc.get("balance", 0.0) for acc in userAccounts) - - poolAccount = self.getOrCreateMandateAccount(mandateId, initialBalance=0.0) - newPoolBalance = poolAccount.get("balance", 0.0) + totalUserBalance - self.updateAccountBalance(poolAccount["id"], newPoolBalance) - for acc in userAccounts: - self.updateAccountBalance(acc["id"], 0.0) - + b = acc.get("balance", 0.0) + if b > 0: + self.createTransaction( + BillingTransaction( + accountId=acc["id"], + transactionType=TransactionTypeEnum.DEBIT, + amount=b, + description="Model switch: consolidated to mandate pool", + referenceType=ReferenceTypeEnum.SYSTEM, + ) + ) + poolAccount = self.getOrCreateMandateAccount(mandateId, initialBalance=0.0) + if totalUserBalance > 0: + self.createTransaction( + BillingTransaction( + accountId=poolAccount["id"], + transactionType=TransactionTypeEnum.CREDIT, + amount=totalUserBalance, + description="Model switch: consolidated from user accounts", + referenceType=ReferenceTypeEnum.SYSTEM, + ) + ) result["migratedAmount"] = totalUserBalance result["userCount"] = len(userAccounts) - - logger.info(f"Switched {mandateId} USER->MANDATE: consolidated {totalUserBalance} CHF from {len(userAccounts)} users into pool") + logger.info( + "Switched %s USER->MANDATE: consolidated %.4f CHF from %d users into pool (transactions logged)", + mandateId, + totalUserBalance, + len(userAccounts), + ) + return result - elif newModel == BillingModelEnum.PREPAY_MANDATE or newModel == BillingModelEnum.CREDIT_POSTPAY: - # Any -> MANDATE/CREDIT: ensure pool account exists + if newModel == BillingModelEnum.PREPAY_MANDATE: self.getOrCreateMandateAccount(mandateId, initialBalance=0.0) return result @@ -1027,8 +1088,6 @@ class BillingObjects: Shows the effective available budget: - PREPAY_USER: user's own account balance - PREPAY_MANDATE: mandate pool balance (shared budget visible to user) - - CREDIT_POSTPAY: mandate pool balance - Args: userId: User ID @@ -1060,25 +1119,20 @@ class BillingObjects: if not settings: continue - billingModel = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value)) - if billingModel == BillingModelEnum.UNLIMITED: - continue + billingModel = parseBillingModelFromStoredValue(settings.get("billingModel")) - # Determine effective balance based on billing model if billingModel == BillingModelEnum.PREPAY_USER: account = self.getOrCreateUserAccount(mandateId, userId) if not account: continue balance = account.get("balance", 0.0) warningThreshold = account.get("warningThreshold", 0.0) - creditLimit = account.get("creditLimit") - elif billingModel in [BillingModelEnum.PREPAY_MANDATE, BillingModelEnum.CREDIT_POSTPAY]: + elif billingModel == BillingModelEnum.PREPAY_MANDATE: poolAccount = self.getOrCreateMandateAccount(mandateId) if not poolAccount: continue balance = poolAccount.get("balance", 0.0) warningThreshold = poolAccount.get("warningThreshold", 0.0) - creditLimit = poolAccount.get("creditLimit") else: continue @@ -1089,7 +1143,6 @@ class BillingObjects: balance=balance, warningThreshold=warningThreshold, isWarning=balance <= warningThreshold, - creditLimit=creditLimit )) except Exception as e: logger.error(f"Error getting balances for user: {e}") @@ -1183,7 +1236,7 @@ class BillingObjects: if not mandateId: continue - billingModel = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value)) + billingModel = parseBillingModelFromStoredValue(settings.get("billingModel")) # Get mandate info mandate = appInterface.getMandate(mandateId) @@ -1198,12 +1251,9 @@ class BillingObjects: ) userCount = len(userAccounts) - # Total balance depends on billing model if billingModel == BillingModelEnum.PREPAY_USER: - # Budget is distributed across user accounts totalBalance = sum(acc.get("balance", 0.0) for acc in userAccounts) - elif billingModel in [BillingModelEnum.PREPAY_MANDATE, BillingModelEnum.CREDIT_POSTPAY]: - # Budget is in the mandate pool + elif billingModel == BillingModelEnum.PREPAY_MANDATE: poolAccount = self.getMandateAccount(mandateId) totalBalance = poolAccount.get("balance", 0.0) if poolAccount else 0.0 else: @@ -1215,9 +1265,8 @@ class BillingObjects: "billingModel": billingModel.value, "totalBalance": totalBalance, "userCount": userCount, - "defaultUserCredit": settings.get("defaultUserCredit", 0.0), + "defaultUserCredit": float(settings.get("defaultUserCredit", 0.0) or 0.0), "warningThresholdPercent": settings.get("warningThresholdPercent", 10.0), - "blockOnZeroBalance": settings.get("blockOnZeroBalance", True) }) except Exception as e: diff --git a/modules/interfaces/interfaceRbac.py b/modules/interfaces/interfaceRbac.py index bedc6a81..b4c9a3b4 100644 --- a/modules/interfaces/interfaceRbac.py +++ b/modules/interfaces/interfaceRbac.py @@ -17,7 +17,7 @@ Data Namespace Structure: GROUP-Berechtigung: - data.uam.*: GROUP filtert nach Mandant (via UserMandate) -- data.chat.*, data.files.*, data.automation.*: GROUP = MY (benutzer-eigen, kein Mandantenkontext) +- data.chat.*, data.files.*, data.automation.*: GROUP = MY (benutzer-eigen); bei gesetztem featureInstanceId zusätzlich _createdBy - data.feature.*: GROUP filtert nach mandateId/featureInstanceId """ @@ -344,6 +344,20 @@ def buildRbacWhereClause( # All records within the feature instance - only featureInstanceId filtering if readLevel == AccessLevel.ALL: + # Chat / AI Workspace: even DATA read ALL must not list other users' rows in a + # shared featureInstance (stale RBAC rules or merged roles). Same as MY. + namespaceAll = TABLE_NAMESPACE.get(table, "system") + if featureInstanceId and namespaceAll == "chat": + userIdFieldAll = "_createdBy" + if table == "UserInDB": + userIdFieldAll = "id" + elif table == "UserConnection": + userIdFieldAll = "userId" + conditionsAll = list(baseConditions) + valuesAll = list(baseValues) + conditionsAll.append(f'"{userIdFieldAll}" = %s') + valuesAll.append(currentUser.id) + return {"condition": " AND ".join(conditionsAll), "values": valuesAll} if baseConditions: return {"condition": " AND ".join(baseConditions), "values": baseValues} return None @@ -379,6 +393,14 @@ def buildRbacWhereClause( # But still apply featureInstanceId filter if provided if namespace in USER_OWNED_NAMESPACES: if baseConditions: + # Shared feature instance: GROUP would otherwise only filter by featureInstanceId + # and expose every user's rows in that instance (e.g. ChatWorkflow). + if featureInstanceId and readLevel == AccessLevel.GROUP: + conditions = list(baseConditions) + values = list(baseValues) + conditions.append('"_createdBy" = %s') + values.append(currentUser.id) + return {"condition": " AND ".join(conditions), "values": values} return {"condition": " AND ".join(baseConditions), "values": baseValues} return None diff --git a/modules/routes/routeAdminFeatures.py b/modules/routes/routeAdminFeatures.py index 227fbb4c..1bada7fa 100644 --- a/modules/routes/routeAdminFeatures.py +++ b/modules/routes/routeAdminFeatures.py @@ -22,9 +22,18 @@ from modules.datamodels.datamodelFeatures import Feature, FeatureInstance from modules.interfaces.interfaceDbApp import getRootInterface from modules.interfaces.interfaceFeatures import getFeatureInterface from modules.security.rbacCatalog import getCatalogService +from modules.routes.routeNotifications import create_access_change_notification logger = logging.getLogger(__name__) + +def _feature_instance_display_name(instance: Any) -> str: + if instance is None: + return "" + if isinstance(instance, dict): + return str(instance.get("label") or instance.get("uiLabel") or instance.get("id", "")) + return str(getattr(instance, "label", None) or getattr(instance, "uiLabel", None) or getattr(instance, "id", "")) + router = APIRouter( prefix="/api/features", tags=["Features"], @@ -1024,6 +1033,15 @@ def add_user_to_feature_instance( f"User {context.user.id} added user {data.userId} to feature instance {instanceId} " f"with roles {data.roleIds}" ) + + iname = _feature_instance_display_name(instance) + create_access_change_notification( + data.userId, + "Feature-Zugriff", + f"Sie haben Zugriff auf die Feature-Instanz «{iname}» erhalten.", + "feature_access", + instanceId, + ) return { "featureAccessId": featureAccessId, @@ -1104,6 +1122,15 @@ def remove_user_from_feature_instance( logger.info( f"User {context.user.id} removed user {userId} from feature instance {instanceId}" ) + + iname = _feature_instance_display_name(instance) + create_access_change_notification( + userId, + "Feature-Zugriff", + f"Ihr Zugriff auf die Feature-Instanz «{iname}» wurde entfernt.", + "feature_access", + instanceId, + ) return { "message": "User access removed", @@ -1197,6 +1224,15 @@ def update_feature_instance_user_roles( logger.info( f"User {context.user.id} updated roles for user {userId} in feature instance {instanceId}: {data.roleIds}" ) + + iname = _feature_instance_display_name(instance) + create_access_change_notification( + userId, + "Feature-Rollen geändert", + f"Ihre Rollen in der Feature-Instanz «{iname}» wurden angepasst.", + "feature_access", + instanceId, + ) return { "featureAccessId": featureAccessId, diff --git a/modules/routes/routeBilling.py b/modules/routes/routeBilling.py index ab12c568..eeee79a7 100644 --- a/modules/routes/routeBilling.py +++ b/modules/routes/routeBilling.py @@ -28,7 +28,6 @@ from modules.datamodels.datamodelBilling import ( BillingAccount, BillingTransaction, BillingSettings, - BillingAddress, BillingModelEnum, TransactionTypeEnum, ReferenceTypeEnum, @@ -37,6 +36,7 @@ from modules.datamodels.datamodelBilling import ( BillingStatisticsResponse, BillingStatisticsChartData, BillingCheckResult, + parseBillingModelFromStoredValue, ) # Configure logger @@ -263,10 +263,8 @@ class BillingSettingsUpdate(BaseModel): billingModel: Optional[BillingModelEnum] = None defaultUserCredit: Optional[float] = Field(None, ge=0) warningThresholdPercent: Optional[float] = Field(None, ge=0, le=100) - blockOnZeroBalance: Optional[bool] = None notifyOnWarning: Optional[bool] = None notifyEmails: Optional[List[str]] = None - billingAddress: Optional[BillingAddress] = None class TransactionResponse(BaseModel): @@ -295,7 +293,6 @@ class AccountSummary(BaseModel): userId: Optional[str] accountType: str balance: float - creditLimit: Optional[float] warningThreshold: float enabled: bool @@ -323,7 +320,6 @@ class MandateBalanceResponse(BaseModel): userCount: int defaultUserCredit: float warningThresholdPercent: float - blockOnZeroBalance: bool class UserBalanceResponse(BaseModel): @@ -427,12 +423,12 @@ def _creditStripeSessionIfNeeded( if not settings: raise HTTPException(status_code=404, detail="Billing settings not found") - billing_model = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value)) + billing_model = parseBillingModelFromStoredValue(settings.get("billingModel")) if billing_model == BillingModelEnum.PREPAY_USER: if not user_id: raise HTTPException(status_code=400, detail="userId required for PREPAY_USER") account = billingInterface.getOrCreateUserAccount(mandate_id, user_id, initialBalance=0.0) - elif billing_model in [BillingModelEnum.PREPAY_MANDATE, BillingModelEnum.CREDIT_POSTPAY]: + elif billing_model == BillingModelEnum.PREPAY_MANDATE: account = billingInterface.getOrCreateMandateAccount(mandate_id, initialBalance=0.0) else: raise HTTPException(status_code=400, detail=f"Cannot add credit to {billing_model.value}") @@ -529,11 +525,10 @@ def getBalanceForMandate( return BillingBalanceResponse( mandateId=targetMandateId, mandateName=mandateName, - billingModel=checkResult.billingModel or BillingModelEnum.UNLIMITED, + billingModel=checkResult.billingModel or BillingModelEnum.PREPAY_MANDATE, balance=checkResult.currentBalance or 0.0, warningThreshold=0.0, # TODO: Get from account isWarning=False, - creditLimit=None ) except Exception as e: @@ -622,7 +617,7 @@ def getStatistics( costByFeature={} ) - billingModel = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value)) + billingModel = parseBillingModelFromStoredValue(settings.get("billingModel")) # Transactions are always on user accounts (audit trail) account = billingInterface.getUserAccount(ctx.mandateId, ctx.user.id) @@ -750,8 +745,12 @@ def createOrUpdateSettings( if updates: # Check if billing model is changing - trigger budget migration if "billingModel" in updates: - oldModel = BillingModelEnum(existingSettings.get("billingModel", BillingModelEnum.UNLIMITED.value)) - newModel = BillingModelEnum(updates["billingModel"]) if isinstance(updates["billingModel"], str) else updates["billingModel"] + oldModel = parseBillingModelFromStoredValue(existingSettings.get("billingModel")) + newModel = ( + BillingModelEnum(updates["billingModel"]) + if isinstance(updates["billingModel"], str) + else updates["billingModel"] + ) if oldModel != newModel: migrationResult = billingInterface.switchBillingModel(targetMandateId, oldModel, newModel) logger.info(f"Billing model migration for {targetMandateId}: {migrationResult}") @@ -764,13 +763,27 @@ def createOrUpdateSettings( newSettings = BillingSettings( mandateId=targetMandateId, - billingModel=settingsUpdate.billingModel or BillingModelEnum.UNLIMITED, - defaultUserCredit=settingsUpdate.defaultUserCredit or 10.0, - warningThresholdPercent=settingsUpdate.warningThresholdPercent or 10.0, - blockOnZeroBalance=settingsUpdate.blockOnZeroBalance if settingsUpdate.blockOnZeroBalance is not None else True, - notifyOnWarning=settingsUpdate.notifyOnWarning if settingsUpdate.notifyOnWarning is not None else True, + billingModel=( + settingsUpdate.billingModel + if settingsUpdate.billingModel is not None + else BillingModelEnum.PREPAY_MANDATE + ), + defaultUserCredit=( + settingsUpdate.defaultUserCredit + if settingsUpdate.defaultUserCredit is not None + else 0.0 + ), + warningThresholdPercent=( + settingsUpdate.warningThresholdPercent + if settingsUpdate.warningThresholdPercent is not None + else 10.0 + ), + notifyOnWarning=( + settingsUpdate.notifyOnWarning + if settingsUpdate.notifyOnWarning is not None + else True + ), notifyEmails=settingsUpdate.notifyEmails or [], - billingAddress=settingsUpdate.billingAddress ) return billingInterface.createSettings(newSettings) @@ -803,7 +816,7 @@ def addCredit( if not settings: raise HTTPException(status_code=404, detail="Billing settings not found for this mandate") - billingModel = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value)) + billingModel = parseBillingModelFromStoredValue(settings.get("billingModel")) # Validate request based on billing model if billingModel == BillingModelEnum.PREPAY_USER: @@ -816,7 +829,7 @@ def addCredit( creditRequest.userId, initialBalance=0.0 ) - elif billingModel in [BillingModelEnum.PREPAY_MANDATE, BillingModelEnum.CREDIT_POSTPAY]: + elif billingModel == BillingModelEnum.PREPAY_MANDATE: # Create mandate-level account if needed and add credit account = billingInterface.getOrCreateMandateAccount(targetMandateId, initialBalance=0.0) else: @@ -866,7 +879,7 @@ def createCheckoutSession( if not settings: raise HTTPException(status_code=404, detail="Billing settings not found for this mandate") - billingModel = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value)) + billingModel = parseBillingModelFromStoredValue(settings.get("billingModel")) if billingModel == BillingModelEnum.PREPAY_USER: if not checkoutRequest.userId: @@ -875,7 +888,7 @@ def createCheckoutSession( raise HTTPException(status_code=403, detail="Users can only load credit to their own account") if not _isMemberOfMandate(ctx, targetMandateId): raise HTTPException(status_code=403, detail="User is not a member of this mandate") - elif billingModel in [BillingModelEnum.PREPAY_MANDATE, BillingModelEnum.CREDIT_POSTPAY]: + elif billingModel == BillingModelEnum.PREPAY_MANDATE: if not _isAdminOfMandate(ctx, targetMandateId): raise HTTPException(status_code=403, detail="Mandate admin role required to load mandate credit") else: @@ -933,7 +946,7 @@ def confirmCheckoutSession( if not settings: raise HTTPException(status_code=404, detail="Billing settings not found") - billing_model = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value)) + billing_model = parseBillingModelFromStoredValue(settings.get("billingModel")) if billing_model == BillingModelEnum.PREPAY_USER: if not user_id: raise HTTPException(status_code=400, detail="userId required for PREPAY_USER") @@ -941,7 +954,7 @@ def confirmCheckoutSession( raise HTTPException(status_code=403, detail="Users can only confirm their own payment sessions") if not _isMemberOfMandate(ctx, mandate_id): raise HTTPException(status_code=403, detail="User is not a member of this mandate") - elif billing_model in [BillingModelEnum.PREPAY_MANDATE, BillingModelEnum.CREDIT_POSTPAY]: + elif billing_model == BillingModelEnum.PREPAY_MANDATE: if not _isAdminOfMandate(ctx, mandate_id): raise HTTPException(status_code=403, detail="Mandate admin role required") else: @@ -1041,7 +1054,6 @@ def getAccounts( userId=acc.get("userId"), accountType=acc.get("accountType"), balance=acc.get("balance", 0.0), - creditLimit=acc.get("creditLimit"), warningThreshold=acc.get("warningThreshold", 0.0), enabled=acc.get("enabled", True) )) diff --git a/modules/routes/routeDataConnections.py b/modules/routes/routeDataConnections.py index 099b04c2..2a6be738 100644 --- a/modules/routes/routeDataConnections.py +++ b/modules/routes/routeDataConnections.py @@ -16,6 +16,7 @@ from fastapi import status import logging import json import math +from urllib.parse import quote from modules.datamodels.datamodelUam import User, UserConnection, AuthAuthority, ConnectionStatus from modules.datamodels.datamodelSecurity import Token @@ -445,24 +446,12 @@ def connect_service( detail="Connection not found" ) - # Initiate OAuth flow with state=connect + # Data-app OAuth (JWT state issued server-side in /auth/connect) auth_url = None if connection.authority == AuthAuthority.MSFT: - # Use the same login endpoint with state=connect to ensure account selection - # Include current user ID in state - state_data = { - "type": "connect", - "connectionId": connectionId, - "userId": currentUser.id # Add current user ID - } - auth_url = f"/api/msft/login?state={json.dumps(state_data)}" + auth_url = f"/api/msft/auth/connect?connectionId={quote(connectionId, safe='')}" elif connection.authority == AuthAuthority.GOOGLE: - state_data = { - "type": "connect", - "connectionId": connectionId, - "userId": currentUser.id # Add current user ID - } - auth_url = f"/api/google/login?state={json.dumps(state_data)}" + auth_url = f"/api/google/auth/connect?connectionId={quote(connectionId, safe='')}" else: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, diff --git a/modules/routes/routeDataMandates.py b/modules/routes/routeDataMandates.py index ff4bab3e..16b7166d 100644 --- a/modules/routes/routeDataMandates.py +++ b/modules/routes/routeDataMandates.py @@ -21,6 +21,7 @@ from modules.auth import limiter, requireSysAdminRole, getRequestContext, Reques # Import interfaces import modules.interfaces.interfaceDbApp as interfaceDbApp +from modules.interfaces.interfaceDbBilling import _getRootInterface as _getBillingRootInterface from modules.shared.attributeUtils import getModelAttributeDefinitions from modules.shared.auditLogger import audit_logger @@ -29,6 +30,7 @@ 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.routes.routeNotifications import create_access_change_notification # ============================================================================= @@ -247,6 +249,15 @@ def create_mandate( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to create mandate" ) + + try: + billingInterface = _getBillingRootInterface() + billingInterface.getOrCreateSettings(str(newMandate.id)) + logger.debug(f"Ensured billing settings for new mandate {newMandate.id}") + except Exception as billingErr: + logger.warning( + f"Could not create default billing settings for mandate {newMandate.id}: {billingErr}" + ) logger.info(f"Mandate {newMandate.id} created by SysAdmin {currentUser.id}") @@ -612,6 +623,15 @@ def add_user_to_mandate( f"User {context.user.id} added user {data.targetUserId} to mandate {targetMandateId} " f"with roles {data.roleIds}" ) + + mname = _mandate_display_name(mandate) + create_access_change_notification( + data.targetUserId, + "Mandantenzugriff", + f"Sie wurden dem Mandanten «{mname}» hinzugefügt.", + "mandate_access", + targetMandateId, + ) return UserMandateResponse( id=str(userMandate.id), # UserMandate ID as primary key @@ -696,6 +716,15 @@ def remove_user_from_mandate( ) logger.info(f"User {context.user.id} removed user {targetUserId} from mandate {targetMandateId}") + + mname = _mandate_display_name(mandate) + create_access_change_notification( + targetUserId, + "Mandantenzugriff", + f"Sie wurden aus dem Mandanten «{mname}» entfernt.", + "mandate_access", + targetMandateId, + ) return {"message": "User removed from mandate", "userId": targetUserId} @@ -791,6 +820,16 @@ def update_user_roles_in_mandate( f"User {context.user.id} updated roles for user {targetUserId} " f"in mandate {targetMandateId} to {roleIds}" ) + + mandate_meta = rootInterface.getMandate(targetMandateId) + mname = _mandate_display_name(mandate_meta) + create_access_change_notification( + targetUserId, + "Mandantenrollen geändert", + f"Ihre Rollen im Mandanten «{mname}» wurden angepasst.", + "mandate_access", + targetMandateId, + ) return UserMandateResponse( id=str(membership.id), # UserMandate ID as primary key @@ -814,6 +853,28 @@ def update_user_roles_in_mandate( # Helper Functions # ============================================================================= +def _mandate_display_name(mandate: Any) -> str: + """Human-readable mandate label for notifications.""" + if mandate is None: + return "" + if isinstance(mandate, dict): + if mandate.get("label"): + return str(mandate["label"]) + name = mandate.get("name") + if isinstance(name, dict): + return str(name.get("de") or name.get("en") or (next(iter(name.values()), "") if name else "")) + return str(name or mandate.get("id", "")) + label = getattr(mandate, "label", None) + if label: + return str(label) + name = getattr(mandate, "name", None) + if isinstance(name, dict): + return str(name.get("de") or name.get("en") or (next(iter(name.values()), "") if name else "")) + if name is not None: + return str(name) + return str(getattr(mandate, "id", "")) + + def _getAdminMandateIds(context: RequestContext) -> List[str]: """ Get list of mandate IDs where the user has the admin role. diff --git a/modules/routes/routeNotifications.py b/modules/routes/routeNotifications.py index 3d3c791d..a533a535 100644 --- a/modules/routes/routeNotifications.py +++ b/modules/routes/routeNotifications.py @@ -89,6 +89,31 @@ def _createNotification( return notification +def create_access_change_notification( + userId: str, + title: str, + message: str, + reference_type: str, + reference_id: Optional[str] = None, +) -> None: + """ + In-app notification for mandate/feature access changes (triggers client nav refresh). + Failures are logged only so RBAC mutations still succeed. + """ + try: + _createNotification( + userId=userId, + notificationType=NotificationType.SYSTEM, + title=title, + message=message, + referenceType=reference_type, + referenceId=reference_id, + icon="shield", + ) + except Exception as e: + logger.warning(f"Could not create access-change notification for user {userId}: {e}") + + def createInvitationNotification( userId: str, invitationId: str, diff --git a/modules/routes/routeSecurityGoogle.py b/modules/routes/routeSecurityGoogle.py index ad0dcd52..ff775ec3 100644 --- a/modules/routes/routeSecurityGoogle.py +++ b/modules/routes/routeSecurityGoogle.py @@ -1,582 +1,498 @@ # Copyright (c) 2025 Patrick Motsch # All rights reserved. """ -Routes for Google authentication. +Routes for Google authentication — split Auth app vs Data app. + +See wiki: concepts/OAuth-Auth-vs-Data-Connection-Konzept.md """ -from fastapi import APIRouter, HTTPException, Request, Response, status, Depends, Body, Query +from fastapi import APIRouter, HTTPException, Request, Response, status, Depends, Query from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse import logging import json +import time from typing import Dict, Any, Optional from requests_oauthlib import OAuth2Session import httpx +from jose import jwt as jose_jwt +from jose import JWTError from modules.shared.configuration import APP_CONFIG from modules.interfaces.interfaceDbApp import getInterface, getRootInterface from modules.datamodels.datamodelUam import AuthAuthority, User, ConnectionStatus, UserConnection +from modules.datamodels.datamodelSecurity import Token, TokenPurpose from modules.auth import getCurrentUser, limiter, SECRET_KEY, ALGORITHM -from modules.auth import createAccessToken, setAccessTokenCookie, createRefreshToken, setRefreshTokenCookie +from modules.auth import ( + createAccessToken, + setAccessTokenCookie, + createRefreshToken, + setRefreshTokenCookie, + clearAccessTokenCookie, + clearRefreshTokenCookie, +) from modules.auth.tokenManager import TokenManager +from modules.auth.oauthProviderConfig import googleAuthScopes, googleDataScopes from modules.shared.timeUtils import createExpirationTimestamp, getUtcTimestamp, parseTimestamp -# Configure logger logger = logging.getLogger(__name__) +_FLOW_LOGIN = "google_login" +_FLOW_CONNECT = "google_connect" + + async def verify_google_token(access_token: str) -> Dict[str, Any]: - """ - Verify Google access token validity and get token info. - Returns token information including scopes and expiration. - """ + """Verify Google access token and return token info including scopes.""" try: headers = { - 'Authorization': f"Bearer {access_token}", - 'Content-Type': 'application/json' + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", } - async with httpx.AsyncClient() as client: - # Use Google's tokeninfo endpoint to verify token response = await client.get( "https://www.googleapis.com/oauth2/v1/tokeninfo", headers=headers, - params={"access_token": access_token} + params={"access_token": access_token}, ) - if response.status_code == 200: token_info = response.json() - logger.debug(f"Token verification successful: {token_info.get('email', 'unknown')}") return { "valid": True, "token_info": token_info, - "scopes": token_info.get("scope", "").split(" ") if token_info.get("scope") else [], + "scopes": token_info.get("scope", "").split(" ") + if token_info.get("scope") + else [], "expires_in": int(token_info.get("expires_in", 0)), "user_id": token_info.get("user_id"), - "email": token_info.get("email") + "email": token_info.get("email"), } - else: - logger.warning(f"Token verification failed: {response.status_code} - {response.text}") - return { - "valid": False, - "error": f"HTTP {response.status_code}", - "details": response.text - } - + return { + "valid": False, + "error": f"HTTP {response.status_code}", + "details": response.text, + } except Exception as e: logger.error(f"Error verifying Google token: {str(e)}") - return { - "valid": False, - "error": str(e) - } + return {"valid": False, "error": str(e)} + + +def _issue_oauth_state(claims: Dict[str, Any]) -> str: + body = {**claims, "exp": int(time.time()) + 600} + return jose_jwt.encode(body, SECRET_KEY, algorithm=ALGORITHM) + + +def _parse_oauth_state(state: str) -> Dict[str, Any]: + try: + return jose_jwt.decode(state, SECRET_KEY, algorithms=[ALGORITHM]) + except JWTError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid OAuth state: {e}" + ) from e + -# Create router router = APIRouter( prefix="/api/google", tags=["Security Google"], responses={ - 404: {"description": "Not found"}, + 404: {"description": "Not found"}, 400: {"description": "Bad request"}, 401: {"description": "Unauthorized"}, 403: {"description": "Forbidden"}, - 500: {"description": "Internal server error"} - } + 500: {"description": "Internal server error"}, + }, ) -# Google OAuth configuration -CLIENT_ID = APP_CONFIG.get("Service_GOOGLE_CLIENT_ID") -CLIENT_SECRET = APP_CONFIG.get("Service_GOOGLE_CLIENT_SECRET") -REDIRECT_URI = APP_CONFIG.get("Service_GOOGLE_REDIRECT_URI") -SCOPES = [ - "https://www.googleapis.com/auth/gmail.readonly", - "https://www.googleapis.com/auth/drive.readonly", - "https://www.googleapis.com/auth/userinfo.profile", - "https://www.googleapis.com/auth/userinfo.email", - "openid", -] +AUTH_CLIENT_ID = APP_CONFIG.get("Service_GOOGLE_AUTH_CLIENT_ID") +AUTH_CLIENT_SECRET = APP_CONFIG.get("Service_GOOGLE_AUTH_CLIENT_SECRET") +AUTH_REDIRECT_URI = APP_CONFIG.get("Service_GOOGLE_AUTH_REDIRECT_URI") +DATA_CLIENT_ID = APP_CONFIG.get("Service_GOOGLE_DATA_CLIENT_ID") +DATA_CLIENT_SECRET = APP_CONFIG.get("Service_GOOGLE_DATA_CLIENT_SECRET") +DATA_REDIRECT_URI = APP_CONFIG.get("Service_GOOGLE_DATA_REDIRECT_URI") + @router.get("/config") def get_config(): - """Debug endpoint to check Google OAuth configuration""" + """Debug: OAuth configuration (Auth vs Data apps).""" return { - "client_id": CLIENT_ID, - "client_secret": "***" if CLIENT_SECRET else None, - "redirect_uri": REDIRECT_URI, - "scopes": SCOPES, - "config_loaded": bool(CLIENT_ID and CLIENT_SECRET and REDIRECT_URI), - "config_source": { - "client_id_from": "config.ini" if CLIENT_ID and "354925410565" in CLIENT_ID else "env file", - "redirect_uri_from": "config.ini" if REDIRECT_URI and "gateway-int.poweron-center.net" in REDIRECT_URI else "env file" - } + "auth_client_id": AUTH_CLIENT_ID, + "auth_client_secret": "***" if AUTH_CLIENT_SECRET else None, + "auth_redirect_uri": AUTH_REDIRECT_URI, + "auth_scopes": googleAuthScopes, + "data_client_id": DATA_CLIENT_ID, + "data_client_secret": "***" if DATA_CLIENT_SECRET else None, + "data_redirect_uri": DATA_REDIRECT_URI, + "data_scopes": googleDataScopes, + "config_loaded": bool( + AUTH_CLIENT_ID and AUTH_CLIENT_SECRET and AUTH_REDIRECT_URI and DATA_CLIENT_ID and DATA_CLIENT_SECRET and DATA_REDIRECT_URI + ), } -@router.get("/login") -@limiter.limit("5/minute") -def login( - request: Request, - state: str = Query("login", description="State parameter to distinguish between login and connection flows"), - connectionId: Optional[str] = Query(None, description="Connection ID for connection flow") -) -> RedirectResponse: - """Initiate Google login""" - try: - # Debug: Log configuration values - logger.info(f"Google OAuth Configuration - CLIENT_ID: {CLIENT_ID}, REDIRECT_URI: {REDIRECT_URI}") - - # Validate required configuration - if not CLIENT_ID: - logger.error("Google OAuth CLIENT_ID is not configured") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Google OAuth CLIENT_ID is not configured" - ) - - if not CLIENT_SECRET: - logger.error("Google OAuth CLIENT_SECRET is not configured") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Google OAuth CLIENT_SECRET is not configured" - ) - - if not REDIRECT_URI: - logger.error("Google OAuth REDIRECT_URI is not configured") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Google OAuth REDIRECT_URI is not configured" - ) - - # Generate auth URL with state - use state as is if it's already JSON, otherwise create new state - try: - # Try to parse state as JSON to check if it's already encoded - json.loads(state) - state_param = state # Use state as is if it's valid JSON - except json.JSONDecodeError: - # If not JSON, create new state object - state_param = json.dumps({ - "type": state, - "connectionId": connectionId - }) - - logger.info(f"Using state parameter: {state_param}") - - # Use OAuth2Session directly - it works reliably - oauth = OAuth2Session( - client_id=CLIENT_ID, - redirect_uri=REDIRECT_URI, - scope=SCOPES - ) - - extra_params = { - "access_type": "offline", - "include_granted_scopes": "true", - "state": state_param - } - # If targeting specific connection, add login_hint and hd to preselect account - try: - if connectionId: - rootInterface = getRootInterface() - connection = rootInterface.getUserConnectionById(connectionId) - if connection: - login_hint = connection.externalEmail or connection.externalUsername - if login_hint: - extra_params["login_hint"] = login_hint - if "@" in login_hint: - extra_params["hd"] = login_hint.split("@", 1)[1] - # Avoid account picker when targeting a known account - extra_params["prompt"] = "consent" - else: - extra_params["prompt"] = "consent select_account" - else: - extra_params["prompt"] = "consent select_account" - except Exception: - extra_params["prompt"] = "consent select_account" - auth_url, state = oauth.authorization_url( - "https://accounts.google.com/o/oauth2/auth", - **extra_params - ) - - logger.info(f"Generated Google OAuth URL using OAuth2Session: {auth_url}") - - return RedirectResponse(auth_url) - - except Exception as e: - logger.error(f"Error initiating Google login: {str(e)}") +def _require_google_auth_config(): + if not AUTH_CLIENT_ID or not AUTH_CLIENT_SECRET or not AUTH_REDIRECT_URI: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to initiate Google login: {str(e)}" + detail="Google Auth OAuth is not configured (Service_GOOGLE_AUTH_*)", ) -@router.get("/auth/callback") -async def auth_callback(code: str, state: str, request: Request, response: Response) -> HTMLResponse: - """Handle Google OAuth callback""" + +def _require_google_data_config(): + if not DATA_CLIENT_ID or not DATA_CLIENT_SECRET or not DATA_REDIRECT_URI: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Google Data OAuth is not configured (Service_GOOGLE_DATA_*)", + ) + + +@router.get("/auth/login") +@limiter.limit("5/minute") +def auth_login(request: Request) -> RedirectResponse: + """Start Google login (Auth app — minimal scopes).""" try: - # Import Token at function level to avoid scoping issues - from modules.datamodels.datamodelSecurity import Token - - # Parse state - state_data = json.loads(state) - state_type = state_data.get("type", "login") - connection_id = state_data.get("connectionId") - user_id = state_data.get("userId") # Get user ID from state - - logger.info(f"Processing Google auth callback: state_type={state_type}, connection_id={connection_id}, user_id={user_id}") - - # Use OAuth2Session directly for token exchange + _require_google_auth_config() + state_jwt = _issue_oauth_state({"flow": _FLOW_LOGIN}) oauth = OAuth2Session( - client_id=CLIENT_ID, - redirect_uri=REDIRECT_URI + client_id=AUTH_CLIENT_ID, + redirect_uri=AUTH_REDIRECT_URI, + scope=googleAuthScopes, ) - - # Get token using OAuth2Session - token_data = oauth.fetch_token( - "https://oauth2.googleapis.com/token", - client_secret=CLIENT_SECRET, - code=code, - include_client_id=True + auth_url, _ = oauth.authorization_url( + "https://accounts.google.com/o/oauth2/auth", + state=state_jwt, + access_type="online", + prompt="consent select_account", ) - - # Verify which scopes were actually granted (as per Google OAuth 2.0 spec) - granted_scopes = token_data.get("scope", "") - logger.info(f"Granted scopes: {granted_scopes}") - - # Check if all requested scopes were granted - missing_scopes = [] - for requested_scope in SCOPES: - if requested_scope not in granted_scopes: - missing_scopes.append(requested_scope) - - if missing_scopes: - logger.warning(f"Some requested scopes were not granted: {missing_scopes}") - # Continue with available scopes, but log the limitation - - token_response = { - "access_token": token_data.get("access_token"), - "refresh_token": token_data.get("refresh_token", ""), - "token_type": token_data.get("token_type", "bearer"), - "expires_in": token_data.get("expires_in", 0) - } - - # If Google did not return a refresh_token, try to reuse an existing one for this user/connection - if not token_response.get("refresh_token"): - try: - rootInterface = getRootInterface() - # Prefer connection flow reuse; fallback to user access token - if connection_id: - existing_tokens = rootInterface.getTokensByConnectionIdAndAuthority( - connection_id, AuthAuthority.GOOGLE - ) - if existing_tokens: - # Use most recent by createdAt - existing_tokens.sort(key=lambda x: parseTimestamp(x.createdAt, default=0), reverse=True) - token_response["refresh_token"] = existing_tokens[0].tokenRefresh or "" - if not token_response.get("refresh_token") and user_id: - existing_access_tokens = rootInterface.getTokensByUserIdNoConnection( - user_id, AuthAuthority.GOOGLE - ) - if existing_access_tokens: - existing_access_tokens.sort(key=lambda x: parseTimestamp(x.createdAt, default=0), reverse=True) - token_response["refresh_token"] = existing_access_tokens[0].tokenRefresh or "" - except Exception: - # Non-fatal; continue without refresh token - pass - - - - if not token_response.get("access_token"): - logger.error("Token acquisition failed: No access token received") - return HTMLResponse( - content="

Authentication Failed

Could not acquire token.

", - status_code=400 - ) - - # Verify the token before proceeding (as per Google OAuth 2.0 spec) - token_verification = await verify_google_token(token_response['access_token']) - if not token_verification.get("valid"): - logger.error(f"Token verification failed: {token_verification.get('error')}") - return HTMLResponse( - content=f"

Authentication Failed

Token verification failed: {token_verification.get('error')}

", - status_code=400 - ) - - # Get user info using the verified access token - headers = { - 'Authorization': f"Bearer {token_response['access_token']}", - 'Content-Type': 'application/json' - } - async with httpx.AsyncClient() as client: - user_info_response = await client.get( - "https://www.googleapis.com/oauth2/v2/userinfo", - headers=headers - ) - if user_info_response.status_code != 200: - logger.error(f"Failed to get user info: {user_info_response.text}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to get user info from Google" - ) - user_info = user_info_response.json() - logger.info(f"Got user info from Google: {user_info.get('email')}") - - # Log verified scopes for debugging - verified_scopes = token_verification.get("scopes", []) - logger.info(f"Verified token scopes: {verified_scopes}") - - if state_type == "login": - # Handle login flow - rootInterface = getRootInterface() - user = rootInterface.getUserByUsername(user_info.get("email")) - - if not user: - # Create new user if doesn't exist - user = rootInterface.createUser( - username=user_info.get("email"), - email=user_info.get("email"), - fullName=user_info.get("name"), - authenticationAuthority=AuthAuthority.GOOGLE, - externalId=user_info.get("id"), - externalUsername=user_info.get("email"), - externalEmail=user_info.get("email") - ) - - # Create JWT token data (like Microsoft does) - # MULTI-TENANT: Token does NOT contain mandateId anymore - jwt_token_data = { - "sub": user.username, - "userId": str(user.id), - "authenticationAuthority": AuthAuthority.GOOGLE.value - # NO mandateId in token - stateless multi-tenant design - } - - # Create JWT access token - jwt_token, jwt_expires_at = createAccessToken(jwt_token_data) - - # Create refresh token - refresh_token, _refresh_expires = createRefreshToken(jwt_token_data) - - # Decode token to get jti for database record - from jose import jwt - payload = jwt.decode(jwt_token, SECRET_KEY, algorithms=[ALGORITHM]) - jti = payload.get("jti") - - # Create JWT token with matching id - # MULTI-TENANT: Token model no longer has mandateId field - token = Token( - id=jti, - userId=user.id, # Use local user's ID - authority=AuthAuthority.GOOGLE, - tokenAccess=jwt_token, # Use JWT token instead of Google access token - tokenRefresh=token_response.get("refresh_token", ""), - tokenType="bearer", - expiresAt=jwt_expires_at.timestamp(), - createdAt=getUtcTimestamp() - # NO mandateId - Token is not mandate-bound - ) - - # Save access token (no connectionId) - appInterface = getInterface(user) - appInterface.saveAccessToken(token) - - # Convert token to dict and ensure proper timestamp handling - token_dict = token.model_dump() - - # Create HTML response - html_response = HTMLResponse( - content=f""" - - Authentication Successful - - - - - """ - ) - - # Set access token as httpOnly cookie (like local login) - # HTMLResponse inherits from Response, so we can set cookies directly on it - setAccessTokenCookie(html_response, jwt_token, expiresDelta=None) - - # Set refresh token as httpOnly cookie - setRefreshTokenCookie(html_response, refresh_token) - - return html_response - else: - # Handle connection flow - if not connection_id or not user_id: - logger.error("Connection ID or User ID is missing in connection flow") - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Connection ID and User ID are required for connection flow" - ) - - # Get user directly by ID - rootInterface = getRootInterface() - user = rootInterface.getUser(user_id) - - if not user: - logger.error(f"User {user_id} not found in database") - return HTMLResponse( - content=f""" - - Connection Failed - - - - - """, - status_code=404 - ) - - # Get the connection from the connections table - interface = getInterface(user) - connections = interface.getUserConnections(user_id) - connection = None - for conn in connections: - if conn.id == connection_id: - connection = conn - logger.info(f"Found existing connection for user {user.username}") - break - - try: - if not connection: - logger.error(f"Connection {connection_id} not found in user's connections") - return HTMLResponse( - content=f""" - - Connection Failed - - - - - """, - status_code=404 - ) - - logger.info(f"Updating connection {connection_id} for user {user.username}") - # Update connection with external service details - connection.status = ConnectionStatus.ACTIVE - connection.lastChecked = getUtcTimestamp() - connection.expiresAt = getUtcTimestamp() + token_response.get("expires_in", 0) - connection.externalId = user_info.get("id") - connection.externalUsername = user_info.get("email") - connection.externalEmail = user_info.get("email") - # Store actually granted scopes for this connection - granted_scopes_list = granted_scopes if isinstance(granted_scopes, list) else (granted_scopes.split(" ") if granted_scopes else SCOPES) - connection.grantedScopes = granted_scopes_list - logger.info(f"Storing granted scopes for connection {connection_id}: {granted_scopes_list}") - - # Update connection record directly - rootInterface.db.recordModify(UserConnection, connection_id, connection.model_dump()) - - - # Save token - token = Token( - userId=user.id, # Use local user's ID - authority=AuthAuthority.GOOGLE, - connectionId=connection_id, # Link token to this specific connection - tokenAccess=token_response["access_token"], - tokenRefresh=token_response.get("refresh_token", ""), - tokenType=token_response.get("token_type", "bearer"), - expiresAt=createExpirationTimestamp(token_response.get("expires_in", 0)), - createdAt=getUtcTimestamp() - ) - interface.saveConnectionToken(token) - - # Return success page with connection data - return HTMLResponse( - content=f""" - - Connection Successful - - - - - """ - ) - except Exception as e: - logger.error(f"Error updating connection: {str(e)}", exc_info=True) - return HTMLResponse( - content=f""" - - Connection Failed - - - - - """, - status_code=500 - ) - + return RedirectResponse(auth_url) + except HTTPException: + raise except Exception as e: - logger.error(f"Error in auth callback: {str(e)}", exc_info=True) + logger.error(f"Error initiating Google auth login: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to initiate Google login: {str(e)}", + ) + + +@router.get("/auth/login/callback") +async def auth_login_callback( + code: str, state: str, request: Request, response: Response +) -> HTMLResponse: + """OAuth callback for Google Auth app (login only).""" + state_data = _parse_oauth_state(state) + if state_data.get("flow") != _FLOW_LOGIN: + raise HTTPException(status_code=400, detail="Invalid OAuth flow for this callback") + + _require_google_auth_config() + oauth = OAuth2Session(client_id=AUTH_CLIENT_ID, redirect_uri=AUTH_REDIRECT_URI) + token_data = oauth.fetch_token( + "https://oauth2.googleapis.com/token", + client_secret=AUTH_CLIENT_SECRET, + code=code, + include_client_id=True, + ) + access_token = token_data.get("access_token") + if not access_token: + return HTMLResponse( + content="

Authentication Failed

No access token.

", + status_code=400, + ) + + token_verification = await verify_google_token(access_token) + if not token_verification.get("valid"): + return HTMLResponse( + content=f"

Authentication Failed

{token_verification.get('error')}

", + status_code=400, + ) + + headers = { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + } + async with httpx.AsyncClient() as client: + user_info_response = await client.get( + "https://www.googleapis.com/oauth2/v2/userinfo", headers=headers + ) + if user_info_response.status_code != 200: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to get user info from Google", + ) + user_info = user_info_response.json() + + rootInterface = getRootInterface() + user = rootInterface.getUserByUsername(user_info.get("email")) + if not user: + user = rootInterface.createUser( + username=user_info.get("email"), + email=user_info.get("email"), + fullName=user_info.get("name"), + authenticationAuthority=AuthAuthority.GOOGLE, + externalId=user_info.get("id"), + externalUsername=user_info.get("email"), + externalEmail=user_info.get("email"), + addExternalIdentityConnection=False, + ) + + jwt_token_data = { + "sub": user.username, + "userId": str(user.id), + "authenticationAuthority": AuthAuthority.GOOGLE.value, + } + jwt_token, jwt_expires_at = createAccessToken(jwt_token_data) + refresh_token, _refresh_expires = createRefreshToken(jwt_token_data) + from jose import jwt + + payload = jwt.decode(jwt_token, SECRET_KEY, algorithms=[ALGORITHM]) + jti = payload.get("jti") + + token = Token( + id=jti, + userId=user.id, + authority=AuthAuthority.GOOGLE, + tokenPurpose=TokenPurpose.AUTH_SESSION, + tokenAccess=jwt_token, + tokenRefresh="", + tokenType="bearer", + expiresAt=jwt_expires_at.timestamp(), + createdAt=getUtcTimestamp(), + ) + appInterface = getInterface(user) + appInterface.saveAccessToken(token) + token_dict = token.model_dump() + + html_response = HTMLResponse( + content=f""" + + Authentication Successful + + + + + """ + ) + setAccessTokenCookie(html_response, jwt_token, expiresDelta=None) + setRefreshTokenCookie(html_response, refresh_token) + return html_response + + +@router.get("/auth/connect") +@limiter.limit("5/minute") +def auth_connect( + request: Request, + connectionId: str = Query(..., description="UserConnection id"), + currentUser: User = Depends(getCurrentUser), +) -> RedirectResponse: + """Start Google Data OAuth for an existing connection (requires gateway session).""" + try: + _require_google_data_config() + interface = getInterface(currentUser) + connections = interface.getUserConnections(currentUser.id) + connection = None + for conn in connections: + if conn.id == connectionId and conn.authority == AuthAuthority.GOOGLE: + connection = conn + break + if not connection: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Google connection not found") + + state_jwt = _issue_oauth_state( + { + "flow": _FLOW_CONNECT, + "connectionId": connectionId, + "userId": str(currentUser.id), + } + ) + oauth = OAuth2Session( + client_id=DATA_CLIENT_ID, + redirect_uri=DATA_REDIRECT_URI, + scope=googleDataScopes, + ) + extra_params: Dict[str, Any] = { + "access_type": "offline", + "include_granted_scopes": "true", + "state": state_jwt, + } + login_hint = connection.externalEmail or connection.externalUsername + if login_hint: + extra_params["login_hint"] = login_hint + if "@" in login_hint: + extra_params["hd"] = login_hint.split("@", 1)[1] + extra_params["prompt"] = "consent" + else: + extra_params["prompt"] = "consent select_account" + + auth_url, _ = oauth.authorization_url( + "https://accounts.google.com/o/oauth2/auth", **extra_params + ) + return RedirectResponse(auth_url) + except HTTPException: + raise + except Exception as e: + logger.error(f"Error initiating Google connect: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to initiate Google connect: {str(e)}", + ) + + +@router.get("/auth/connect/callback") +async def auth_connect_callback( + code: str, state: str, request: Request, response: Response +) -> HTMLResponse: + """OAuth callback for Google Data app (UserConnection).""" + state_data = _parse_oauth_state(state) + if state_data.get("flow") != _FLOW_CONNECT: + raise HTTPException(status_code=400, detail="Invalid OAuth flow for this callback") + connection_id = state_data.get("connectionId") + user_id = state_data.get("userId") + if not connection_id or not user_id: + raise HTTPException(status_code=400, detail="Missing connection or user in OAuth state") + + _require_google_data_config() + oauth = OAuth2Session(client_id=DATA_CLIENT_ID, redirect_uri=DATA_REDIRECT_URI) + token_data = oauth.fetch_token( + "https://oauth2.googleapis.com/token", + client_secret=DATA_CLIENT_SECRET, + code=code, + include_client_id=True, + ) + granted_scopes = token_data.get("scope", "") + token_response = { + "access_token": token_data.get("access_token"), + "refresh_token": token_data.get("refresh_token", ""), + "token_type": token_data.get("token_type", "bearer"), + "expires_in": token_data.get("expires_in", 0), + } + + if not token_response.get("refresh_token"): + try: + rootInterface = getRootInterface() + existing_tokens = rootInterface.getTokensByConnectionIdAndAuthority( + connection_id, AuthAuthority.GOOGLE + ) + if existing_tokens: + existing_tokens.sort( + key=lambda x: parseTimestamp(x.createdAt, default=0), reverse=True + ) + token_response["refresh_token"] = existing_tokens[0].tokenRefresh or "" + except Exception: + pass + + if not token_response.get("access_token"): + return HTMLResponse( + content="

Connection Failed

No access token.

", + status_code=400, + ) + + token_verification = await verify_google_token(token_response["access_token"]) + if not token_verification.get("valid"): + return HTMLResponse( + content=f"

Connection Failed

{token_verification.get('error')}

", + status_code=400, + ) + + headers = { + "Authorization": f"Bearer {token_response['access_token']}", + "Content-Type": "application/json", + } + async with httpx.AsyncClient() as client: + user_info_response = await client.get( + "https://www.googleapis.com/oauth2/v2/userinfo", headers=headers + ) + if user_info_response.status_code != 200: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to get user info from Google", + ) + user_info = user_info_response.json() + + rootInterface = getRootInterface() + user = rootInterface.getUser(user_id) + if not user: + return HTMLResponse( + content=f""" + + """, + status_code=404, + ) + + interface = getInterface(user) + connections = interface.getUserConnections(user_id) + connection = None + for conn in connections: + if conn.id == connection_id: + connection = conn + break + if not connection: + return HTMLResponse( + content=f""" + + """, + status_code=404, + ) + + try: + connection.status = ConnectionStatus.ACTIVE + connection.lastChecked = getUtcTimestamp() + connection.expiresAt = getUtcTimestamp() + token_response.get("expires_in", 0) + connection.externalId = user_info.get("id") + connection.externalUsername = user_info.get("email") + connection.externalEmail = user_info.get("email") + granted_scopes_list = ( + granted_scopes + if isinstance(granted_scopes, list) + else (granted_scopes.split(" ") if granted_scopes else googleDataScopes) + ) + connection.grantedScopes = granted_scopes_list + rootInterface.db.recordModify(UserConnection, connection_id, connection.model_dump()) + + token = Token( + userId=user.id, + authority=AuthAuthority.GOOGLE, + connectionId=connection_id, + tokenPurpose=TokenPurpose.DATA_CONNECTION, + tokenAccess=token_response["access_token"], + tokenRefresh=token_response.get("refresh_token", ""), + tokenType=token_response.get("token_type", "bearer"), + expiresAt=createExpirationTimestamp(token_response.get("expires_in", 0)), + createdAt=getUtcTimestamp(), + ) + interface.saveConnectionToken(token) + return HTMLResponse( content=f""" - Authentication Failed + Connection Successful - """, - status_code=500 + """ ) + except Exception as e: + logger.error(f"Error updating Google connection: {str(e)}", exc_info=True) + return HTMLResponse( + content=f""" + + """, + status_code=500, + ) + @router.get("/me", response_model=User) @limiter.limit("30/minute") def get_current_user( request: Request, - currentUser: User = Depends(getCurrentUser) + currentUser: User = Depends(getCurrentUser), ) -> User: - """Get current user information""" - try: - return currentUser - except Exception as e: - logger.error(f"Error getting current user: {str(e)}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=str(e) - ) + return currentUser + @router.post("/logout") @limiter.limit("10/minute") def logout( request: Request, - currentUser: User = Depends(getCurrentUser) -) -> Dict[str, Any]: - """Logout current user""" + currentUser: User = Depends(getCurrentUser), +) -> JSONResponse: + """ + End only the PowerOn gateway session. Does not revoke the Google account session in the browser. + """ try: appInterface = getInterface(currentUser) - appInterface.logout() - - # Log successful logout - # MULTI-TENANT: Logout is a system-level function, no mandate context + + token = request.cookies.get("auth_token") + if not token: + auth_header = request.headers.get("Authorization") + if auth_header and auth_header.lower().startswith("bearer "): + token = auth_header.split(" ", 1)[1].strip() + + if not token: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="No token found", + ) + + try: + payload = jose_jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + session_id = payload.get("sid") or payload.get("sessionId") + jti = payload.get("jti") + except Exception as e: + logger.error(f"Failed to decode JWT on Google logout: {str(e)}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid token", + ) + + revoked = 0 + if session_id: + revoked = appInterface.revokeTokensBySessionId( + session_id, + currentUser.id, + AuthAuthority.GOOGLE, + revokedBy=currentUser.id, + reason="logout", + ) + elif jti: + appInterface.revokeTokenById(jti, revokedBy=currentUser.id, reason="logout") + revoked = 1 + try: from modules.shared.auditLogger import audit_logger + audit_logger.logUserAccess( userId=str(currentUser.id), mandateId="system", action="logout", - successInfo="google_auth_logout", + successInfo=f"google_gateway_logout revoked={revoked}", ipAddress=request.client.host if request.client else None, userAgent=request.headers.get("user-agent"), - success=True + success=True, ) except Exception: - # Don't fail if audit logging fails pass - - return {"message": "Logged out successfully"} + + json_response = JSONResponse( + { + "message": "Successfully logged out from application (Google account stays signed in elsewhere)", + "revokedTokens": revoked, + } + ) + clearAccessTokenCookie(json_response) + clearRefreshTokenCookie(json_response) + return json_response + except HTTPException: + raise except Exception as e: logger.error(f"Error during logout: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to logout: {str(e)}" + detail=f"Failed to logout: {str(e)}", ) + @router.post("/verify") @limiter.limit("30/minute") async def verify_token( request: Request, - currentUser: User = Depends(getCurrentUser) + currentUser: User = Depends(getCurrentUser), ) -> Dict[str, Any]: - """Verify current user's Google token validity and get token info""" try: appInterface = getInterface(currentUser) - - # Find Google connection for this user connections = appInterface.getUserConnections(currentUser.id) google_connection = None - for conn in connections: if conn.authority == AuthAuthority.GOOGLE: google_connection = conn break - if not google_connection: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="No Google connection found for current user" + detail="No Google connection found for current user", ) - - # Get a fresh token via TokenManager convenience method current_token = TokenManager().getFreshToken(google_connection.id) - if not current_token: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="No Google token found for this connection" + detail="No Google token found for this connection", ) - - # Verify the (fresh) token token_verification = await verify_google_token(current_token.tokenAccess) - return { "valid": token_verification.get("valid", False), "scopes": token_verification.get("scopes", []), "expires_in": token_verification.get("expires_in", 0), "email": token_verification.get("email"), "user_id": token_verification.get("user_id"), - "error": token_verification.get("error") if not token_verification.get("valid") else None + "error": token_verification.get("error") + if not token_verification.get("valid") + else None, } - except HTTPException: raise except Exception as e: logger.error(f"Error verifying Google token: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to verify token: {str(e)}" + detail=f"Failed to verify token: {str(e)}", ) + @router.post("/refresh") @limiter.limit("10/minute") async def refresh_token( request: Request, - currentUser: User = Depends(getCurrentUser) + currentUser: User = Depends(getCurrentUser), ) -> Dict[str, Any]: - """Refresh Google OAuth token for current user. Accepts optional { connectionId } to target a specific connection.""" try: appInterface = getInterface(currentUser) - - # Optional: use provided connectionId to target a specific connection payload = {} try: payload = await request.json() except Exception: payload = {} - requested_connection_id = payload.get("connectionId") if isinstance(payload, dict) else None - - # Find Google connection for this user - logger.debug(f"Looking for Google connection for user {currentUser.id}") + requested_connection_id = ( + payload.get("connectionId") if isinstance(payload, dict) else None + ) connections = appInterface.getUserConnections(currentUser.id) google_connection = None if requested_connection_id: @@ -723,58 +678,48 @@ async def refresh_token( google_connection = conn break if not google_connection: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Requested Google connection not found for current user") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Requested Google connection not found for current user", + ) else: for conn in connections: if conn.authority == AuthAuthority.GOOGLE: google_connection = conn break - if not google_connection: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="No Google connection found for current user" + detail="No Google connection found for current user", ) - - logger.debug(f"Found Google connection: {google_connection.id}, status={google_connection.status}") - - # Get the token for this specific connection (fresh if expiring soon) current_token = TokenManager().getFreshToken(google_connection.id) - if not current_token: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="No Google token found for this connection" + detail="No Google token found for this connection", ) - - - # If we could not obtain a fresh token, report error - if not current_token: - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to refresh token") - - # Update the connection status and timing expiresAtValue = parseTimestamp(current_token.expiresAt) - google_connection.expiresAt = expiresAtValue if expiresAtValue else google_connection.expiresAt + google_connection.expiresAt = ( + expiresAtValue if expiresAtValue else google_connection.expiresAt + ) google_connection.lastChecked = getUtcTimestamp() google_connection.status = ConnectionStatus.ACTIVE - appInterface.db.recordModify(UserConnection, google_connection.id, google_connection.model_dump()) - - # Calculate time until expiration + appInterface.db.recordModify( + UserConnection, google_connection.id, google_connection.model_dump() + ) currentTime = getUtcTimestamp() expiresAt = parseTimestamp(current_token.expiresAt) expiresIn = int(expiresAt - currentTime) if expiresAt else 0 - return { "message": "Token refreshed successfully", "expires_at": expiresAt, - "expires_in_seconds": expiresIn + "expires_in_seconds": expiresIn, } - except HTTPException: raise except Exception as e: logger.error(f"Error refreshing Google token: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to refresh token: {str(e)}" - ) \ No newline at end of file + detail=f"Failed to refresh token: {str(e)}", + ) diff --git a/modules/routes/routeSecurityLocal.py b/modules/routes/routeSecurityLocal.py index c83d0d3f..19c8f8f7 100644 --- a/modules/routes/routeSecurityLocal.py +++ b/modules/routes/routeSecurityLocal.py @@ -18,7 +18,7 @@ from modules.auth import getCurrentUser, limiter, SECRET_KEY, ALGORITHM from modules.auth import createAccessToken, createRefreshToken, setAccessTokenCookie, setRefreshTokenCookie, clearAccessTokenCookie, clearRefreshTokenCookie from modules.interfaces.interfaceDbApp import getInterface, getRootInterface from modules.datamodels.datamodelUam import User, UserInDB, AuthAuthority, Mandate -from modules.datamodels.datamodelSecurity import Token +from modules.datamodels.datamodelSecurity import Token, TokenPurpose from modules.shared.configuration import APP_CONFIG from modules.shared.timeUtils import getUtcTimestamp @@ -164,6 +164,7 @@ def login( id=jti, userId=user.id, authority=AuthAuthority.LOCAL, + tokenPurpose=TokenPurpose.AUTH_SESSION, tokenAccess=access_token, tokenType="bearer", expiresAt=expires_at.timestamp(), diff --git a/modules/routes/routeSecurityMsft.py b/modules/routes/routeSecurityMsft.py index 97604e67..00c9c415 100644 --- a/modules/routes/routeSecurityMsft.py +++ b/modules/routes/routeSecurityMsft.py @@ -1,169 +1,464 @@ # Copyright (c) 2025 Patrick Motsch # All rights reserved. """ -Routes for Microsoft authentication. +Routes for Microsoft authentication — split Auth app vs Data app. + +See wiki: concepts/OAuth-Auth-vs-Data-Connection-Konzept.md """ -from fastapi import APIRouter, HTTPException, Request, Response, status, Depends, Body, Query +from fastapi import APIRouter, HTTPException, Request, Response, status, Depends, Query from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse import logging import json +import time from typing import Dict, Any, Optional +from urllib.parse import quote import msal import httpx +from jose import jwt as jose_jwt +from jose import JWTError from modules.shared.configuration import APP_CONFIG from modules.interfaces.interfaceDbApp import getInterface, getRootInterface from modules.datamodels.datamodelUam import AuthAuthority, User, ConnectionStatus, UserConnection -from modules.datamodels.datamodelSecurity import Token +from modules.datamodels.datamodelSecurity import Token, TokenPurpose from modules.auth import getCurrentUser, limiter, SECRET_KEY, ALGORITHM -from modules.auth import createAccessToken, setAccessTokenCookie, createRefreshToken, setRefreshTokenCookie +from modules.auth import ( + createAccessToken, + setAccessTokenCookie, + createRefreshToken, + setRefreshTokenCookie, + clearAccessTokenCookie, + clearRefreshTokenCookie, +) from modules.auth.tokenManager import TokenManager +from modules.auth.oauthProviderConfig import msftAuthScopes, msftDataScopes, msftDataScopesForRefresh from modules.shared.timeUtils import createExpirationTimestamp, getUtcTimestamp, parseTimestamp -# Configure logger logger = logging.getLogger(__name__) -# Create router +_FLOW_LOGIN = "msft_login" +_FLOW_CONNECT = "msft_connect" + router = APIRouter( prefix="/api/msft", tags=["Security Microsoft"], responses={ - 404: {"description": "Not found"}, + 404: {"description": "Not found"}, 400: {"description": "Bad request"}, 401: {"description": "Unauthorized"}, 403: {"description": "Forbidden"}, - 500: {"description": "Internal server error"} - } + 500: {"description": "Internal server error"}, + }, ) -# Microsoft OAuth configuration -CLIENT_ID = APP_CONFIG.get("Service_MSFT_CLIENT_ID") -CLIENT_SECRET = APP_CONFIG.get("Service_MSFT_CLIENT_SECRET") +AUTH_CLIENT_ID = APP_CONFIG.get("Service_MSFT_AUTH_CLIENT_ID") +AUTH_CLIENT_SECRET = APP_CONFIG.get("Service_MSFT_AUTH_CLIENT_SECRET") +AUTH_REDIRECT_URI = APP_CONFIG.get("Service_MSFT_AUTH_REDIRECT_URI") +DATA_CLIENT_ID = APP_CONFIG.get("Service_MSFT_DATA_CLIENT_ID") +DATA_CLIENT_SECRET = APP_CONFIG.get("Service_MSFT_DATA_CLIENT_SECRET") +DATA_REDIRECT_URI = APP_CONFIG.get("Service_MSFT_DATA_REDIRECT_URI") TENANT_ID = APP_CONFIG.get("Service_MSFT_TENANT_ID", "common") -REDIRECT_URI = APP_CONFIG.get("Service_MSFT_REDIRECT_URI") AUTHORITY = f"https://login.microsoftonline.com/{TENANT_ID}" -# Validate configuration at module load -if not CLIENT_ID: - logger.warning("Service_MSFT_CLIENT_ID is not configured") -if not CLIENT_SECRET: - logger.warning("Service_MSFT_CLIENT_SECRET is not configured") -if not REDIRECT_URI: - logger.warning("Service_MSFT_REDIRECT_URI is not configured") -if CLIENT_SECRET and CLIENT_SECRET.startswith(("PROD_ENC:", "INT_ENC:", "DEV_ENC:")): - logger.warning("Service_MSFT_CLIENT_SECRET appears to be encrypted - ensure decryption is working") -SCOPES = [ - "User.Read", # Read user profile - "Mail.ReadWrite", # Read and write mail - "Mail.Send", # Send mail - "Files.ReadWrite.All", # Read and write files (SharePoint/OneDrive) - "Sites.ReadWrite.All", # Read and write SharePoint sites - "Team.ReadBasic.All", # List joined teams and channels - # Teams Bot: Meeting and chat access (requires admin consent) - "OnlineMeetings.Read", # Read user's Teams meeting details (delegated scope) - "Chat.ReadWrite", # Read and write Teams chat messages - "ChatMessage.Send", # Send messages to Teams meeting chats -] -# NOTE: Sites.ReadWrite.All, Files.ReadWrite.All, and Teams scopes require admin consent. -# An admin must grant consent ONCE at: /api/msft/adminconsent -# After that, all users can connect without individual admin approval. -@router.get("/login") -@limiter.limit("5/minute") -def login( - request: Request, - state: str = Query("login", description="State parameter to distinguish between login and connection flows"), - connectionId: Optional[str] = Query(None, description="Connection ID for connection flow") -) -> RedirectResponse: - """Initiate Microsoft login""" +def _issue_oauth_state(claims: Dict[str, Any]) -> str: + body = {**claims, "exp": int(time.time()) + 600} + return jose_jwt.encode(body, SECRET_KEY, algorithm=ALGORITHM) + + +def _parse_oauth_state(state: str) -> Dict[str, Any]: try: - # Create MSAL app - msal_app = msal.ConfidentialClientApplication( - CLIENT_ID, - authority=AUTHORITY, - client_credential=CLIENT_SECRET - ) - - # Generate auth URL with state - use state as is if it's already JSON, otherwise create new state - try: - # Try to parse state as JSON to check if it's already encoded - json.loads(state) - state_param = state # Use state as is if it's valid JSON - except json.JSONDecodeError: - # If not JSON, create new state object - state_param = json.dumps({ - "type": state, - "connectionId": connectionId - }) - - # If a specific connection is targeted, set login_hint/domain_hint to preselect that account - login_kwargs = {} - if connectionId: - try: - rootInterface = getRootInterface() - # Fetch the connection by ID directly using interface method - connection = rootInterface.getUserConnectionById(connectionId) - if connection: - login_hint = connection.externalEmail or connection.externalUsername - if login_hint: - login_kwargs["login_hint"] = login_hint - # Derive domain hint from email/UPN - if "@" in login_hint: - domain = login_hint.split("@", 1)[1] - # Use common MSAL guidance: pass domain_hint to reduce account switching - login_kwargs["domain_hint"] = domain - # When targeting a specific account, avoid account picker - login_kwargs["prompt"] = "login" # force re-auth for that account only - else: - # Fall back to default behavior if connection not found - login_kwargs["prompt"] = "select_account" - except Exception: - login_kwargs["prompt"] = "select_account" - else: - # Generic login/connect flow: allow choosing account - login_kwargs["prompt"] = "select_account" + return jose_jwt.decode(state, SECRET_KEY, algorithms=[ALGORITHM]) + except JWTError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid OAuth state: {e}" + ) from e - # MSAL automatically adds openid, profile, offline_access - we just need to provide our business scopes - auth_url = msal_app.get_authorization_request_url( - scopes=SCOPES, # Only our business scopes - MSAL adds the required ones automatically - redirect_uri=REDIRECT_URI, - state=state_param, - **login_kwargs - ) - - return RedirectResponse(auth_url) - - except Exception as e: - logger.error(f"Error initiating Microsoft login: {str(e)}") + +def _require_msft_auth_config(): + if not AUTH_CLIENT_ID or not AUTH_CLIENT_SECRET or not AUTH_REDIRECT_URI: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to initiate Microsoft login: {str(e)}" + detail="Microsoft Auth OAuth is not configured (Service_MSFT_AUTH_*)", ) + +def _require_msft_data_config(): + if not DATA_CLIENT_ID or not DATA_CLIENT_SECRET or not DATA_REDIRECT_URI: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Microsoft Data OAuth is not configured (Service_MSFT_DATA_*)", + ) + + +def _admin_consent_redirect_uri() -> str: + if "/auth/connect/callback" in (DATA_REDIRECT_URI or ""): + return DATA_REDIRECT_URI.replace("/auth/connect/callback", "/adminconsent/callback") + if DATA_REDIRECT_URI: + return DATA_REDIRECT_URI.rstrip("/").rsplit("/", 1)[0] + "/adminconsent/callback" + return "" + + +@router.get("/auth/login") +@limiter.limit("5/minute") +def auth_login(request: Request) -> RedirectResponse: + """Start Microsoft login (Auth app — User.Read only).""" + try: + _require_msft_auth_config() + msal_app = msal.ConfidentialClientApplication( + AUTH_CLIENT_ID, + authority=AUTHORITY, + client_credential=AUTH_CLIENT_SECRET, + ) + state_jwt = _issue_oauth_state({"flow": _FLOW_LOGIN}) + auth_url = msal_app.get_authorization_request_url( + scopes=msftAuthScopes, + redirect_uri=AUTH_REDIRECT_URI, + state=state_jwt, + prompt="select_account", + ) + return RedirectResponse(auth_url) + except HTTPException: + raise + except Exception as e: + logger.error(f"Error initiating Microsoft auth login: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to initiate Microsoft login: {str(e)}", + ) + + +@router.get("/auth/login/callback") +async def auth_login_callback( + code: str, state: str, request: Request, response: Response +) -> HTMLResponse: + state_data = _parse_oauth_state(state) + if state_data.get("flow") != _FLOW_LOGIN: + raise HTTPException(status_code=400, detail="Invalid OAuth flow for this callback") + + _require_msft_auth_config() + msal_app = msal.ConfidentialClientApplication( + AUTH_CLIENT_ID, + authority=AUTHORITY, + client_credential=AUTH_CLIENT_SECRET, + ) + token_response = msal_app.acquire_token_by_authorization_code( + code, + scopes=msftAuthScopes, + redirect_uri=AUTH_REDIRECT_URI, + ) + if "error" in token_response: + err = token_response.get("error_description", token_response.get("error")) + return HTMLResponse( + content=f"

Authentication Failed

{err}

", + status_code=400, + ) + + headers = { + "Authorization": f"Bearer {token_response['access_token']}", + "Content-Type": "application/json", + } + async with httpx.AsyncClient() as client: + user_info_response = await client.get( + "https://graph.microsoft.com/v1.0/me", headers=headers + ) + if user_info_response.status_code != 200: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to get user info from Microsoft", + ) + user_info = user_info_response.json() + + rootInterface = getRootInterface() + user = rootInterface.getUserByUsername(user_info.get("userPrincipalName")) + if not user: + user = rootInterface.createUser( + username=user_info.get("userPrincipalName"), + email=user_info.get("mail"), + fullName=user_info.get("displayName"), + authenticationAuthority=AuthAuthority.MSFT, + externalId=user_info.get("id"), + externalUsername=user_info.get("userPrincipalName"), + externalEmail=user_info.get("mail"), + addExternalIdentityConnection=False, + ) + + jwt_token_data = { + "sub": user.username, + "userId": str(user.id), + "authenticationAuthority": AuthAuthority.MSFT.value, + } + jwt_token, jwt_expires_at = createAccessToken(jwt_token_data) + refresh_token_cookie, _refresh_expires = createRefreshToken(jwt_token_data) + payload = jose_jwt.decode(jwt_token, SECRET_KEY, algorithms=[ALGORITHM]) + jti = payload.get("jti") + + jwt_token_obj = Token( + id=jti, + userId=user.id, + authority=AuthAuthority.MSFT, + tokenPurpose=TokenPurpose.AUTH_SESSION, + tokenAccess=jwt_token, + tokenType="bearer", + expiresAt=jwt_expires_at.timestamp(), + createdAt=getUtcTimestamp(), + tokenRefresh="", + ) + appInterface = getInterface(user) + appInterface.saveAccessToken(jwt_token_obj) + token_dict = jwt_token_obj.model_dump() + + html_response = HTMLResponse( + content=f""" + + Authentication Successful + + + + + """ + ) + setAccessTokenCookie(html_response, jwt_token, expiresDelta=None) + setRefreshTokenCookie(html_response, refresh_token_cookie) + return html_response + + +@router.get("/auth/connect") +@limiter.limit("5/minute") +def auth_connect( + request: Request, + connectionId: str = Query(..., description="UserConnection id"), + currentUser: User = Depends(getCurrentUser), +) -> RedirectResponse: + """Start Microsoft Data OAuth for an existing connection.""" + try: + _require_msft_data_config() + interface = getInterface(currentUser) + connections = interface.getUserConnections(currentUser.id) + connection = None + for conn in connections: + if conn.id == connectionId and conn.authority == AuthAuthority.MSFT: + connection = conn + break + if not connection: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Microsoft connection not found" + ) + + msal_app = msal.ConfidentialClientApplication( + DATA_CLIENT_ID, + authority=AUTHORITY, + client_credential=DATA_CLIENT_SECRET, + ) + state_jwt = _issue_oauth_state( + { + "flow": _FLOW_CONNECT, + "connectionId": connectionId, + "userId": str(currentUser.id), + } + ) + login_kwargs: Dict[str, Any] = {"prompt": "select_account", "state": state_jwt} + login_hint = connection.externalEmail or connection.externalUsername + if login_hint: + login_kwargs["login_hint"] = login_hint + if "@" in login_hint: + login_kwargs["domain_hint"] = login_hint.split("@", 1)[1] + login_kwargs["prompt"] = "login" + + auth_url = msal_app.get_authorization_request_url( + scopes=msftDataScopes, + redirect_uri=DATA_REDIRECT_URI, + **login_kwargs, + ) + return RedirectResponse(auth_url) + except HTTPException: + raise + except Exception as e: + logger.error(f"Error initiating Microsoft connect: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to initiate Microsoft connect: {str(e)}", + ) + + +@router.get("/auth/connect/callback") +async def auth_connect_callback( + code: str, state: str, request: Request, response: Response +) -> HTMLResponse: + state_data = _parse_oauth_state(state) + if state_data.get("flow") != _FLOW_CONNECT: + raise HTTPException(status_code=400, detail="Invalid OAuth flow for this callback") + connection_id = state_data.get("connectionId") + user_id = state_data.get("userId") + if not connection_id or not user_id: + raise HTTPException(status_code=400, detail="Missing connection or user in OAuth state") + + _require_msft_data_config() + msal_app = msal.ConfidentialClientApplication( + DATA_CLIENT_ID, + authority=AUTHORITY, + client_credential=DATA_CLIENT_SECRET, + ) + token_response = msal_app.acquire_token_by_authorization_code( + code, + scopes=msftDataScopes, + redirect_uri=DATA_REDIRECT_URI, + ) + if "error" in token_response: + err = token_response.get("error_description", token_response.get("error")) + return HTMLResponse( + content=f""" + + """, + status_code=400, + ) + + headers = { + "Authorization": f"Bearer {token_response['access_token']}", + "Content-Type": "application/json", + } + async with httpx.AsyncClient() as client: + user_info_response = await client.get( + "https://graph.microsoft.com/v1.0/me", headers=headers + ) + if user_info_response.status_code != 200: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to get user info from Microsoft", + ) + user_info = user_info_response.json() + + scope_str = token_response.get("scope") or msftDataScopesForRefresh() + granted_list = scope_str.split() if isinstance(scope_str, str) else list(msftDataScopes) + + rootInterface = getRootInterface() + user = rootInterface.getUser(user_id) + if not user: + return HTMLResponse( + content=""" + + """, + status_code=404, + ) + + interface = getInterface(user) + connections = interface.getUserConnections(user_id) + connection = None + for conn in connections: + if conn.id == connection_id: + connection = conn + break + if not connection: + return HTMLResponse( + content=""" + + """, + status_code=404, + ) + + try: + connection.status = ConnectionStatus.ACTIVE + connection.lastChecked = getUtcTimestamp() + connection.expiresAt = getUtcTimestamp() + token_response.get("expires_in", 0) + connection.externalId = user_info.get("id") + connection.externalUsername = user_info.get("userPrincipalName") + connection.externalEmail = user_info.get("mail") + connection.grantedScopes = ( + granted_list if isinstance(granted_list, list) else list(msftDataScopes) + ) + rootInterface.db.recordModify(UserConnection, connection_id, connection.model_dump()) + + token = Token( + userId=user.id, + authority=AuthAuthority.MSFT, + connectionId=connection_id, + tokenPurpose=TokenPurpose.DATA_CONNECTION, + tokenAccess=token_response["access_token"], + tokenRefresh=token_response.get("refresh_token", ""), + tokenType=token_response.get("token_type", "bearer"), + expiresAt=createExpirationTimestamp(token_response.get("expires_in", 0)), + createdAt=getUtcTimestamp(), + ) + interface.saveConnectionToken(token) + + return HTMLResponse( + content=f""" + + Connection Successful + + + + + """ + ) + except Exception as e: + logger.error(f"Error updating Microsoft connection: {str(e)}", exc_info=True) + return HTMLResponse( + content=f""" + + """, + status_code=500, + ) + + @router.get("/adminconsent") @limiter.limit("5/minute") def adminconsent(request: Request) -> RedirectResponse: - """Initiate Microsoft Admin Consent flow. - - An Azure AD admin must visit this URL once to grant consent for the entire tenant. - After admin consent, all users can connect without individual approval. - """ - try: - adminConsentRedirectUri = REDIRECT_URI.replace("/auth/callback", "/adminconsent/callback") - adminConsentUrl = ( - f"{AUTHORITY}/adminconsent" - f"?client_id={CLIENT_ID}" - f"&redirect_uri={adminConsentRedirectUri}" - ) - logger.info(f"Redirecting to admin consent URL for tenant: {TENANT_ID}") - return RedirectResponse(adminConsentUrl) - except Exception as e: - logger.error(f"Error generating admin consent URL: {str(e)}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to generate admin consent URL: {str(e)}" - ) + _require_msft_data_config() + admin_consent_redirect = _admin_consent_redirect_uri() + admin_consent_url = ( + f"{AUTHORITY}/adminconsent" + f"?client_id={DATA_CLIENT_ID}" + f"&redirect_uri={quote(admin_consent_redirect, safe='')}" + ) + logger.info(f"Redirecting to admin consent URL for tenant: {TENANT_ID}") + return RedirectResponse(admin_consent_url) + @router.get("/adminconsent/callback") def adminconsent_callback( @@ -171,7 +466,7 @@ def adminconsent_callback( tenant: Optional[str] = Query(None), error: Optional[str] = Query(None), error_description: Optional[str] = Query(None), - request: Request = None + request: Request = None, ) -> HTMLResponse: """Handle Microsoft Admin Consent callback""" try: @@ -185,13 +480,11 @@ def adminconsent_callback(

Admin Consent Failed

Error: {error}

Description: {error_description or 'No description provided'}

-

Please contact your administrator.

""", - status_code=400 + status_code=400, ) - if admin_consent == "True" and tenant: logger.info(f"Admin consent granted for tenant: {tenant}") return HTMLResponse( @@ -200,514 +493,158 @@ def adminconsent_callback( Admin Consent Successful

Admin Consent Successful

-

The application has been granted admin consent for tenant: {tenant}

-

All users in this tenant can now use the application without individual consent.

-

You can close this window.

- - - - """ - ) - else: - logger.warning(f"Admin consent callback received unexpected parameters: admin_consent={admin_consent}, tenant={tenant}") - return HTMLResponse( - content=f""" - - Admin Consent Status - -

Admin Consent Status

-

Admin Consent: {admin_consent or 'Not provided'}

-

Tenant: {tenant or 'Not provided'}

+

Tenant: {tenant}

+ """ ) + return HTMLResponse( + content=f""" + +

Admin Consent Status

+

Admin Consent: {admin_consent or 'Not provided'}

+

Tenant: {tenant or 'Not provided'}

+ + """ + ) except Exception as e: logger.error(f"Error in admin consent callback: {str(e)}", exc_info=True) return HTMLResponse( - content=f""" - - Admin Consent Error - -

Error Processing Admin Consent

-

{str(e)}

- - - """, - status_code=500 + content=f"

Error

{str(e)}

", + status_code=500, ) -@router.get("/auth/callback") -async def auth_callback(code: str, state: str, request: Request, response: Response) -> HTMLResponse: - """Handle Microsoft OAuth callback""" - try: - # Parse state - state_data = json.loads(state) - state_type = state_data.get("type", "login") - connection_id = state_data.get("connectionId") - user_id = state_data.get("userId") # Get user ID from state - - logger.info(f"Processing Microsoft auth callback: state_type={state_type}, connection_id={connection_id}, user_id={user_id}") - - # Create MSAL app - msal_app = msal.ConfidentialClientApplication( - CLIENT_ID, - authority=AUTHORITY, - client_credential=CLIENT_SECRET - ) - - # Get token from code - MSAL automatically handles the required scopes - token_response = msal_app.acquire_token_by_authorization_code( - code, - scopes=SCOPES, # Only our business scopes - MSAL adds the required ones automatically - redirect_uri=REDIRECT_URI - ) - - if "error" in token_response: - error_code = token_response.get('error') - error_description = token_response.get('error_description', 'No description provided') - error_uri = token_response.get('error_uri', '') - - logger.error( - f"Token acquisition failed: {error_code} - {error_description} | " - f"CLIENT_ID: {CLIENT_ID[:8]}... | " - f"REDIRECT_URI: {REDIRECT_URI} | " - f"TENANT_ID: {TENANT_ID}" - ) - - # Provide more helpful error message based on error code - if error_code == "invalid_client": - error_msg = "Invalid client credentials. Please check CLIENT_ID and CLIENT_SECRET configuration." - elif error_code == "invalid_grant": - error_msg = "Invalid authorization code or redirect URI mismatch." - else: - error_msg = f"Authentication failed: {error_description or error_code}" - - return HTMLResponse( - content=f""" - - Authentication Failed - -

Authentication Failed

-

{error_msg}

-

Error code: {error_code}

-

Please contact support if this issue persists.

- - - """, - status_code=400 - ) - - # Get user info using the access token - headers = { - 'Authorization': f"Bearer {token_response['access_token']}", - 'Content-Type': 'application/json' - } - async with httpx.AsyncClient() as client: - user_info_response = await client.get( - "https://graph.microsoft.com/v1.0/me", - headers=headers - ) - if user_info_response.status_code != 200: - logger.error(f"Failed to get user info: {user_info_response.text}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to get user info from Microsoft" - ) - user_info = user_info_response.json() - logger.info(f"Got user info from Microsoft: {user_info.get('userPrincipalName')}") - - if state_type == "login": - # Handle login flow - rootInterface = getRootInterface() - user = rootInterface.getUserByUsername(user_info.get("userPrincipalName")) - - if not user: - logger.info(f"Creating new user for {user_info.get('userPrincipalName')}") - # Create new user if doesn't exist - user = rootInterface.createUser( - username=user_info.get("userPrincipalName"), - email=user_info.get("mail"), - fullName=user_info.get("displayName"), - authenticationAuthority=AuthAuthority.MSFT, - externalId=user_info.get("id"), - externalUsername=user_info.get("userPrincipalName"), - externalEmail=user_info.get("mail") - ) - - # Create token - token = Token( - userId=user.id, # Use local user's ID - authority=AuthAuthority.MSFT, - tokenAccess=token_response["access_token"], - tokenRefresh=token_response.get("refresh_token", ""), - tokenType=token_response.get("token_type", "bearer"), - expiresAt=createExpirationTimestamp(token_response.get("expires_in", 0)), - createdAt=getUtcTimestamp() - ) - - # Save access token (no connectionId) - appInterface = getInterface(user) - appInterface.saveAccessToken(token) - - # Create JWT token data - # MULTI-TENANT: Token does NOT contain mandateId anymore - jwt_token_data = { - "sub": user.username, - "userId": str(user.id), - "authenticationAuthority": AuthAuthority.MSFT.value - # NO mandateId in token - stateless multi-tenant design - } - - # Create JWT access token - jwt_token, jwt_expires_at = createAccessToken(jwt_token_data) - - # Create refresh token - refresh_token, _refresh_expires = createRefreshToken(jwt_token_data) - - # Decode token to get jti for database record - from jose import jwt - payload = jwt.decode(jwt_token, SECRET_KEY, algorithms=[ALGORITHM]) - jti = payload.get("jti") - - # Create JWT token with matching id - # MULTI-TENANT: Token model no longer has mandateId field - jwt_token_obj = Token( - id=jti, - userId=user.id, - authority=AuthAuthority.MSFT, - tokenAccess=jwt_token, - tokenType="bearer", - expiresAt=jwt_expires_at.timestamp(), - createdAt=getUtcTimestamp() - # NO mandateId - Token is not mandate-bound - ) - - # Save JWT access token - appInterface.saveAccessToken(jwt_token_obj) - - # Convert token to dict and ensure proper timestamp handling - token_dict = jwt_token_obj.model_dump() - # Remove datetime conversion logic - models now handle this automatically - # The token model already returns float timestamps - - # Create HTML response - html_response = HTMLResponse( - content=f""" - - Authentication Successful - - - - - """ - ) - - # Set access token as httpOnly cookie (like local login) - # HTMLResponse inherits from Response, so we can set cookies directly on it - setAccessTokenCookie(html_response, jwt_token, expiresDelta=None) - - # Set refresh token as httpOnly cookie - setRefreshTokenCookie(html_response, refresh_token) - - return html_response - else: - # Handle connection flow - if not connection_id or not user_id: - logger.error("Connection ID or User ID is missing in connection flow") - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Connection ID and User ID are required for connection flow" - ) - - # Get user directly by ID - rootInterface = getRootInterface() - user = rootInterface.getUser(user_id) - - if not user: - logger.error(f"User {user_id} not found in database") - return HTMLResponse( - content=f""" - - Connection Failed - - - - - """, - status_code=404 - ) - - # Get the connection from the connections table - interface = getInterface(user) - connections = interface.getUserConnections(user_id) - connection = None - for conn in connections: - if conn.id == connection_id: - connection = conn - logger.info(f"Found existing connection for user {user.username}") - break - - try: - if not connection: - logger.error(f"Connection {connection_id} not found in user's connections") - return HTMLResponse( - content=f""" - - Connection Failed - - - - - """, - status_code=404 - ) - - logger.info(f"Updating connection {connection_id} for user {user.username}") - # Update connection with external service details - connection.status = ConnectionStatus.ACTIVE - connection.lastChecked = getUtcTimestamp() - connection.expiresAt = getUtcTimestamp() + token_response.get("expires_in", 0) - connection.externalId = user_info.get("id") - connection.externalUsername = user_info.get("userPrincipalName") - connection.externalEmail = user_info.get("mail") - # Store granted scopes for this connection - connection.grantedScopes = SCOPES - logger.info(f"Storing granted scopes for connection {connection_id}: {SCOPES}") - - # Update connection record directly - rootInterface.db.recordModify(UserConnection, connection_id, connection.model_dump()) - - - # Save token - - token = Token( - userId=user.id, # Use local user's ID - authority=AuthAuthority.MSFT, - connectionId=connection_id, # Link token to this specific connection - tokenAccess=token_response["access_token"], - tokenRefresh=token_response.get("refresh_token", ""), - tokenType=token_response.get("token_type", "bearer"), - expiresAt=createExpirationTimestamp(token_response.get("expires_in", 0)), - createdAt=getUtcTimestamp() - ) - - - interface.saveConnectionToken(token) - - - # Return success page with connection data - return HTMLResponse( - content=f""" - - Connection Successful - - - - - """ - ) - except Exception as e: - logger.error(f"Error updating connection or saving token: {str(e)}", exc_info=True) - return HTMLResponse( - content=f""" - - Connection Failed - - - - - """, - status_code=500 - ) - - except Exception as e: - logger.error(f"Error in auth callback: {str(e)}", exc_info=True) - return HTMLResponse( - content=f""" - - Authentication Failed - - - - - """, - status_code=500 - ) @router.get("/me", response_model=User) @limiter.limit("30/minute") def get_current_user( request: Request, - currentUser: User = Depends(getCurrentUser) + currentUser: User = Depends(getCurrentUser), ) -> User: - """Get current user information""" - try: - return currentUser - except Exception as e: - logger.error(f"Error getting current user: {str(e)}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to get current user: {str(e)}" - ) + return currentUser + @router.post("/logout") @limiter.limit("10/minute") def logout( request: Request, - currentUser: User = Depends(getCurrentUser) -) -> Dict[str, Any]: - """Logout current user""" + currentUser: User = Depends(getCurrentUser), +) -> JSONResponse: + """ + End only the PowerOn gateway session (JWT + DB token row). Does not sign the user out of Microsoft + in the browser (no AAD logout redirect). + """ try: appInterface = getInterface(currentUser) - appInterface.logout() - - # Log successful logout - # MULTI-TENANT: Logout is a system-level function, no mandate context + + token = request.cookies.get("auth_token") + if not token: + auth_header = request.headers.get("Authorization") + if auth_header and auth_header.lower().startswith("bearer "): + token = auth_header.split(" ", 1)[1].strip() + + if not token: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="No token found", + ) + + try: + payload = jose_jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + session_id = payload.get("sid") or payload.get("sessionId") + jti = payload.get("jti") + except Exception as e: + logger.error(f"Failed to decode JWT on Microsoft logout: {str(e)}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid token", + ) + + revoked = 0 + if session_id: + revoked = appInterface.revokeTokensBySessionId( + session_id, + currentUser.id, + AuthAuthority.MSFT, + revokedBy=currentUser.id, + reason="logout", + ) + elif jti: + appInterface.revokeTokenById(jti, revokedBy=currentUser.id, reason="logout") + revoked = 1 + try: from modules.shared.auditLogger import audit_logger + audit_logger.logUserAccess( userId=str(currentUser.id), mandateId="system", action="logout", - successInfo="microsoft_auth_logout", + successInfo=f"microsoft_gateway_logout revoked={revoked}", ipAddress=request.client.host if request.client else None, userAgent=request.headers.get("user-agent"), - success=True + success=True, ) except Exception: - # Don't fail if audit logging fails pass - - return {"message": "Logged out successfully"} + + json_response = JSONResponse( + { + "message": "Successfully logged out from application (Microsoft account stays signed in elsewhere)", + "revokedTokens": revoked, + } + ) + clearAccessTokenCookie(json_response) + clearRefreshTokenCookie(json_response) + return json_response + except HTTPException: + raise except Exception as e: logger.error(f"Error during logout: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to logout: {str(e)}" + detail=f"Failed to logout: {str(e)}", ) + @router.post("/cleanup") @limiter.limit("5/minute") def cleanup_expired_tokens( request: Request, - currentUser: User = Depends(getCurrentUser) + currentUser: User = Depends(getCurrentUser), ) -> Dict[str, Any]: - """Clean up expired tokens for the current user""" try: appInterface = getInterface(currentUser) - - # Clean up expired tokens cleaned_count = appInterface.cleanupExpiredTokens() - - return { - "message": f"Cleanup completed successfully", - "tokens_cleaned": cleaned_count - } - + return {"message": "Cleanup completed successfully", "tokens_cleaned": cleaned_count} except Exception as e: logger.error(f"Error cleaning up expired tokens: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to cleanup expired tokens: {str(e)}" + detail=f"Failed to cleanup expired tokens: {str(e)}", ) + @router.post("/refresh") @limiter.limit("10/minute") async def refresh_token( request: Request, - currentUser: User = Depends(getCurrentUser) + currentUser: User = Depends(getCurrentUser), ) -> Dict[str, Any]: - """Refresh Microsoft OAuth token for current user""" try: appInterface = getInterface(currentUser) - - # Optional: use provided connectionId to target a specific connection payload = {} try: payload = await request.json() except Exception: payload = {} - requested_connection_id = payload.get("connectionId") if isinstance(payload, dict) else None - - # Find Microsoft connection for this user - logger.debug(f"Looking for Microsoft connection for user {currentUser.id}") + requested_connection_id = ( + payload.get("connectionId") if isinstance(payload, dict) else None + ) connections = appInterface.getUserConnections(currentUser.id) msft_connection = None - if requested_connection_id: - # Validate the requested connection belongs to current user and is MSFT for conn in connections: if conn.id == requested_connection_id and conn.authority == AuthAuthority.MSFT: msft_connection = conn @@ -715,73 +652,53 @@ async def refresh_token( if not msft_connection: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Requested Microsoft connection not found for current user" + detail="Requested Microsoft connection not found for current user", ) else: - # Fallback: first MSFT connection for conn in connections: if conn.authority == AuthAuthority.MSFT: msft_connection = conn break - if not msft_connection: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="No Microsoft connection found for current user" + detail="No Microsoft connection found for current user", ) - - logger.debug(f"Found Microsoft connection: {msft_connection.id}, status={msft_connection.status}") - - # Get a fresh token via TokenManager convenience method current_token = TokenManager().getFreshToken(msft_connection.id) - if not current_token: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="No Microsoft token found for this connection" + detail="No Microsoft token found for this connection", ) - - - - # Always attempt refresh (as per your requirement) token_manager = TokenManager() - - refreshedToken = token_manager.refreshToken(current_token) - if refreshedToken: - # Save the new connection token (which will automatically replace old ones) - appInterface.saveConnectionToken(refreshedToken) - - # Update the connection's expiration time - expiresAtValue = parseTimestamp(refreshedToken.expiresAt) - if expiresAtValue: - msft_connection.expiresAt = expiresAtValue + refreshed_token = token_manager.refreshToken(current_token) + if refreshed_token: + appInterface.saveConnectionToken(refreshed_token) + expires_at_val = parseTimestamp(refreshed_token.expiresAt) + if expires_at_val: + msft_connection.expiresAt = expires_at_val msft_connection.lastChecked = getUtcTimestamp() msft_connection.status = ConnectionStatus.ACTIVE - - # Save updated connection - appInterface.db.recordModify(UserConnection, msft_connection.id, msft_connection.model_dump()) - - # Calculate time until expiration - currentTime = getUtcTimestamp() - expiresAt = parseTimestamp(refreshedToken.expiresAt) - expiresIn = int(expiresAt - currentTime) if expiresAt else 0 - + appInterface.db.recordModify( + UserConnection, msft_connection.id, msft_connection.model_dump() + ) + current_time = getUtcTimestamp() + expires_at = parseTimestamp(refreshed_token.expiresAt) + expires_in = int(expires_at - current_time) if expires_at else 0 return { "message": "Token refreshed successfully", - "expires_at": expiresAt, - "expires_in_seconds": expiresIn + "expires_at": expires_at, + "expires_in_seconds": expires_in, } - else: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to refresh token" - ) - + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to refresh token", + ) except HTTPException: raise except Exception as e: logger.error(f"Error refreshing Microsoft token: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to refresh token: {str(e)}" - ) \ No newline at end of file + detail=f"Failed to refresh token: {str(e)}", + ) diff --git a/modules/routes/routeStore.py b/modules/routes/routeStore.py index 5bb18103..087b68d2 100644 --- a/modules/routes/routeStore.py +++ b/modules/routes/routeStore.py @@ -110,15 +110,38 @@ def _getUserFeatureAccess(db, userId: str, instanceId: str) -> Dict[str, Any] | return accesses[0] if accesses else None -def _findUserRole(rootInterface, instanceId: str, featureCode: str) -> str | None: - """Find the user-level role for a feature instance.""" +def _findStoreUserRoleId( + rootInterface, + catalogService, + instanceId: str, + featureCode: str, +) -> str | None: + """ + Resolve the feature's primary *user* role on this instance (e.g. workspace-user). + Uses catalog template labels first, then a safe fallback on instance roles. + """ instanceRoles = rootInterface.getRolesByFeatureInstance(instanceId) - userRoleLabel = f"{featureCode}-user" + labelToId = {r.roleLabel: str(r.id) for r in instanceRoles if r.roleLabel} + + preferred = f"{featureCode}-user" + if preferred in labelToId: + return labelToId[preferred] + + for tpl in catalogService.getTemplateRoles(featureCode): + lbl = (tpl.get("roleLabel") or "").strip() + if not lbl: + continue + low = lbl.lower() + if "admin" in low: + continue + if lbl.endswith("-user") and lbl in labelToId: + return labelToId[lbl] + for role in instanceRoles: - if role.roleLabel == userRoleLabel: - return str(role.id) - for role in instanceRoles: - if "user" in role.roleLabel.lower() and "admin" not in role.roleLabel.lower(): + low = (role.roleLabel or "").lower() + if "admin" in low: + continue + if "user" in low: return str(role.id) return None @@ -249,13 +272,25 @@ def activateStoreFeature( createdAccess = db.recordCreate(FeatureAccess, featureAccess.model_dump()) featureAccessId = createdAccess.get("id") - userRoleId = _findUserRole(rootInterface, instanceId, featureCode) - if userRoleId: - featureAccessRole = FeatureAccessRole( - featureAccessId=featureAccessId, - roleId=userRoleId + userRoleId = _findStoreUserRoleId(rootInterface, catalogService, instanceId, featureCode) + if not userRoleId: + db.recordDelete(FeatureAccess, featureAccessId) + logger.error( + f"Store activate rollback: no user role on instance {instanceId} for feature '{featureCode}'" ) - db.recordCreate(FeatureAccessRole, featureAccessRole.model_dump()) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=( + f"No '{featureCode}-user' (or equivalent) role found on the shared instance; " + "cannot grant store access. Contact an administrator." + ), + ) + + featureAccessRole = FeatureAccessRole( + featureAccessId=featureAccessId, + roleId=userRoleId + ) + db.recordCreate(FeatureAccessRole, featureAccessRole.model_dump()) logger.info( f"User {userId} activated store feature '{featureCode}' " diff --git a/modules/serviceCenter/core/serviceSecurity/mainServiceSecurity.py b/modules/serviceCenter/core/serviceSecurity/mainServiceSecurity.py index 6495d5d9..4591c36e 100644 --- a/modules/serviceCenter/core/serviceSecurity/mainServiceSecurity.py +++ b/modules/serviceCenter/core/serviceSecurity/mainServiceSecurity.py @@ -8,7 +8,7 @@ Core service - not requested by features directly. import logging from typing import Optional, Callable, Any -from modules.datamodels.datamodelSecurity import Token +from modules.datamodels.datamodelSecurity import Token, TokenPurpose from modules.auth import TokenManager logger = logging.getLogger(__name__) @@ -34,6 +34,16 @@ class SecurityService: token = self._interfaceDbApp.getConnectionToken(connectionId) if not token: return None + _tp = ( + token.tokenPurpose.value + if isinstance(token.tokenPurpose, TokenPurpose) + else token.tokenPurpose + ) + if _tp != TokenPurpose.DATA_CONNECTION.value: + logger.warning( + f"getFreshToken: connection {connectionId} tokenPurpose is {_tp}, expected dataConnection" + ) + return None return self._tokenManager.ensureFreshToken( token, secondsBeforeExpiry=secondsBeforeExpiry, diff --git a/modules/serviceCenter/services/serviceAgent/agentLoop.py b/modules/serviceCenter/services/serviceAgent/agentLoop.py index 1cf74152..3569d330 100644 --- a/modules/serviceCenter/services/serviceAgent/agentLoop.py +++ b/modules/serviceCenter/services/serviceAgent/agentLoop.py @@ -22,6 +22,9 @@ from modules.serviceCenter.services.serviceAgent.conversationManager import ( ) from modules.shared.timeUtils import getUtcTimestamp from modules.shared.jsonUtils import closeJsonStructures +from modules.serviceCenter.services.serviceBilling.mainServiceBilling import ( + InsufficientBalanceException, +) logger = logging.getLogger(__name__) @@ -175,6 +178,18 @@ async def runAgentLoop( else: aiResponse = await aiCallFn(aiRequest) + except InsufficientBalanceException as e: + logger.warning( + f"Insufficient balance in round {state.currentRound} (mandate={mandateId}): {e.message}" + ) + state.status = AgentStatusEnum.ERROR + state.abortReason = e.message + yield AgentEvent( + type=AgentEventTypeEnum.ERROR, + content=e.message, + data=e.toClientDict(), + ) + break except Exception as e: logger.error(f"AI call failed in round {state.currentRound}: {e}", exc_info=True) state.status = AgentStatusEnum.ERROR diff --git a/modules/serviceCenter/services/serviceAi/mainServiceAi.py b/modules/serviceCenter/services/serviceAi/mainServiceAi.py index 17ff2da2..90fd4d9a 100644 --- a/modules/serviceCenter/services/serviceAi/mainServiceAi.py +++ b/modules/serviceCenter/services/serviceAi/mainServiceAi.py @@ -17,6 +17,10 @@ from modules.shared.jsonUtils import ( ) from .subJsonResponseHandling import JsonResponseHandler from modules.datamodels.datamodelAi import JsonAccumulationState +from modules.datamodels.datamodelBilling import BillingModelEnum +from modules.serviceCenter.services.serviceBilling.billingExhaustedNotify import ( + maybeEmailMandatePoolExhausted, +) from modules.serviceCenter.services.serviceBilling.mainServiceBilling import ( getService as getBillingService, InsufficientBalanceException, @@ -592,10 +596,19 @@ detectedIntent-Werte: f"Balance {balance_str} CHF, " f"Reason: {balanceCheck.reason}" ) - raise InsufficientBalanceException( - currentBalance=balanceCheck.currentBalance or 0.0, - requiredAmount=estimatedCost, - message=f"Ungenugendes Guthaben. Aktuell: CHF {balance_str}" + if balanceCheck.billingModel == BillingModelEnum.PREPAY_MANDATE: + ulabel = (getattr(user, "email", None) or getattr(user, "username", None) or str(user.id)) + maybeEmailMandatePoolExhausted( + str(mandateId), + str(user.id), + str(ulabel), + float(balanceCheck.currentBalance or 0.0), + float(estimatedCost), + ) + raise InsufficientBalanceException.fromBalanceCheck( + balanceCheck, + str(mandateId), + float(estimatedCost), ) balance_str = f"{(balanceCheck.currentBalance or 0):.2f}" diff --git a/modules/serviceCenter/services/serviceBilling/billingExhaustedNotify.py b/modules/serviceCenter/services/serviceBilling/billingExhaustedNotify.py new file mode 100644 index 00000000..6c9cb74e --- /dev/null +++ b/modules/serviceCenter/services/serviceBilling/billingExhaustedNotify.py @@ -0,0 +1,138 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +""" +When the shared mandate pool (PREPAY_MANDATE) is exhausted, notify billing contacts. + +Recipients: BillingSettings.notifyEmails for the mandate (configure as mandate owner / finance). +Emails are throttled per mandate to avoid spam (one notification per cooldown window). +""" + +from __future__ import annotations + +import html +import logging +import time +from typing import Any, Dict, List, Optional + +from modules.datamodels.datamodelMessaging import MessagingChannel +from modules.interfaces.interfaceDbBilling import getInterface as getBillingInterface +from modules.interfaces.interfaceMessaging import getInterface as getMessagingInterface +from modules.security.rootAccess import getRootUser + +logger = logging.getLogger(__name__) + +# mandate_id -> unix timestamp of last pool-exhausted notification email +_poolExhaustedEmailLastSent: Dict[str, float] = {} +_DEFAULT_COOLDOWN_SEC = 3600 + + +def _normalizeNotifyEmails(raw: Any) -> List[str]: + if raw is None: + return [] + if isinstance(raw, list): + return [str(e).strip() for e in raw if str(e).strip()] + if isinstance(raw, str): + s = raw.strip() + if not s: + return [] + # JSON array string + if s.startswith("["): + try: + import json + + parsed = json.loads(s) + if isinstance(parsed, list): + return [str(e).strip() for e in parsed if str(e).strip()] + except Exception: + pass + return [s] + return [] + + +def maybeEmailMandatePoolExhausted( + mandateId: str, + triggeringUserId: str, + triggeringUserLabel: str, + currentBalance: float, + requiredAmount: float, + cooldownSec: float = _DEFAULT_COOLDOWN_SEC, +) -> None: + """ + Send one email per mandate per cooldown to BillingSettings.notifyEmails. + + Args: + mandateId: Mandate whose pool is empty. + triggeringUserId: User who hit the block. + triggeringUserLabel: Display (e.g. email or username). + currentBalance: Pool balance (CHF). + requiredAmount: Minimum required (CHF). + cooldownSec: Minimum seconds between emails for this mandate. + """ + if not mandateId: + return + + now = time.time() + last = _poolExhaustedEmailLastSent.get(mandateId, 0.0) + if last and (now - last) < cooldownSec: + logger.debug( + "Skip mandate pool exhausted email (cooldown): mandate=%s last=%.0fs ago", + mandateId, + now - last, + ) + return + + try: + billing = getBillingInterface(getRootUser(), mandateId) + settings = billing.getSettings(mandateId) or {} + recipients = _normalizeNotifyEmails(settings.get("notifyEmails")) + if not recipients: + logger.warning( + "PREPAY_MANDATE pool exhausted for mandate %s but notifyEmails is empty — " + "configure BillingSettings.notifyEmails for owner alerts", + mandateId, + ) + return + + subject = f"[PowerOn] Mandanten-Budget aufgebraucht (Mandant {mandateId[:8]}…)" + body = ( + f"Das gemeinsame Guthaben (PREPAY_MANDATE) für diesen Mandanten ist nicht mehr ausreichend.\n\n" + f"Mandanten-ID: {mandateId}\n" + f"Aktuelles Guthaben (Pool): CHF {currentBalance:.2f}\n" + f"Benötigt (mind.): CHF {requiredAmount:.2f}\n\n" + f"Auslösende/r Benutzer/in: {triggeringUserLabel} (ID: {triggeringUserId})\n\n" + f"Bitte laden Sie das Mandats-Guthaben in der Billing-Verwaltung auf, " + f"damit Benutzer wieder AI-Funktionen nutzen können.\n" + ) + escaped = html.escape(body) + htmlMessage = f""" + + +{escaped.replace(chr(10), '
\n')} +""" + + messaging = getMessagingInterface() + any_ok = False + for to in recipients: + try: + ok = messaging.send( + channel=MessagingChannel.EMAIL, + recipient=to, + subject=subject, + message=htmlMessage, + ) + if ok: + any_ok = True + else: + logger.warning("Pool exhausted email failed for %s", to) + except Exception as send_err: + logger.error("Error sending pool exhausted email to %s: %s", to, send_err) + + if any_ok: + _poolExhaustedEmailLastSent[mandateId] = now + logger.info( + "Sent mandate pool exhausted notification for mandate %s to %s recipient(s)", + mandateId, + len(recipients), + ) + except Exception as e: + logger.error("maybeEmailMandatePoolExhausted failed: %s", e, exc_info=True) diff --git a/modules/serviceCenter/services/serviceBilling/mainServiceBilling.py b/modules/serviceCenter/services/serviceBilling/mainServiceBilling.py index ac475251..d0325a5e 100644 --- a/modules/serviceCenter/services/serviceBilling/mainServiceBilling.py +++ b/modules/serviceCenter/services/serviceBilling/mainServiceBilling.py @@ -22,6 +22,7 @@ from modules.datamodels.datamodelBilling import ( ReferenceTypeEnum, BillingTransaction, BillingBalanceResponse, + parseBillingModelFromStoredValue, ) from modules.interfaces.interfaceDbBilling import getInterface as getBillingInterface @@ -333,7 +334,7 @@ class BillingService: logger.warning(f"No billing settings for mandate {self.mandateId}") return None - billingModel = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value)) + billingModel = parseBillingModelFromStoredValue(settings.get("billingModel")) # Get or create account if billingModel == BillingModelEnum.PREPAY_USER: @@ -389,15 +390,127 @@ class BillingService: # Exception Classes # ============================================================================ +BILLING_USER_ACTION_TOP_UP_SELF = "TOP_UP_SELF" +BILLING_USER_ACTION_CONTACT_MANDATE_ADMIN = "CONTACT_MANDATE_ADMIN" + + +def _userActionForBillingModel(bm: BillingModelEnum) -> str: + if bm == BillingModelEnum.PREPAY_USER: + return BILLING_USER_ACTION_TOP_UP_SELF + return BILLING_USER_ACTION_CONTACT_MANDATE_ADMIN + + +def _buildInsufficientBalanceMessages( + bm: BillingModelEnum, + currentBalance: float, + requiredAmount: float, +) -> tuple: + bal_s = f"{currentBalance:.2f}" + req_s = f"{requiredAmount:.2f}" + if bm == BillingModelEnum.PREPAY_USER: + msg_de = ( + f"Ihr persönliches Guthaben ist aufgebraucht (aktuell CHF {bal_s}, benötigt mindestens CHF {req_s}). " + "Bitte laden Sie unter „Billing“ Guthaben nach." + ) + msg_en = ( + f"Your personal balance is exhausted (current CHF {bal_s}, at least CHF {req_s} required). " + "Please top up under Billing." + ) + else: + msg_de = ( + f"Das Mandanten-Budget ist aufgebraucht (aktuell CHF {bal_s}, benötigt mindestens CHF {req_s}). " + "Bitte informieren Sie die Administratorin bzw. den Administrator Ihres Mandanten. " + "Die in den Billing-Einstellungen hinterlegten Kontakte wurden per E-Mail informiert (falls konfiguriert)." + ) + msg_en = ( + f"The organization budget is exhausted (current CHF {bal_s}, at least CHF {req_s} required). " + "Please contact your mandate administrator. Billing notification contacts were emailed if configured." + ) + return msg_de, msg_en + + class InsufficientBalanceException(Exception): - """Raised when there's insufficient balance for an operation.""" - - def __init__(self, currentBalance: float, requiredAmount: float, message: str = None): - self.currentBalance = currentBalance - self.requiredAmount = requiredAmount - self.message = message or f"Insufficient balance. Current: {currentBalance:.2f} CHF, Required: {requiredAmount:.2f} CHF" + """Raised when there's insufficient balance for an operation. + + Carries structured fields for API/SSE clients (userAction, billingModel, localized hints). + """ + + def __init__( + self, + currentBalance: float, + requiredAmount: float, + message: Optional[str] = None, + *, + billing_model: Optional[BillingModelEnum] = None, + mandate_id: str = "", + user_action: Optional[str] = None, + message_de: Optional[str] = None, + message_en: Optional[str] = None, + ): + self.currentBalance = float(currentBalance) + self.requiredAmount = float(requiredAmount) + self.billing_model = billing_model + self.mandate_id = mandate_id or "" + if billing_model is not None: + self.user_action = user_action or _userActionForBillingModel(billing_model) + else: + self.user_action = user_action or BILLING_USER_ACTION_TOP_UP_SELF + + if message_de is not None and message_en is not None: + self.message_de = message_de + self.message_en = message_en + self.message = message or message_de + elif message: + self.message = message + self.message_de = message + self.message_en = message + else: + bm = billing_model or BillingModelEnum.PREPAY_USER + md, me = _buildInsufficientBalanceMessages(bm, self.currentBalance, self.requiredAmount) + self.message_de = md + self.message_en = me + self.message = md super().__init__(self.message) + @classmethod + def fromBalanceCheck( + cls, + check: BillingCheckResult, + mandate_id: str, + required_amount: float, + ) -> "InsufficientBalanceException": + bm = check.billingModel or BillingModelEnum.PREPAY_MANDATE + bal = float(check.currentBalance or 0.0) + msg_de, msg_en = _buildInsufficientBalanceMessages(bm, bal, required_amount) + return cls( + bal, + required_amount, + message=msg_de, + billing_model=bm, + mandate_id=mandate_id or "", + message_de=msg_de, + message_en=msg_en, + ) + + def toClientDict(self) -> Dict[str, Any]: + """Structured payload for HTTP 402, SSE item, or JSON error details.""" + out: Dict[str, Any] = { + "error": "INSUFFICIENT_BALANCE", + "currentBalance": round(self.currentBalance, 4), + "requiredAmount": round(self.requiredAmount, 4), + "message": self.message, + "messageDe": self.message_de, + "messageEn": self.message_en, + "userAction": self.user_action, + } + if self.billing_model is not None: + out["billingModel"] = self.billing_model.value + if self.mandate_id: + out["mandateId"] = self.mandate_id + if self.user_action == BILLING_USER_ACTION_TOP_UP_SELF: + out["billingUiPath"] = "/billing" + return out + class ProviderNotAllowedException(Exception): """Raised when a user doesn't have permission to use an AI provider.""" diff --git a/modules/system/mainSystem.py b/modules/system/mainSystem.py index 35f06ed1..a4b92ca4 100644 --- a/modules/system/mainSystem.py +++ b/modules/system/mainSystem.py @@ -447,6 +447,11 @@ RESOURCE_OBJECTS = [ "label": {"en": "Store: AI Workspace", "de": "Store: AI Workspace", "fr": "Store: AI Workspace"}, "meta": {"category": "store", "featureCode": "workspace"} }, + { + "objectKey": "resource.store.commcoach", + "label": {"en": "Store: CommCoach", "de": "Store: CommCoach", "fr": "Store: CommCoach"}, + "meta": {"category": "store", "featureCode": "commcoach"} + }, { "objectKey": "resource.system.api.auth", "label": {"en": "Authentication API", "de": "Authentifizierungs-API", "fr": "API d'authentification"}, From 8cf31077a62100f2cac3595282c225e9a6258fe4 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Sun, 22 Mar 2026 01:20:44 +0100 Subject: [PATCH 2/2] fixed global RAG and admin consent msft --- modules/datamodels/datamodelKnowledge.py | 45 ++++ .../workspace/routeFeatureWorkspace.py | 252 ++++++++++++++++-- modules/interfaces/interfaceDbChat.py | 20 +- modules/interfaces/interfaceDbKnowledge.py | 54 +++- modules/routes/routeSecurityMsft.py | 112 ++++++-- .../services/serviceAgent/agentLoop.py | 103 ++++++- .../serviceAgent/conversationManager.py | 101 +++++-- .../services/serviceAgent/mainServiceAgent.py | 241 +++++++++++++++++ .../serviceBilling/billingExhaustedNotify.py | 4 +- .../serviceKnowledge/mainServiceKnowledge.py | 94 ++++++- 10 files changed, 950 insertions(+), 76 deletions(-) diff --git a/modules/datamodels/datamodelKnowledge.py b/modules/datamodels/datamodelKnowledge.py index 4bc43500..d03e9d5a 100644 --- a/modules/datamodels/datamodelKnowledge.py +++ b/modules/datamodels/datamodelKnowledge.py @@ -96,6 +96,51 @@ registerModelLabels( ) +class RoundMemory(BaseModel): + """Persistent per-round memory for agent tool results, file refs, and decisions. + + Stored after each agent round so that RAG can retrieve relevant context + even after the ConversationManager summarises older messages away. + """ + id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key") + workflowId: str = Field(description="FK to the workflow") + roundNumber: int = Field(default=0, description="Agent round that produced this memory") + memoryType: str = Field( + description="Category: file_ref, tool_result, decision, data_source_ref" + ) + key: str = Field(description="Dedup key, e.g. 'readFile:' or 'plan'") + summary: str = Field(default="", description="Compact summary (max ~2000 chars)") + fullData: Optional[str] = Field( + default=None, + description="Full tool output when small enough (max ~8000 chars)", + ) + fileIds: List[str] = Field(default_factory=list, description="Referenced file IDs") + embedding: Optional[List[float]] = Field( + default=None, + description="Embedding of summary for semantic retrieval", + json_schema_extra={"db_type": "vector(1536)"}, + ) + createdAt: float = Field(default_factory=getUtcTimestamp, description="Creation timestamp") + + +registerModelLabels( + "RoundMemory", + {"en": "Round Memory", "fr": "Mémoire de tour"}, + { + "id": {"en": "ID", "fr": "ID"}, + "workflowId": {"en": "Workflow ID", "fr": "ID du workflow"}, + "roundNumber": {"en": "Round Number", "fr": "Numéro de tour"}, + "memoryType": {"en": "Memory Type", "fr": "Type de mémoire"}, + "key": {"en": "Key", "fr": "Clé"}, + "summary": {"en": "Summary", "fr": "Résumé"}, + "fullData": {"en": "Full Data", "fr": "Données complètes"}, + "fileIds": {"en": "File IDs", "fr": "IDs de fichier"}, + "embedding": {"en": "Embedding", "fr": "Vecteur d'embedding"}, + "createdAt": {"en": "Created At", "fr": "Créé le"}, + }, +) + + class WorkflowMemory(BaseModel): """Workflow-scoped key-value cache for entities and facts. Extracted during agent rounds, persisted for cross-round and cross-workflow reuse.""" diff --git a/modules/features/workspace/routeFeatureWorkspace.py b/modules/features/workspace/routeFeatureWorkspace.py index 6f317e2b..cf8efc04 100644 --- a/modules/features/workspace/routeFeatureWorkspace.py +++ b/modules/features/workspace/routeFeatureWorkspace.py @@ -8,6 +8,7 @@ SSE-based endpoints for the agent-driven AI Workspace. import logging import json import asyncio +import uuid from typing import Any, Dict, Optional, List from fastapi import APIRouter, HTTPException, Depends, Body, Path, Query, Request, UploadFile, File @@ -22,6 +23,7 @@ from modules.interfaces import interfaceDbChat, interfaceDbManagement from modules.interfaces.interfaceAiObjects import AiObjects from modules.serviceCenter.core.serviceStreaming import get_event_manager from modules.serviceCenter.services.serviceAgent.datamodelAgent import AgentEventTypeEnum, PendingFileEdit +from modules.shared.timeUtils import parseTimestamp logger = logging.getLogger(__name__) @@ -245,8 +247,120 @@ def _buildFeatureDataSourceContext(featureDataSourceIds: List[str]) -> str: return "\n".join(parts) if found else "" +def _workspaceFilesToChatDocuments(dbMgmt, fileIds: List[str]) -> List[Dict[str, Any]]: + """Build ChatDocument payloads for workspace files referenced on the user message.""" + documents: List[Dict[str, Any]] = [] + for fid in fileIds or []: + try: + fr = dbMgmt.getFile(fid) + if not fr: + logger.warning(f"Workspace user message: file {fid} not found, skipping attachment record") + continue + fd = fr if isinstance(fr, dict) else fr.model_dump() + documents.append({ + "id": str(uuid.uuid4()), + "fileId": fd.get("id") or fid, + "fileName": fd.get("fileName") or "file", + "fileSize": int(fd.get("fileSize") or 0), + "mimeType": fd.get("mimeType") or "application/octet-stream", + "roundNumber": 0, + "taskNumber": 0, + "actionNumber": 0, + }) + except Exception as e: + logger.warning(f"Workspace user message: could not load file {fid}: {e}") + return documents + + +def _buildWorkspaceAttachmentLabel(chatService: Any, dataSourceIds: List[str], featureDataSourceIds: List[str]) -> str: + """Short human-readable line for non-file attachments (data sources) on the user message.""" + parts: List[str] = [] + dsLabels: List[str] = [] + for dsId in dataSourceIds or []: + try: + ds = chatService.getDataSource(dsId) if chatService and hasattr(chatService, "getDataSource") else None + if ds: + label = ds.get("label") or ds.get("path") or dsId[:8] + dsLabels.append(str(label)) + except Exception as e: + logger.debug(f"Label for data source {dsId}: {e}") + if dsLabels: + parts.append("Datenquellen: " + ", ".join(dsLabels)) + + fdsLabels: List[str] = [] + for fdsId in featureDataSourceIds or []: + try: + from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource + from modules.interfaces.interfaceDbApp import getRootInterface + rootIf = getRootInterface() + records = rootIf.db.getRecordset(FeatureDataSource, recordFilter={"id": fdsId}) + if records: + fds = records[0] + tbl = fds.get("tableName") or "" + lbl = fds.get("label") or tbl + fdsLabels.append(f"{tbl} ({lbl})".strip() if tbl else str(lbl)) + except Exception as e: + logger.debug(f"Label for feature data source {fdsId}: {e}") + if fdsLabels: + parts.append("Feature-Daten: " + ", ".join(fdsLabels)) + + return " | ".join(parts) + + +def _workspaceMessageToClientDict(msg: Any) -> Dict[str, Any]: + """Serialize ChatMessage (or dict) for workspace GET /messages including documents.""" + if isinstance(msg, dict): + raw = dict(msg) + elif hasattr(msg, "model_dump"): + raw = msg.model_dump() + elif hasattr(msg, "dict"): + raw = msg.dict() + else: + raw = { + "id": getattr(msg, "id", None), + "workflowId": getattr(msg, "workflowId", None), + "role": getattr(msg, "role", ""), + "message": getattr(msg, "message", None) or getattr(msg, "content", None), + "publishedAt": getattr(msg, "publishedAt", None), + "sequenceNr": getattr(msg, "sequenceNr", None), + "documentsLabel": getattr(msg, "documentsLabel", None), + "documents": getattr(msg, "documents", None) or [], + } + if raw.get("message") is not None and raw.get("content") is None: + raw["content"] = raw["message"] + docs = raw.get("documents") or [] + serialized_docs: List[Dict[str, Any]] = [] + for doc in docs: + if isinstance(doc, dict): + serialized_docs.append(doc) + elif hasattr(doc, "model_dump"): + serialized_docs.append(doc.model_dump()) + elif hasattr(doc, "dict"): + serialized_docs.append(doc.dict()) + else: + serialized_docs.append({ + "id": getattr(doc, "id", ""), + "messageId": getattr(doc, "messageId", ""), + "fileId": getattr(doc, "fileId", ""), + "fileName": getattr(doc, "fileName", ""), + "fileSize": getattr(doc, "fileSize", 0), + "mimeType": getattr(doc, "mimeType", ""), + "roundNumber": getattr(doc, "roundNumber", None), + "taskNumber": getattr(doc, "taskNumber", None), + "actionNumber": getattr(doc, "actionNumber", None), + "actionId": getattr(doc, "actionId", None), + }) + raw["documents"] = serialized_docs + return raw + + def _loadConversationHistory(chatInterface, workflowId: str, currentPrompt: str) -> List[Dict[str, str]]: - """Load prior messages from DB for follow-up context, excluding the current prompt.""" + """Load prior messages from DB for follow-up context, excluding the current prompt. + + File documents attached to user messages are serialized as a short + ``[Attached files: …]`` block appended to the message content so the + agent sees which files a previous prompt referred to. + """ try: rawMessages = chatInterface.getMessages(workflowId) or [] except Exception as e: @@ -258,17 +372,55 @@ def _loadConversationHistory(chatInterface, workflowId: str, currentPrompt: str) if isinstance(msg, dict): role = msg.get("role", "") content = msg.get("message", "") or msg.get("content", "") + docs = msg.get("documents") or [] + docsLabel = msg.get("documentsLabel") or "" else: role = getattr(msg, "role", "") content = getattr(msg, "message", "") or getattr(msg, "content", "") - if role in ("user", "assistant") and content: - history.append({"role": role, "content": content}) + docs = getattr(msg, "documents", None) or [] + docsLabel = getattr(msg, "documentsLabel", "") or "" + if role not in ("user", "assistant"): + continue + if not content and not docs: + continue + + enriched = content or "" + + if role == "user" and docs: + fileParts = [] + for doc in docs: + if isinstance(doc, dict): + fName = doc.get("fileName", "") + fId = doc.get("fileId", "") + fMime = doc.get("mimeType", "") + fSize = doc.get("fileSize", 0) + elif hasattr(doc, "fileName"): + fName = getattr(doc, "fileName", "") + fId = getattr(doc, "fileId", "") + fMime = getattr(doc, "mimeType", "") + fSize = getattr(doc, "fileSize", 0) + else: + continue + if fId or fName: + fileParts.append(f" - {fName} (id: {fId}, type: {fMime}, size: {fSize} bytes)") + if fileParts: + enriched += "\n\n[Attached files]\n" + "\n".join(fileParts) + + if role == "user" and docsLabel: + enriched += f"\n[Attachments: {docsLabel}]" + + if enriched.strip(): + history.append({"role": role, "content": enriched}) if not history: return [] # Drop the last user message if it matches the current prompt (already added by the agent loop) - if history[-1]["role"] == "user" and history[-1]["content"].strip() == currentPrompt.strip(): + lastContent = history[-1].get("content", "").strip() + currentStripped = currentPrompt.strip() + if history[-1]["role"] == "user" and ( + lastContent == currentStripped or lastContent.startswith(currentStripped) + ): history = history[:-1] if history: @@ -276,6 +428,36 @@ def _loadConversationHistory(chatInterface, workflowId: str, currentPrompt: str) return history +def _collectPriorFileIds(chatInterface, workflowId: str) -> List[str]: + """Collect fileIds from all prior user messages in the workflow. + + Returns a deduplicated list of file IDs so follow-up prompts + can reference files that were attached to earlier messages. + """ + try: + rawMessages = chatInterface.getMessages(workflowId) or [] + except Exception: + return [] + + seen: set = set() + result: List[str] = [] + for msg in rawMessages: + if isinstance(msg, dict): + role = msg.get("role", "") + docs = msg.get("documents") or [] + else: + role = getattr(msg, "role", "") + docs = getattr(msg, "documents", None) or [] + if role != "user": + continue + for doc in docs: + fid = doc.get("fileId", "") if isinstance(doc, dict) else getattr(doc, "fileId", "") + if fid and fid not in seen: + seen.add(fid) + result.append(fid) + return result + + async def _deriveWorkflowName(prompt: str, aiService) -> str: """Use AI to generate a concise workflow title from the user prompt.""" from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum, PriorityEnum @@ -344,11 +526,36 @@ async def streamWorkspaceStart( queueId = f"workspace-{workflowId}" eventManager.create_queue(queueId) - chatInterface.createMessage({ + dbMgmt = _getDbManagement(context, featureInstanceId=instanceId) + userDocuments = _workspaceFilesToChatDocuments(dbMgmt, userInput.fileIds or []) + + from modules.serviceCenter import getService + from modules.serviceCenter.context import ServiceCenterContext + + svcCtx = ServiceCenterContext( + user=context.user, + mandate_id=mandateId or "", + feature_instance_id=instanceId, + workflow_id=workflowId, + ) + chatSvc = getService("chat", svcCtx) + attachmentLabel = _buildWorkspaceAttachmentLabel( + chatSvc, + userInput.dataSourceIds or [], + userInput.featureDataSourceIds or [], + ) + + userMessageData: Dict[str, Any] = { "workflowId": workflowId, "role": "user", "message": userInput.prompt, - }) + } + if userDocuments: + userMessageData["documents"] = userDocuments + if attachmentLabel: + userMessageData["documentsLabel"] = attachmentLabel + + chatInterface.createMessage(userMessageData) agentTask = asyncio.ensure_future( _runWorkspaceAgent( @@ -472,6 +679,18 @@ async def _runWorkspaceAgent( conversationHistory = _loadConversationHistory(chatInterface, workflowId, prompt) + priorFileIds = _collectPriorFileIds(chatInterface, workflowId) + currentFileIdSet = set(fileIds or []) + mergedFileIds = list(fileIds or []) + for pf in priorFileIds: + if pf not in currentFileIdSet: + mergedFileIds.append(pf) + if len(mergedFileIds) > len(fileIds or []): + logger.info( + f"Merged {len(mergedFileIds) - len(fileIds or [])} prior file(s) into agent context " + f"(total: {len(mergedFileIds)}) for workflow {workflowId}" + ) + accumulatedText = "" messagePersisted = False @@ -483,7 +702,7 @@ async def _runWorkspaceAgent( async for event in agentService.runAgent( prompt=enrichedPrompt, - fileIds=fileIds, + fileIds=mergedFileIds, workflowId=workflowId, userLanguage=userLanguage, conversationHistory=conversationHistory, @@ -755,17 +974,14 @@ async def getWorkspaceMessages( _validateInstanceAccess(instanceId, context) chatInterface = _getChatInterface(context, featureInstanceId=instanceId) messages = chatInterface.getMessages(workflowId) or [] - items = [] - for msg in messages: - if isinstance(msg, dict): - items.append(msg) - else: - items.append({ - "id": getattr(msg, "id", None), - "role": getattr(msg, "role", ""), - "content": getattr(msg, "message", "") or getattr(msg, "content", ""), - "createdAt": getattr(msg, "publishedAt", None) or getattr(msg, "createdAt", None), - }) + items = [_workspaceMessageToClientDict(m) for m in messages] + items.sort( + key=lambda m: ( + parseTimestamp(m.get("publishedAt"), default=0) or 0, + m.get("sequenceNr") or 0, + str(m.get("id") or ""), + ) + ) return JSONResponse({"messages": items}) diff --git a/modules/interfaces/interfaceDbChat.py b/modules/interfaces/interfaceDbChat.py index 9ad072ad..f2a3d4c6 100644 --- a/modules/interfaces/interfaceDbChat.py +++ b/modules/interfaces/interfaceDbChat.py @@ -885,7 +885,7 @@ class ChatObjects: "role": msg.get("role", "assistant"), "status": msg.get("status", "step"), "sequenceNr": msg.get("sequenceNr", 0), - "publishedAt": msg.get("publishedAt", msg.get("timestamp", getUtcTimestamp())), + "publishedAt": msg.get("publishedAt") or msg.get("_createdAt") or msg.get("timestamp") or 0, "success": msg.get("success"), "actionId": msg.get("actionId"), "actionMethod": msg.get("actionMethod"), @@ -899,8 +899,15 @@ class ChatObjects: # Apply default sorting by publishedAt if no sort specified. # Use parseTimestamp to tolerate mixed DB types (float/string) on INT. + # Tie-break with sequenceNr then id so order matches conversation flow. if pagination is None or not pagination.sort: - messageDicts.sort(key=lambda x: parseTimestamp(x.get("publishedAt"), default=0)) + messageDicts.sort( + key=lambda x: ( + parseTimestamp(x.get("publishedAt"), default=0) or 0, + x.get("sequenceNr") or 0, + str(x.get("id") or ""), + ) + ) # Apply filtering (if filters provided) if pagination and pagination.filters: @@ -1039,6 +1046,15 @@ class ChatObjects: if "actionNumber" not in messageData: messageData["actionNumber"] = workflow.currentAction + + if not messageData.get("publishedAt"): + messageData["publishedAt"] = getUtcTimestamp() + + if not messageData.get("sequenceNr"): + existing = self._getRecordset( + ChatMessage, recordFilter={"workflowId": workflowId} + ) + messageData["sequenceNr"] = len(existing) + 1 # Note: Chat data is user-owned, no mandate/featureInstance context stored # mandateId/featureInstanceId removed from ChatMessage model diff --git a/modules/interfaces/interfaceDbKnowledge.py b/modules/interfaces/interfaceDbKnowledge.py index fa83f1c8..c8a597df 100644 --- a/modules/interfaces/interfaceDbKnowledge.py +++ b/modules/interfaces/interfaceDbKnowledge.py @@ -10,7 +10,7 @@ import logging from typing import Dict, Any, List, Optional from modules.connectors.connectorDbPostgre import _get_cached_connector -from modules.datamodels.datamodelKnowledge import FileContentIndex, ContentChunk, WorkflowMemory +from modules.datamodels.datamodelKnowledge import FileContentIndex, ContentChunk, RoundMemory, WorkflowMemory from modules.datamodels.datamodelUam import User from modules.shared.configuration import APP_CONFIG from modules.shared.timeUtils import getUtcTimestamp @@ -125,6 +125,58 @@ class KnowledgeObjects: count += 1 return count + # ========================================================================= + # RoundMemory CRUD + # ========================================================================= + + def storeRoundMemory(self, memory: RoundMemory) -> Dict[str, Any]: + """Create or update a RoundMemory entry (upsert by id).""" + data = memory.model_dump() + existing = self.db._loadRecord(RoundMemory, memory.id) + if existing: + return self.db.recordModify(RoundMemory, memory.id, data) + return self.db.recordCreate(RoundMemory, data) + + def getRoundMemories(self, workflowId: str) -> List[Dict[str, Any]]: + """Get all RoundMemory entries for a workflow, sorted by roundNumber.""" + records = self.db.getRecordset(RoundMemory, recordFilter={"workflowId": workflowId}) + records.sort(key=lambda r: r.get("roundNumber", 0)) + return records + + def getRoundMemoriesByType( + self, workflowId: str, memoryType: str + ) -> List[Dict[str, Any]]: + """Get RoundMemory entries filtered by type (e.g. 'file_ref').""" + return self.db.getRecordset( + RoundMemory, recordFilter={"workflowId": workflowId, "memoryType": memoryType} + ) + + def semanticSearchRoundMemory( + self, + queryVector: List[float], + workflowId: str, + limit: int = 10, + minScore: float = None, + ) -> List[Dict[str, Any]]: + """Semantic search across RoundMemory entries for a workflow.""" + return self.db.semanticSearch( + modelClass=RoundMemory, + vectorColumn="embedding", + queryVector=queryVector, + limit=limit, + recordFilter={"workflowId": workflowId}, + minScore=minScore, + ) + + def deleteRoundMemories(self, workflowId: str) -> int: + """Delete all RoundMemory entries for a workflow. Returns count.""" + entries = self.db.getRecordset(RoundMemory, recordFilter={"workflowId": workflowId}) + count = 0 + for entry in entries: + if self.db.recordDelete(RoundMemory, entry["id"]): + count += 1 + return count + # ========================================================================= # WorkflowMemory CRUD # ========================================================================= diff --git a/modules/routes/routeSecurityMsft.py b/modules/routes/routeSecurityMsft.py index 00c9c415..d7fac372 100644 --- a/modules/routes/routeSecurityMsft.py +++ b/modules/routes/routeSecurityMsft.py @@ -100,6 +100,11 @@ def _admin_consent_redirect_uri() -> str: return "" +def _msft_data_admin_consent_scope_param() -> str: + """Space-separated delegated Graph scopes (not .default) for v2.0/adminconsent.""" + return " ".join(f"https://graph.microsoft.com/{s}" for s in msftDataScopes) + + @router.get("/auth/login") @limiter.limit("5/minute") def auth_login(request: Request) -> RedirectResponse: @@ -449,26 +454,46 @@ async def auth_connect_callback( @router.get("/adminconsent") @limiter.limit("5/minute") def adminconsent(request: Request) -> RedirectResponse: + """Tenant admin grants delegated Graph permissions for the Data app (msftDataScopes only). + + Uses the v2.0 admin consent endpoint (not /oauth2/v2.0/authorize with prompt=admin_consent, + which returns AADSTS901001). The ``scope`` parameter limits consent to the listed delegated + permissions instead of every API permission on the app registration. + """ _require_msft_data_config() - admin_consent_redirect = _admin_consent_redirect_uri() - admin_consent_url = ( - f"{AUTHORITY}/adminconsent" - f"?client_id={DATA_CLIENT_ID}" - f"&redirect_uri={quote(admin_consent_redirect, safe='')}" + redirect_uri = _admin_consent_redirect_uri() + if not redirect_uri: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Could not derive admin consent redirect URI from Service_MSFT_DATA_REDIRECT_URI", + ) + state_jwt = _issue_oauth_state({"flow": "admin_consent"}) + scope_param = _msft_data_admin_consent_scope_param() + # /v2.0/adminconsent rejects /common and /consumers — use the concrete tenant + # or fall back to /organizations (lets Microsoft resolve to the admin's tenant). + consent_tenant = TENANT_ID if TENANT_ID not in ("common", "consumers") else "organizations" + consent_authority = f"https://login.microsoftonline.com/{consent_tenant}" + admin_url = ( + f"{consent_authority}/v2.0/adminconsent" + f"?client_id={quote(DATA_CLIENT_ID, safe='')}" + f"&redirect_uri={quote(redirect_uri, safe='')}" + f"&scope={quote(scope_param, safe='')}" + f"&state={quote(state_jwt, safe='')}" ) - logger.info(f"Redirecting to admin consent URL for tenant: {TENANT_ID}") - return RedirectResponse(admin_consent_url) + logger.info(f"Redirecting to v2.0 admin consent for tenant: {TENANT_ID}") + return RedirectResponse(admin_url) @router.get("/adminconsent/callback") def adminconsent_callback( + request: Request, + state: Optional[str] = Query(None, description="OAuth state JWT returned by Microsoft"), admin_consent: Optional[str] = Query(None), tenant: Optional[str] = Query(None), error: Optional[str] = Query(None), error_description: Optional[str] = Query(None), - request: Request = None, ) -> HTMLResponse: - """Handle Microsoft Admin Consent callback""" + """Handle v2.0/adminconsent redirect (admin_consent=True, tenant=..., state=...).""" try: if error: logger.error(f"Admin consent error: {error} - {error_description}") @@ -485,29 +510,72 @@ def adminconsent_callback( """, status_code=400, ) - if admin_consent == "True" and tenant: - logger.info(f"Admin consent granted for tenant: {tenant}") + + if not state: + logger.error("Admin consent success callback missing state") return HTMLResponse( - content=f""" + content=""" - Admin Consent Successful + Admin Consent Failed -

Admin Consent Successful

-

Tenant: {tenant}

- +

Admin Consent Failed

+

Parameter „state“ fehlt.

- """ + """, + status_code=400, ) + + state_data = _parse_oauth_state(state) + if state_data.get("flow") != "admin_consent": + raise HTTPException(status_code=400, detail="Invalid OAuth flow for this callback") + + granted = str(admin_consent or "").strip().lower() in ("true", "1", "yes") + if not granted: + logger.error("Admin consent callback missing admin_consent=true") + return HTMLResponse( + content=""" + + Admin Consent Failed + +

Admin Consent Failed

+

Die Administratorzustimmung wurde nicht bestätigt (admin_consent fehlt oder ist falsch).

+ + + """, + status_code=400, + ) + if not tenant: + logger.error("Admin consent callback missing tenant id") + return HTMLResponse( + content=""" + + Admin Consent Failed + +

Admin Consent Failed

+

Keine Tenant-ID in der Antwort (tenant fehlt).

+ + + """, + status_code=400, + ) + + logger.info(f"Admin consent granted for tenant: {tenant}") return HTMLResponse( content=f""" - -

Admin Consent Status

-

Admin Consent: {admin_consent or 'Not provided'}

-

Tenant: {tenant or 'Not provided'}

- + + Admin Consent Successful + +

Admin Consent Successful

+

Die Berechtigungen wurden für den Tenant erteilt.

+

Tenant: {tenant}

+ + + """ ) + except HTTPException: + raise except Exception as e: logger.error(f"Error in admin consent callback: {str(e)}", exc_info=True) return HTMLResponse( diff --git a/modules/serviceCenter/services/serviceAgent/agentLoop.py b/modules/serviceCenter/services/serviceAgent/agentLoop.py index 3569d330..69fe31b2 100644 --- a/modules/serviceCenter/services/serviceAgent/agentLoop.py +++ b/modules/serviceCenter/services/serviceAgent/agentLoop.py @@ -43,6 +43,8 @@ async def runAgentLoop( aiCallStreamFn: Callable = None, userLanguage: str = "", conversationHistory: List[Dict[str, Any]] = None, + persistRoundMemoryFn: Callable[..., Awaitable[None]] = None, + getExternalMemoryKeysFn: Callable[[], List[str]] = None, ) -> AsyncGenerator[AgentEvent, None]: """Run the agent loop. Yields AgentEvent for each step (SSE-ready). @@ -59,6 +61,9 @@ async def runAgentLoop( mandateId: Mandate ID for RAG scoping userLanguage: ISO 639-1 language code for agent responses conversationHistory: Prior messages [{role, content/message}] for follow-up context + persistRoundMemoryFn: Optional callback to persist round memories after tool execution + getExternalMemoryKeysFn: Optional callback that returns RoundMemory keys for + this workflow, used by summarization to de-duplicate persisted facts """ state = AgentState(workflowId=workflowId, maxRounds=config.maxRounds) trace = AgentTrace( @@ -79,7 +84,7 @@ async def runAgentLoop( conversation = ConversationManager(systemPrompt) if conversationHistory: conversation.loadHistory(conversationHistory) - conversation.addUserMessage(prompt) + conversation.addUserMessage(prompt, isCurrentPrompt=True) while state.status == AgentStatusEnum.RUNNING and state.currentRound < state.maxRounds: await asyncio.sleep(0) @@ -142,7 +147,15 @@ async def runAgentLoop( state.totalAiCalls += 1 return resp.content - await conversation.summarize(state.currentRound, _summarizeCall) + memKeys: List[str] = [] + if getExternalMemoryKeysFn: + try: + memKeys = getExternalMemoryKeysFn() + except Exception: + pass + await conversation.summarize( + state.currentRound, _summarizeCall, externalMemoryKeys=memKeys or None + ) # AI call aiRequest = AiCallRequest( @@ -307,6 +320,18 @@ async def runAgentLoop( ] conversation.addToolResults(toolResultMessages) + # Persist round memories (file refs, tool results, decisions) + if persistRoundMemoryFn: + try: + await persistRoundMemoryFn( + toolCalls=toolCalls, + results=results, + textContent=textContent, + roundNumber=state.currentRound, + ) + except Exception as memErr: + logger.warning(f"RoundMemory persist failed (non-blocking): {memErr}") + roundLog.durationMs = int((time.time() - roundStartTime) * 1000) trace.rounds.append(roundLog) @@ -501,6 +526,80 @@ def _buildProgressSummary(state: AgentState, reason: str) -> str: ) +_FILE_REF_TOOLS = {"readFile", "readContentObjects", "describeImage", "listFiles"} +_DATA_SOURCE_TOOLS = {"browseDataSource", "searchDataSource", "downloadFromDataSource"} +_DECISION_TOOLS = {"writeFile", "replaceInFile"} + + +def _classifyToolResult( + tc: ToolCallRequest, result: ToolResult +) -> Optional[Dict[str, Any]]: + """Classify a successful tool result into a RoundMemory dict. + + Returns a dict with keys {memoryType, key, summary, fullData, fileIds} + or None if the result is not worth persisting. + """ + name = tc.name + data = result.data or "" + + if len(data) < 50: + return None + + truncSummary = data[:2000] + fullData = data if len(data) < 8000 else None + fileId = tc.args.get("fileId", "") + fileIds = [fileId] if fileId else [] + + if name in _FILE_REF_TOOLS: + return { + "memoryType": "file_ref", + "key": f"{name}:{fileId}" if fileId else name, + "summary": truncSummary, + "fullData": fullData, + "fileIds": fileIds, + } + + if name in _DATA_SOURCE_TOOLS: + dsId = tc.args.get("dataSourceId", "") or tc.args.get("featureDataSourceId", "") + path = tc.args.get("path", "") + return { + "memoryType": "data_source_ref", + "key": f"{name}:{dsId}:{path}" if dsId else name, + "summary": truncSummary, + "fullData": fullData, + "fileIds": fileIds, + } + + if name in _DECISION_TOOLS: + return { + "memoryType": "decision", + "key": f"{name}:{fileId}" if fileId else name, + "summary": truncSummary, + "fullData": None, + "fileIds": fileIds, + } + + if name == "queryFeatureInstance": + return { + "memoryType": "tool_result", + "key": f"queryFeatureInstance:{tc.args.get('query', '')[:60]}", + "summary": truncSummary, + "fullData": fullData, + "fileIds": [], + } + + if len(data) > 500: + return { + "memoryType": "tool_result", + "key": f"{name}:{tc.id}", + "summary": truncSummary, + "fullData": fullData, + "fileIds": fileIds, + } + + return None + + _ARTIFACT_TOOLS = {"writeFile", "replaceInFile", "deleteFile", "renameFile", "copyFile", "createFolder", "deleteFolder", "renderDocument", "generateImage"} diff --git a/modules/serviceCenter/services/serviceAgent/conversationManager.py b/modules/serviceCenter/services/serviceAgent/conversationManager.py index 79570c03..fe53a921 100644 --- a/modules/serviceCenter/services/serviceAgent/conversationManager.py +++ b/modules/serviceCenter/services/serviceAgent/conversationManager.py @@ -10,9 +10,9 @@ from modules.serviceCenter.services.serviceAgent.datamodelAgent import ToolDefin logger = logging.getLogger(__name__) -FIRST_SUMMARY_ROUND = 4 -META_SUMMARY_ROUND = 7 -KEEP_RECENT_MESSAGES = 4 +FIRST_SUMMARY_ROUND = 6 +META_SUMMARY_ROUND = 10 +KEEP_RECENT_MESSAGES = 6 MAX_ESTIMATED_TOKENS = 60000 _MAX_HISTORY_MESSAGES = 40 _MAX_HISTORY_MSG_CHARS = 12000 @@ -22,9 +22,12 @@ class ConversationManager: """Manages the conversation history and context window for agent runs. Progressive summarization strategy: - - Rounds 1-3: full conversation retained - - Round 4+: older messages compressed into a running summary - - Round 7+: meta-summary replaces prior summaries + - Rounds 1-5: full conversation retained + - Round 6+: older messages compressed into a running summary + - Round 10+: meta-summary replaces prior summaries + Long-term facts (file refs, tool results, decisions) are persisted + externally in RoundMemory and retrieved via RAG, so the summary + can focus on reasoning and relationships. Supports RAG context injection before each round via injectRagContext.""" def __init__(self, systemPrompt: str): @@ -69,9 +72,19 @@ class ConversationManager: for msg in self._messages ] - def addUserMessage(self, content: str): - """Add a user message.""" - self._messages.append({"role": "user", "content": content}) + def addUserMessage(self, content: str, isCurrentPrompt: bool = False): + """Add a user message. + + Args: + content: Message text. + isCurrentPrompt: If True, this message is the user's current + task prompt and will never be removed by progressive + summarization. + """ + msg: Dict[str, Any] = {"role": "user", "content": content} + if isCurrentPrompt: + msg["_isCurrentPrompt"] = True + self._messages.append(msg) def addAssistantMessage(self, content: str, toolCalls: List[Dict[str, Any]] = None): """Add an assistant message, optionally with tool calls.""" @@ -135,8 +148,8 @@ class ConversationManager: """Check if progressive summarization should be triggered. Triggers: - - At round FIRST_SUMMARY_ROUND (4) if not yet summarized - - At round META_SUMMARY_ROUND (7) for meta-summary + - At round FIRST_SUMMARY_ROUND (6) if not yet summarized + - At round META_SUMMARY_ROUND (10) for meta-summary - Every 5 rounds after that - When estimated token count exceeds MAX_ESTIMATED_TOKENS """ @@ -149,12 +162,23 @@ class ConversationManager: return True return False - async def summarize(self, currentRound: int, aiCallFn) -> Optional[str]: + async def summarize( + self, + currentRound: int, + aiCallFn, + externalMemoryKeys: List[str] = None, + ) -> Optional[str]: """Perform progressive summarization of older messages. - Rounds 1-3: full history retained, no summarization. - Round 4+: compress older messages into a running summary. - Round 7+: meta-summary that consolidates prior summaries. + Rounds 1-5: full history retained, no summarization. + Round 6+: compress older messages into a running summary. + Round 10+: meta-summary that consolidates prior summaries. + + Args: + currentRound: Current agent round number. + aiCallFn: Async function that takes a prompt string and returns summary text. + externalMemoryKeys: Keys of RoundMemory entries for this workflow, + so the summary prompt can de-duplicate already-persisted facts. """ if currentRound < FIRST_SUMMARY_ROUND and self.estimateTokenCount() <= MAX_ESTIMATED_TOKENS: return None @@ -184,11 +208,25 @@ class ConversationManager: messagesToSummarize = nonSystemMessages[:splitIdx] recentMessages = nonSystemMessages[splitIdx:] + # Protect the current user prompt: it must NEVER be summarized away. + promptInRecent = any(m.get("_isCurrentPrompt") for m in recentMessages) + if not promptInRecent: + for i, m in enumerate(messagesToSummarize): + if m.get("_isCurrentPrompt"): + recentMessages = messagesToSummarize[i:] + recentMessages + messagesToSummarize = messagesToSummarize[:i] + break + if not messagesToSummarize: + return None + summaryInput = _formatMessagesForSummary(messagesToSummarize) previousSummary = self._summaries[-1]["content"] if self._summaries else "" isMetaSummary = currentRound >= META_SUMMARY_ROUND and len(self._summaries) >= 2 - summaryPrompt = _buildSummaryPrompt(summaryInput, previousSummary, isMetaSummary) + summaryPrompt = _buildSummaryPrompt( + summaryInput, previousSummary, isMetaSummary, + externalMemoryKeys=externalMemoryKeys, + ) try: summaryText = await aiCallFn(summaryPrompt) @@ -241,8 +279,30 @@ def _formatMessagesForSummary(messages: List[Dict[str, Any]]) -> str: return "\n\n".join(parts) -def _buildSummaryPrompt(messagesText: str, previousSummary: str, isMetaSummary: bool = False) -> str: - """Build the prompt for progressive summarization.""" +def _buildSummaryPrompt( + messagesText: str, + previousSummary: str, + isMetaSummary: bool = False, + externalMemoryKeys: List[str] = None, +) -> str: + """Build the prompt for progressive summarization. + + When externalMemoryKeys is provided, the summary prompt tells the AI + that those facts are preserved in external memory and need not be + repeated verbatim — the summary can focus on reasoning, decisions, + and relationships instead. + """ + externalHint = "" + if externalMemoryKeys: + keyList = ", ".join(externalMemoryKeys[:20]) + externalHint = ( + "NOTE: The following facts are preserved in external persistent memory " + "and do NOT need to be repeated in detail in the summary: " + f"[{keyList}]. " + "Focus on reasoning, decisions, relationships, and anything that is " + "NOT captured by those external memory entries.\n\n" + ) + if isMetaSummary: prompt = ( "Create a comprehensive meta-summary consolidating the previous summary " @@ -251,10 +311,11 @@ def _buildSummaryPrompt(messagesText: str, previousSummary: str, isMetaSummary: ) else: prompt = ( - "Summarize the following conversation concisely. Preserve all key facts, " - "decisions, entities (names, numbers, dates), and tool results. " + "Summarize the following conversation concisely. Preserve key decisions, " + "reasoning chains, entities (names, numbers, dates), and action outcomes. " "Do not lose any important information.\n\n" ) + prompt += externalHint if previousSummary: prompt += f"Previous Summary:\n{previousSummary}\n\n" prompt += f"New Messages to Summarize:\n{messagesText}\n\nProvide a concise, factual summary:" diff --git a/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py b/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py index 905621b9..cec64813 100644 --- a/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py +++ b/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py @@ -142,6 +142,8 @@ class AgentService: aiCallStreamFn = self._createAiCallStreamFn() getWorkflowCostFn = self._createGetWorkflowCostFn(workflowId) buildRagContextFn = self._createBuildRagContextFn() + persistRoundMemoryFn = self._createPersistRoundMemoryFn(workflowId) + getExternalMemoryKeysFn = self._createGetExternalMemoryKeysFn(workflowId) async for event in runAgentLoop( prompt=enrichedPrompt, @@ -157,6 +159,8 @@ class AgentService: aiCallStreamFn=aiCallStreamFn, userLanguage=resolvedLanguage, conversationHistory=conversationHistory, + persistRoundMemoryFn=persistRoundMemoryFn, + getExternalMemoryKeysFn=getExternalMemoryKeysFn, ): if event.type == AgentEventTypeEnum.AGENT_SUMMARY: await self._persistTrace(workflowId, event.data or {}) @@ -347,18 +351,120 @@ class AgentService: ) -> str: try: knowledgeService = self.services.getService("knowledge") + workflowHintItems = _buildWorkflowHintItems( + self.services, workflowId + ) return await knowledgeService.buildAgentContext( currentPrompt=currentPrompt, workflowId=workflowId, userId=userId, featureInstanceId=featureInstanceId, mandateId=mandateId, + workflowHintItems=workflowHintItems, ) except Exception as e: logger.debug(f"RAG context not available: {e}") return "" return _buildRagContext + def _createPersistRoundMemoryFn(self, workflowId: str): + """Create callback that persists RoundMemory entries after tool execution.""" + from modules.serviceCenter.services.serviceAgent.agentLoop import _classifyToolResult + from modules.datamodels.datamodelKnowledge import RoundMemory + + async def _persistRoundMemory( + toolCalls, results, textContent: str, roundNumber: int + ): + try: + knowledgeService = self.services.getService("knowledge") + except Exception: + return + knowledgeDb = knowledgeService._knowledgeDb + + for tc, result in zip(toolCalls, results): + if not result.success: + continue + classified = _classifyToolResult(tc, result) + if not classified: + continue + + summary = classified["summary"] + embedding = await knowledgeService._embedSingle(summary[:500]) if summary else [] + + mem = RoundMemory( + workflowId=workflowId, + roundNumber=roundNumber, + memoryType=classified["memoryType"], + key=classified["key"], + summary=summary, + fullData=classified.get("fullData"), + fileIds=classified.get("fileIds", []), + embedding=embedding if embedding else None, + ) + knowledgeDb.storeRoundMemory(mem) + + return _persistRoundMemory + + def _createGetExternalMemoryKeysFn(self, workflowId: str): + """Create callback that returns RoundMemory keys for summarization hints.""" + def _getKeys() -> List[str]: + try: + knowledgeService = self.services.getService("knowledge") + memories = knowledgeService._knowledgeDb.getRoundMemories(workflowId) + return [m.get("key", "") for m in memories if m.get("key")] + except Exception: + return [] + return _getKeys + + +def _buildWorkflowHintItems( + services, currentWorkflowId: str +) -> List[Dict[str, Any]]: + """Build a compact list of other workflows for the RAG cross-workflow hint. + + Returns key-value items like: + key="Pendenzenliste Excel (3 msgs)" value="last: 2h ago" + Limited to 10 most recent other workflows to keep the hint small. + """ + try: + chatInterface = services.chat.interfaceDbChat + allWorkflows = chatInterface.getWorkflows() or [] + except Exception: + return [] + + others = [w for w in allWorkflows if w.get("id") != currentWorkflowId] + if not others: + return [] + + import time as _time + now = _time.time() + others.sort(key=lambda w: w.get("_createdAt") or w.get("startedAt") or 0, reverse=True) + others = others[:10] + + items = [] + for wf in others: + name = wf.get("name") or "(unnamed)" + createdAt = wf.get("_createdAt") or wf.get("startedAt") or 0 + ageSec = now - createdAt if createdAt else 0 + if ageSec < 3600: + ageStr = f"{int(ageSec / 60)}m ago" + elif ageSec < 86400: + ageStr = f"{int(ageSec / 3600)}h ago" + else: + ageStr = f"{int(ageSec / 86400)}d ago" + + wfId = wf.get("id", "") + items.append({ + "key": f"{name} (id: {wfId})", + "value": ageStr, + }) + + countLabel = f"{len(allWorkflows) - 1} other conversation(s)" + if len(allWorkflows) - 1 > 10: + countLabel += f" (showing 10 newest)" + items.insert(0, {"key": countLabel, "value": "use listWorkflowHistory to browse"}) + return items + def _getOrCreateTempFolder(chatService) -> Optional[str]: """Return the ID of the root-level 'Temp' folder, creating it if it doesn't exist.""" @@ -2952,3 +3058,138 @@ def _registerCoreTools(registry: ToolRegistry, services): }, readOnly=True ) + + # ---- Cross-workflow tools ---- + + async def _listWorkflowHistory(args: Dict[str, Any], context: Dict[str, Any]) -> ToolResult: + """List all chat workflows in this workspace with metadata.""" + import json as _json + try: + chatService = services.chat + chatInterface = chatService.interfaceDbChat + allWorkflows = chatInterface.getWorkflows() or [] + + allWorkflows.sort( + key=lambda w: w.get("_createdAt") or w.get("startedAt") or 0, + reverse=True, + ) + allWorkflows = allWorkflows[:50] + + items = [] + for wf in allWorkflows: + wfId = wf.get("id", "") + name = wf.get("name") or "(unnamed)" + createdAt = wf.get("_createdAt") or wf.get("startedAt") or 0 + lastActivity = wf.get("lastActivity") or createdAt + + msgs = chatInterface.getMessages(wfId) or [] + messageCount = len(msgs) + lastPreview = "" + if msgs: + lastMsg = msgs[-1] if isinstance(msgs[-1], dict) else ( + msgs[-1].model_dump() if hasattr(msgs[-1], "model_dump") else {} + ) + content = lastMsg.get("message") or lastMsg.get("content") or "" + lastPreview = content[:150] + + items.append({ + "id": wfId, + "name": name, + "createdAt": createdAt, + "lastActivity": lastActivity, + "messageCount": messageCount, + "lastMessagePreview": lastPreview, + }) + + return ToolResult( + toolCallId="", toolName="listWorkflowHistory", + success=True, data=_json.dumps(items, ensure_ascii=False), + ) + except Exception as e: + return ToolResult( + toolCallId="", toolName="listWorkflowHistory", + success=False, error=str(e), + ) + + registry.register( + "listWorkflowHistory", _listWorkflowHistory, + description=( + "List all chat conversations/workflows in this workspace. " + "Returns id, name, createdAt, lastActivity, messageCount, and a preview " + "of the last message for each workflow. Use this to discover previous " + "conversations when the user asks about past chats or wants a summary " + "across conversations." + ), + parameters={ + "type": "object", + "properties": {}, + }, + readOnly=True, + ) + + async def _readWorkflowMessages(args: Dict[str, Any], context: Dict[str, Any]) -> ToolResult: + """Read messages from a specific workflow.""" + import json as _json + targetWorkflowId = args.get("workflowId", "") + limit = int(args.get("limit", 20)) + offset = int(args.get("offset", 0)) + + if not targetWorkflowId: + return ToolResult( + toolCallId="", toolName="readWorkflowMessages", + success=False, error="workflowId is required", + ) + + try: + chatService = services.chat + chatInterface = chatService.interfaceDbChat + allMsgs = chatInterface.getMessages(targetWorkflowId) or [] + + sliced = allMsgs[offset:offset + limit] + items = [] + for msg in sliced: + raw = msg if isinstance(msg, dict) else ( + msg.model_dump() if hasattr(msg, "model_dump") else {} + ) + content = raw.get("message") or raw.get("content") or "" + if len(content) > 2000: + content = content[:2000] + "..." + items.append({ + "role": raw.get("role", ""), + "message": content, + "publishedAt": raw.get("publishedAt") or raw.get("_createdAt") or 0, + }) + + header = f"Workflow {targetWorkflowId}: {len(allMsgs)} total messages" + if offset > 0 or len(allMsgs) > offset + limit: + header += f" (showing {offset + 1}-{offset + len(sliced)})" + + return ToolResult( + toolCallId="", toolName="readWorkflowMessages", + success=True, + data=header + "\n" + _json.dumps(items, ensure_ascii=False), + ) + except Exception as e: + return ToolResult( + toolCallId="", toolName="readWorkflowMessages", + success=False, error=str(e), + ) + + registry.register( + "readWorkflowMessages", _readWorkflowMessages, + description=( + "Read messages from a specific chat workflow/conversation. " + "Use this after listWorkflowHistory to read the content of a " + "specific past conversation. Supports pagination via offset/limit." + ), + parameters={ + "type": "object", + "properties": { + "workflowId": {"type": "string", "description": "ID of the workflow to read messages from"}, + "limit": {"type": "integer", "description": "Max messages to return (default 20)"}, + "offset": {"type": "integer", "description": "Skip first N messages (default 0)"}, + }, + "required": ["workflowId"], + }, + readOnly=True, + ) diff --git a/modules/serviceCenter/services/serviceBilling/billingExhaustedNotify.py b/modules/serviceCenter/services/serviceBilling/billingExhaustedNotify.py index 6c9cb74e..aba08b89 100644 --- a/modules/serviceCenter/services/serviceBilling/billingExhaustedNotify.py +++ b/modules/serviceCenter/services/serviceBilling/billingExhaustedNotify.py @@ -104,10 +104,12 @@ def maybeEmailMandatePoolExhausted( f"damit Benutzer wieder AI-Funktionen nutzen können.\n" ) escaped = html.escape(body) + # Cannot use '\\n' inside f-string {…} expression (SyntaxError); build replacement outside. + brWithNl = "
" + "\n" htmlMessage = f""" -{escaped.replace(chr(10), '
\n')} +{escaped.replace(chr(10), brWithNl)} """ messaging = getMessagingInterface() diff --git a/modules/serviceCenter/services/serviceKnowledge/mainServiceKnowledge.py b/modules/serviceCenter/services/serviceKnowledge/mainServiceKnowledge.py index 91e85da4..d6943c58 100644 --- a/modules/serviceCenter/services/serviceKnowledge/mainServiceKnowledge.py +++ b/modules/serviceCenter/services/serviceKnowledge/mainServiceKnowledge.py @@ -17,7 +17,7 @@ logger = logging.getLogger(__name__) CHARS_PER_TOKEN = 4 DEFAULT_CHUNK_TOKENS = 400 -DEFAULT_CONTEXT_BUDGET = 8000 +DEFAULT_CONTEXT_BUDGET = 12000 class KnowledgeService: @@ -170,8 +170,18 @@ class KnowledgeService: featureInstanceId: str = "", mandateId: str = "", contextBudget: int = DEFAULT_CONTEXT_BUDGET, + workflowHintItems: List[Dict[str, Any]] = None, ) -> str: - """Build RAG context for an agent round by searching all 3 layers. + """Build RAG context for an agent round by searching all layers. + + Layer priority: + 0 - File refs from RoundMemory (always included so the agent knows + which files exist in this workflow) + 1 - Instance documents (user's own indexed files) + 1.5 - Semantically relevant RoundMemory entries + 2 - Workflow entities + 3 - Shared knowledge + 4 - Cross-workflow hint (other conversations in this workspace) Args: currentPrompt: The current user prompt to find relevant context for. @@ -180,6 +190,8 @@ class KnowledgeService: featureInstanceId: Feature instance scope. mandateId: Mandate scope. contextBudget: Maximum characters for the context string. + workflowHintItems: Optional pre-built list of other workflow summaries + for the cross-workflow hint layer. Returns: Formatted context string for injection into the agent's system prompt. @@ -190,6 +202,21 @@ class KnowledgeService: builder = _ContextBuilder(budget=contextBudget) + # Layer 0: File references from RoundMemory (always included) + fileRefMemories = self._knowledgeDb.getRoundMemoriesByType(workflowId, "file_ref") + if fileRefMemories: + refItems = [ + {"key": m.get("key", ""), "value": m.get("summary", "")[:300]} + for m in fileRefMemories + ] + builder.add( + priority=0, + label="Known Files", + items=refItems, + isKeyValue=True, + maxChars=2000, + ) + # Layer 1: Instance Layer (user's own documents, highest priority) instanceChunks = self._knowledgeDb.semanticSearch( queryVector=queryVector, @@ -199,12 +226,43 @@ class KnowledgeService: minScore=0.65, ) if instanceChunks: - builder.add(priority=1, label="Relevant Documents", items=instanceChunks) + builder.add(priority=1, label="Relevant Documents", items=instanceChunks, maxChars=4000) + + # Layer 1.5: Semantically relevant RoundMemory entries + roundMemories = self._knowledgeDb.semanticSearchRoundMemory( + queryVector=queryVector, + workflowId=workflowId, + limit=10, + minScore=0.55, + ) + if roundMemories: + memItems = [] + for m in roundMemories: + data = m.get("fullData") or m.get("summary", "") + memItems.append({ + "data": data, + "contextRef": { + "type": m.get("memoryType", ""), + "key": m.get("key", ""), + "round": m.get("roundNumber", 0), + }, + }) + seen = {m.get("key") for m in fileRefMemories} if fileRefMemories else set() + memItems = [ + mi for mi in memItems if mi["contextRef"].get("key") not in seen + ] + if memItems: + builder.add( + priority=2, + label="Previous Round Context", + items=memItems, + maxChars=4000, + ) # Layer 2: Workflow Layer (current workflow entities & memory) entities = self._knowledgeDb.getWorkflowEntities(workflowId) if entities: - builder.add(priority=2, label="Workflow Context", items=entities, isKeyValue=True) + builder.add(priority=3, label="Workflow Context", items=entities, isKeyValue=True, maxChars=2000) # Layer 3: Shared Layer (mandate-wide shared documents) sharedChunks = self._knowledgeDb.semanticSearch( @@ -215,7 +273,17 @@ class KnowledgeService: minScore=0.7, ) if sharedChunks: - builder.add(priority=3, label="Shared Knowledge", items=sharedChunks) + builder.add(priority=4, label="Shared Knowledge", items=sharedChunks, maxChars=2000) + + # Layer 4: Cross-workflow hint (other conversations in this workspace) + if workflowHintItems: + builder.add( + priority=5, + label="Other Conversations", + items=workflowHintItems, + isKeyValue=True, + maxChars=500, + ) return builder.build() @@ -520,12 +588,14 @@ class _ContextBuilder: label: str, items: List[Dict[str, Any]], isKeyValue: bool = False, + maxChars: int = 0, ): self._sections.append({ "priority": priority, "label": label, "items": items, "isKeyValue": isKeyValue, + "maxChars": maxChars, }) def build(self) -> str: @@ -537,12 +607,15 @@ class _ContextBuilder: if remaining <= 0: break + sectionCap = section.get("maxChars") or remaining + sectionRemaining = min(sectionCap, remaining) + header = f"### {section['label']}\n" sectionText = header - remaining -= len(header) + sectionRemaining -= len(header) for item in section["items"]: - if remaining <= 0: + if sectionRemaining <= 0: break if section["isKeyValue"]: @@ -550,14 +623,15 @@ class _ContextBuilder: else: data = item.get("data", "") ref = item.get("contextRef", {}) - score = item.get("_score", "") refStr = f" [{ref}]" if ref else "" line = f"{data}{refStr}\n" - if len(line) <= remaining: + if len(line) <= sectionRemaining: sectionText += line - remaining -= len(line) + sectionRemaining -= len(line) + consumed = min(sectionCap, remaining) - sectionRemaining + remaining -= consumed parts.append(sectionText) return "\n".join(parts).strip()