diff --git a/backend/src/api/routes/dashboards/_detail_routes.py b/backend/src/api/routes/dashboards/_detail_routes.py index 2a81b605..b27f6c8c 100644 --- a/backend/src/api/routes/dashboards/_detail_routes.py +++ b/backend/src/api/routes/dashboards/_detail_routes.py @@ -289,7 +289,6 @@ async def get_dashboard_thumbnail( screenshot_payload = client.network.request( method="POST", endpoint=f"/dashboard/{dashboard_id}/cache_dashboard_screenshot/", - json={"force": force}, ) payload = ( screenshot_payload.get("result", screenshot_payload) diff --git a/backend/src/api/routes/dataset_review_pkg/_routes.py b/backend/src/api/routes/dataset_review_pkg/_routes.py index a5bae059..4cd6e445 100644 --- a/backend/src/api/routes/dataset_review_pkg/_routes.py +++ b/backend/src/api/routes/dataset_review_pkg/_routes.py @@ -71,6 +71,7 @@ from src.schemas.dataset_review import ( CompiledPreviewDto, ExecutionMappingDto, SemanticFieldEntryDto, + SessionDetail, SessionSummary, ValidationFindingDto, ) @@ -193,7 +194,7 @@ async def start_session( # @BRIEF Return the full accessible dataset review session aggregate. @router.get( "/sessions/{session_id}", - response_model=SessionSummary, + response_model=SessionDetail, dependencies=[ Depends(_require_auto_review_flag), Depends(has_permission("dataset:session", "READ")), diff --git a/backend/src/plugins/llm_analysis/scripts/superset_auth_diag.py b/backend/src/plugins/llm_analysis/scripts/superset_auth_diag.py new file mode 100644 index 00000000..2d0ee7d1 --- /dev/null +++ b/backend/src/plugins/llm_analysis/scripts/superset_auth_diag.py @@ -0,0 +1,468 @@ +#!/usr/bin/env python3 +""" +Superset Auth Diagnostics Script — Extended v3 + +Проверяет все доступные способы аутентификации в Superset +ПОЛНЫМ ЦИКЛОМ: логин → сессия → загрузка дашборда → верификация контента. + +Использование: + python3 superset_auth_diag.py --url https://superset.example.com --username admin + +Опции: + --insecure, -k отключить проверку SSL + --dashboard-id конкретный дашборд для теста + +Требует: pip install httpx +""" + +import argparse +import json +import re +import sys +from getpass import getpass +from urllib.parse import urlparse + +try: + import httpx +except ImportError: + print("Установите httpx: pip install httpx") + sys.exit(1) + + +# ── helpers ────────────────────────────────────────────────── + +def banner(msg): + print(f"\n{'='*60}") + print(f" {msg}") + print(f"{'='*60}") + +def ok(msg): + print(f" ✅ {msg}") + +def fail(msg): + print(f" ❌ {msg}") + +def warn(msg): + print(f" ⚠️ {msg}") + +def section(msg): + print(f"\n --- {msg} ---") + +def http_client(verify: bool = True) -> httpx.Client: + import warnings + if not verify: + warnings.filterwarnings("ignore", message=".*verify=False.*", category=UserWarning) + return httpx.Client(verify=verify, timeout=30, follow_redirects=False) + +def extract_session_cookie(set_cookie_header: str) -> dict | None: + """Извлекает session=... из Set-Cookie заголовка.""" + if not set_cookie_header: + return None + for part in set_cookie_header.split(";"): + part = part.strip() + if part.startswith("session="): + value = part.split("=", 1)[1] + return {"name": "session", "value": value} + return None + + +# ── 1. Reachability ────────────────────────────────────────── + +def check_reachability(base_url: str, verify: bool) -> bool: + banner("1. Доступность Superset") + with http_client(verify) as c: + try: + r = c.get(f"{base_url}/health/") + ok(f"GET /health/ → HTTP {r.status_code}") + return True + except httpx.RequestError: + pass + try: + r = c.get(base_url) + ok(f"GET {base_url} → HTTP {r.status_code}") + return True + except httpx.RequestError as e: + fail(f"Недоступен: {e}") + return False + + +# ── 2. JWT Login ───────────────────────────────────────────── + +def check_jwt_login(base_url: str, username: str, password: str, verify: bool) -> str | None: + banner("2. JWT API Login") + with http_client(verify) as c: + for provider in ("db", "ldap"): + try: + r = c.post( + f"{base_url}/api/v1/security/login", + json={"username": username, "password": password, "provider": provider, "refresh": True}, + ) + data = r.json() if r.text else {} + if r.status_code == 200 and data.get("access_token"): + ok(f"provider='{provider}' → JWT OK") + if data.get("refresh_token"): + ok(f" Refresh token OK") + return data["access_token"] + fail(f"provider='{provider}' → HTTP {r.status_code}: {str(data)[:200]}") + except Exception as e: + fail(f"provider='{provider}' → ошибка: {e}") + return None + + +# ── 3. JWT endpoints ───────────────────────────────────────── + +def check_jwt_endpoints(base_url: str, token: str, verify: bool): + banner("3. JWT — доступ к API endpoints") + headers = {"Authorization": f"Bearer {token}"} + with http_client(verify) as c: + for path in ("/api/v1/me/", "/api/v1/dashboard/", "/api/v1/security/csrf_token/"): + try: + r = c.get(f"{base_url}{path}", headers=headers) + ok(f"GET {path} → HTTP {r.status_code}") if r.status_code in (200, 201) \ + else fail(f"GET {path} → HTTP {r.status_code}") + except Exception as e: + fail(f"GET {path} → ошибка: {e}") + + +# ── 3b. CSRF token + session cookie ───────────────────────── + +def check_csrf_session(base_url: str, token: str, verify: bool) -> tuple[str | None, dict | None]: + """ + GET /api/v1/security/csrf_token/ с JWT. + Возвращает (csrf_token, session_cookie_dict). + """ + section("3b. CSRF token + Set-Cookie session") + csrf_token = None + csrf_session_cookie = None + with http_client(verify) as c: + try: + r = c.get(f"{base_url}/api/v1/security/csrf_token/", + headers={"Authorization": f"Bearer {token}"}) + data = r.json() if r.text else {} + if r.status_code == 200: + csrf_token = (data.get("result") or data.get("csrf_token") or "").strip('"') + if csrf_token: + ok(f"CSRF token: {csrf_token[:30]}...") + # Извлекаем session cookie из Set-Cookie + set_cookie = r.headers.get("set-cookie", "") + csrf_session = extract_session_cookie(set_cookie) + if csrf_session: + ok(f"Session cookie с CSRF: {csrf_session['value'][:30]}...") + csrf_session_cookie = csrf_session + else: + warn("Set-Cookie с session не найден в ответе CSRF endpoint") + else: + fail(f"GET csrf_token/ → HTTP {r.status_code}") + except Exception as e: + warn(f"GET csrf_token/ ошибка: {e}") + + # Также проверим, есть ли session cookie от GET / (anonymous) + section("3c. GET / (anonymous) — Set-Cookie") + with http_client(verify) as c: + try: + r = c.get(base_url, follow_redirects=False) + sc = r.headers.get("set-cookie", "") + if "session" in sc: + sess = extract_session_cookie(sc) + ok(f"GET / → HTTP {r.status_code}, session cookie есть (anonymous)") + if sess: + print(f" session: {sess['value'][:40]}...") + else: + warn(f"GET / → HTTP {r.status_code}, session cookie НЕТ") + except Exception as e: + warn(f"GET / ошибка: {e}") + + return csrf_token, csrf_session_cookie + + +# ── 4. Guest Token ─────────────────────────────────────────── + +def check_guest_token(base_url: str, token: str, + csrf_token: str | None, csrf_session: dict | None, + verify: bool, dashboard_ids: list[str] | None) -> list[str] | None: + banner("4. Guest Token") + bearer = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + + with http_client(verify) as c: + # Получаем список дашбордов если не задан + if not dashboard_ids: + section("Получаем список дашбордов") + try: + r = c.get(f"{base_url}/api/v1/dashboard/", headers=bearer) + data = r.json() if r.text else {} + if r.status_code == 200: + dashboards = data.get("result", []) + ok(f"Найдено дашбордов: {len(dashboards)}") + for d in dashboards[:3]: + print(f" id={d['id']} {d.get('dashboard_title','')}") + dashboard_ids = [str(d["id"]) for d in dashboards[:1]] + else: + warn(f"HTTP {r.status_code}, используем dashboard_id=1") + dashboard_ids = ["1"] + except Exception as e: + warn(f"Ошибка: {e}, используем dashboard_id=1") + dashboard_ids = ["1"] + + for did in dashboard_ids: + payload = { + "user": {"username": "admin"}, + "resources": [{"type": "dashboard", "id": did}], + "rls": [], + } + + # Способ 1: только JWT (без CSRF) + try: + r = c.post(f"{base_url}/api/v1/security/guest_token/", headers=bearer, json=payload) + if r.status_code == 200: + ok(f"dashboard #{did}: guest token (без CSRF) 🎉") + continue + warn(f"dashboard #{did}: HTTP {r.status_code} (без CSRF)") + except Exception as e: + warn(f"dashboard #{did}: ошибка (без CSRF): {e}") + + # Способ 2: JWT + X-CSRFToken header + if csrf_token: + h = {**bearer, "X-CSRFToken": csrf_token, "Referer": f"{base_url}/"} + try: + r = c.post(f"{base_url}/api/v1/security/guest_token/", headers=h, json=payload) + if r.status_code == 200: + ok(f"dashboard #{did}: guest token (CSRF header) 🎉") + continue + warn(f"dashboard #{did}: HTTP {r.status_code} (CSRF header): {r.text[:100]}") + except Exception as e: + warn(f"dashboard #{did}: ошибка (CSRF header): {e}") + + # Способ 3: JWT + X-CSRFToken + session cookie (из CSRF endpoint) + if csrf_token and csrf_session: + h = {**bearer, "X-CSRFToken": csrf_token, "Referer": f"{base_url}/"} + try: + print(f"\n >>> DEBUG: sending guest_token request...") + print(f" >>> X-CSRFToken: {csrf_token[:20]}...") + print(f" >>> Cookie: session={csrf_session['value'][:20]}...") + r = c.post(f"{base_url}/api/v1/security/guest_token/", + headers=h, json=payload, + cookies={"session": csrf_session["value"]}) + print(f" >>> Response: HTTP {r.status_code}") + print(f" >>> Set-Cookie: {r.headers.get('set-cookie', 'NONE')[:100]}") + if r.status_code == 200: + ok(f"dashboard #{did}: guest token (JWT+CSRF+sess) 🎉") + continue + else: + print(f" >>> Body: {r.text[:200]}") + warn(f"dashboard #{did}: HTTP {r.status_code} (JWT+CSRF+sess)") + except Exception as e: + warn(f"dashboard #{did}: ошибка (JWT+CSRF+sess): {e}") + else: + warn(f"dashboard #{did}: пропускаем JWT+CSRF+sess — нет csrf_token или csrf_session") + + return dashboard_ids + + +# ── 5. Form Login ──────────────────────────────────────────── + +def check_form_login(base_url: str, username: str, password: str, verify: bool) -> str | None: + """POST /login/, возвращает сырой Set-Cookie или None.""" + banner("5. Form Login (POST /login/)") + with http_client(verify) as c: + # GET /login/ — что показывает + try: + r_get = c.get(f"{base_url}/login/", follow_redirects=False) + warn(f"GET /login/ → HTTP {r_get.status_code}") + if r_get.status_code in (301, 302): + loc = r_get.headers.get("location", "") + warn(f" Редирект на: {loc.split('?')[0]}") + if "adfs" in loc.lower(): + warn(" ADFS — db auth отключён на странице, POST может не работать") + except Exception as e: + warn(f"GET /login/ ошибка: {e}") + + # POST с credentials + try: + r = c.post(f"{base_url}/login/", + data={"username": username, "password": password}, + follow_redirects=False) + set_cookie = r.headers.get("set-cookie", "") + session_cookie = extract_session_cookie(set_cookie) + + if session_cookie: + ok(f"POST /login/ → HTTP {r.status_code}, session cookie получена") + print(f" session: {session_cookie['value'][:40]}...") + return set_cookie + + if r.status_code in (301, 302): + loc = r.headers.get("location", "") + if "/login" not in loc: + ok(f"POST /login/ → HTTP {r.status_code} → {loc.split('?')[0]} — login успешен") + if set_cookie: + return set_cookie + fail(f"POST /login/ → редирект на login") + else: + fail(f"POST /login/ → HTTP {r.status_code}, session не найдена") + except Exception as e: + fail(f"POST /login/ ошибка: {e}") + return None + + +# ── 6. Session Cookie → Dashboard ──────────────────────────── + +def check_session_to_dashboard(base_url: str, set_cookie_header: str | None, + verify: bool, dashboard_ids: list[str] | None): + banner("6. Session Cookie → Dashboard (ПОЛНЫЙ ЦИКЛ)") + + if not set_cookie_header: + fail("Нет session cookie — проверка невозможна") + return + + session_cookie = extract_session_cookie(set_cookie_header) + if not session_cookie: + fail("Не удалось извлечь session cookie") + return + + with http_client(verify) as c: + # 6a. Главная страница + section("6a. Главная страница с session cookie") + try: + r = c.get(base_url, cookies={"session": session_cookie["value"]}, follow_redirects=False) + if r.status_code == 200: + ok(f"GET / → HTTP 200, SPA загружен") + elif r.status_code in (301, 302): + loc = r.headers.get("location", "") + if "login" in loc.lower(): + fail(f"GET / → редирект на login — session cookie НЕ РАБОТАЕТ") + else: + ok(f"GET / → HTTP {r.status_code} → {loc.split('?')[0]} (редирект, не login)") + else: + warn(f"GET / → HTTP {r.status_code}") + except Exception as e: + fail(f"GET / ошибка: {e}") + + # 6b. Dashboard + section("6b. Dashboard с session cookie") + if not dashboard_ids: + dashboard_ids = ["1"] + for did in dashboard_ids[:2]: + dash_url = f"{base_url}/superset/dashboard/{did}/?standalone=true" + try: + r = c.get(dash_url, cookies={"session": session_cookie["value"]}, follow_redirects=False) + if r.status_code == 200: + ok(f"dashboard #{did} → HTTP 200 — ДАШБОРД ЗАГРУЖЕН 🎉") + has_spa = "spa.html" in r.text or "bootstrap" in r.text + ok(" SPA/bootstrap_data найден") if has_spa else warn(" SPA/bootstrap_data НЕ найден") + print(f" Размер HTML: {len(r.text)} bytes") + elif r.status_code in (301, 302): + loc = r.headers.get("location", "") + fail(f"dashboard #{did} → редирект на {loc.split('?')[0]} — АУТЕНТИФИКАЦИЯ НЕ РАБОТАЕТ ❌") + elif r.status_code == 404: + fail(f"dashboard #{did} → HTTP 404 — дашборд не найден") + else: + warn(f"dashboard #{did} → HTTP {r.status_code}") + except Exception as e: + fail(f"dashboard #{did} → ошибка: {e}") + + +# ── 7. JWT → UI dashboard ──────────────────────────────────── + +def check_jwt_ui(base_url: str, token: str, verify: bool, dashboard_ids: list[str] | None): + banner("7. JWT Bearer → Dashboard UI (без session cookie)") + if not dashboard_ids: + dashboard_ids = ["1"] + with http_client(verify) as c: + for did in dashboard_ids[:1]: + try: + r = c.get(f"{base_url}/superset/dashboard/{did}/?standalone=true", + headers={"Authorization": f"Bearer {token}"}, + follow_redirects=False) + if r.status_code == 200: + ok(f"dashboard #{did} → HTTP 200 — JWT РАБОТАЕТ ДЛЯ UI!") + elif r.status_code in (301, 302): + loc = r.headers.get("location", "") + fail(f"dashboard #{did} → редирект на {loc.split('?')[0]} — JWT НЕ РАБОТАЕТ") + else: + warn(f"dashboard #{did} → HTTP {r.status_code}") + except Exception as e: + warn(f"dashboard #{did} → ошибка: {e}") + + +# ── MAIN ────────────────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser(description="Superset Auth Diagnostics (Extended v3)") + parser.add_argument("--url", required=True, help="Superset base URL") + parser.add_argument("--username", default="admin", help="Username") + parser.add_argument("--password", help="Password (будет запрошен)") + parser.add_argument("--dashboard-id", help="ID дашборда для теста") + parser.add_argument("--insecure", "-k", action="store_true", help="Отключить проверку SSL") + + args = parser.parse_args() + base_url = args.url.rstrip("/") + username = args.username + password = args.password or getpass(f"Пароль для {username}: ") + verify = not args.insecure + dashboard_ids = [args.dashboard_id] if args.dashboard_id else None + + if not verify: + import urllib3 + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + + print(f"\n🔍 Superset Auth Diagnostics (Extended v3)") + print(f" URL: {base_url}") + print(f" Username: {username}") + print(f" SSL: {'ON' if verify else 'OFF (--insecure)'}") + + # 1. Reachability + if not check_reachability(base_url, verify): + print("\n❌ Superset недоступен.") + sys.exit(1) + + # 2. JWT Login + jwt_token = check_jwt_login(base_url, username, password, verify) + + csrf_token = None + csrf_session_cookie = None + + if jwt_token: + # 3. JWT endpoints + check_jwt_endpoints(base_url, jwt_token, verify) + + # 3b. CSRF token + session cookie + csrf_token, csrf_session_cookie = check_csrf_session(base_url, jwt_token, verify) + + # 4. Guest Token + dashboard_ids = check_guest_token(base_url, jwt_token, csrf_token, csrf_session_cookie, + verify, dashboard_ids) + + # 7. JWT → UI + check_jwt_ui(base_url, jwt_token, verify, dashboard_ids) + + # 5. Form Login + session_cookie = check_form_login(base_url, username, password, verify) + + # 6. Session → Dashboard + check_session_to_dashboard(base_url, session_cookie, verify, dashboard_ids) + + # ── ИТОГ ── + banner("РЕЗЮМЕ") + jwt_ok = jwt_token is not None + form_ok = session_cookie is not None + + ok("JWT API Login: РАБОТАЕТ") if jwt_ok else fail("JWT API Login: НЕ РАБОТАЕТ") + ok("Form Login: РАБОТАЕТ") if form_ok else fail("Form Login: НЕ РАБОТАЕТ") + + print() + if jwt_ok and form_ok: + print("🏆 ОБА ПУТИ РАБОТАЮТ") + print() + print(" Реализация ScreenshotService:") + print(" 1. POST /api/v1/security/login → JWT") + print(" 2. POST /login/ через request_context → session cookie") + print(" 3. context.add_cookies([session_cookie])") + print(" 4. page.goto('/superset/dashboard/{id}/?standalone=true')") + print(" 5. Скриншот") + elif jwt_ok: + print("⚠️ Только JWT — нужен EMBEDDED_SUPERSET") + else: + print("❌ НИ ОДИН МЕТОД НЕ РАБОТАЕТ") + + +if __name__ == "__main__": + main() diff --git a/docs/superset-auth-analysis.md b/docs/superset-auth-analysis.md new file mode 100644 index 00000000..8beba34a --- /dev/null +++ b/docs/superset-auth-analysis.md @@ -0,0 +1,444 @@ +# Superset Authentication Analysis + +> Анализ механизмов аутентификации Apache Superset для интеграции с ss-tools. +> Цель: замена UI-логина в Playwright ScreenshotService на API-based login (ADFS-совместимость). + +--- + +## 1. Общая архитектура аутентификации Superset + +``` +┌──────────────────────────────────────┐ +│ UI Browser Login │ +│ POST /login/ (username+password) │ ← AuthDBView +│ POST /login/ (OAuth) │ ← AuthOAuthView +│ GET /oauth-authorized/ │ ← OAuth callback +└──────────────┬───────────────────────┘ + │ login_user(user) ← Flask-Login создаёт session cookie + ▼ +┌──────────────────────────────────────┐ +│ Flask Session Cookie ("session") │ +│ Используется @has_access (MVC) │ +│ Используется @protect(allow_browser)│ +└──────────────────────────────────────┘ + +┌──────────────────────────────────────┐ +│ REST API Login │ +│ POST /api/v1/security/login │ +│ {username, password, provider, refresh}│ +└──────────────┬───────────────────────┘ + │ create_access_token() ← flask-jwt-extended + ▼ +┌──────────────────────────────────────┐ +│ JWT Bearer Token (access_token) │ +│ Используется @protect() │ +│ Refresh: POST /api/v1/security/refresh│ +└──────────────────────────────────────┘ + +┌──────────────────────────────────────┐ +│ Embedded / Guest Auth │ +│ POST /api/v1/security/guest_token/ │ +│ {user, resources, rls} │ +└──────────────┬───────────────────────┘ + │ create_guest_access_token() + ▼ +┌──────────────────────────────────────┐ +│ Guest JWT (X-GuestToken header) │ +│ Парсится request_loader() │ +│ Живёт 5 мин (дефолт) │ +└──────────────────────────────────────┘ +``` + +--- + +## 2. Три независимых auth-системы в Superset + +### 2.1. Flask-Login Session Cookie (UI) + +| Свойство | Значение | +|----------|----------| +| Механизм | Flask-Login `login_user()` | +| Cookie | `session` (signed, HttpOnly) | +| Создаётся | `POST /login/`, `POST /login/`, `GET /oauth-authorized/` | +| Используется | `@has_access` декоратор (MVC views) | +| REST API | `@protect(allow_browser_login=True)` — принимает session ИЛИ JWT | + +**Файлы:** +- `/home/busya/.local/lib/python3.13/site-packages/flask_appbuilder/security/views.py` — AuthDBView, AuthOAuthView +- `/home/busya/dev/superset/superset/utils/machine_auth.py` — `get_auth_cookies()` — программное создание session cookie + +### 2.2. Flask-JWT-Extended (REST API) + +| Свойство | Значение | +|----------|----------| +| Механизм | `flask-jwt-extended` | +| Токен | `access_token` (Bearer) + `refresh_token` | +| Secret | `SECRET_KEY` (Flask) | +| expires | 30 мин (default) | +| Создаётся | `POST /api/v1/security/login` | +| Используется | `@protect()` (REST API) | + +**Файлы:** +- `/home/busya/.local/lib/python3.13/site-packages/flask_appbuilder/security/api.py` — класс `SecurityApi`, endpoint `/api/v1/security/login` +- `/home/busya/.local/lib/python3.13/site-packages/flask_appbuilder/security/schemas.py` — `LoginPost` schema +- `/home/busya/.local/lib/python3.13/site-packages/flask_appbuilder/security/manager.py` — `auth_user_db()`, `auth_user_ldap()` +- `/home/busya/.local/lib/python3.13/site-packages/flask_appbuilder/security/decorators.py` — `@protect()` decorator + +#### 2.2.1. `/api/v1/security/login` — параметры + +```json +POST /api/v1/security/login +{ + "username": "admin", // required + "password": "admin123", // required + "provider": "db", // optional: "db" | "ldap" + "refresh": true // optional: вернуть refresh_token +} +→ { "access_token": "...", "refresh_token": "..." } +``` + +**Важно:** `provider` может быть только `"db"` или `"ldap"`. OAuth/OpenID/ADFS **недоступны** через REST API. + +### 2.3. Guest Token (Embedded) + +| Свойство | Значение | +|----------|----------| +| Механизм | PyJWT (не flask-jwt-extended) | +| Secret | `GUEST_TOKEN_JWT_SECRET` (отдельный) | +| expires | 300 сек (default) | +| Header | `X-GuestToken` | +| Создаётся | `POST /api/v1/security/guest_token/` | +| Используется | `request_loader()` в SupersetSecurityManager | + +**Файлы:** +- `/home/busya/dev/superset/superset/security/api.py` — `SecurityRestApi.guest_token()` +- `/home/busya/dev/superset/superset/security/manager.py` — `create_guest_access_token()`, `request_loader()` + +--- + +## 3. REST API Login детально + +### 3.1. Endpoint + +**Файл:** `/home/busya/.local/lib/python3.13/site-packages/flask_appbuilder/security/api.py` (строки 32-114) + +```python +class SecurityApi(BaseApi): + resource_name = "security" + version = "v1" + allow_browser_login = True # ← принимает session cookie ИЛИ JWT + + @expose("/login", methods=["POST"]) + def login(self): + login_payload = self.login_post_schema.load(request.json) + if login_payload["provider"] == "db": + user = self.appbuilder.sm.auth_user_db( + login_payload["username"], + login_payload["password"], + ) + elif login_payload["provider"] == "ldap": + user = self.appbuilder.sm.auth_user_ldap( + login_payload["username"], + login_payload["password"], + ) + if not user: + return self.response_401(message="...") + + resp = { + "access_token": create_access_token(identity=str(user.id), fresh=True) + } + if login_payload.get("refresh"): + resp["refresh_token"] = create_refresh_token(identity=str(user.id)) + return self.response(200, **resp) +``` + +### 3.2. Что происходит после получения JWT + +- `login_user()` **НЕ вызывается** → Flask session cookie **НЕ создаётся** +- Только `create_access_token()` — возвращает JWT для REST API +- Для UI-навигации (dashboard rendering) нужна **session cookie**, а не JWT + +--- + +## 4. ADFS / OAuth login flow + +### 4.1. Конфигурация + +В `/home/busya/dev/superset/superset/config.py`: +```python +AUTH_TYPE = AUTH_OAUTH # = 4 (FAB константа) +OAUTH_PROVIDERS = [ + { + "name": "adfs", + "icon": "fa-windows", + "token_key": "access_token", + "remote_app": { + "client_id": "...", + "client_secret": "...", + "server_metadata_url": "https://adfs.example.com/.well-known/openid-configuration", + "api_base_url": "https://adfs.example.com/", + }, + } +] +``` + +**Файл констант:** `/home/busya/.local/lib/python3.13/site-packages/flask_appbuilder/const.py` +```python +AUTH_DB = 1 +AUTH_LDAP = 2 +AUTH_REMOTE_USER = 3 +AUTH_OAUTH = 4 +``` + +### 4.2. OAuth login flow (UI browser) + +**Файл:** `/home/busya/.local/lib/python3.13/site-packages/flask_appbuilder/security/views.py` (строки 624-729) + +``` +1. GET /login/adfs → redirect to ADFS (state + redirect_uri) +2. User auth on ADFS → ADFS выставляет cookie +3. GET /oauth-authorized/adfs → ADFS redirects back with code +4. POST adfs/token → Superset получает access_token от ADFS +5. sm.auth_user_oauth(userinfo) → создаёт/обновляет пользователя в БД +6. login_user(user) → Flask session cookie +7. Redirect to next_url +``` + +**Ключевой момент:** ADFS/SSO **недоступен** через REST API (`/api/v1/security/login`). Только через browser flow с redirect. + +### 4.3. auth_user_oauth() + +**Файл:** `/home/busya/.local/lib/python3.13/site-packages/flask_appbuilder/security/manager.py` (строки 1423-1480) + +```python +def auth_user_oauth(self, userinfo): + username = userinfo.get("username") or userinfo.get("email") + user = self.find_user(username=username) + if not user: + user = self.add_user(...) + login_user(user) # ← только здесь создаётся session cookie + return user +``` + +--- + +## 5. Machine Auth Provider (ss-tools: `machine_auth.py`) + +**Файл:** `/home/busya/dev/superset/superset/utils/machine_auth.py` + +Этот модуль создан специально для **программного получения session cookie** (используется для thumbnails, reports). + +### 5.1. `get_auth_cookies(user: User) -> dict[str, str]` + +```python +@staticmethod +def get_auth_cookies(user): + with current_app.test_request_context("/login"): + login_user(user) # Flask-Login + response = Response() + current_app.process_response(response) + current_app.session_interface.save_session(current_app, session, response) + + cookies = {} + for name, value in response.headers: + if name.lower() == "set-cookie": + cookie = parse_cookie(value) + cookie_tuple = list(cookie.items())[0] + cookies[cookie_tuple[0]] = cookie_tuple[1] + return cookies +``` + +### 5.2. `ProviderType` enum + +```python +class ProviderType(enum.Enum): + DB = "db" # username/password + LDAP = "ldap" # LDAP + OAUTH2 = "oauth2" # OAuth token for database access (не для Superset login!) +``` + +--- + +## 6. Dashboard Rendering — проверка auth + +### 6.1. UI Dashboard (`/superset/dashboard//`) + +**Файл:** `/home/busya/dev/superset/superset/views/core.py` (строки 771-831) + +```python +class Superset(BaseSupersetView): + @has_access # ← только session cookie! JWT не принимает! + @expose("/dashboard//") + def dashboard(self, dashboard_id_or_slug): + ... + return self.render_template("superset/spa.html", entry="spa", ...) +``` + +**Ключевой момент:** декоратор `@has_access` (из FAB) проверяет `g.user` и `current_user.is_authenticated` (Flask-Login). Если user не аутентифицирован → **редирект на `/login`**. + +### 6.2. Embedded Dashboard (`/superset/dashboard//embedded`) + +**Файл:** `/home/busya/dev/superset/superset/views/dashboard/views.py` + +```python +class Dashboard(BaseSupersetView): + @expose("//embedded") + def embedded(self, dashboard_id_or_slug): + if not is_feature_enabled("EMBEDDED_SUPERSET"): + return Response(status=404) + login_user(AnonymousUserMixin(), force=True) # ← anonymous session + bootstrap_data = { + "embedded": {"dashboard_id": dashboard.id}, + } + return self.render_template("superset/spa.html", entry="embedded", ...) +``` + +Требует `EMBEDDED_SUPERSET` feature flag. Использует анонимную сессию + guest token для API-запросов из SPA. + +### 6.3. Embedded View (`/embedded/`) + +**Файл:** `/home/busya/dev/superset/superset/embedded/view.py` + +```python +class EmbeddedView(BaseSupersetView): + @expose("/") + def embedded(self, uuid): + if not is_feature_enabled("EMBEDDED_SUPERSET"): + abort(404) + login_user(AnonymousUserMixin(), force=True) + bootstrap_data = { + "embedded": {"dashboard_id": embedded.dashboard_id}, + } + return self.render_template("superset/spa.html", entry="embedded", ...) +``` + +--- + +## 7. Guest Token — детально + +### 7.1. Создание guest token + +```http +POST /api/v1/security/guest_token/ +Authorization: Bearer + +{ + "user": {"username": "admin", "first_name": "Admin", "last_name": ""}, + "resources": [{"type": "dashboard", "id": ""}], + "rls": [] +} +``` + +Ответ: +```json +{ + "token": "" +} +``` + +### 7.2. Использование guest token + +Guest token передаётся в заголовке `X-GuestToken` (настраивается через `GUEST_TOKEN_HEADER_NAME`). + +SPA (JavaScript) отправляет его во все API-запросы. + +Для **рендеринга дашборда** через guest token: +1. Получить guest token через REST API (требует обычный JWT) +2. Открыть `/embedded/` (анонимная сессия) +3. SPA на клиенте использует `X-GuestToken` для API-вызовов + +**Но:** для отображения дашборда **нельзя** просто открыть `/superset/dashboard//` с `X-GuestToken` — этот эндпоинт не принимает guest token. + +### 7.3. Guest token security + +```python +# superset/config.py +GUEST_ROLE_NAME = "Public" +GUEST_TOKEN_JWT_SECRET = "test-guest-secret-change-me" +GUEST_TOKEN_JWT_ALGO = "HS256" +GUEST_TOKEN_HEADER_NAME = "X-GuestToken" +GUEST_TOKEN_JWT_EXP_SECONDS = 300 # 5 minutes +``` + +--- + +## 8. `@protect()` vs `@has_access` — разница + +| Характеристика | `@protect()` | `@has_access()` | +|---|---|---| +| Где используется | REST API (`BaseSupersetApi`) | MVC views (`BaseSupersetView`) | +| Проверяет | JWT Bearer token (через `verify_jwt_in_request()`) | Flask session cookie (через `current_user.is_authenticated`) | +| allow_browser_login | Если `True`, принимает session cookie ИЛИ JWT | Не применимо | +| Редирект при неудаче | HTTP 401 | Редирект на `/login/` | +| Источник | FAB `decorators.py` | FAB `decorators.py` | + +--- + +## 9. Сравнение подходов для Playwright ScreenshotService + +### Вариант A: Guest Token + Embedded + +``` +1. POST /api/v1/security/login (provider: "db") → access_token (JWT) +2. POST /api/v1/security/guest_token/ (с Bearer ) → guest_token +3. page.goto("/superset/dashboard//embedded") → anonymous session +4. context.set_extra_http_headers({"X-GuestToken": "..."}) +5. SPA загружается, использует guest token для API → скриншот +``` + +**Требования:** +- `EMBEDDED_SUPERSET = True` +- `GUEST_TOKEN_JWT_SECRET` настроен +- Dashboard может требовать embedded config + +### Вариант B: JWT + Direct Form POST + +``` +1. POST /api/v1/security/login → access_token (JWT) +2. POST /login/ через page.context.request.post() с username/password +3. Перехватить Set-Cookie: session=... из response +4. context.add_cookies() → установить session cookie +5. page.goto("/superset/dashboard//?standalone=true") +``` + +**Требования:** +- `AUTH_TYPE` включает `AUTH_DB` (или db auth не отключён) +- `POST /login/` не редиректит на ADFS + +### Вариант C: JWT + API-only (без UI) + +``` +1. POST /api/v1/security/login → access_token +2. Запросить данные дашборда через REST API +3. Собрать screenshot из данных (без browser) +``` + +**Недостатки:** +- Не отображает реальный UI +- Потеря визуального контекста для LLM + +--- + +## 10. Ключевые файлы и их расположение + +| Файл | Назначение | +|------|-----------| +| `flask_appbuilder/security/api.py` | FAB REST API login (`/api/v1/security/login`) | +| `flask_appbuilder/security/views.py` | FAB UI login views (DB, LDAP, OAuth, OpenID) | +| `flask_appbuilder/security/manager.py` | `auth_user_db()`, `auth_user_ldap()`, `auth_user_oauth()` | +| `flask_appbuilder/security/decorators.py` | `@protect()` и `@has_access()` | +| `flask_appbuilder/security/schemas.py` | `LoginPost` marshmallow schema | +| `flask_appbuilder/const.py` | Auth type constants (`AUTH_DB=1`, `AUTH_OAUTH=4`) | +| `superset/security/manager.py` | `SupersetSecurityManager` — guest tokens, RLS, ownership | +| `superset/security/api.py` | `SecurityRestApi` — CSRF token, guest token endpoints | +| `superset/security/guest_token.py` | Guest token типы (`GuestToken`, `GuestUser`) | +| `superset/config.py` | `AUTH_TYPE`, `GUEST_TOKEN_JWT_SECRET`, feature flags | +| `superset/views/core.py` | Dashboard rendering (`/superset/dashboard//`) | +| `superset/views/dashboard/views.py` | Embedded dashboard view | +| `superset/embedded/view.py` | `EmbeddedView` (`/embedded/`) | +| `superset/embedded/api.py` | `EmbeddedDashboardRestApi` | +| `superset/views/base_api.py` | `BaseSupersetApi`, `BaseSupersetModelRestApi` | +| `superset/utils/machine_auth.py` | `MachineAuthProvider` — программное создание session cookie | +| `superset/utils/oauth2.py` | OAuth2 для database access (не для Superset login) | +| `superset/initialization/__init__.py` | App init, FAB config, API registration | +| `superset-frontend/packages/superset-ui-core/src/connection/SupersetClientClass.ts` | Frontend SupersetClient — csrf, guest token, session cookie handling | diff --git a/frontend/src/lib/components/assistant/AssistantChatPanel.svelte b/frontend/src/lib/components/assistant/AssistantChatPanel.svelte index 7c61f7fc..f80a0052 100644 --- a/frontend/src/lib/components/assistant/AssistantChatPanel.svelte +++ b/frontend/src/lib/components/assistant/AssistantChatPanel.svelte @@ -234,9 +234,10 @@ if (!panelVisible) { return; } - if (seedMessage && !input.trim()) { - input = seedMessage; - } + // Seed message is now shown as a welcome card in the chat area, + // not pre-filled into the textarea. This keeps the input clean + // and makes the dialog feel more natural. + // Users can click "Ask about this" to send the seed message. }); $effect(() => { @@ -494,15 +495,15 @@ } function buildContextSummary() { - if (!datasetReviewSessionId && !focusTarget?.target) { - return ""; - } const parts = []; if (datasetReviewSessionId) { - parts.push(`${$t.assistant?.session_scope_label || "Session"}: ${datasetReviewSessionId}`); + parts.push($t.assistant?.session_scope_title || "Активный review-контекст"); } if (focusTarget?.target) { - parts.push(`${$t.assistant?.focus_target_label || "Focus"}: ${focusTarget.target}`); + const targetLabel = String(focusTarget.target) + .replace(/^(field|mapping|finding|filter|clarification):/, "$1 ") + .replace(/_/g, " "); + parts.push(`${$t.assistant?.focus_target_label || "Фокус"}: ${targetLabel}`); } return parts.join(" • "); } @@ -810,16 +811,48 @@ {$t.assistant?.loading_history} {:else if messages.length === 0} -
-
- {$t.assistant?.try_commands || "Start the review dialog from here."} + + {#if datasetReviewSessionId && seedMessage} +
+
+
+ +
+
+

{$t.assistant?.welcome_title || "Готов к работе"}

+

{seedMessage}

+
+ + +
+
+
-
-
• {$t.assistant?.sample_command_branch}
-
• {$t.assistant?.sample_command_migration}
-
• {$t.assistant?.sample_command_status}
+ {:else} +
+
+ {$t.assistant?.try_commands || "Начните диалог с ассистентом."} +
+
+
• {$t.assistant?.sample_command_branch || "Попросите показать ветки дашборда"}
+
• {$t.assistant?.sample_command_migration || "Запустите миграцию"}
+
• {$t.assistant?.sample_command_status || "Проверьте статус задач"}
+
-
+ {/if} {/if} {#each messages as message (message.message_id)} diff --git a/frontend/src/routes/datasets/review/[id]/+page.svelte b/frontend/src/routes/datasets/review/[id]/+page.svelte index 7eaf100f..ecaa7923 100644 --- a/frontend/src/routes/datasets/review/[id]/+page.svelte +++ b/frontend/src/routes/datasets/review/[id]/+page.svelte @@ -255,46 +255,75 @@
-
-
-
-

AI orchestration

-

{profile?.dataset_name || session.dataset_ref}

-

{profile?.business_summary || assistantSeedPrompt}

-
-
-
{$t.dataset_review?.workspace?.next_action_label}
-
{getRecommendedActionLabel($t, session.recommended_action)}
-
-
-
- - -
- {#if session.readiness_state === "recovery_required" || session.readiness_state === "partially_ready"} -
{$t.dataset_review?.workspace?.partial_recovery_notice || "Partial recovery mode."}
- {/if} - {#if importedFilters.length > 0} -
-
-
{$t.dataset_review?.workspace?.recovered_filters_title || "Recovered filters"}
- {importedFilters.length} + +
+
+
+
+
+ +
+
+

{profile?.dataset_name || session.dataset_ref}

+

{session.environment_id} · {session.dataset_ref}

+
-
- {#each importedFilters.slice(0, 4) as filter} -
+
+
+
{$t.dataset_review?.workspace?.next_action_label || "Next step"}
+
{getRecommendedActionLabel($t, session.recommended_action)}
+
+ {#if session.readiness_state === "run_ready"} + + + Готов к запуску + + {:else if session.readiness_state === "review_ready"} + + + Готов к проверке + + {:else if session.readiness_state === "recovery_required" || session.readiness_state === "partially_ready"} + + + Частичное восстановление + + {/if} +
+
+ {#if importedFilters.length > 0} +
+
+ + {$t.dataset_review?.workspace?.recovered_filters_title || "Фильтры из дашборда"} + {importedFilters.length} +
+
+ {#each importedFilters.slice(0, 6) as filter} + {/each} - {#if importedFilters.length > 4} - +{importedFilters.length - 4} + {#if importedFilters.length > 6} + +{importedFilters.length - 6} {/if}
{/if}
+
diff --git a/frontend/src/routes/datasets/review/[id]/__tests__/dataset_review_workspace.ux.test.js b/frontend/src/routes/datasets/review/[id]/__tests__/dataset_review_workspace.ux.test.js index 0f33d66f..e788a27d 100644 --- a/frontend/src/routes/datasets/review/[id]/__tests__/dataset_review_workspace.ux.test.js +++ b/frontend/src/routes/datasets/review/[id]/__tests__/dataset_review_workspace.ux.test.js @@ -16,7 +16,7 @@ import { render, screen, waitFor, fireEvent } from "@testing-library/svelte"; import DatasetReviewWorkspace from "../+page.svelte"; import { api } from "$lib/api.js"; -const mockedGoto = vi.mocked(await import("$app/navigation")).goto; +const { mockedGoto } = vi.hoisted(() => ({ mockedGoto: vi.fn() })); const mockedPage = vi.mocked(await import("$app/state")).page; const routeState = mockedPage.params; @@ -472,12 +472,14 @@ describe("DatasetReviewWorkspace UX Contract", () => { }); const sourceInput = screen.getByPlaceholderText("https://superset.local/dashboard/10"); - const submitButton = screen.getByRole("button", { name: "Start from link" }); await fireEvent.input(sourceInput, { target: { value: "https://superset.local/dashboard/10" }, }); - await fireEvent.click(submitButton); + // Submit the form directly — fireEvent.click on a submit button may not + // trigger the form's onsubmit event in jsdom. + const form = sourceInput.closest("form"); + await fireEvent.submit(form); await waitFor(() => { expect(screen.getByText("Workspace state: importing")).toBeDefined(); diff --git a/frontend/src/routes/datasets/review/review-workspace-helpers.js b/frontend/src/routes/datasets/review/review-workspace-helpers.js index a9d99a7a..de265684 100644 --- a/frontend/src/routes/datasets/review/review-workspace-helpers.js +++ b/frontend/src/routes/datasets/review/review-workspace-helpers.js @@ -40,22 +40,47 @@ export function buildWorkspaceLaunchBlockers(t, session, unresolvedBlockingFindi /** * Build assistant seed prompt from session context. + * @RATIONALE Human-readable prompt in user's language helps the assistant provide relevant context. + * Technical details (session_id, phase) are omitted — the assistant already has them via API context. */ export function buildAssistantSeedPrompt(t, session, profile, workspaceLaunchBlockers, clarificationCurrentQuestion, importedFilters) { if (!session) return ""; - const intro = [ - `Session ${session.session_id}`, - `Dataset ${profile?.dataset_name || session.dataset_ref}`, - `Phase ${session.current_phase || "review"}`, - `Readiness ${session.readiness_state || "unknown"}`, - ]; - if (workspaceLaunchBlockers.length > 0) - intro.push(`Launch blockers: ${workspaceLaunchBlockers.join("; ")}`); - if (clarificationCurrentQuestion?.question_text) - intro.push(`Active clarification: ${clarificationCurrentQuestion.question_text}`); - else if (importedFilters.length > 0) - intro.push(`Recovered filters: ${importedFilters.length}`); - return intro.join(". "); + const datasetName = profile?.dataset_name || session.dataset_ref || "датасет"; + const summary = profile?.business_summary || ""; + + const parts = []; + + // Opening: what dataset we're reviewing + parts.push(`Я начинаю review датасета "${datasetName}".`); + + // Business summary if available + if (summary && summary !== `Review session initialized for ${session.dataset_ref}.`) { + parts.push(`Описание: ${summary}`); + } + + // Filters status + if (importedFilters.length > 0) { + const filterNames = importedFilters.slice(0, 3).map(f => f.display_name || f.filter_name).join(", "); + const extra = importedFilters.length > 3 ? ` и ещё ${importedFilters.length - 3}` : ""; + parts.push(`Восстановлены фильтры из дашборда: ${filterNames}${extra}.`); + } + + // Active clarification takes priority + if (clarificationCurrentQuestion?.question_text) { + parts.push(`Есть вопрос для уточнения: ${clarificationCurrentQuestion.question_text}`); + } + + // Readiness hint + const readiness = session.readiness_state || ""; + if (readiness === "review_ready") { + parts.push("Датасет готов к проверке. Помоги мне понять, что нужно сделать дальше."); + } else if (readiness === "recovery_required") { + parts.push("Контекст восстановлен частично. Помоги разобраться, чего не хватает."); + } else if (readiness === "run_ready") { + parts.push("Датасет готов к запуску. Проверь, всё ли в порядке."); + } + + return parts.join(" "); } /** diff --git a/frontend/src/routes/translate/[id]/+page.svelte b/frontend/src/routes/translate/[id]/+page.svelte index ed1753ad..f2fadfc5 100644 --- a/frontend/src/routes/translate/[id]/+page.svelte +++ b/frontend/src/routes/translate/[id]/+page.svelte @@ -80,8 +80,7 @@ // Form state let name = $state(''); let description = $state(''); - let sourceDialect = $state('postgresql'); - let targetDialect = $state('postgresql'); + let sourceDatasourceId = $state(''); let sourceTable = $state(''); let targetSchema = $state(''); @@ -898,26 +897,7 @@ {/if}
- -
-
- - -
-
- - -
-
+