From a295417287927cbbd251f75e2836151317fdd98c Mon Sep 17 00:00:00 2001 From: Raykov-MS Date: Thu, 4 Jun 2026 14:13:30 +0300 Subject: [PATCH] move to streamlit --- .gitignore | 11 +++++++ .gitlab-ci.yml | 9 ++++++ README.md | 47 ++++++++++++++++++++++++++++ app/__init__.py | 2 ++ app/auth/__init__.py | 2 ++ app/auth/mock_provider.py | 34 ++++++++++++++++++++ app/auth/providers.py | 66 +++++++++++++++++++++++++++++++++++++++ app/auth/service.py | 50 +++++++++++++++++++++++++++++ app/config.py | 32 +++++++++++++++++++ app/ui.py | 31 ++++++++++++++++++ main.py | 23 ++++++++++++++ raisa_config.yml | 11 +++++++ requirements.txt | 7 +++++ roles.json | 1 + users.json | 1 + 15 files changed, 327 insertions(+) create mode 100644 .gitignore create mode 100644 .gitlab-ci.yml create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/auth/__init__.py create mode 100644 app/auth/mock_provider.py create mode 100644 app/auth/providers.py create mode 100644 app/auth/service.py create mode 100644 app/config.py create mode 100644 app/ui.py create mode 100644 main.py create mode 100644 raisa_config.yml create mode 100644 requirements.txt create mode 100644 roles.json create mode 100644 users.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..937dd47 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.venv/ +venv/ +__pycache__/ +.vscode/ +.idea/ +.cursor/ +.pytest_cache/ +.ruff_cache/ + +.env +.env.example \ No newline at end of file diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..ba1ea92 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,9 @@ +include: + - project: 'cicd/ci' + file: 'streamlit-app.yml' +variables: + GIT_STRATEGY: 'clone' + APP_VERSION: '1.0.0' + APP_PORT: 8501 + APP_SHARED: 1 + BASE_IMAGE: 'streamlit-app-base:2.1.0' diff --git a/README.md b/README.md new file mode 100644 index 0000000..5ddeafa --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) [![Imports: isort](https://img.shields.io/badge/%20imports-isort-%231674b1?style=flat&labelColor=ef8336)](https://pycqa.github.io/isort/) [![linting: pylint](https://img.shields.io/badge/linting-pylint-yellowgreen)](https://github.com/pylint-dev/pylint) +__________________________________________________________________________________________________________ +# sfm, v 0.0.1 +__sfm__ + +| | Исполнитель | Заказчик | +|-----------|:----------------------------------------:|:----------------------------------------------:| +| Сотрудник | raykov-mse | raykov-mse | +| Отдел | aurora | aurora | +| Контакты | raykovmse@rshb.ru | raykovmse@rshb.ru | +----- +### Структура проекта: +![Структура проекта](/assets/image.jpg) +### Рабочий процесс: +1. Клонируем репозиторий +```commandline +git@:/.git +``` +2. Создаем виртуальное окружение +```commandline +python -m venv venv +source venv/bin/activate +pip install -r requirements.txt +``` +Если что-то устанавливаем, не забываем обновлять список зависимостей +```commandline +pip freeze -l > requirements.txt +``` +3. Создаем feature ветку +```commandline +git checkout -b "feature--" +``` +4. Пишем свой код. Коммитим изменения +```commandline +git add . +git commit -m "" +``` +Как писать хорошие коммит сообщения: +https://www.conventionalcommits.org/ru/v1.0.0/ + +5. Пушим изменения в Gitlab +```commandline +git push origin +``` +6. Создаем Merge Request в master +7. Проходим код-ревью с коллегами +8. Мерджите diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..d91541d --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,2 @@ +"""Пакет приложения SFM Streamlit.""" + diff --git a/app/auth/__init__.py b/app/auth/__init__.py new file mode 100644 index 0000000..c02ea22 --- /dev/null +++ b/app/auth/__init__.py @@ -0,0 +1,2 @@ +"""Вспомогательные модули аутентификации SFM Streamlit.""" + diff --git a/app/auth/mock_provider.py b/app/auth/mock_provider.py new file mode 100644 index 0000000..3278d62 --- /dev/null +++ b/app/auth/mock_provider.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import streamlit as st + + +def protect_application(render_exit: bool = True) -> None: + _ = render_exit + + +def get_jwt_token() -> dict[str, str] | None: + email = st.session_state.get("local_user_email") + username = st.session_state.get("local_username") + full_name = st.session_state.get("local_full_name") + + if not email: + cols = st.columns([1, 2, 1]) + with cols[1]: + with st.container(border=True): + st.info("Локальный режим: введите профиль пользователя") + email = st.text_input("Email", key="local_user_email") + username = st.text_input("Username", key="local_username") + full_name = st.text_input("Full name", key="local_full_name") + + if not email: + st.stop() + + st.session_state["local_mock_profile"] = { + "email": email, + "preferred_username": username or email.split("@")[0], + "name": full_name or username or email, + "realm_access": {"roles": ["local_dev"]}, + } + return {"id_token": "local-dev-token"} + diff --git a/app/auth/providers.py b/app/auth/providers.py new file mode 100644 index 0000000..7370f54 --- /dev/null +++ b/app/auth/providers.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from importlib.util import find_spec +from typing import Any, Protocol + +from streamlit_oauth import OAuth2Component + +from app.config import get_location, get_setting + +from .mock_provider import get_jwt_token as get_jwt_token_local +from .mock_provider import protect_application as protect_application_local + + +class AuthProvider(Protocol): + def get_token_data(self) -> dict[str, Any] | None: ... + + +class LocalMockProvider: + def get_token_data(self) -> dict[str, Any] | None: + protect_application_local(render_exit=False) + return get_jwt_token_local() + + +class RaisaProvider: + def get_token_data(self) -> dict[str, Any] | None: + from raisa_streamlit_oauth import ( # type: ignore[reportMissingImports] + get_jwt_token as get_jwt_token_raisa, + ) + from raisa_streamlit_oauth import ( # type: ignore[reportMissingImports] + protect_application as protect_application_raisa, + ) + + protect_application_raisa(render_exit=False) + return get_jwt_token_raisa() + + +class OAuthFallbackProvider: + def get_token_data(self) -> dict[str, Any] | None: + oauth2 = OAuth2Component( + client_id=get_setting("KEYCLOAK_CLIENT_ID"), + client_secret=get_setting("KEYCLOAK_CLIENT_SECRET"), + authorize_endpoint=get_setting("KEYCLOAK_AUTHORIZE_URL"), + token_endpoint=get_setting("KEYCLOAK_TOKEN_URL"), + refresh_token_endpoint=get_setting("KEYCLOAK_TOKEN_URL"), + revoke_token_endpoint=get_setting("KEYCLOAK_LOGOUT_URL"), + ) + result = oauth2.authorize_button( + name="Войти через Keycloak", + redirect_uri=get_setting("KEYCLOAK_REDIRECT_URI"), + scope=get_setting("KEYCLOAK_SCOPE"), + key="keycloak-login", + use_container_width=True, + pkce="S256", + ) + if result and "token" in result: + return result["token"] + return None + + +def build_auth_provider() -> AuthProvider: + if get_location() == "local": + return LocalMockProvider() + if find_spec("raisa_streamlit_oauth") is not None: + return RaisaProvider() + return OAuthFallbackProvider() + diff --git a/app/auth/service.py b/app/auth/service.py new file mode 100644 index 0000000..75d1081 --- /dev/null +++ b/app/auth/service.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from typing import Any + +import jwt +import streamlit as st + +from app.config import get_location, get_setting + +from .providers import build_auth_provider + + +def clear_session() -> None: + for key in list(st.session_state.keys()): + del st.session_state[key] + + +class AuthService: + def run(self) -> bool: + if "user_profile" in st.session_state: + return True + + token_data = build_auth_provider().get_token_data() + if not token_data: + return False + + id_token = token_data.get("id_token") + if not id_token: + st.error("Не удалось получить id_token из ответа авторизации.") + return False + + profile = self._decode_id_token(id_token) + st.session_state["token_data"] = token_data + st.session_state["user_profile"] = profile + return True + + def _decode_id_token(self, id_token: str) -> dict[str, Any]: + if get_location() == "local": + return st.session_state.get("local_mock_profile", {"email": "local@local"}) + + jwks_client = jwt.PyJWKClient(get_setting("KEYCLOAK_JWKS_URL")) + signing_key = jwks_client.get_signing_key_from_jwt(id_token) + return jwt.decode( + id_token, + signing_key.key, + algorithms=[get_setting("KEYCLOAK_JWT_ALGORITHM")], + audience=get_setting("KEYCLOAK_CLIENT_ID"), + options={"verify_exp": True}, + ) + diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..0fa27f0 --- /dev/null +++ b/app/config.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import os + +SETTINGS_PREFIX = "OPENBAO__SETTINGS__" +REQUIRED_KEYCLOAK_SETTINGS = ( + "KEYCLOAK_CLIENT_ID", + "KEYCLOAK_CLIENT_SECRET", + "KEYCLOAK_AUTHORIZE_URL", + "KEYCLOAK_TOKEN_URL", + "KEYCLOAK_LOGOUT_URL", + "KEYCLOAK_JWKS_URL", + "KEYCLOAK_REDIRECT_URI", + "KEYCLOAK_SCOPE", + "KEYCLOAK_JWT_ALGORITHM", +) + + +def get_setting(name: str) -> str: + prefixed = os.getenv(f"{SETTINGS_PREFIX}{name}", "").strip() + if prefixed: + return prefixed + return os.getenv(name, "").strip() + + +def get_missing_settings() -> list[str]: + return [name for name in REQUIRED_KEYCLOAK_SETTINGS if not get_setting(name)] + + +def get_location() -> str: + return get_setting("LOCATION").lower() + diff --git a/app/ui.py b/app/ui.py new file mode 100644 index 0000000..8085137 --- /dev/null +++ b/app/ui.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import streamlit as st + +from app.config import SETTINGS_PREFIX, get_missing_settings + + +def render_env_check() -> None: + with st.expander("Проверка env окружения"): + if st.button("Проверить OPENBAO__SETTINGS", use_container_width=True): + missing = get_missing_settings() + if missing: + st.error("Не заданы обязательные переменные:") + for name in missing: + st.code(f"{SETTINGS_PREFIX}{name} (резерв: {name})", language="text") + else: + st.success("Все обязательные переменные заданы.") + + +def render_profile(profile: dict) -> None: + st.success("Аутентификация через Keycloak Raisa выполнена.") + st.subheader("Профиль пользователя") + st.json( + { + "email": profile.get("email"), + "preferred_username": profile.get("preferred_username"), + "name": profile.get("name"), + "realm_roles": profile.get("realm_access", {}).get("roles", []), + } + ) + diff --git a/main.py b/main.py new file mode 100644 index 0000000..5f46070 --- /dev/null +++ b/main.py @@ -0,0 +1,23 @@ +import streamlit as st + +from app.auth.service import AuthService, clear_session +from app.ui import render_env_check, render_profile + + +def main() -> None: + st.set_page_config(page_title="SFM Streamlit", page_icon=":lock:", layout="wide") + st.title("SFM Streamlit: Keycloak-only auth") + render_env_check() + + if not AuthService().run(): + return + + render_profile(st.session_state["user_profile"]) + + if st.button("Выйти", type="secondary"): + clear_session() + st.rerun() + + +if __name__ == "__main__": + main() diff --git a/raisa_config.yml b/raisa_config.yml new file mode 100644 index 0000000..684458c --- /dev/null +++ b/raisa_config.yml @@ -0,0 +1,11 @@ +# Конфигурация ресурсов приложения +# Доступные профили: Малый (0.25CPU/1GB), Средний (0.5CPU/2GB), Большой (1CPU/4GB), custom +# CPU Cores указывается, только с шагом 0.25 +# RAM GB указывается, только с шагом 1 +profile: + # Последние данные по наименованию профиля: Малый + cpu: 0.25 # значение Cores + ram: 1.0 # значение GB +resourcePolicy: + allowCpuOverbooking: false + allowRamOverbooking: false \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..18218c6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +openpyxl==3.1.2 +streamlit-oauth==0.1.8 +pyjwt[crypto]==2.8.0 +python-decouple==3.8 +black[all]==24.4.2 +flake8==7.0.0 +isort==5.13.2 diff --git a/roles.json b/roles.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/roles.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/users.json b/users.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/users.json @@ -0,0 +1 @@ +{} \ No newline at end of file