This commit is contained in:
2026-05-29 16:15:41 +03:00
parent e7d4121dc9
commit 5deb01e182
9 changed files with 1067 additions and 86 deletions

View File

@@ -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/<provider> (OAuth) │ ← AuthOAuthView
│ GET /oauth-authorized/<provider> │ ← 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/<provider>`, `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/<id>/`)
**Файл:** `/home/busya/dev/superset/superset/views/core.py` (строки 771-831)
```python
class Superset(BaseSupersetView):
@has_access # ← только session cookie! JWT не принимает!
@expose("/dashboard/<dashboard_id_or_slug>/")
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/<id>/embedded`)
**Файл:** `/home/busya/dev/superset/superset/views/dashboard/views.py`
```python
class Dashboard(BaseSupersetView):
@expose("/<dashboard_id_or_slug>/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/<uuid>`)
**Файл:** `/home/busya/dev/superset/superset/embedded/view.py`
```python
class EmbeddedView(BaseSupersetView):
@expose("/<uuid>")
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 <access_token>
{
"user": {"username": "admin", "first_name": "Admin", "last_name": ""},
"resources": [{"type": "dashboard", "id": "<dashboard_id>"}],
"rls": []
}
```
Ответ:
```json
{
"token": "<jwt_guest_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/<uuid>` (анонимная сессия)
3. SPA на клиенте использует `X-GuestToken` для API-вызовов
**Но:** для отображения дашборда **нельзя** просто открыть `/superset/dashboard/<id>/` с `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 <JWT>) → guest_token
3. page.goto("/superset/dashboard/<id>/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/<id>/?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/<id>/`) |
| `superset/views/dashboard/views.py` | Embedded dashboard view |
| `superset/embedded/view.py` | `EmbeddedView` (`/embedded/<uuid>`) |
| `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 |