這很重要
這項功能目前處於 公開預覽版。
本頁說明外部使用者的內嵌運作方式、如何設定 Azure Databricks 工作區以安全共用內嵌儀表板,以及如何使用範例應用程式來開始使用。 外部使用者的內嵌會使用服務主體和範圍存取權杖來驗證和授權對內嵌儀表板的存取。 此方法可讓您與組織外部的檢視者共用儀錶板,例如合作夥伴和客戶,而不需要為這些使用者佈建 Azure Databricks 帳戶。
若要瞭解其他內嵌選項,包括內嵌組織內使用者的儀表板,請參閱 內嵌儀表板。
外部使用者內嵌的運作方式
下列圖表和編號步驟說明當您內嵌外部使用者的儀表板時,如何驗證使用者,以及儀表板會填入使用者範圍的結果。
- 使用者驗證和請求: 使用者登入您的應用程式。 應用程式的前端會將經過驗證的請求傳送到您的伺服器,以取得儀表板存取權杖。
-
服務主體驗證: 您的伺服器會使用服務主體秘密,從 Databricks 伺服器要求和接收 OAuth 權杖。 這是範圍廣泛的權杖,可以呼叫 Azure Databricks 代表服務主體存取的所有儀錶板 API。 您的伺服器
/tokeninfo使用此權杖呼叫端點,並傳入基本使用者資訊,例如external_viewer_id和external_value。 請參閱 安全地向個別使用者呈現儀表板。 -
使用者範圍的權杖產生: 使用端點和 Databricks OpenID Connect (OIDC) 端點的
/tokeninfo回應,您的伺服器會產生新的緊密範圍權杖,以編碼您已傳入的使用者資訊。 -
儀表板轉譯和資料篩選: 應用程式頁面會在
DatabricksDashboard建構期間從使用者範圍的權杖實@databricks/aibi-client例化並傳遞。 儀表板會使用使用者的內容呈現。 此權杖授權存取、支援稽核 、 並external_viewer_id攜帶external_value資料過濾。 儀表板資料集中的查詢可以參考__aibi_external_value以套用每個使用者篩選器,確保每個檢視者只看到他們被允許檢視的資料。
安全地向個別使用者呈現儀表板
配置您的應用程式伺服器,以根據其 為每一個 external_viewer_id使用者產生唯一的使用者範圍記號。 這可讓您透過稽核記錄追蹤儀表板檢視和使用情況。 與 external_viewer_id 配對 external_value,可作為全域變數,插入儀表板資料集中使用的 SQL 查詢中。 這可讓您篩選儀表板上顯示的每個使用者的資料。
external_viewer_id 傳遞至您的儀表板稽核記錄,且不得包含個人識別資訊。 此值也應該是每個使用者唯一的。
external_value 用於查詢處理, 可以 包含個人識別資訊。
下列範例示範如何使用外部值作為資料集查詢中的篩選器:
SELECT *
FROM sales
WHERE region = __aibi_external_value
設定概觀
本節包含您需要執行的步驟的高階概念概觀,以設定在外部位置內嵌儀表板。
若要在外部應用程式中內嵌儀表板,請先在 Azure Databricks 中建立 服務主體 ,並產生秘密。 必須授與服務主體儀表板及其基礎資料的讀取權限。 您的伺服器會使用服務主體密碼來擷取權杖,以代表服務主體存取儀表板 API。 使用此記號,伺服器會呼叫 /tokeninfo API 端點,這是傳回基本使用者設定檔資訊 (包括 和 external_valueexternal_viewer_id 值) 的 OpenID Connect (OIDC) 端點。 這些值可讓您將請求與個別使用者建立關聯。
使用從服務主體取得的權杖,您的伺服器會產生一個新權杖,其範圍限定為存取儀表板的特定使用者。 此使用者範圍的權杖會傳遞至應用程式頁面,應用程式會在其中從程式庫具DatabricksDashboard現化@databricks/aibi-client物件。 權杖會攜帶支援稽核的使用者特定資訊,並強制執行篩選,讓每個使用者只能看到他們有權存取的資料。 從使用者的角度來看,登入應用程式會自動提供對嵌入式儀表板的存取,並具有正確的資料可見性。
速率限制和效能考量
外部內嵌的速率限制為每秒 20 個儀表板載入。 您可以一次開啟 20 多個儀表板,但不能同時開始載入超過 20 個。
先決條件
若要實作外部內嵌,請確定您符合下列先決條件:
- 您必須至少擁有已發佈儀表板的 CAN MANAGE 許可權。 請參閱 教學課程: 如有必要,使用範例儀表板快速建立和發佈範例儀表板。
- 您必須安裝 Databricks CLI 0.205 版或更新版本。 如需指示,請參閱 安裝或更新 Databricks CLI 。 若要設定和使用 OAuth 驗證,請參閱 OAuth 使用者對機器 (U2M) 驗證。
- 工作區管理員必須定義可託管內嵌儀表板的已核准網域清單。 如需指示,請參閱管理儀表板內嵌。
- 用於託管內嵌儀表板的外部應用程式。 您可以使用自己的應用程式,或使用提供的範例應用程式。
步驟 1:建立服務主體
建立服務主體,以作為 Azure Databricks 內外部應用程式的身分識別。 此服務主體會代表您的應用程式驗證要求。
若要建立服務主體:
- 身為工作區管理員,登入 Azure Databricks 工作區。
- 按下 Azure Databricks 工作區頂端列中的使用者名稱,然後選取 [設定]。
- 按一下左窗格中的 身分識別和存取權 。
- 在 服務主體 旁,點選 管理。
- 按一下 新增服務主體。
- 按兩下 [新增]。
- 輸入服務主體的描述性名稱。
- 按下 新增。
- 從 [服務主體] 清單頁面開啟您剛建立的服務主體。 如有必要,請使用 篩選 文字輸入欄位 依名稱搜尋它。
- 在 [服務主體詳細資料 ] 頁面上,記錄 應用程式識別碼。確認已選取 [Databricks SQL 存取 ] 和 [工作區存取 ] 複選框。
步驟 2:建立 OAuth 密碼
產生服務主體的秘密,並收集外部應用程式所需的下列設定值:
- 服務主體 (用戶端) 識別碼
- 客戶端密碼
服務主體會在向外部應用程式要求存取權杖時,使用 OAuth 秘密來驗證其身分識別。
若要產生密碼:
- 按一下 [服務主體詳細資料] 頁面上的 [秘密]。
- 按一下 產生密碼。
- 輸入新密碼的存留期值(以天為單位)(例如,介於 1 到 730 天之間)。
- 立即複製密碼。 離開此畫面後,您無法再次檢視此密碼。
步驟 3:將許可權指派給服務主體
您建立的服務主體會做為身分識別,透過應用程式提供儀表板存取權。 只有當儀表板 未以共用 資料權限發佈時,該權限才會被套用。 如果使用共用資料許可權,發行者的認證會存取資料。 如需更多詳細資訊和建議,請參閱 內嵌驗證方法。
- 按一下工作區側邊欄中的 儀表板 以開啟儀表板清單頁面。
- 按一下您要內嵌的儀表板名稱。 已發佈的儀表板隨即開啟。
- 按一下 [分享]。
- 使用 [共用] 對話方塊中的文字輸入欄位來尋找您的服務主體,然後按一下它。 將權限層級設定為 CAN RUN。 然後,按一下 [新增]。
- 記錄 儀表板 ID。 您可以在儀表板的 URL 中找到儀表板 ID (例如
https://<your-workspace-url>/dashboards/<dashboard-id>)。 請參閱 Databricks 工作區詳細數據。
備註
如果您發佈具有個別資料權限的儀表板,則必須授與服務主體儀表板所用資料的存取權。 計算存取一律會使用發行者的認證,因此您不需要將計算權限授與服務主體。
若要讀取和顯示資料,服務主體必須至少 SELECT 具有儀表板中參考的資料表和檢視的許可權。 請參閱 誰可以管理權限?。
步驟 4:使用範例應用程式進行驗證並產生權杖
使用範例應用程式來練習從外部內嵌儀表板。 這些應用程式包括指令和程式碼,可啟動必要的權杖交換以產生作用域權杖。 下列程式碼區塊沒有相依性。 複製並儲存下列其中一個應用程式。
Python
將其複製並保存在名為 example.py的檔案中。
#!/usr/bin/env python3
import os
import sys
import json
import base64
import urllib.request
import urllib.parse
from http.server import HTTPServer, BaseHTTPRequestHandler
# -----------------------------------------------------------------------------
# Config
# -----------------------------------------------------------------------------
CONFIG = {
"instance_url": os.environ.get("INSTANCE_URL"),
"dashboard_id": os.environ.get("DASHBOARD_ID"),
"service_principal_id": os.environ.get("SERVICE_PRINCIPAL_ID"),
"service_principal_secret": os.environ.get("SERVICE_PRINCIPAL_SECRET"),
"external_viewer_id": os.environ.get("EXTERNAL_VIEWER_ID"),
"external_value": os.environ.get("EXTERNAL_VALUE"),
"workspace_id": os.environ.get("WORKSPACE_ID"),
"port": int(os.environ.get("PORT", 3000)),
}
basic_auth = base64.b64encode(
f"{CONFIG['service_principal_id']}:{CONFIG['service_principal_secret']}".encode()
).decode()
# -----------------------------------------------------------------------------
# HTTP Request Helper
# -----------------------------------------------------------------------------
def http_request(url, method="GET", headers=None, body=None):
headers = headers or {}
if body is not None and not isinstance(body, (bytes, str)):
raise ValueError("Body must be bytes or str")
req = urllib.request.Request(url, method=method, headers=headers)
if body is not None:
if isinstance(body, str):
body = body.encode()
req.data = body
try:
with urllib.request.urlopen(req) as resp:
data = resp.read().decode()
try:
return {"data": json.loads(data)}
except json.JSONDecodeError:
return {"data": data}
except urllib.error.HTTPError as e:
raise RuntimeError(f"HTTP {e.code}: {e.read().decode()}") from None
# -----------------------------------------------------------------------------
# Token logic
# -----------------------------------------------------------------------------
def get_scoped_token():
# 1. Get all-api token
oidc_res = http_request(
f"{CONFIG['instance_url']}/oidc/v1/token",
method="POST",
headers={
"Content-Type": "application/x-www-form-urlencoded",
"Authorization": f"Basic {basic_auth}",
},
body=urllib.parse.urlencode({
"grant_type": "client_credentials",
"scope": "all-apis"
})
)
oidc_token = oidc_res["data"]["access_token"]
# 2. Get token info
token_info_url = (
f"{CONFIG['instance_url']}/api/2.0/lakeview/dashboards/"
f"{CONFIG['dashboard_id']}/published/tokeninfo"
f"?external_viewer_id={urllib.parse.quote(CONFIG['external_viewer_id'])}"
f"&external_value={urllib.parse.quote(CONFIG['external_value'])}"
)
token_info = http_request(
token_info_url,
headers={"Authorization": f"Bearer {oidc_token}"}
)["data"]
# 3. Generate scoped token
params = token_info.copy()
authorization_details = params.pop("authorization_details", None)
params.update({
"grant_type": "client_credentials",
"authorization_details": json.dumps(authorization_details)
})
scoped_res = http_request(
f"{CONFIG['instance_url']}/oidc/v1/token",
method="POST",
headers={
"Content-Type": "application/x-www-form-urlencoded",
"Authorization": f"Basic {basic_auth}",
},
body=urllib.parse.urlencode(params)
)
return scoped_res["data"]["access_token"]
# -----------------------------------------------------------------------------
# HTML generator
# -----------------------------------------------------------------------------
def generate_html(token):
return f"""<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard Demo</title>
<style>
body {{ font-family: system-ui; margin: 0; padding: 20px; background: #f5f5f5; }}
.container {{ max-width: 1200px; margin: 0 auto; height:calc(100vh - 40px) }}
</style>
</head>
<body>
<div id="dashboard-content" class="container"></div>
<script type="module">
import {{ DatabricksDashboard }} from "https://cdn.jsdelivr.net/npm/@databricks/aibi-client@0.0.0-alpha.7/+esm";
const dashboard = new DatabricksDashboard({{
instanceUrl: "{CONFIG['instance_url']}",
workspaceId: "{CONFIG['workspace_id']}",
dashboardId: "{CONFIG['dashboard_id']}",
token: "{token}",
container: document.getElementById("dashboard-content")
}});
dashboard.initialize();
</script>
</body>
</html>"""
# -----------------------------------------------------------------------------
# HTTP server
# -----------------------------------------------------------------------------
class RequestHandler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path != "/":
self.send_response(404)
self.send_header("Content-Type", "text/plain")
self.end_headers()
self.wfile.write(b"Not Found")
return
try:
token = get_scoped_token()
html = generate_html(token)
status = 200
except Exception as e:
html = f"<h1>Error</h1><p>{e}</p>"
status = 500
self.send_response(status)
self.send_header("Content-Type", "text/html")
self.end_headers()
self.wfile.write(html.encode())
def start_server():
missing = [k for k, v in CONFIG.items() if not v]
if missing:
print(f"Missing: {', '.join(missing)}", file=sys.stderr)
sys.exit(1)
server = HTTPServer(("localhost", CONFIG["port"]), RequestHandler)
print(f":rocket: Server running on http://localhost:{CONFIG['port']}")
try:
server.serve_forever()
except KeyboardInterrupt:
sys.exit(0)
if __name__ == "__main__":
start_server()
JavaScript
將其複製並保存在名為 example.js的檔案中。
#!/usr/bin/env node
const http = require('http');
const https = require('https');
const { URL, URLSearchParams } = require('url');
// This constant is just a mapping of environment variables to their respective
// values.
const CONFIG = {
instanceUrl: process.env.INSTANCE_URL,
dashboardId: process.env.DASHBOARD_ID,
servicePrincipalId: process.env.SERVICE_PRINCIPAL_ID,
servicePrincipalSecret: process.env.SERVICE_PRINCIPAL_SECRET,
externalViewerId: process.env.EXTERNAL_VIEWER_ID,
externalValue: process.env.EXTERNAL_VALUE,
workspaceId: process.env.WORKSPACE_ID,
port: process.env.PORT || 3000,
};
const basicAuth = Buffer.from(`${CONFIG.servicePrincipalId}:${CONFIG.servicePrincipalSecret}`).toString('base64');
// ------------------------------------------------------------------------------------------------
// Main
// ------------------------------------------------------------------------------------------------
function startServer() {
const missing = Object.keys(CONFIG).filter((key) => !CONFIG[key]);
if (missing.length > 0) throw new Error(`Missing: ${missing.join(', ')}`);
const server = http.createServer(async (req, res) => {
// This is a demo server, we only support GET requests to the root URL.
if (req.method !== 'GET' || req.url !== '/') {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('Not Found');
return;
}
let html = '';
let status = 200;
try {
const token = await getScopedToken();
html = generateHTML(token);
} catch (error) {
html = `<h1>Error</h1><p>${error.message}</p>`;
status = 500;
} finally {
res.writeHead(status, { 'Content-Type': 'text/html' });
res.end(html);
}
});
server.listen(CONFIG.port, () => {
console.log(`🚀 Server running on http://localhost:${CONFIG.port}`);
});
process.on('SIGINT', () => process.exit(0));
process.on('SIGTERM', () => process.exit(0));
}
async function getScopedToken() {
// 1. Get all-api token. This will allow you to access the /tokeninfo
// endpoint, which contains the information required to generate a scoped token
const {
data: { access_token: oidcToken },
} = await httpRequest(`${CONFIG.instanceUrl}/oidc/v1/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Basic ${basicAuth}`,
},
body: new URLSearchParams({
grant_type: 'client_credentials',
scope: 'all-apis',
}),
});
// 2. Get token info. This information is **required** for generating a token that is correctly downscoped.
// A correctly downscoped token will only have access to a handful of APIs, and within those APIs, only
// a the specific resources required to render the dashboard.
//
// This is essential to prevent leaking a privileged token.
//
// At the time of writing, OAuth tokens in Databricks are valid for 1 hour.
const tokenInfoUrl = new URL(
`${CONFIG.instanceUrl}/api/2.0/lakeview/dashboards/${CONFIG.dashboardId}/published/tokeninfo`,
);
tokenInfoUrl.searchParams.set('external_viewer_id', CONFIG.externalViewerId);
tokenInfoUrl.searchParams.set('external_value', CONFIG.externalValue);
const { data: tokenInfo } = await httpRequest(tokenInfoUrl.toString(), {
headers: { Authorization: `Bearer ${oidcToken}` },
});
// 3. Generate scoped token. This call is very similar to what was issued before, but now we are providing the scoping to make the generated token
// safe to pass to a browser.
const { authorization_details, ...params } = tokenInfo;
const {
data: { access_token },
} = await httpRequest(`${CONFIG.instanceUrl}/oidc/v1/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Basic ${basicAuth}`,
},
body: new URLSearchParams({
grant_type: 'client_credentials',
...params,
authorization_details: JSON.stringify(authorization_details),
}),
});
return access_token;
}
startServer();
// ------------------------------------------------------------------------------------------------
// Helper functions
// ------------------------------------------------------------------------------------------------
/**
* Helper function to create HTTP requests.
* @param {string} url - The URL to make the request to.
* @param {Object} options - The options for the request.
* @param {string} options.method - The HTTP method to use.
* @param {Object} options.headers - The headers to include in the request.
* @param {Object} options.body - The body to include in the request.
* @returns {Promise<Object>} A promise that resolves to the response data.
*/
function httpRequest(url, { method = 'GET', headers = {}, body } = {}) {
return new Promise((resolve, reject) => {
const isHttps = url.startsWith('https://');
const lib = isHttps ? https : http;
const options = new URL(url);
options.method = method;
options.headers = headers;
const req = lib.request(options, (res) => {
let data = '';
res.on('data', (chunk) => (data += chunk));
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
try {
resolve({ data: JSON.parse(data) });
} catch {
resolve({ data });
}
} else {
reject(new Error(`HTTP ${res.statusCode}: ${data}`));
}
});
});
req.on('error', reject);
if (body) {
if (typeof body === 'string' || Buffer.isBuffer(body)) {
req.write(body);
} else if (body instanceof URLSearchParams) {
req.write(body.toString());
} else {
req.write(JSON.stringify(body));
}
}
req.end();
});
}
function generateHTML(token) {
return `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard Demo</title>
<style>
body { font-family: system-ui; margin: 0; padding: 20px; background: #f5f5f5; }
.container { max-width: 1200px; margin: 0 auto; height:calc(100vh - 40px) }
</style>
</head>
<body>
<div id="dashboard-content" class="container"></div>
<script type="module">
/**
* We recommend bundling the dependency instead of using a CDN. However, for demonstration purposes,
* we are just using a CDN.
*
* We do not recommend one CDN over another and encourage decoupling the dependency from third-party code.
*/
import { DatabricksDashboard } from "https://cdn.jsdelivr.net/npm/@databricks/aibi-client@0.0.0-alpha.7/+esm";
const dashboard = new DatabricksDashboard({
instanceUrl: "${CONFIG.instanceUrl}",
workspaceId: "${CONFIG.workspaceId}",
dashboardId: "${CONFIG.dashboardId}",
token: "${token}",
container: document.getElementById("dashboard-content")
});
dashboard.initialize();
</script>
</body>
</html>`;
}
步驟 5:執行範例應用程式
取代下列值,然後從終端機執行程式碼區塊。 您的值 不應 用尖括號 ()< > 括住:
- 使用 工作區 URL 來尋找並取代下列值:
<your-instance><workspace_id><dashboard_id>
- 將下列值取代為您在建立服務主體時建立的值 (步驟 2):
<service_principal_id>-
<service_principal_secret>(客戶密碼)
- 將下列值取代為與外部應用程式使用者相關聯的識別碼:
<some-external-viewer><some-external-value>
- 取代
</path/to/example>為您在上一個步驟中建立的.py或.js檔案的路徑。 包括副檔名。
備註
請勿在值中 EXTERNAL_VIEWER_ID 包含任何個人識別資訊 (PII)。
INSTANCE_URL='https://<your-instance>.databricks.com' \
WORKSPACE_ID='<workspace_id>' \
DASHBOARD_ID='<dashboard_id>' \
SERVICE_PRINCIPAL_ID='<service-principal-id>' \
SERVICE_PRINCIPAL_SECRET='<service-principal_secret>' \
EXTERNAL_VIEWER_ID='<some-external-viewer>' \
EXTERNAL_VALUE='<some-external-value>' \
~</path/to/example>
# Terminal will output: :rocket: Server running on http://localhost:3000