hotfix msft/google login tokens end to end separated from connection

feat(billing): Nutzerhinweise bei leerem Budget + Mandats-Mail (402/SSE)
Gateway
- InsufficientBalanceException: billingModel, userAction (TOP_UP_SELF /
  CONTACT_MANDATE_ADMIN), DE/EN-Texte, toClientDict(), fromBalanceCheck()
- HTTP 402 + JSON detail für globale API-Fehlerbehandlung
- AI/Chatbot: vor Raise ggf. E-Mail an BillingSettings.notifyEmails
  (PREPAY_MANDATE, Throttle 1h/Mandat) via billingExhaustedNotify
- Agent-Loop & Workspace-Route: SSE-ERROR mit strukturiertem Billing-Payload
- datamodelBilling: notifyEmails-Doku für Pool-Alerts
frontend_nyla
- useWorkspace: SSE onError für INSUFFICIENT_BALANCE mit messageDe/En
  und Hinweis auf Billing-Pfad bei TOP_UP_SELF
This commit is contained in:
ValueOn AG 2026-03-21 01:34:40 +01:00
parent eefb37050e
commit 0a0973d41b
32 changed files with 2179 additions and 1539 deletions

16
app.py
View file

@ -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 (

View file

@ -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=

View file

@ -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=

View file

@ -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=

View file

@ -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

View file

@ -24,10 +24,17 @@ class CSRFMiddleware(BaseHTTPMiddleware):
self.exempt_paths = exempt_paths or {
"/api/local/login",
"/api/local/register",
"/api/msft/login",
"/api/google/login",
"/api/msft/callback",
"/api/google/callback",
# 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)
}

View file

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

View file

@ -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"),
@ -165,6 +168,15 @@ class TokenManager:
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
try:
@ -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,

View file

@ -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

View file

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

View file

@ -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:

View file

@ -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"},
]
},
]

View file

@ -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,6 +584,15 @@ async def _runWorkspaceAgent(
})
except Exception as e:
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",

View file

@ -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

View file

@ -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
@ -1691,6 +1705,24 @@ class AppObjects:
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:
logger.error(f"Error deleting UserMandate: {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]

View file

@ -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
appDb = _getAppDatabaseConnector()
rootMandateId = _getCachedRootMandateId()
# Step 1: Get all billing settings (all models except UNLIMITED need user accounts)
# 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,68 +766,43 @@ 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
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}
)
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)
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,
)
)
result["migratedAmount"] = poolBalance
result["userCount"] = len(userAccounts)
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
logger.info(f"Switched {mandateId} MANDATE->USER: distributed {result['migratedAmount']} CHF to {result['userCount']} users")
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(
"Switched %s USER->MANDATE: consolidated %.4f CHF from %d users into pool (transactions logged)",
mandateId,
totalUserBalance,
len(userAccounts),
)
return result
logger.info(f"Switched {mandateId} USER->MANDATE: consolidated {totalUserBalance} CHF from {len(userAccounts)} users into pool")
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:

View file

@ -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

View file

@ -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"],
@ -1025,6 +1034,15 @@ def add_user_to_feature_instance(
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,
"userId": data.userId,
@ -1105,6 +1123,15 @@ def remove_user_from_feature_instance(
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",
"userId": userId,
@ -1198,6 +1225,15 @@ def update_feature_instance_user_roles(
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,
"userId": userId,

View file

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

View file

@ -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,

View file

@ -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
# =============================================================================
@ -248,6 +250,15 @@ def create_mandate(
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}")
return newMandate
@ -613,6 +624,15 @@ def add_user_to_mandate(
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
userId=data.targetUserId,
@ -697,6 +717,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}
except HTTPException:
@ -792,6 +821,16 @@ def update_user_roles_in_mandate(
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
userId=targetUserId,
@ -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.

View file

@ -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,

File diff suppressed because it is too large Load diff

View file

@ -18,7 +18,7 @@ from modules.auth import getCurrentUser, limiter, SECRET_KEY, ALGORITHM
from modules.auth import createAccessToken, createRefreshToken, setAccessTokenCookie, setRefreshTokenCookie, clearAccessTokenCookie, clearRefreshTokenCookie
from modules.interfaces.interfaceDbApp import getInterface, getRootInterface
from modules.datamodels.datamodelUam import User, UserInDB, AuthAuthority, Mandate
from modules.datamodels.datamodelSecurity import Token
from modules.datamodels.datamodelSecurity import Token, TokenPurpose
from modules.shared.configuration import APP_CONFIG
from modules.shared.timeUtils import getUtcTimestamp
@ -164,6 +164,7 @@ def login(
id=jti,
userId=user.id,
authority=AuthAuthority.LOCAL,
tokenPurpose=TokenPurpose.AUTH_SESSION,
tokenAccess=access_token,
tokenType="bearer",
expiresAt=expires_at.timestamp(),

File diff suppressed because it is too large Load diff

View file

@ -110,15 +110,38 @@ def _getUserFeatureAccess(db, userId: str, instanceId: str) -> Dict[str, Any] |
return accesses[0] if accesses else None
def _findUserRole(rootInterface, instanceId: str, featureCode: str) -> str | None:
"""Find the user-level role for a feature instance."""
def _findStoreUserRoleId(
rootInterface,
catalogService,
instanceId: str,
featureCode: str,
) -> str | None:
"""
Resolve the feature's primary *user* role on this instance (e.g. workspace-user).
Uses catalog template labels first, then a safe fallback on instance roles.
"""
instanceRoles = rootInterface.getRolesByFeatureInstance(instanceId)
userRoleLabel = f"{featureCode}-user"
labelToId = {r.roleLabel: str(r.id) for r in instanceRoles if r.roleLabel}
preferred = f"{featureCode}-user"
if preferred in labelToId:
return labelToId[preferred]
for tpl in catalogService.getTemplateRoles(featureCode):
lbl = (tpl.get("roleLabel") or "").strip()
if not lbl:
continue
low = lbl.lower()
if "admin" in low:
continue
if lbl.endswith("-user") and lbl in labelToId:
return labelToId[lbl]
for role in instanceRoles:
if role.roleLabel == userRoleLabel:
return str(role.id)
for role in instanceRoles:
if "user" in role.roleLabel.lower() and "admin" not in role.roleLabel.lower():
low = (role.roleLabel or "").lower()
if "admin" in low:
continue
if "user" in low:
return str(role.id)
return None
@ -249,8 +272,20 @@ def activateStoreFeature(
createdAccess = db.recordCreate(FeatureAccess, featureAccess.model_dump())
featureAccessId = createdAccess.get("id")
userRoleId = _findUserRole(rootInterface, instanceId, featureCode)
if userRoleId:
userRoleId = _findStoreUserRoleId(rootInterface, catalogService, instanceId, featureCode)
if not userRoleId:
db.recordDelete(FeatureAccess, featureAccessId)
logger.error(
f"Store activate rollback: no user role on instance {instanceId} for feature '{featureCode}'"
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=(
f"No '{featureCode}-user' (or equivalent) role found on the shared instance; "
"cannot grant store access. Contact an administrator."
),
)
featureAccessRole = FeatureAccessRole(
featureAccessId=featureAccessId,
roleId=userRoleId

View file

@ -8,7 +8,7 @@ Core service - not requested by features directly.
import logging
from typing import Optional, Callable, Any
from modules.datamodels.datamodelSecurity import Token
from modules.datamodels.datamodelSecurity import Token, TokenPurpose
from modules.auth import TokenManager
logger = logging.getLogger(__name__)
@ -34,6 +34,16 @@ class SecurityService:
token = self._interfaceDbApp.getConnectionToken(connectionId)
if not token:
return None
_tp = (
token.tokenPurpose.value
if isinstance(token.tokenPurpose, TokenPurpose)
else token.tokenPurpose
)
if _tp != TokenPurpose.DATA_CONNECTION.value:
logger.warning(
f"getFreshToken: connection {connectionId} tokenPurpose is {_tp}, expected dataConnection"
)
return None
return self._tokenManager.ensureFreshToken(
token,
secondsBeforeExpiry=secondsBeforeExpiry,

View file

@ -22,6 +22,9 @@ from modules.serviceCenter.services.serviceAgent.conversationManager import (
)
from modules.shared.timeUtils import getUtcTimestamp
from modules.shared.jsonUtils import closeJsonStructures
from modules.serviceCenter.services.serviceBilling.mainServiceBilling import (
InsufficientBalanceException,
)
logger = logging.getLogger(__name__)
@ -175,6 +178,18 @@ async def runAgentLoop(
else:
aiResponse = await aiCallFn(aiRequest)
except InsufficientBalanceException as e:
logger.warning(
f"Insufficient balance in round {state.currentRound} (mandate={mandateId}): {e.message}"
)
state.status = AgentStatusEnum.ERROR
state.abortReason = e.message
yield AgentEvent(
type=AgentEventTypeEnum.ERROR,
content=e.message,
data=e.toClientDict(),
)
break
except Exception as e:
logger.error(f"AI call failed in round {state.currentRound}: {e}", exc_info=True)
state.status = AgentStatusEnum.ERROR

View file

@ -17,6 +17,10 @@ from modules.shared.jsonUtils import (
)
from .subJsonResponseHandling import JsonResponseHandler
from modules.datamodels.datamodelAi import JsonAccumulationState
from modules.datamodels.datamodelBilling import BillingModelEnum
from modules.serviceCenter.services.serviceBilling.billingExhaustedNotify import (
maybeEmailMandatePoolExhausted,
)
from modules.serviceCenter.services.serviceBilling.mainServiceBilling import (
getService as getBillingService,
InsufficientBalanceException,
@ -592,10 +596,19 @@ detectedIntent-Werte:
f"Balance {balance_str} CHF, "
f"Reason: {balanceCheck.reason}"
)
raise InsufficientBalanceException(
currentBalance=balanceCheck.currentBalance or 0.0,
requiredAmount=estimatedCost,
message=f"Ungenugendes Guthaben. Aktuell: CHF {balance_str}"
if balanceCheck.billingModel == BillingModelEnum.PREPAY_MANDATE:
ulabel = (getattr(user, "email", None) or getattr(user, "username", None) or str(user.id))
maybeEmailMandatePoolExhausted(
str(mandateId),
str(user.id),
str(ulabel),
float(balanceCheck.currentBalance or 0.0),
float(estimatedCost),
)
raise InsufficientBalanceException.fromBalanceCheck(
balanceCheck,
str(mandateId),
float(estimatedCost),
)
balance_str = f"{(balanceCheck.currentBalance or 0):.2f}"

View file

@ -0,0 +1,138 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
When the shared mandate pool (PREPAY_MANDATE) is exhausted, notify billing contacts.
Recipients: BillingSettings.notifyEmails for the mandate (configure as mandate owner / finance).
Emails are throttled per mandate to avoid spam (one notification per cooldown window).
"""
from __future__ import annotations
import html
import logging
import time
from typing import Any, Dict, List, Optional
from modules.datamodels.datamodelMessaging import MessagingChannel
from modules.interfaces.interfaceDbBilling import getInterface as getBillingInterface
from modules.interfaces.interfaceMessaging import getInterface as getMessagingInterface
from modules.security.rootAccess import getRootUser
logger = logging.getLogger(__name__)
# mandate_id -> unix timestamp of last pool-exhausted notification email
_poolExhaustedEmailLastSent: Dict[str, float] = {}
_DEFAULT_COOLDOWN_SEC = 3600
def _normalizeNotifyEmails(raw: Any) -> List[str]:
if raw is None:
return []
if isinstance(raw, list):
return [str(e).strip() for e in raw if str(e).strip()]
if isinstance(raw, str):
s = raw.strip()
if not s:
return []
# JSON array string
if s.startswith("["):
try:
import json
parsed = json.loads(s)
if isinstance(parsed, list):
return [str(e).strip() for e in parsed if str(e).strip()]
except Exception:
pass
return [s]
return []
def maybeEmailMandatePoolExhausted(
mandateId: str,
triggeringUserId: str,
triggeringUserLabel: str,
currentBalance: float,
requiredAmount: float,
cooldownSec: float = _DEFAULT_COOLDOWN_SEC,
) -> None:
"""
Send one email per mandate per cooldown to BillingSettings.notifyEmails.
Args:
mandateId: Mandate whose pool is empty.
triggeringUserId: User who hit the block.
triggeringUserLabel: Display (e.g. email or username).
currentBalance: Pool balance (CHF).
requiredAmount: Minimum required (CHF).
cooldownSec: Minimum seconds between emails for this mandate.
"""
if not mandateId:
return
now = time.time()
last = _poolExhaustedEmailLastSent.get(mandateId, 0.0)
if last and (now - last) < cooldownSec:
logger.debug(
"Skip mandate pool exhausted email (cooldown): mandate=%s last=%.0fs ago",
mandateId,
now - last,
)
return
try:
billing = getBillingInterface(getRootUser(), mandateId)
settings = billing.getSettings(mandateId) or {}
recipients = _normalizeNotifyEmails(settings.get("notifyEmails"))
if not recipients:
logger.warning(
"PREPAY_MANDATE pool exhausted for mandate %s but notifyEmails is empty — "
"configure BillingSettings.notifyEmails for owner alerts",
mandateId,
)
return
subject = f"[PowerOn] Mandanten-Budget aufgebraucht (Mandant {mandateId[:8]}…)"
body = (
f"Das gemeinsame Guthaben (PREPAY_MANDATE) für diesen Mandanten ist nicht mehr ausreichend.\n\n"
f"Mandanten-ID: {mandateId}\n"
f"Aktuelles Guthaben (Pool): CHF {currentBalance:.2f}\n"
f"Benötigt (mind.): CHF {requiredAmount:.2f}\n\n"
f"Auslösende/r Benutzer/in: {triggeringUserLabel} (ID: {triggeringUserId})\n\n"
f"Bitte laden Sie das Mandats-Guthaben in der Billing-Verwaltung auf, "
f"damit Benutzer wieder AI-Funktionen nutzen können.\n"
)
escaped = html.escape(body)
htmlMessage = f"""<!DOCTYPE html>
<html><head><meta charset="utf-8"></head>
<body style="font-family: Arial, sans-serif; line-height: 1.6;">
{escaped.replace(chr(10), '<br>\n')}
</body></html>"""
messaging = getMessagingInterface()
any_ok = False
for to in recipients:
try:
ok = messaging.send(
channel=MessagingChannel.EMAIL,
recipient=to,
subject=subject,
message=htmlMessage,
)
if ok:
any_ok = True
else:
logger.warning("Pool exhausted email failed for %s", to)
except Exception as send_err:
logger.error("Error sending pool exhausted email to %s: %s", to, send_err)
if any_ok:
_poolExhaustedEmailLastSent[mandateId] = now
logger.info(
"Sent mandate pool exhausted notification for mandate %s to %s recipient(s)",
mandateId,
len(recipients),
)
except Exception as e:
logger.error("maybeEmailMandatePoolExhausted failed: %s", e, exc_info=True)

View file

@ -22,6 +22,7 @@ from modules.datamodels.datamodelBilling import (
ReferenceTypeEnum,
BillingTransaction,
BillingBalanceResponse,
parseBillingModelFromStoredValue,
)
from modules.interfaces.interfaceDbBilling import getInterface as getBillingInterface
@ -333,7 +334,7 @@ class BillingService:
logger.warning(f"No billing settings for mandate {self.mandateId}")
return None
billingModel = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value))
billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
# Get or create account
if billingModel == BillingModelEnum.PREPAY_USER:
@ -389,15 +390,127 @@ class BillingService:
# Exception Classes
# ============================================================================
class InsufficientBalanceException(Exception):
"""Raised when there's insufficient balance for an operation."""
BILLING_USER_ACTION_TOP_UP_SELF = "TOP_UP_SELF"
BILLING_USER_ACTION_CONTACT_MANDATE_ADMIN = "CONTACT_MANDATE_ADMIN"
def __init__(self, currentBalance: float, requiredAmount: float, message: str = None):
self.currentBalance = currentBalance
self.requiredAmount = requiredAmount
self.message = message or f"Insufficient balance. Current: {currentBalance:.2f} CHF, Required: {requiredAmount:.2f} CHF"
def _userActionForBillingModel(bm: BillingModelEnum) -> str:
if bm == BillingModelEnum.PREPAY_USER:
return BILLING_USER_ACTION_TOP_UP_SELF
return BILLING_USER_ACTION_CONTACT_MANDATE_ADMIN
def _buildInsufficientBalanceMessages(
bm: BillingModelEnum,
currentBalance: float,
requiredAmount: float,
) -> tuple:
bal_s = f"{currentBalance:.2f}"
req_s = f"{requiredAmount:.2f}"
if bm == BillingModelEnum.PREPAY_USER:
msg_de = (
f"Ihr persönliches Guthaben ist aufgebraucht (aktuell CHF {bal_s}, benötigt mindestens CHF {req_s}). "
"Bitte laden Sie unter „Billing“ Guthaben nach."
)
msg_en = (
f"Your personal balance is exhausted (current CHF {bal_s}, at least CHF {req_s} required). "
"Please top up under Billing."
)
else:
msg_de = (
f"Das Mandanten-Budget ist aufgebraucht (aktuell CHF {bal_s}, benötigt mindestens CHF {req_s}). "
"Bitte informieren Sie die Administratorin bzw. den Administrator Ihres Mandanten. "
"Die in den Billing-Einstellungen hinterlegten Kontakte wurden per E-Mail informiert (falls konfiguriert)."
)
msg_en = (
f"The organization budget is exhausted (current CHF {bal_s}, at least CHF {req_s} required). "
"Please contact your mandate administrator. Billing notification contacts were emailed if configured."
)
return msg_de, msg_en
class InsufficientBalanceException(Exception):
"""Raised when there's insufficient balance for an operation.
Carries structured fields for API/SSE clients (userAction, billingModel, localized hints).
"""
def __init__(
self,
currentBalance: float,
requiredAmount: float,
message: Optional[str] = None,
*,
billing_model: Optional[BillingModelEnum] = None,
mandate_id: str = "",
user_action: Optional[str] = None,
message_de: Optional[str] = None,
message_en: Optional[str] = None,
):
self.currentBalance = float(currentBalance)
self.requiredAmount = float(requiredAmount)
self.billing_model = billing_model
self.mandate_id = mandate_id or ""
if billing_model is not None:
self.user_action = user_action or _userActionForBillingModel(billing_model)
else:
self.user_action = user_action or BILLING_USER_ACTION_TOP_UP_SELF
if message_de is not None and message_en is not None:
self.message_de = message_de
self.message_en = message_en
self.message = message or message_de
elif message:
self.message = message
self.message_de = message
self.message_en = message
else:
bm = billing_model or BillingModelEnum.PREPAY_USER
md, me = _buildInsufficientBalanceMessages(bm, self.currentBalance, self.requiredAmount)
self.message_de = md
self.message_en = me
self.message = md
super().__init__(self.message)
@classmethod
def fromBalanceCheck(
cls,
check: BillingCheckResult,
mandate_id: str,
required_amount: float,
) -> "InsufficientBalanceException":
bm = check.billingModel or BillingModelEnum.PREPAY_MANDATE
bal = float(check.currentBalance or 0.0)
msg_de, msg_en = _buildInsufficientBalanceMessages(bm, bal, required_amount)
return cls(
bal,
required_amount,
message=msg_de,
billing_model=bm,
mandate_id=mandate_id or "",
message_de=msg_de,
message_en=msg_en,
)
def toClientDict(self) -> Dict[str, Any]:
"""Structured payload for HTTP 402, SSE item, or JSON error details."""
out: Dict[str, Any] = {
"error": "INSUFFICIENT_BALANCE",
"currentBalance": round(self.currentBalance, 4),
"requiredAmount": round(self.requiredAmount, 4),
"message": self.message,
"messageDe": self.message_de,
"messageEn": self.message_en,
"userAction": self.user_action,
}
if self.billing_model is not None:
out["billingModel"] = self.billing_model.value
if self.mandate_id:
out["mandateId"] = self.mandate_id
if self.user_action == BILLING_USER_ACTION_TOP_UP_SELF:
out["billingUiPath"] = "/billing"
return out
class ProviderNotAllowedException(Exception):
"""Raised when a user doesn't have permission to use an AI provider."""

View file

@ -447,6 +447,11 @@ RESOURCE_OBJECTS = [
"label": {"en": "Store: AI Workspace", "de": "Store: AI Workspace", "fr": "Store: AI Workspace"},
"meta": {"category": "store", "featureCode": "workspace"}
},
{
"objectKey": "resource.store.commcoach",
"label": {"en": "Store: CommCoach", "de": "Store: CommCoach", "fr": "Store: CommCoach"},
"meta": {"category": "store", "featureCode": "commcoach"}
},
{
"objectKey": "resource.system.api.auth",
"label": {"en": "Authentication API", "de": "Authentifizierungs-API", "fr": "API d'authentification"},