はじめに
GitHub Actions が Azure へ OIDC で認証するための ID も、Terraform で管理できます。ただしCI の認証に使う ID を、その CI 自身で作ることはできません。最初の 1 回は CI の外で用意します。Terraform を手元から実行して作る方法もありますが、私は Azure CLI のスクリプトで作り、あとから import で Terraform に取り込みました。
本記事では、Azure AD(Entra ID)のアプリ登録、フェデレーション資格情報、サービスプリンシパル、ロールといった CI 用の ID を、Terraform の import で管理下に置いたときに、つまずいた点と対処を紹介します。import 自体の使い方は「Terraform importで既存リソースをコード管理する」、ワークロード ID フェデレーションの構成そのものはGitHub ActionsからAzureへOIDCで認証する手順で解説しています。
本記事のまとめ
- 作成直後に読み戻すと、Microsoft Graph の反映待ちで 404 になります。
- CI 自身の ID は別の state に分け、CI からは plan だけにします。
ownersに実行者を入れると、誰が実行したかで plan の結果が変わります。Apply complete!は実体が変わった保証ではありません。apply の後は必ず plan し直します。

Plan: 8 to import, 0 to add, 1 to change, 0 to destroy. と、取り込む 8 件のリソースが並んでいるCI 用の ID は最初だけ人が作る
CI 用の ID を人が作る理由は 2 つあります。1 つめは、CI の認証に使う ID を、その CI 自身では作れないためです。2 つめは、Microsoft Graph のアプリケーション権限への同意には、Privileged Role Administrator 以上の強いロールが要るためです。この強いロールを CI に持たせたくありませんでした。
私は Azure CLI のスクリプトで作りました。最初の実行は、アプリ登録を作った直後に次のエラーで止まりました。
ERROR: Resource '<作成したアプリの object ID>' does not exist or one of its
queried reference-property objects are not present.エラーに出ている ID は、いま作ったアプリ自身のものでした。作成は成功しており、直後に読み戻そうとした GET が Microsoft Graph の反映待ちで 404 になっていました。Terraform でアプリ登録を作ったときにも、同じ原因で apply が失敗しています。
スクリプトは次のように直しました。
- 作成したときの応答から ID を受け取り、読み戻しをやめる。
- 次の処理の前に、GET が通るまで 5 秒おきに待つ。変更系のコマンドは失敗したら数回やり直す。
- 同じ名前のアプリが 2 つ以上あれば止める。Entra ID の表示名には一意の制約が無く、失敗後にやり直すと同名のアプリが 2 つできうるためです。
この 3 点に対応する部分を、スクリプトから抜き出します。
readonly APP_DISPLAY_NAME="github-actions-azure-terraform"
readonly PROPAGATION_ATTEMPTS=40
readonly PROPAGATION_DELAY=5
# Entra ID の表示名に一意の制約は無い。反映待ちで失敗したあとに
# やり直すと二重に作られるため、2 つ以上あればここで止める。
existing_count="$(az ad app list --display-name "${APP_DISPLAY_NAME}" --query 'length(@)' -o tsv)"
if [[ "${existing_count}" -gt 1 ]]; then
echo "同名のアプリ登録が ${existing_count} 件ある。重複を解消してから再実行する" >&2
exit 1
fi
# 作成の応答から appId と object ID を受け取る。az ad app show で読み戻すと
# 反映待ちで 404 になる。-o tsv は値を改行で区切って返すので 1 行ずつ読む。
{ read -r APP_ID; read -r APP_OBJECT_ID; } <<<"$(az ad app create \
--display-name "${APP_DISPLAY_NAME}" \
--sign-in-audience AzureADMyOrg \
--query "[appId, id]" -o tsv)"
# 次の処理に進む前に、GET が通るまで待つ。
wait_for_graph_object() {
local url=$1 i
for ((i = 1; i <= PROPAGATION_ATTEMPTS; i++)); do
az rest --method GET --url "${url}" --output none 2>/dev/null && return 0
sleep "${PROPAGATION_DELAY}"
done
return 1
}
wait_for_graph_object "https://graph.microsoft.com/v1.0/applications/${APP_OBJECT_ID}"スクリプトを書くときに踏んだ Azure CLI の挙動もあります。-o tsv で 2 つの値を出すと、タブ区切りではなく改行区切りになりました。read -r A B で受けると 2 つ目が空になるので、1 行ずつ読む形に変えています。

GET が通るまで待ってから次の処理へ進む作った ID を import で Terraform に取り込む
人が作った ID は、Terraform の import ブロックで state に取り込みました。取り込む前に Azure 側の実体を調べ、それに合わせて書くと、取り込んだ時点で差分が出ません。
resource "azuread_application_registration" "github_actions" {
display_name = "github-actions-azure-terraform"
sign_in_audience = "AzureADMyOrg"
# Azure CLI は設定しないため、import で 0 -> 2 の差分が出る(機能への影響はない)
requested_access_token_version = 2
}
import {
to = azuread_application_registration.github_actions
id = "/applications/APP_OBJECT_ID"
}取り込みで気をつけた点は次のとおりです。
- import ID の形式: Graph の権限の付与(
azuread_app_role_assignment)は、付与される側ではなく、権限を提供する Microsoft Graph 側のサービスプリンシパルの ID で指定します。/servicePrincipals/GRAPH_SP_OBJECT_ID/appRoleAssignedTo/ASSIGNMENT_IDの形です。 - 権限の「要求」と「付与」は別のリソース: アプリが要求する権限は
azuread_application_api_access、管理者が実際に付与した権限はazuread_app_role_assignmentです。数え漏れていたので、取り込む数を数え直しました。 - Microsoft Graph 自身のサービスプリンシパル:
dataで参照し、state には入れません。 - 差分が 1 つだけ残る:
requested_access_token_versionは Azure 側が未設定で、provider が既定値の2を入れようとします。API として公開していないアプリなので影響は無く、2を明示しました。
azuread provider v3.9.0 では、1 つのリソースにまとめて書く azuread_application の機能が、16 本の分かれたリソースに分割されています。属性を足すときは、それがアプリ登録の属性か、分かれたリソースかを先にスキーマで確かめるようにしました。取り込み済みの import ブロックは消さずに残し、通常の apply 以外で作ったリソースであることをコードに残しています。

azuread_application_api_access、付与が azuread_app_role_assignment と別のリソースになるCI 自身の ID は別の state に分ける
CI 用の ID は、Entra ID のほかの設定とは別のディレクトリと state に置きました。CI が apply する state に、CI 自身の ID を入れないためです。
terraform apply はディレクトリ全体が対象です。同じ state に CI 自身の ID があると、そこに差分が出た瞬間に CI が止まり、しかも CI 自身では直せません。state を分ければ、CI が apply する対象に CI の ID が物理的に存在しなくなります。
このディレクトリも、PR を作ると CI で plan が走ります。差分をレビューで見られるようにするためです。apply はローカルで行い、CI からは実行できないようにしています。ほかの環境は、PR に terraform apply dev とコメントして GitHub Actions から apply します。
ただし、state を分けるだけでは足りません。Microsoft Graph の書き込み権限はテナント全体に効くため、CI に書き込み権限があれば、state と関係なく API から自分のアプリを変更できます。state の分割は Terraform 経由の事故を、権限の絞り込みは API を直接呼ぶ事故を防ぎます。役割が違うので両方必要です。

state へのロール割り当ては Terraform の外に置く
CI に state を読み書きさせるロール割り当ても、最初は Terraform で管理しようとしました。しかし CI の plan が、自分自身のロール割り当てを読めずに 403 で失敗しました。
403 AuthorizationFailed: The client ... does not have authorization to perform action
'Microsoft.Authorization/roleAssignments/read' over scope '.../containers/tfstate/...'Storage Blob Data Contributor はデータを扱うためのロールで、ロール割り当てを読む権限を含みません。読めるようにするには state 側のリソースグループに閲覧以上の権限が要り、せっかく絞った権限が広がります。
state を置くストレージアカウント自体も、もともと Terraform の外で管理していました。自分が動くための土台を自分で管理すると循環するためで、このロール割り当ても同じ理由で Terraform の外に置きました。作るのは最初のスクリプトだけです。

Microsoft.Authorization/roleAssignments/read の権限不足で plan が止まる。サービスプリンシパルの object ID は <SP-ID> に伏せたowners に実行者を入れると plan が変わる
CI で plan を回すと、ローカルでは出なかった差分が 2 件出ました。どちらも owners が「人」から「CI のサービスプリンシパル」に置き換わる差分です。
原因は、既存のコードが owners = [data.azuread_client_config.current.object_id] と書いていたことでした。この data source は「いま Terraform を実行している主体」を返します。ローカルなら人、CI なら CI のサービスプリンシパルになり、実行者によって plan の結果が変わっていました。
ローカルで気づけなかったのは、実行者が人だったことに加え、-target で新しいリソースだけに絞って plan していたためです。実行者が違い、-target も付けない CI の plan で初めて見えました。
最終的に、owners は設定しないことにしました。このテナントでは全体管理者がすべてを管理でき、オーナーを付けても管理できる範囲が変わらないためです。CI のサービスプリンシパルをオーナーにするのは避けました。アプリ登録のオーナーはそのアプリに client secret を追加できるので、CI が OIDC だけの認証を迂回できてしまいます。

azuread_group と azuread_service_principal の 2 件で owners が実行者から CI のサービスプリンシパルに置き換わっている。object ID は <HUMAN-OBJECT-ID> と <CI-SERVICE-PRINCIPAL-OBJECT-ID> に伏せたapply が成功しても実体が変わっていないことがある
owners を外す変更を apply すると、0 added, 2 changed と成功が報告されました。しかし plan し直すと、1 件の差分が戻ってきました。
Azure 側を確かめると、サービスプリンシパルのオーナーは消えていましたが、グループのオーナーは残っていました。手作業で消そうとすると、次のように拒否されました。
The group must have at least one owner, hence this owner cannot be removed.Entra ID のグループは、オーナーを 0 人にできませんでした。provider はこの拒否を伝えずに、成功として報告していたことになります。azuread_group の owners は、引数を書かなければ Azure 側の値を受け入れて差分が出ません。グループについては引数を書かない形に落ち着きました。
この経験から、apply の後は必ず plan し直し、No changes になることを確かめています。Apply complete! は Terraform が成功したと認識しているだけで、実体が変わった保証にはなりません。

Apply complete! は実体が変わった保証にならないため、apply の後は plan し直すまとめ
CI 用の ID は、最初だけ人が作り、import で Terraform に取り込み、CI からは plan だけにする形に落ち着きました。
- 作成直後の読み戻しは Microsoft Graph の反映待ちで失敗します。応答から ID を受け取り、待ってから次へ進みます。
- CI 自身の ID は別の state に分け、state へのロール割り当ては Terraform の外に置きます。
- 実行者に依存する値を設定に入れず、apply の後は plan し直して
No changesを確かめます。

