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