背景
在最近的一個專案上,客戶想要為他們的多租戶(Multi-tenant)系統新增一個新的報表中心。技術選型自然沿用之前的選擇:Apache Superset,一款由愛彼迎貢獻給開源社群的框架。
關於Superset
Superset的前端是中規中矩的React,圖表功能則是使用NVD3/D3。後端沒有使用萬年Java,而是Python3。Web方面使用的是Flask框架,其他的框架沒有過多的深入瞭解。
需要解決的問題
由於之前的業務原因,之前的系統在使用者登入時,只能選擇其中的一個租戶繫結到會話中。這個模式在業務早期沒有什麼困擾,但隨著多租戶使用者的增多,系統的使用者更希望看到跨租戶的總覽資料。
為此,我們新增了一個資源服務,提供了一個介面用於查詢到當前使用者的租戶資訊。使用者的認證時通過OAuth 2.0,連線到鑑權服務。
這種變化對於原來的解決方案帶來了兩個問題:
- Superset需要接入資源服務所用的鑑權服務,並且在OAuth 2.0鑑權後訪問資源服務,通過介面獲取到當前使用者的租戶資訊。
- Superset需要在執行查詢時,動態插入行級過濾條件,這個過濾條件的值是依賴當前使用者的租戶資訊。這可以使用SQL Templating,SQL templating,內建了一些表示式(官方稱之為macro,巨集,下文稱之為巨集命令),但功能有限。
之前的做法是當通過OAuth登入Superset,登入的使用者名稱被改為租戶的ID,也就是一個租戶下的多個使用者在使用Superset時使用的是一個Superset使用者。這是一個安全的隱患,無法準確地追蹤使用者的行為。另外,因為Superset的Row level security只能繫結到角色上,所以每個租戶使用者又有一個獨有的角色。這樣的影響是顯而易見的:但隨著業務的增長,租戶相關的資料會越來越多,一定程度上造成管理上的混亂。
定製化改造
針對問題1,在現在的Superset(1.3.2)中,早已提供了對OAuth登入的支援,官方提供的教程也很詳細。但是在開發過程中,還是遇到了一些小問題。
針對問題2,想辦法改造這個SQL templating的文字處理邏輯,增加更多的巨集命令,來獲取當前使用者的租戶資訊。對於這個功能,官方文件只提供了一個針對Presto資料庫的文字處理改造方案,對於這部分功能改造的部落格,網上的資訊很少。但是經過摸索,還是走出了一條路。
準備環境
官方提供兩種方案,一種容器化的,另一種是本地化加虛擬環境。為了除錯方便,我採用了後者。
Superset預設使用sqlite,本地啟動的話,sqlite檔案在~/.superset/superset.db,可以使用IDEA的database皮膚開啟。資料庫schema請選擇main。
教程中提到的環境變數PYTHONPATH,可以理解為Java中的CLASS_PATH(是目錄,而不是具體的某個檔案),用於載入外部的模組(module)。因為Python是解釋型語言,所以可以在這個目錄直接放入Pythone檔案。Superset在啟動時會載入這個目錄下的superset_config.py
,並根據其中的程式碼,載入其他模組。
改造OAuth SSO
請先閱讀官方教程:傳送門(英文)。
安裝依賴
Superset接入OAuth SSO需要依賴庫Authlib,可以通過pip安裝。
pip install Authlib
對於採用容器化部署的小夥伴,要注意容器被重置時要安裝下載這個依賴。
對於喜歡多個命令列視窗的小夥伴,要注意安裝這個依賴時,要啟用superset虛擬環境(virtualenv)。
配置SSO
根據教程,我們會在superset_config.py
中選擇認證方式為OAuth,並新增鑑權服務的配置,其中配置的詳細說明如下:
from flask_appbuilder.security.manager import AUTH_OAUTH
AUTH_TYPE = AUTH_OAUTH # 選擇認證方式,注意,這個值是引用自flask_appbuilder.security.manager
OAUTH_PROVIDERS = [
{
'name': 'spring-sso', # SSO的名字,用於展示在登入頁面,格式為SIGN WITH {SSO的名字,大寫}。可以配置多個SSO。
'token_key': 'access_token', # AccessToken在ResponseBody中的名字,必須指定,用於框架儲存AccessToken。
'remote_app': {
'client_id': 'superset-client', # Superset在鑑權註冊的id
'client_secret': 'superset', # 配套的金鑰
'client_kwargs': {
'scope': 'openid' # OAuth2的scope,多個值用空格分開
},
'access_token_method': 'POST', # 請求access token介面時的HTTP方法
'access_token_params': {
# 請求access token介面附在URL上的引數,視鑑權服務的介面規範新增。可選配置。
'client_id': 'superset-client',
},
'access_token_headers': {
# 請求access token介面附在HEADER上的引數,視鑑權服務的介面規範新增。可選配置。
'Authorization': 'Basic Base64EncodedClientIdAndSecret'
},
'api_base_url': 'http://resource-server', # 資源服務API根路徑,用於獲取AccessToken後請求使用者資訊。
'authorize_url': 'http://auth-server/oauth2/authorize', # OAuth 2.0中的authorize介面
'access_token_url': 'http://auth-server/oauth2/token', # OAuth 2.0中的token介面
}
}
]
# 是否允許建立不存在的使用者。通過SSO登入的使用者有可能沒有儲存在Superset的使用者表中,如果這個配置項為False,那麼使用者將被拒絕登入。
AUTH_USER_REGISTRATION = True
# 建立時的預設許可權,只允許一個值。
AUTH_USER_REGISTRATION_ROLE = "Admin"
DEFAULT_FEATURE_FLAGS: Dict[str, bool] = {
# 當配置項AUTH_ROLES_SYNC_AT_LOGIN為True時,每次SSO登入後會將使用者資訊中的角色同步至Superset資料庫。
# 具體做法見下一節內容。
"AUTH_ROLES_SYNC_AT_LOGIN": False,
}
新增自定義的SecurityManager
Superset預設支援OAuth 2.0的登入方式有GitHub、Twitter、LinkedIn、Google等。但如果鑑權服務是自建的話,就需要編寫配套的SecurityManager,以便返回給框架正確的使用者資訊。
在PYTHONPATH下新增一個新的檔案:custom_sso_security_manager.py
,新增一個SecurityManager繼承類,覆蓋oauth_user_info
方法:
import jwt
from flask import session
from superset.security import SupersetSecurityManager
class CustomSsoSecurityManager(SupersetSecurityManager):
def oauth_user_info(self, provider, response=None):
if provider == 'spring-sso': # 判斷SSO的名字
access_token = response.get('access_token') # 從Response中獲取AccessToken
decoded = jwt.decode(access_token, verify=False) # 解析JWT
sub = decoded.get('sub') # 得到OpenId
# 向資源服務請求,通過oauth_remotes呼叫時,框架會自動在Authorization Header新增AccessToken。
# 這個AccessToken就是通過之前配置裡的token_key解析得到的。
# 這裡的路徑就是之前配置裡的api_base_url。
# 理論上資源服務和鑑權服務是分開的,但大部分的SSO vendor提供的獲取使用者資訊介面與token介面的根路徑是一致的。
# 這裡是根據業務的需要,向資源服務獲取當前使用者的租戶資訊。
user_details_resp = self.appbuilder.sm.oauth_remotes[provider].get('tenants')
# 將租戶資訊儲存在session中。
session["tenants"] = user_details_resp.json()
# 拼接成使用者資訊。
# 使用者資訊中必須要有username或email,否則日誌會丟擲異常:OAUTH userinfo does not have username or email
# 使用者資訊可以新增role_keys列表,作為使用者的角色列表。
# 當配置項AUTH_ROLES_SYNC_AT_LOGIN為True時,每次SSO登入後都將列表中的角色同步至Superset資料庫。
user_info = { 'username': sub, 'first_name': sub }
return user_info
然後再superset_config.py
追加以下幾行:
from custom_sso_security_manager import CustomSsoSecurityManager
CUSTOM_SECURITY_MANAGER = CustomSsoSecurityManager
執行一下吧
大功告成,可以試著執行一下,看看是否可以正常介面SSO。
自定義巨集命令
開啟配置
為了根據使用者的租戶資訊對查詢的資料進行過濾,需要Superset的SQL Templating和Row level security兩個特性的配合。在superset_config.py
中開啟這兩個配置:
DEFAULT_FEATURE_FLAGS: Dict[str, bool] = {
# ...
"ENABLE_TEMPLATE_PROCESSING": True,
"ROW_LEVEL_SECURITY": True,
# ...
}
清先閱讀下官方文件:SQL Templating和Row level security。
目前Superset的Row level security功能是比較完備的,可以在頁面上配置過濾的從句(Clause)。而且過濾從句可以被SQL Templating處理,所以這裡可以寫入巨集命令,只是注意這裡不需要寫上where
關鍵字。因此Row level security無需進行任何改造。
但是對於官方提供的巨集命令,還不足以支撐業務的需要(比如一個巨集命令tenants()
,從session中獲取當前使用者的租戶資訊)。所以需要對其進行擴充套件。
新增自定義巨集命令
Superset在jinja_context.py
下實現了SQL Templating,對於SQL語句中的巨集命令的替換處理,主要是通過JinjaTemplateProcessor
來實現的,對於HQL的支援是通過HiveTemplateProcessor
來實現的。後者在前者的基礎上新增了一些針對分割槽(partition)的巨集命令。
對於巨集命令的擴充套件,可以參考Superset的教程,在superset_config.py
中新增CUSTOM_TEMPLATE_PROCESSORS
:
from custom_template_processor import CustomTemplateProcessor
from superset.jinja_context import BaseTemplateProcessor
from typing import Type, Dict
CUSTOM_TEMPLATE_PROCESSORS: Dict[str, Type[BaseTemplateProcessor]] = {
"sqlite": CustomTemplateProcessor
}
CUSTOM_TEMPLATE_PROCESSORS
是一個Dict
物件,可以理解為Java中的Map
。鍵型別為str
,代表著所負責的資料庫引擎型別,在我的本地環境中,資料庫使用的是sqlite,所以這裡的寫的是sqlite。值型別是BaseTemplateProcessor
的子類,這裡我自定義了一個CustomTemplateProcessor
,儲存在同目錄的custom_template_processor.py
中:
from functools import partial
from flask import session
from superset.jinja_context import JinjaTemplateProcessor, safe_proxy
from typing import Any
def tenants() -> (): return session["tenants"]
# 只需繼承JinjaTemplateProcessor即可。
class CustomTemplateProcessor(JinjaTemplateProcessor):
# 官方的文件中給出的列子是將巨集命令的識別由{{}}改為$,所以覆蓋的是process_template。
# 現在的需要是新增新的巨集命令,所以只需覆蓋set_context方法即可。記得執行父類的方法!
def set_context(self, **kwargs: Any) -> None:
# 執行父類的方法。
super().set_context(**kwargs)
# 更新context
self._context.update(
{
# 鍵值是巨集命令表示式
# 值一定要寫為partial(safe_proxy, func, args),否則父類在更新context會丟擲安全異常
"tenants": partial(safe_proxy, tenants),
}
)
新增後,重啟服務,就可以去Row level security新增新增的巨集命令了:
tenant IN ({{ "'" + "','".join(tenants()) + "'" }})
補充說明
任何TemplateProcessor
都是單例模式,所以不要在這個類中儲存與請求或執行緒相關的狀態。
目前租戶資訊是儲存在服務session(記憶體)中,後期也可以優化為redis,或是持久化到Superset的資料庫,在每次登入時更新下。
小結
本篇部落格主要是指導如何使用Superset介入OAuth 2.0鑑權服務並從其下的資源服務獲取相關資訊,以及如何新增自定義的巨集命令。