本記事は GitHub Copilot および Microsoft Foundry を活用して作成されています。内容の正確性については各公式ドキュメントをご確認ください。
異なる Entra テナント間で Blob ストレージのデータを安全に連携する構成を、Azure CLI で実際に構築・検証した記録です。Event Grid を使わず、Azure Functions の Timer Trigger で Blob Change Feed を Pull する方式を採用しています。
なぜこの検証を実施したのか
企業の合併・買収(M&A)やグループ会社間の連携、あるいは SaaS 提供者と利用企業の間など、別々の Entra テナントに分かれた組織同士でデータを受け渡したいケースは珍しくありません。同一テナント内であればマネージドID とプライベートネットワークで完結しますが、テナント境界をまたぐと次のような論点が一気に表面化します。
- 認証: マネージドID のトークンは発行元テナントに閉じるため、そのままでは他テナントのリソースにアクセスできない。どの認証方式なら成立するのか
- ネットワーク: パブリックアクセスを閉じた状態で、Private Endpoint をテナント越しに張れるのか。DNS 解決はどうなるのか
- 権限: 連携を構築する担当者が、必ずしも Entra の管理者ロールを持っているとは限らない。サブスクリプションの所有者権限だけでどこまで実現できるのか
これらは特定の製品やシナリオに限らず、クロステナントでデータプレーン連携を設計する際に共通して問われる論点です。本記事ではこれを「Tenant A の Blob を Tenant B の Azure Functions がコピーする」という最小構成に落とし込み、どこまでが成立し、どこにポリシーの壁があるのかを実際に手を動かして確かめました。
TL;DR(先に結論)
- クロステナントでの Blob 連携は 技術的に成立する。実際に Tenant A にアップロードした Blob が、Tenant B の Functions 経由で Tenant B 側へコピーされることを確認した
- 認証は Tenant A のアプリ登録 + クライアントシークレット(
ClientSecretCredential)で実現。マネージドID はテナント境界を越えられないため、クロステナントではこの方式が確実(パスワードレスの FIC 方式は他テナントだとポリシーで制限されることが多い) - クロステナント Private Endpoint は 手動承認モデルで成立。承認は Tenant A 側で実施し、DNS は Private DNS Zone への手動 A レコード登録が必須(自動統合されない)
- Change Feed は準リアルタイム(数十秒〜分の遅延)。低遅延・リアルタイム性が求められる場合は Event Grid のイベント通知を検討すべき(公式ドキュメントでも、変更ログのリアルタイム処理には Event Grid(Blob Storage events)、ログの定期バッチ処理には Change Feed という使い分けが推奨されている)
検証で得られた「ハマりどころ」は記事末尾に 4 点まとめています。
検証の背景・要件
| 項目 | 内容 |
|---|---|
| Tenant A | Blob のアップロード元(ソース側) |
| Tenant B | Functions を実行しコピー先となる側 |
| データの流れ | A の Storage に Blob アップロード → B の Functions が A の Change Feed を Pull → 検知した BlobCreated を B の Storage にコピー |
| 制約1 | A の Storage へのアクセスは Private Endpoint 経由 |
| 制約2 | Tenant B 側は Entra ロールを持たず、サブスクリプションの所有者(Owner)権限のみ |
アーキテクチャ(最終形)
下図のように、Tenant A のアプリ登録に発行した クライアントシークレット でクロステナント認証を行う構成です。
ポイントは認証経路が 2 系統に分かれることです。
-
Tenant A(他テナント)への読み取り: アプリ登録のクライアントシークレットで
ClientSecretCredentialを生成。Private Endpoint + Private DNS により FQDN がプライベート IP に解決され、B → A の outbound のみで通信 -
Tenant B(自テナント)への書き込み: Functions のマネージドID(RBAC で
Storage Blob Data Contributor)
なぜこの認証方式なのか
クロステナントでデータプレーンにアクセスする場合、マネージドID はテナント境界を越えられません(マネージドID のトークンは発行元テナントに閉じる)。そこで本検証では、Tenant A にアプリ登録を作成し、そのクライアントシークレットを Functions のアプリ設定に格納して ClientSecretCredential で認証する方式を採用しました。Tenant A 側で必要なのはアプリ登録 + RBAC(Storage Blob Data Reader)だけで、シンプルかつ確実に成立します。
補足: 当初はパスワードレスを狙って、Tenant A のアプリに Federated Identity Credential(FIC) を設定し Tenant B の UAMI を信頼させる構成を試みましたが、Tenant A の Entra ポリシーで他テナント issuer の FIC が禁止されており断念しました(詳細はステップ4)。クロステナントの FIC はセキュリティ上の理由で制限されているテナントが多いため、クライアントシークレット方式を主軸に据えるのが現実的です。
検証手順(Azure CLI ベース)
すべて Azure CLI / PowerShell で構築し、Azure Portal は結果確認のスクリーンショットにのみ使用しています。スクリプトは連番(01-... 〜 15-...)で管理しました。
ステップ0: 前提と共通変数
# Tenant A / B のサブスクリプションID(環境に合わせて設定)
$subA = "<Tenant A のサブスクリプションID>" # ソース側
$subB = "<Tenant B のサブスクリプションID>" # Functions 側
$rg = "rg-crosstenant-je"
$loc = "japaneast"
ステップ1: Tenant B にリソースグループを作成
まず Tenant B 側にリソースグループを作成します。
# RG 作成(タグは運用方針に合わせて付与)
az group create -n $rg -l $loc `
--tags Environment=Sandbox Project=cross-tenant-blob
知見: Tenant B 側で作成するリソース(ストレージ、VNet、Functions、システム割り当てマネージドID など)は、いずれも Azure ロールの Owner だけで作成・設定できました。Entra ロール(アプリ管理者など)は不要です。
ステップ2: Tenant B に Storage Account B を作成
az account set --subscription $subB
$storageB = "stxtenantb" + -join ((48..57)+(97..122) | Get-Random -Count 5 | % {[char]$_})
az storage account create -n $storageB -g $rg -l $loc --sku Standard_LRS `
--tags Environment=Sandbox Project=cross-tenant-blob
# コピー先コンテナ
az storage container create --account-name $storageB --auth-mode login -n "copied"
作成したコンテナーは Portal の「コンテナー」から確認できます。コピー先の copied に加え、後のステップでコードが継続トークンを保存する state コンテナーも作成されます。
ステップ3: Tenant A に Storage Account A を作成し Change Feed を有効化
az account set --subscription $subA
$storageA = "stxtenanta" + -join ((48..57)+(97..122) | Get-Random -Count 5 | % {[char]$_})
az storage account create -n $storageA -g $rg -l $loc --sku Standard_LRS `
--tags Environment=Sandbox Project=cross-tenant-blob
# Change Feed とバージョニングを有効化(Pull 方式の要)
az storage account blob-service-properties update `
--account-name $storageA -g $rg `
--enable-change-feed true --enable-versioning true
# ソースコンテナ
az storage container create --account-name $storageA --auth-mode login -n "incoming"
Change Feed が有効になったことを確認します。
az storage account blob-service-properties show --account-name $storageA -g $rg `
--query "{changeFeed:changeFeed.enabled, versioning:isVersioningEnabled}" -o json
# => { "changeFeed": true, "versioning": true }
Portal の Storage Account A 「データ保護」で、変更フィードとバージョン管理が有効になっていることを確認できます。
ステップ4: Tenant A でアプリ登録とクライアントシークレットの発行
# アプリ登録 + サービスプリンシパル
$appId = az ad app create --display-name "func-xtenant-blob-reader" --query appId -o tsv
az ad sp create --id $appId
クライアントシークレットを発行します。有効期限は運用ポリシーに合わせて設定します。
$end = (Get-Date).AddDays(90).ToString("yyyy-MM-ddTHH:mm:ssZ")
az ad app credential reset --id $appId --display-name "func-xtenant-secret" --end-date $end
作成したシークレットは Portal の「証明書とシークレット」で確認できます。クロステナント認証はこのクライアントシークレットで行うため、フェデレーション資格情報(FIC)は使用しません。
ステップ5: Storage A に RBAC を付与
アプリ(SP)に Storage A スコープで Storage Blob Data Reader を付与します。
$spId = az ad sp show --id $appId --query id -o tsv
$storageAId = az storage account show -n $storageA -g $rg --query id -o tsv
az role assignment create --assignee-object-id $spId --assignee-principal-type ServicePrincipal `
--role "Storage Blob Data Reader" --scope $storageAId
Storage Account A の「アクセス制御 (IAM)」で、アプリ(func-xtenant-blob-reader)に ストレージ BLOB データ閲覧者 がこのリソーススコープで付与されていることを確認できます。
ステップ5.5: クロステナント読み取りの単体検証
Functions を作る前に、シークレット方式で本当に他テナントの Blob が読めるかを Python で先行検証しました。
# verify_read.py(抜粋):クライアントシークレットでクロステナント Blob を読む
from azure.identity import ClientSecretCredential
from azure.storage.blob import BlobServiceClient
cred = ClientSecretCredential(tenant_id=tenant_a, client_id=app_id, client_secret=password)
svc = BlobServiceClient(f"https://{storage_a}.blob.core.windows.net", credential=cred)
for b in svc.get_container_client("incoming").list_blobs():
print(b.name, b.size)
=== incoming コンテナの Blob 一覧 ===
- hello.txt (57 bytes)
=== hello.txt の中身を読み取り ===
Hello cross-tenant at 2026-06-11T22:36:43+09:00
*** クロステナント読み取り成功 ***
これで 認証方式の妥当性が確定しました。
ステップ6: Tenant B で VNet / Subnet を作成
az account set --subscription $subB
az network vnet create -g $rg -n "vnet-crosstenant" --address-prefixes "10.20.0.0/16" `
--subnet-name "subnet-pe" --subnet-prefixes "10.20.1.0/24"
# Functions VNet 統合用サブネット
az network vnet subnet create -g $rg --vnet-name "vnet-crosstenant" `
-n "subnet-func" --address-prefixes "10.20.2.0/24"
# subnet-pe に Private Endpoint を配置できるよう、PE のネットワークポリシーを無効化
#(このサブネットでは PE トラフィックに NSG/UDR を適用しない)
az network vnet subnet update -g $rg --vnet-name "vnet-crosstenant" -n "subnet-pe" `
--private-endpoint-network-policies Disabled
2 つのサブネットが作成されます。subnet-pe(10.20.1.0/24)は Private Endpoint 用、subnet-func(10.20.2.0/24)は Functions の VNet 統合用で、後のステップで Flex 用の委任(Microsoft.App/environments)が設定されます。
なお、最後の --private-endpoint-network-policies Disabled は、subnet-pe に Private Endpoint を配置するための前提設定です。Private Endpoint はサブネット内に NIC(プライベート IP)を持ちますが、この「ネットワークポリシー」が有効なままだと PE 作成が拒否されるため、無効化しています(Disabled は NSG・UDR のいずれも PE トラフィックに適用しない設定。必要に応じて NetworkSecurityGroupEnabled / RouteTableEnabled を選べます)。
ステップ7: クロステナント Private Endpoint を作成(手動接続要求)
Tenant B の subnet-pe から、Tenant A の Storage A(blob サブリソース)に対して 手動接続要求で PE を作成します。
$storageAId = "/subscriptions/$subA/resourceGroups/$rg/providers/Microsoft.Storage/storageAccounts/$storageA"
az network private-endpoint create `
--name "pe-storageA-blob" -g $rg -l $loc `
--vnet-name "vnet-crosstenant" --subnet "subnet-pe" `
--private-connection-resource-id $storageAId `
--group-id "blob" --connection-name "conn-to-storageA" `
--manual-request true `
--request-message "Cross-tenant PE verification from Tenant B"
作成直後の接続状態は Pending です(手動要求のため manualPrivateLinkServiceConnections 配下に入ります)。次のステップで Tenant A 側が承認すると、接続状態が「承認済み」に変わります。
ステップ8: Tenant A 側で PE 接続を承認
承認は Tenant A 側のオペレーションです。Portal でディレクトリを切り替えてもよいですが、今回は Azure CLI(Tenant A サブスクにアクセス可能)で承認しました。
az account set --subscription $subA
$pe = az network private-endpoint-connection list --id $storageAId -o json | ConvertFrom-Json `
| ? { $_.properties.privateLinkServiceConnectionState.status -eq "Pending" } | select -First 1
az network private-endpoint-connection approve --id $pe.id `
--description "Approved by Tenant A admin for cross-tenant verification"
承認後、Tenant B 側で状態が Approved になります。
{ "actionsRequired": "None", "status": "Approved" }
上のスクリーンショットでは、Tenant B の Private Endpoint が Tenant A の Storage Account の blob に対して「承認済み」になっていることが確認できます。これが本検証の山場の一つです。
Tenant A 側の Storage Account の「ネットワーク > プライベート エンドポイント」からも、同じ接続が Approved になっていることが確認できます(説明欄に承認時のメッセージが残ります)。
ステップ9: Private DNS Zone を設定(A レコードは手動)
クロステナント PE は DNS の自動統合が行われません。Private DNS Zone を作成し、PE のプライベート IP に対して A レコードを手動登録する必要があります。
$zone = "privatelink.blob.core.windows.net"
az network private-dns zone create -g $rg -n $zone
az network private-dns link vnet create -g $rg -n "link-vnet-crosstenant" `
-z $zone -v "vnet-crosstenant" -e false
# PE のプライベート IP(例: 10.20.1.4)を手動で A レコード登録
az network private-dns record-set a add-record -g $rg -z $zone -n $storageA -a "10.20.1.4"
これで VNet 内では Storage A の FQDN(<storageA>.blob.core.windows.net)が PE のプライベート IP に解決されます。
ステップ10: Functions を作成し VNet 統合
本検証では Functions を Flex Consumption で作成しました。Flex は VNet 統合のサブネット委任が Microsoft.App/environments(Microsoft.Web/serverFarms ではない)で、最小 /27 が要件です。
Flex Consumption Function App の作成と VNet 統合(クリックで展開)
# subnet-func の委任を Flex 用に変更
az network vnet subnet update -g $rg --vnet-name "vnet-crosstenant" -n "subnet-func" `
--delegations "Microsoft.App/environments"
# Functions 名とランタイム用ストレージ(Flex の動作に必要)
$funcName = "func-xtenant-b"
$rtStorage = "stfuncrt" + -join ((48..57)+(97..122) | Get-Random -Count 5 | % {[char]$_})
az storage account create -n $rtStorage -g $rg -l $loc --sku Standard_LRS `
--tags Environment=Sandbox Project=cross-tenant-blob
# Flex Consumption Function App を作成し VNet 統合
$vnetId = az network vnet show -g $rg -n "vnet-crosstenant" --query id -o tsv
az functionapp create -g $rg -n $funcName `
--storage-account $rtStorage `
--flexconsumption-location $loc `
--runtime dotnet-isolated --runtime-version 8.0 `
--vnet $vnetId --subnet "subnet-func"
# 全 outbound を VNet 経由に(Private DNS 解決のため)
az functionapp config appsettings set -g $rg -n $funcName `
--settings "WEBSITE_VNET_ROUTE_ALL=1" "WEBSITE_DNS_SERVER=168.63.129.16"
# Tenant A の資格情報など
az functionapp config appsettings set -g $rg -n $funcName --settings `
"TENANT_A_ID=<TenantA>" "CLIENT_ID=$appId" "CLIENT_SECRET=<secret>" `
"STORAGE_A=$storageA" "STORAGE_B=$storageB" `
"SOURCE_CONTAINER=incoming" "COPY_DEST_CONTAINER=copied"
書き込み用に Functions のシステム割り当てマネージドID を有効化し、Storage B に Storage Blob Data Contributor を付与します。
$principalId = az functionapp identity assign -g $rg -n $funcName --query principalId -o tsv
$storageBId = az storage account show -n $storageB -g $rg --query id -o tsv
az role assignment create --assignee-object-id $principalId --assignee-principal-type ServicePrincipal `
--role "Storage Blob Data Contributor" --scope $storageBId
VNet 統合は Function App の「ネットワーク」から確認できます。送信トラフィックが subnet-func に統合され、送信 DNS が VNet(Private DNS)から継承されていることがポイントです。
ステップ11: Change Feed 処理コードの実装(.NET 8 isolated)
Change Feed の読み取りは .NET の Azure.Storage.Blobs.ChangeFeed が第一級サポートです。Timer Trigger で 5 分毎に Change Feed を Pull し、BlobCreated を検知して Tenant B へコピーします。継続トークン(cursor)は Tenant B の state コンテナに保存し、処理済みを再処理しないようにします。
CopyFunction.cs(要点・クリックで展開)
// CopyFunction.cs(要点)
// Azure.Identity と Azure.Core の型衝突を避けるため extern alias を使用
extern alias AzIdentity;
using Azure.Storage.Blobs;
using Azure.Storage.Blobs.ChangeFeed;
using Azure.Storage.Blobs.Models;
using Microsoft.Azure.Functions.Worker;
[Function("CopyChangedBlobs")]
public async Task Run([TimerTrigger("0 */5 * * * *")] TimerInfo timer)
{
// Tenant A(他テナント)はクライアントシークレットで認証。FQDN は Private DNS で PE に解決される
var credA = new AzIdentity::Azure.Identity.ClientSecretCredential(tenantA, clientId, clientSecret);
var svcA = new BlobServiceClient(new Uri($"https://{storageA}.blob.core.windows.net"), credA);
// Tenant B(自テナント)はマネージドID
var credB = new AzIdentity::Azure.Identity.DefaultAzureCredential();
var svcB = new BlobServiceClient(new Uri($"https://{storageB}.blob.core.windows.net"), credB);
// cursor を Tenant B の state コンテナから復元
var cursorBlob = svcB.GetBlobContainerClient("state").GetBlobClient("changefeed-cursor.txt");
string? cursor = (await cursorBlob.ExistsAsync())
? (await cursorBlob.DownloadContentAsync()).Value.Content.ToString() : null;
var cf = svcA.GetChangeFeedClient();
var pageable = string.IsNullOrEmpty(cursor) ? cf.GetChangesAsync() : cf.GetChangesAsync(cursor);
await foreach (var page in pageable.AsPages(pageSizeHint: 50))
{
foreach (var change in page.Values)
{
if (change.EventType != BlobChangeFeedEventType.BlobCreated) continue;
var (container, blob) = ParseSubject(change.Subject); // /containers/.../blobs/...
if (container != srcContainer) continue;
using var ms = new MemoryStream();
await svcA.GetBlobContainerClient(container).GetBlobClient(blob).DownloadToAsync(ms); // A から PE 経由 DL
ms.Position = 0;
await destClient.GetBlobClient(blob).UploadAsync(ms, overwrite: true); // B へアップロード
}
await cursorBlob.UploadAsync(BinaryData.FromString(page.ContinuationToken), overwrite: true);
}
}
ビルド時のハマり:
ClientSecretCredential/DefaultAzureCredentialがAzure.CoreとAzure.Identityの両方に存在してCS0433型衝突が発生しました。Azure.IdentityパッケージにAliases="AzIdentity"を付与し、extern aliasで明示解決しました。またAzure.Storage.Blobs.ChangeFeedの preview がAzure.Storage.Blobs 12.29.0を要求するため、明示的にバージョンを揃える必要がありました。
ステップ12: デプロイ
Flex Consumption にコードをデプロイします。
func azure functionapp publish $funcName
The deployment was successful!
Functions in <FUNC_APP_NAME>:
CopyChangedBlobs - [timerTrigger]
ステップ13: エンドツーエンド動作確認
Tenant A の incoming コンテナにテスト Blob をアップロードします。
az account set --subscription $subA
az storage blob upload --account-name $storageA --auth-mode login `
-c incoming -n "e2e-test.txt" -f .\e2e-test.txt --overwrite
Timer Trigger(5 分毎)が実行されると、Change Feed から BlobCreated を検知し、Tenant B の copied コンテナへコピーされます。
# Tenant B の copied コンテナを確認
az account set --subscription $subB
az storage blob list --account-name $storageB --auth-mode login -c copied --query "[].name" -o tsv
5 分後、copied コンテナにコピーが完了しました。最終変更時刻がアップロード時刻より後(タイマー実行後)になっている点が、Functions 経由でコピーされた証跡です。
コピーされた Blob の中身もダウンロードして検証しました。Tenant A にアップロードしたテキストと完全に一致しています。
E2E cross-tenant copy test at 2026-06-11T23:24:26.7087896+09:00
デプロイされた Function App の概要です。Flex 従量課金プランで CopyChangedBlobs(タイマートリガー)が「有効」かつ「実行中」になっています。
ハマったポイント・想定リスク(検証で得た知見)
| # | 知見 | 対処 |
|---|---|---|
| 1 | Tenant B 側のリソース作成(Functions やシステム割り当て MI を含む)は Azure ロールの Owner だけで可能。Entra ロールは不要 | Tenant B 側の権限要件を Azure ロールの Owner のみに抑えられる |
| 2 | マネージドID はテナント境界を越えられず、他テナント issuer の FIC も制限されることが多い | クライアントシークレット方式にフォールバック |
| 3 | クライアントシークレットでの クロステナント Blob 読み取りは成立 | 事前に Python で単体検証 |
| 4 | クロステナント PE は手動承認 + DNS 手動登録が必須(自動統合なし) | A 側で承認、Private DNS に A レコードを手動登録 |
リアルタイム性については、Change Feed は数十秒〜分の遅延があるため、即時連携が必要なユースケースには向きません。公式ドキュメントでも、変更イベントをリアルタイムに処理したい場合は Event Grid、ログを定期的にバッチ処理したい場合は Change Feed という使い分けが推奨されています。今回は Event Grid を使わない方針のため Change Feed を選びましたが、低レイテンシが要件なら Event Grid のイベント通知を検討すべきです。
まとめ
- Event Grid を使わず、Functions の Timer Trigger + Blob Change Feed の Pull 方式でクロステナント Blob コピーが成立することを確認しました
- ただし実運用では、マネージドID のテナント越え不可・他テナント FIC の制限といった、クロステナント特有の制約に随所で直面します
- これらは技術的というよりガバナンス/ポリシー設計の問題であり、検証前にテナント側のポリシーを把握しておくことが重要だと痛感しました
- クロステナント Private Endpoint は手動承認・手動 DNS で確実に運用できますが、その分自動化の作り込みが必要です










