Superset SSO改造和自定義巨集命令

廢土王大錘發表於2022-01-21

背景

在最近的一個專案上,客戶想要為他們的多租戶(Multi-tenant)系統新增一個新的報表中心。技術選型自然沿用之前的選擇:Apache Superset,一款由愛彼迎貢獻給開源社群的框架。

關於Superset

Superset的前端是中規中矩的React,圖表功能則是使用NVD3/D3。後端沒有使用萬年Java,而是Python3。Web方面使用的是Flask框架,其他的框架沒有過多的深入瞭解。

需要解決的問題

由於之前的業務原因,之前的系統在使用者登入時,只能選擇其中的一個租戶繫結到會話中。這個模式在業務早期沒有什麼困擾,但隨著多租戶使用者的增多,系統的使用者更希望看到跨租戶的總覽資料。

為此,我們新增了一個資源服務,提供了一個介面用於查詢到當前使用者的租戶資訊。使用者的認證時通過OAuth 2.0,連線到鑑權服務。

這種變化對於原來的解決方案帶來了兩個問題:

  1. Superset需要接入資源服務所用的鑑權服務,並且在OAuth 2.0鑑權後訪問資源服務,通過介面獲取到當前使用者的租戶資訊。
  2. 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 TemplatingRow 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鑑權服務並從其下的資源服務獲取相關資訊,以及如何新增自定義的巨集命令。

相關文章