1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Azureにおけるクロステナント連携(Private Endpoint経由でのBlobアクセス)

1
Posted at

本記事は 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 コンテナーも作成されます。

Storage Account B のコンテナー一覧(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 「データ保護」で、変更フィードバージョン管理が有効になっていることを確認できます。

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)は使用しません

アプリ登録の証明書とシークレット(クライアントシークレット 1 件)

ステップ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 データ閲覧者このリソーススコープで付与されていることを確認できます。

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 を選べます)。

VNet のサブネット構成。subnet-func に Microsoft.App/environments の委任が設定されている

ステップ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" }

クロステナント PE が承認済み(Tenant B 側)

上のスクリーンショットでは、Tenant B の Private Endpoint が Tenant A の Storage Account の blob に対して「承認済み」になっていることが確認できます。これが本検証の山場の一つです。

Tenant A 側の Storage Account の「ネットワーク > プライベート エンドポイント」からも、同じ接続が Approved になっていることが確認できます(説明欄に承認時のメッセージが残ります)。

Tenant A 側 Storage Account の Private Endpoint 接続が 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 に解決されます。

Private DNS Zone に手動登録した A レコード。Storage A の名前が PE のプライベート IP(10.20.1.4)を指す

ステップ10: Functions を作成し VNet 統合

本検証では Functions を Flex Consumption で作成しました。Flex は VNet 統合のサブネット委任が Microsoft.App/environmentsMicrosoft.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)から継承されていることがポイントです。

Function App のネットワーク構成。送信トラフィックが subnet-func へ VNet 統合され、送信 DNS は VNet から継承される

ステップ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 / DefaultAzureCredentialAzure.CoreAzure.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 経由でコピーされた証跡です。

Tenant B の copied コンテナにコピーされた Blob

コピーされた Blob の中身もダウンロードして検証しました。Tenant A にアップロードしたテキストと完全に一致しています。

E2E cross-tenant copy test at 2026-06-11T23:24:26.7087896+09:00

デプロイされた Function App の概要です。Flex 従量課金プランで CopyChangedBlobs(タイマートリガー)が「有効」かつ「実行中」になっています。

Function App に登録された 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 で確実に運用できますが、その分自動化の作り込みが必要です

参考リンク

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?