fix
This commit is contained in:
444
docs/superset-auth-analysis.md
Normal file
444
docs/superset-auth-analysis.md
Normal 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 |
|
||||
Reference in New Issue
Block a user