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="
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"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""" - -No access token.
", + status_code=400, + ) + + token_verification = await verify_google_token(access_token) + if not token_verification.get("valid"): + return HTMLResponse( + content=f"{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""" + +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"{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""" -{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""" + +Error: {error}
Description: {error_description or 'No description provided'}
-Please contact your administrator.