445 lines
18 KiB
Markdown
445 lines
18 KiB
Markdown
# 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 |
|