SFM/app/app_config.py
Raykov-MS 0bdb83d2f3 Fix event-loop mismatch masking OpenCloud token as "not found" on first click
Each call into opencloud_token_store is wrapped in its own asyncio.run()
(app/app_config.py, main.py's token popover), so every call gets a fresh
event loop. The asyncpg connection pool is bound to the loop it was created
in, so the next asyncio.run() could try to reuse a connection from an
already-closed loop and fail — unrelated to actual DB availability, but the
broad except Exception in resolve_opencloud_access_token() silently
swallowed it and fell through to "token not found", masking the real cause.

- add_token/get_token/remove_token/list_usernames now call engine.dispose()
  in a finally block so each asyncio.run() starts with a clean pool.
- resolve_opencloud_access_token() now surfaces the real exception via
  st.error() before falling back to the .env token (kept only for local dev
  without a DB).
- Added tests/unit/test_opencloud_token_store.py covering that dispose() is
  called after every store operation, including when commit() raises.
2026-09-17 16:56:08 +03:00

71 lines
3.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import os
import streamlit as st
def resolve_user_id() -> str:
profile = st.session_state.get("user_profile", {})
if not isinstance(profile, dict):
return "anonymous|anonymous"
email = str(
profile.get("email") or profile.get("preferred_username") or "anonymous"
)
full_name = str(profile.get("name") or profile.get("full_name") or "anonymous")
return f"{email}|{full_name}"
def resolve_opencloud_username() -> str | None:
"""preferred_username из Keycloak — им же заказчик регистрировал App Token
в веб-интерфейсе OpenCloud (сверено вручную, совпадает без домена/регистра).
Для локального теста (mock-провайдер) — fallback на .env.
"""
profile = st.session_state.get("user_profile", {})
if isinstance(profile, dict):
value = profile.get("preferred_username")
if value:
return str(value)
return os.getenv("SFM_OPENCLOUD_USERNAME") or None
def resolve_opencloud_access_token() -> str | None:
"""Достаёт App Token заказчика для авторизации в OpenCloud (Basic Auth).
ИСТОРИЯ (см. app/opencloud_access.py): изначально пытались использовать
Bearer ID-токен Keycloak-клиента datalab — Graph API OpenCloud отвечал
401 (несовпадение audience). Подтверждено на проде 16.09: рабочий путь —
HTTP Basic Auth с username (resolve_opencloud_username) и долгоживущим
App Token, который заказчик сам создаёт в веб-интерфейсе OpenCloud.
Токены хранятся зашифрованными в БД (app/opencloud_token_store.py),
заказчики присылают их нам сами при онбординге.
Приложение синхронное (Streamlit) — обёртка asyncio.run вокруг async
store оправдана только частотой вызова (раз на запуск обработки), при
росте нагрузки стоит пересмотреть.
"""
username = resolve_opencloud_username()
if not username:
return None
import asyncio
from app.opencloud_token_store import OpenCloudTokenStoreError, get_token
try:
return asyncio.run(get_token(username))
except OpenCloudTokenStoreError as exc:
st.error(f"Ошибка доступа к токену OpenCloud: {exc}")
return None
except Exception as exc: # noqa: BLE001
# Раньше эта ветка тихо проглатывала ЛЮБУЮ ошибку (например,
# несовпадение event loop'ов asyncpg между последовательными
# asyncio.run() — см. app/opencloud_token_store.py) и превращала
# её в безобидное "токен не найден", маскируя реальный сбой БД.
# Показываем ошибку явно, fallback на .env — только если он там
# реально есть (локальная разработка без поднятой БД).
st.error(f"Ошибка доступа к БД токенов OpenCloud: {exc}")
return os.getenv("SFM_OPENCLOUD_ACCESS_TOKEN") or None