Terraform から OpenTofu へ移行するついでに、Cloudflare の認証を Global API Key からスコープを絞った API トークンに変えることにした。Global API Key はアカウント全権で、課金の変更もアカウント削除もできてしまう。IaC のためだけに持たせておく権限ではない。

移行そのものは1時間もかからなかった。.tf はそのまま使えるし、state の形式も同じなので、実質やることは provider の書き換えだけだ。

問題はトークンだった。必要な権限を揃えるのに4時間かかった。

原因は自分の理解不足ではなく、Cloudflare 側に3つあった。同じところで溶かす人がいそうなので書いておく。

結論から

  • どの権限がどの API を開けるのかは公開されていない。 名前から推測すると外れる
  • 管理画面上の権限名と、API 上の権限名が違う。 「Cache Rules」で探しても Cache Settings Write は見つからない
  • 管理画面は、権限を追加保存するたびに以前追加した別の行を落とす。 3回連続で再現した

対策は「UI を使わず API でトークンを作る」。そして「作ったら必ずエンドポイントを叩いて確認する」。権限が足りなくてもトークンの作成自体は成功してしまうので、確認しないと気づけない。

罠1: 権限とエンドポイントの対応が公開されていない

最初は素直にやった。管理下のリソースを数えて、対応しそうな権限を並べる。DNS レコードがあるから DNS、Access があるから Access、という具合に。

そして tofu plan を打つと、権限が足りないリソースだけがエラーになる。

Error: error getting email routing settings "...": Authentication error (10000)

Email Routing の設定が読めない。当然「Email Routing」の権限は付けてある。付けたのに読めない。

答えはこうだった。

✅ /zones/{zone}/email/routing/rules      → Email Routing Rules で読める
❌ /zones/{zone}/email/routing            → Zone Settings が要る
❌ /accounts/{account}/email/routing/addresses → アカウント階層の別権限が要る

Email Routing の「設定」は、Email 系の権限ではなく「ゾーン設定」で守られていた。 これは名前から推測できない。しかも宛先アドレスだけがアカウント階層にあり、ゾーンの権限では絶対に届かない。

Ruleset も同じだった。3つ管理していて、2つは読めるのに1つだけ読めない。

✅ http_request_firewall_custom  → Zone WAF
✅ http_ratelimit                → Zone WAF
❌ http_request_cache_settings   → 別の権限

同じ cloudflare_ruleset というリソースなのに、フェーズによって必要な権限が違う。

Access も2つに分かれていた。アプリとポリシーは「Access: Apps and Policies」で読めるが、ID プロバイダー(Google SSO の設定)は別枠だった。しかも権限が無いとエラーにならず、一覧が空で返ってくる。「設定が消えたのか?」と一瞬焦った。

公式ドキュメントの権限一覧には、各権限の説明として「write access to Email Routing Addresses」のような一文があるだけで、どのエンドポイントに対応するかは書かれていない。Terraform provider 側でも「権限が文書化されていない」という issue が立っている。

罠2: UI の権限名と API の権限名が違う

http_request_cache_settings フェーズの ruleset に必要な権限を探したとき、私は「Cache Settings」だろうと当たりをつけた。フェーズ名がそうだからだ。

管理画面で「キャッシュ」と検索すると、出てくるのは 「キャッシュパージ」と「Cache Rules」の2つだけ。「Cache Settings」は無い。

正解は「Cache Rules」だった。そして API から権限カタログを引くと、こう入っている。

xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx  Cache Settings Write

UI では「Cache Rules」、API では「Cache Settings Write」。同じものだ。 どちらか片方しか知らないと、検索しても永遠に見つからない。

罠3: 保存すると、前に追加した行が消える

これが一番きつかった。

足りない権限が判明する → 管理画面で1行足して保存する → 再確認すると、別の行が消えている

実際に起きた順番はこうだ。

  1. 「ゾーン設定」を追加 → 保存 → 「Email Routing アドレス」が消えた
  2. 「Email Routing アドレス」を追加 → 保存 → 「ゾーン設定」が消えた
  3. 「Cache Rules」を追加 → 保存 → 「ゾーン設定」と「Access IdP」が消えた

3回連続。しかも消えたことは画面上では分からない。エンドポイントを叩いて初めて気づく。

「さっき直したはずなのに」を3回繰り返したところで、UI と戦うのをやめた。

対策: API でトークンを作る

管理画面を使わなければ、行が消えることはない。

手順1: 権限カタログを取得する

まず、Cloudflare が持っている権限グループの一覧を引く。ここだけは Global API Key が要る(トークン自身にトークン作成権限を持たせない方が良いので、この一回だけ使う)。

bash -c 'read -rsp "Global API Key: " K; echo; curl -s \
  -H "X-Auth-Email: you@example.com" -H "X-Auth-Key: $K" \
  https://api.cloudflare.com/client/v4/user/tokens/permission_groups > /tmp/pg.json'

jq -r '.result | length' /tmp/pg.json

私の環境では 396件あった。この中に、UI に出てくる名前とは違う正式名称と、その ID が入っている。

なお、この記事では権限 ID を xxxx… に伏せてある。全アカウント共通の定数なので秘密ではないが、32文字の16進を貼られても、それが共通の定数なのか誰かのアカウント固有の識別子なのか、見ただけでは判別できない。どのみち下のコマンドで自分で引けるものなので、そちらを見てほしい。

jq -r '.result[] | "\(.id)\t\(.name)\t\(.scopes|join(","))"' /tmp/pg.json | grep -i cache
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx  Cache Purge            com.cloudflare.api.account.zone
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx  Cache Settings Read    com.cloudflare.api.account.zone
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx  Cache Settings Write   com.cloudflare.api.account.zone

scopes を見れば、その権限がアカウント階層かゾーン階層かも一目で分かる。UI では左端のドロップダウンを切り替えないと候補にすら出てこない情報だ。

手順2: トークンを作る

必要な ID を集めて POST する。ポリシーはアカウント階層とゾーン階層で分ける

{
  "name": "opentofu",
  "policies": [
    {
      "effect": "allow",
      "resources": { "com.cloudflare.api.account.<ACCOUNT_ID>": "*" },
      "permission_groups": [
        { "id": "<Email Routing Addresses Write の ID>" },
        { "id": "<Access: Apps and Policies Write の ID>" }
      ]
    },
    {
      "effect": "allow",
      "resources": { "com.cloudflare.api.account.zone.<ZONE_ID>": "*" },
      "permission_groups": [
        { "id": "<DNS Write の ID>" }
      ]
    }
  ]
}
curl -s -X POST \
  -H "X-Auth-Email: you@example.com" -H "X-Auth-Key: $K" \
  -H "Content-Type: application/json" --data @token.json \
  https://api.cloudflare.com/client/v4/user/tokens

レスポンスの .result.value が新しいトークンだ。この一度しか表示されない。

この方法だと、対象リソースも正確に絞れる。UI で作ると既定が「すべてのゾーン」になりがちだが、JSON なら必要なゾーンだけを明示的に書ける。

手順3: 必ず疎通確認する

権限が足りなくても、トークンの作成は成功する。 ここが厄介なところで、実際に API を叩くまで穴に気づけない。

なので、使う予定のエンドポイントを全部叩いて確認する。

T=$(cat token.txt)
probe() {
  local code=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer $T" "$2")
  printf "  %s %-28s HTTP %s\n" "$([ "$code" = 200 ] && echo|| echo ❌)" "$1" "$code"
}
B=https://api.cloudflare.com/client/v4
probe "DNS"          "$B/zones/$ZONE/dns_records?per_page=1"
probe "ゾーン設定"    "$B/zones/$ZONE/settings"
probe "Email 設定"   "$B/zones/$ZONE/email/routing"
probe "Hyperdrive"   "$B/accounts/$ACC/hyperdrive/configs"
# ... 使うぶんだけ並べる

私は最終的に17個のエンドポイントを並べて、全部 ✅ になるまで回した。tofu plan を打っては1件ずつエラーを潰すより、はるかに速い。

ひとつ注意点として、権限の変更が反映されるまで30秒〜1分かかる。追加した直後に叩くと 403 が返ってきて「また消えた」と勘違いする。私はこれで一度無駄に混乱した。

実測で確定した対応表

自分の環境(DNS、Access、Tunnel、Hyperdrive、Turnstile、R2、Email Routing、Ruleset)で必要だったものを、全部実測で確定させた。API 上の名称で書く。

Terraform / OpenTofu リソース必要な権限(API 名)階層
cloudflare_recordDNS Writezone
(ゾーン情報の読み取り)Zone Readzone
cloudflare_ruleset(firewall_custom / ratelimit)Zone WAF Writezone
cloudflare_rulesetcache_settingsCache Settings Writezone
cloudflare_email_routing_settingsZone Settings Writezone
cloudflare_email_routing_catch_allEmail Routing Rules Writezone
cloudflare_email_routing_addressEmail Routing Addresses Writeaccount
cloudflare_zero_trust_access_application / _policy / _service_tokenAccess: Apps and Policies Writeaccount
cloudflare_zero_trust_access_identity_providerAccess: Organizations, Identity Providers, and Groups Writeaccount
cloudflare_zero_trust_tunnel_cloudflaredCloudflare Tunnel Writeaccount
cloudflare_hyperdrive_configHyperdrive Writeaccount
cloudflare_turnstile_widgetTurnstile Sites Writeaccount
cloudflare_r2_bucketWorkers R2 Storage Writeaccount

太字にしたものが、名前から推測できなかったものだ。13個のうち4個。3割が外れるなら、それはもう推測でやる作業ではない。

移行そのものは拍子抜けするほど簡単だった

トークンで消耗したが、肝心の OpenTofu への移行は本当にあっさり終わった。

.tf はそのまま。state もそのまま読める。tofu init して tofu state list を打つと、Terraform のときと同じリソースが同じ数だけ並ぶ。

Terraform: 87 件
OpenTofu : 87 件(完全一致、serial も lineage も不変)

そして目的だった state の暗号化は、設定ブロックを足すだけだ。

terraform {
  encryption {
    key_provider "pbkdf2" "main" {
      passphrase = var.state_passphrase
    }
    method "aes_gcm" "main" {
      keys = key_provider.pbkdf2.main
    }
    state {
      method   = method.aes_gcm.main
      enforced = true
    }
  }
}

既存の平文 state から移行するときだけ、一時的に unencrypted メソッドを噛ませる。

method "unencrypted" "migrate" {}

state {
  method = method.aes_gcm.main
  fallback { method = method.unencrypted.migrate }
}

この状態で tofu apply -refresh-only を打つと state が暗号化されて書き直される。あとは fallback を外して enforced = true にすれば完了だ。

暗号化前の state には、データベースのパスワードもサービストークンも平文で入っていた。Terraform には state を暗号化する手段が無く、sensitive = true を書いても CLI の出力に出なくなるだけで state には平文で書かれる。ずっと気持ち悪かったので、これが消えたのは大きい。

暗号化後は、パスフレーズが無いと state list すら通らない。

Error: Unable to compute static value
encryption.key_provider.pbkdf2.main depends on var.state_passphrase

これが正しい挙動だ。

まとめ

Cloudflare の API トークンは、最小権限を実践しようとすると急に難易度が上がる。権限の設計が悪いのではなく、対応関係が公開されていないのが問題だと思う。カタログ API から名前と ID は引けるので、あと一歩、エンドポイントとの対応表さえ公開されれば誰も困らない。

同じところで詰まったら、こうしてほしい。

  1. 管理画面で当てものをしない。 カタログ API を引いて正式名称を確認する
  2. トークンは API で作る。 UI の編集は行が消える
  3. 作ったら必ずエンドポイントを叩く。 権限が足りなくても作成は成功する
  4. 反映を1分待つ。 直後の 403 は本物とは限らない

そして Global API Key を使っているなら、この機会に手放したほうがいい。4時間かかっても、アカウント全権の鍵を1本減らせたのは割に合う取引だった。