Azure ADアプリ登録をTerraform importで管理する

手で作った Azure AD のアプリ登録が、曲線をたどって Terraform の state の箱に入っていくイラスト。画像内の文字は Terraform import、Azure AD App Registration、Created by Hand、Managed by Code 認証・IAM

はじめに

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 し直します。
GitHub Actions が PR に投稿した Terraform の plan 結果コメント。8 件を import、1 件を変更すると表示されている
CI 用 ID を取り込む PR に GitHub Actions が投稿した 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 行ずつ読む形に変えています。

作成スクリプトと Microsoft Graph のあいだの順序図。az ad app create が 201 を返した直後の GET が 404 になり、5 秒おきに待ち直して 200 が返るまでを示している
アプリ登録を作った直後の読み戻しが、Microsoft Graph の反映待ちで 404 になる。作成は成功しているため、応答から ID を受け取り、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 以外で作ったリソースであることをコードに残しています。

Azure portal のアプリ登録「API のアクセス許可」画面。Microsoft Graph の 4 つの権限が一覧になり、それぞれの状態列に管理者の同意済みと表示されている
アプリが要求した Microsoft Graph の権限と、その右の「状態」列に出る管理者の同意済みの表示。画面では 1 つの表に並んでいるが、Terraform では要求が 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 を直接呼ぶ事故を防ぎます。役割が違うので両方必要です。

CI 用 ID の state とほかの環境の state を並べた図。CI は左の state には plan だけ、右の state には plan と apply ができる。下にテナント全体に効く Microsoft Graph の書き込み権限の帯があり、state の境界を越えて左の state のリソースに届く矢印が引かれている
state を分けると、CI が apply する対象から CI 自身の ID が消える。ただし Microsoft Graph の書き込み権限はテナント全体に効くため、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 の外に置きました。作るのは最初のスクリプトだけです。

CI の plan ジョブが Microsoft.Authorization/roleAssignments/read の権限不足で失敗し、PR に Plan Failed が投稿された画面。サービスプリンシパルの ID は SP-ID と伏せてある
state の置き場へのロール割り当てを Terraform の管理下に置いたときの失敗。CI が自分のロール割り当てを読めず、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 だけの認証を迂回できてしまいます。

plan の差分で、2 件のリソースの owners が HUMAN-OBJECT-ID から CI-SERVICE-PRINCIPAL-OBJECT-ID に置き換わっている
CI が実行した plan の差分。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 が 0 added, 2 changed と成功を報告する一方で、Azure 側ではサービスプリンシパルの所有者だけが消え、グループの所有者は削除を拒否されている。再 plan で 1 件の差分が戻ることを示した図
グループの所有者は Azure 側で削除を拒否されたが、その拒否は Terraform に伝わらない。Apply complete! は実体が変わった保証にならないため、apply の後は plan し直す

まとめ

CI 用の ID は、最初だけ人が作り、import で Terraform に取り込み、CI からは plan だけにする形に落ち着きました。

  • 作成直後の読み戻しは Microsoft Graph の反映待ちで失敗します。応答から ID を受け取り、待ってから次へ進みます。
  • CI 自身の ID は別の state に分け、state へのロール割り当ては Terraform の外に置きます。
  • 実行者に依存する値を設定に入れず、apply の後は plan し直して No changes を確かめます。