Important
この機能は パブリック プレビュー段階です。
このページでは、外部ユーザーの埋め込みのしくみ、埋め込みダッシュボードを安全に共有できるように Azure Databricks ワークスペースを構成する方法、サンプル アプリケーションを使用して開始する方法について説明します。 外部ユーザー向けの埋め込みでは、サービス プリンシパルとスコープ付きアクセス トークンを使用して、埋め込みダッシュボードへのアクセスを認証および承認します。 このアプローチでは、パートナーや顧客など、組織外の閲覧者とダッシュボードを共有できます。これらのユーザーに対して Azure Databricks アカウントをプロビジョニングする必要はありません。
組織内のユーザー向けのダッシュボードの埋め込みなど、その他の埋め込みオプションについては、「 ダッシュボードを埋め込む」を参照してください。
外部ユーザーの埋め込みのしくみ
次の図と番号付き手順では、外部ユーザー用のダッシュボードを埋め込むと、ユーザーが認証され、ダッシュボードにユーザー スコープの結果が入力されます。
- ユーザー認証と要求: ユーザーがアプリケーションにサインインします。 アプリケーションのフロントエンドは、認証された要求をサーバーに送信して、ダッシュボード アクセス トークンを取得します。
-
サービス プリンシパル認証: サーバーは、サービス プリンシパル シークレットを使用して、Databricks サーバーから OAuth トークンを要求および受信します。 これは、Azure Databricks がサービス プリンシパルに代わってアクセスできるすべてのダッシュボード API を呼び出すことができる、広範なスコープのトークンです。 サーバーは、このトークンを使用して
/tokeninfoエンドポイントを呼び出し、external_viewer_idやexternal_valueなどの基本的なユーザー情報を渡します。 個々のユーザーにダッシュボードを安全に表示する方法に関する記事を参照してください。 -
ユーザー スコープのトークンの生成:
/tokeninfoエンドポイントと Databricks OpenID Connect (OIDC) エンドポイントからの応答を使用して、サーバーは、渡したユーザー情報をエンコードする、厳密にスコープされた新しいトークンを生成します。 -
ダッシュボードのレンダリングとデータのフィルター処理:アプリケーション ページは、
DatabricksDashboardから@databricks/aibi-clientをインスタンス化し、構築中にユーザー スコープトークンを渡します。 ダッシュボードは、ユーザーのコンテキストと共にレンダリングされます。 このトークンは、アクセスを承認し、external_viewer_idによる監査をサポートし、データ フィルター処理のexternal_valueを実行します。 ダッシュボード データセット内のクエリは、__aibi_external_valueを参照してユーザーごとのフィルターを適用できるため、各ビューアーには表示が許可されているデータのみが表示されます。
個々のユーザーにダッシュボードを安全に表示する
external_viewer_idに基づいてユーザーごとに一意のユーザー スコープ トークンを生成するようにアプリケーション サーバーを構成します。 これにより、監査ログを使用してダッシュボードのビューと使用状況を追跡できます。
external_viewer_idは、ダッシュボード データセットで使用される SQL クエリに挿入できるグローバル変数として機能するexternal_valueとペアになっています。 これにより、各ユーザーのダッシュボードに表示されるデータをフィルター処理できます。
external_viewer_id はダッシュボード監査ログに渡され、個人を特定できる情報を含めてはなりません。 この値は、ユーザーごとに一意である必要もあります。
external_value はクエリ処理で使用され、個人を特定できる情報を含 めることができます 。
次の例では、データセット クエリで外部値をフィルターとして使用する方法を示します。
SELECT *
FROM sales
WHERE region = __aibi_external_value
セットアップの概要
このセクションでは、ダッシュボードを外部の場所に埋め込むためのセットアップに必要な手順の概要を説明します。
ダッシュボードを外部アプリケーションに埋め込むには、まず Azure Databricks で サービス プリンシパル を作成し、シークレットを生成します。 サービス プリンシパルには、ダッシュボードとその基になるデータへの読み取りアクセス権が付与されている必要があります。 サーバーはサービス プリンシパル シークレットを使用して、サービス プリンシパルの代わりにダッシュボード API にアクセスできるトークンを取得します。 このトークンを使用すると、サーバーは /tokeninfo API エンドポイント、つまり、 external_value と external_viewer_id 値を含む基本的なユーザー プロファイル情報を返す OpenID Connect (OIDC) エンドポイントを呼び出します。 これらの値を使用すると、個々のユーザーに要求を関連付けることができます。
サービス プリンシパルから取得したトークンを使用して、ダッシュボードにアクセスしている特定のユーザーを対象とする新しいトークンがサーバーによって生成されます。 このユーザー スコープ トークンはアプリケーション ページに渡され、アプリケーションはDatabricksDashboard ライブラリから@databricks/aibi-client オブジェクトをインスタンス化します。 このトークンには、監査をサポートするユーザー固有の情報が含まれており、各ユーザーがアクセスを許可されているデータのみを表示するようにフィルター処理が適用されます。 ユーザーの観点から見ると、アプリケーションにログインすると、埋め込まれたダッシュボードに正しいデータ可視性で自動的にアクセスできるようになります。
レート制限とパフォーマンスに関する考慮事項
外部埋め込みには、1 秒あたり 20 ダッシュボードの読み込みのレート制限があります。 一度に 20 個を超えるダッシュボードを開くことができますが、同時に読み込みを開始できるダッシュボードは 20 個以下です。
[前提条件]
外部埋め込みを実装するには、次の前提条件を満たしていることを確認します。
- 発行されたダッシュボードに対して、少なくとも CAN MANAGE アクセス許可が必要です。 「 チュートリアル: サンプル ダッシュボードを使用して、 必要に応じてサンプル ダッシュボードをすばやく作成して発行する」を参照してください。
- Databricks CLI バージョン 0.205 以降がインストールされている必要があります。 手順については、 Databricks CLI のインストールまたは更新 を参照してください。 OAuth 認証を構成して使用するには、 OAuth ユーザーからマシンへの (U2M) 認証を参照してください。
- ワークスペース管理者は、埋め込みダッシュボードをホストできる承認済みドメインの一覧を定義する必要があります。 手順については、「 ダッシュボードの埋め込みの管理 」を参照してください。
- 埋め込みダッシュボードをホストする外部アプリケーション。 独自のアプリケーションを使用することも、提供されているサンプル アプリケーションを使用することもできます。
手順 1: サービス プリンシパルを作成する
Azure Databricks 内で外部アプリケーションの ID として機能するサービス プリンシパルを作成します。 このサービス プリンシパルは、アプリケーションに代わって要求を認証します。
サービス プリンシパルを作成するには:
- ワークスペース管理者として、Azure Databricks ワークスペースにログインします。
- Azure Databricks ワークスペースの上部バーにあるユーザー名をクリックし、[ 設定] を選択します。
- 左側のウィンドウで [ ID とアクセス ] をクリックします。
- サービス プリンシパルの横にある管理をクリックします。
- サービスプリンシパルの追加をクリックします。
- [ 新規追加] をクリックします。
- サービス プリンシパルのわかりやすい名前を入力します。
- 追加をクリックします。
- 先ほど作成したサービス プリンシパルを [ サービス プリンシパル の一覧] ページから開きます。 必要に応じて、[ フィルター テキスト入力] フィールドを使用して名前で検索します。
- [ サービス プリンシパルの詳細 ] ページで、 アプリケーション ID を記録します。 [Databricks SQL アクセス ] チェック ボックスと [ワークスペース アクセス ] チェック ボックスがオンになっていることを確認します。
手順 2: OAuth シークレットを作成する
サービス プリンシパルのシークレットを生成し、外部アプリケーションに必要な次の構成値を収集します。
- サービス プリンシパル (クライアント) ID
- クライアント シークレット
サービス プリンシパルは、OAuth シークレットを使用して、外部アプリケーションからアクセス トークンを要求するときに、その ID を確認します。
シークレットを生成するには:
- [サービス プリンシパルの詳細] ページで [シークレット] をクリックします。
- [ シークレットの生成] をクリックします。
- 新しいシークレットの有効期間の値を日数で入力します (例: 1 ~ 730 日)。
- シークレットをすぐにコピーします。 この画面を終了した後は、このシークレットをもう一度表示することはできません。
手順 3: サービス プリンシパルにアクセス許可を割り当てる
作成したサービス プリンシパルは、アプリケーションを介したダッシュボード アクセスを提供する ID として機能します。 そのアクセス許可は、ダッシュボードが共有データのアクセス許可で公開 されていない 場合にのみ適用されます。 共有データのアクセス許可が使用されている場合、発行元の資格情報はデータにアクセスします。 詳細と推奨事項については、「 認証方法の埋め込み」を参照してください。
- ワークスペース の サイドバーで [ダッシュボード] をクリックして、ダッシュボードの一覧ページを開きます。
- 埋め込むダッシュボードの名前をクリックします。 発行されたダッシュボードが開きます。
- [共有] をクリックします。
- [ 共有 ] ダイアログのテキスト入力フィールドを使用して、サービス プリンシパルを見つけてクリックします。 アクセス許可レベルを CAN RUN に設定します。 次に、[ 追加] をクリックします。
-
ダッシュボード ID を記録します。 ダッシュボード ID は、ダッシュボードの URL (
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ファイルへのパスに置き換えます。 ファイル拡張子を含めます。
注
個人を特定できる情報 (PII) を EXTERNAL_VIEWER_ID 値に含めないでください。
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