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