Terraform Provider for Azure Key Vault を作った 〜Azure の Key Vault secret を Terraform で安全に管理したい〜

Azure の Key Vault secret (AWS でいう paramete store) を Terraform で管理できると次のようなメリットがあります。

  • secret の作成・変更を pull request のレビューフローに乗せることができる
    • ただし secret value は apply 時に指定したり、Terraform では仮の値を指定して別途本当の値で更新したりする想定
  • CI 等で terraform plan/apply するようにしていれば secret の操作に関する権限がない人でも作成・変更できる
    • secret value の更新に関しては同上
  • Terraform file を見るだけでどのような secret があるか確認できる
    • 謎に作成された secret の存在に気付ける
  • 権限のスコープを設定する際に特定の secret に対する read 権限を手軽に付与できる
    • resource ID を手軽に取得できる

ところが、Terraform で Key Vault secret を普通に管理すると、state file に secret value が保存されるという問題があります。
Terraform の state file に一部の attribute を保存しないようにしたいという話は 10 年以上も前から話題になっています。1

その対策として、Terraform 1.11 では write-only arguments が導入され、Terraform Provider for Azure の azurerm_key_vault_secret リソースでも write-only argument である value_wo が導入されました。2

ここで、write-only arguments を使わなくても ignore_changes を使えば良いじゃないかと思った方もいるでしょうが、ignore_changes だと state file に実際の値が保存されます。詳細は [Terraform] 誤解されがちなignore_changesの動き・機密情報はstateに保持されるのか? | DevelopersIO を読んでみてください。

azurerm_key_vault_secretvalue_wo を使えば問題ないかというとそうでもなく、value_wo を使ったとしても Microsoft.KeyVault/vaults/secrets/getSecret/action の権限が必要で、terraform plan を実行できる権限を付与するには secret value を取得する権限を付与することになり使い勝手が悪いです。

というわけで、value_wo を使っている場合に secret value の取得を不要にする PR を 3 ヶ月前に出したんですが、いつまで経ってもレビューされる気配はなく、半年以上前に出した簡単な bugfix PR も同様で、Slack も閑散としていて、レビューしてくれとたまにメッセージを投稿する人がいてもスルーという状態で、マージされるにはまだまだ時間がかかりそうです。

そんなわけで、Microsoft.KeyVault/vaults/secrets/getSecret/action の権限なしで secret を管理できる terraform-provider-azurekv を作りました。

使い方

基本的な使い方はドキュメントを参照してください。
基本的に azurerm_key_vault_secret と同じ使い方ができますが、soft delete 済みの secret を復活させたり、purge させる機能はサポートしていません。

azurerm_key_vault_secret からのマイグレーション

例えば次のようなリソースが定義されていたとします。

resource "azurerm_key_vault_secret" "example" {
  name         = "example"
  key_vault_id = var.key_vault_id

  value_wo         = "This value is manged outside of Terraform"
  value_wo_version = 1
}

この場合、まずは azurerm_key_vault_secret の内容をコピペして、コピーした内容の azurerm_key_vault_secretazurekv_secret に変更し、import ブロックでインポートします。

resource "azurerm_key_vault_secret" "example" {
  name         = "example"
  key_vault_id = var.key_vault_id

  value_wo         = "This value is manged outside of Terraform"
  value_wo_version = 1
}

resource "azurekv_secret" "example" {
  name         = "example"
  key_vault_id = var.key_vault_id

  value_wo         = "This value is manged outside of Terraform"
  value_wo_version = 1
}

import {
  id = azurerm_key_vault_secret.example.id
  to = azurekv_secret.example
}

これで一度 terraform apply を実行し、次に azurerm_key_vault_secret.example を削除します。

resource "azurekv_secret" "example" {
  name         = "example"
  key_vault_id = var.key_vault_id

  value_wo         = "This value is manged outside of Terraform"
  value_wo_version = 1
}

removed {
  from = azurerm_key_vault_secret.example

  lifecycle {
    destroy = false
  }
}

最後に不要になった removed ブロックを削除すれば移行完了です。

その他の選択肢

Azure provider

Microsoft.KeyVault/vaults/secrets/getSecret/action の権限が必要な時点で選択肢にはないんですが、ignore_changes の意味がないことを確認します。

terraform {
  required_version = ">=1.0"
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "=4.41.0"
    }
  }
}

provider "azurerm" {
  features {}
}

resource "azurerm_key_vault_secret" "example" {
  name         = "example"
  key_vault_id = var.key_vault_id

  value = "This value is manged outside of Terraform"

  lifecycle {
    ignore_changes = [value]
  }
}

terraform apply 後の状態は次のとおりです。

$ jq -r '.resources[] | select(.name == "example").instances[].attributes.value' terraform.tfstate
This value is manged outside of Terraform

secret value を更新します。

az keyvault secret set \
  --vault-name $KEY_VAULT_NAME \
  --name example \
  --value 'This is the actual secret!'

terraform refresh の後に state file を確認してみると、Terraform 外で更新した値が保存されていることがわかります。

$ jq -r '.resources[] | select(.name == "example").instances[].attributes.value' terraform.tfstate
This is the actual secret!

AzAPI provider

AzAPI provider を使うと、secret value が state file に保存されても Terraform 外で更新された値には追従しないので、ignore_changes を使った時のような問題は起きません。

terraform {
  required_version = ">=1.0"
  required_providers {
    azapi = {
      source  = "azure/azapi"
      version = "=2.6.1"
    }
  }
}

resource "azapi_resource" "example" {
  name      = "example"
  type      = "Microsoft.KeyVault/vaults/secrets@2024-11-01"
  parent_id = "/subscriptions/f48afe64-0e0a-4ce8-b0bd-21eecb6b9ad1/resourceGroups/terraform-provider-azurekv/providers/Microsoft.KeyVault/vaults/avnjctgtwrxesljlzowkbdlr"

  body = {
    properties = {
      value = "This value is manged outside of Terraform"
    }
  }
}

ただ、次のようにタグを追加するだけでも新しいバージョンの secret が作成され、value も Terraform file で指定しているものに戻ってしまうので使い勝手が悪いです。

resource "azapi_resource" "example" {
  name      = "example"
  type      = "Microsoft.KeyVault/vaults/secrets@2024-11-01"
  parent_id = "/subscriptions/f48afe64-0e0a-4ce8-b0bd-21eecb6b9ad1/resourceGroups/terraform-provider-azurekv/providers/Microsoft.KeyVault/vaults/avnjctgtwrxesljlzowkbdlr"

  body = {
    properties = {
      value = "This value is manged outside of Terraform"
    }

    tags = {
      Environment = "Production"
    }
  }
}

おまけ 〜動作確認用 service principal の作成方法〜

開発方法についてはだいたい https://github.com/abicky/terraform-provider-azurekv の README に書いてあるんですが、動作確認用の service principal の作成について紹介します。

テスト用 service principal

make testacc の実行に使うやつです。

まず、次のような JSON を作成します。

terraform-provider-for-azure-key-vault-tester.json
{
  "Name": "Terraform Provider for Azure Key Vault Tester",
  "Actions": [
    "Microsoft.Resources/subscriptions/resourceGroups/delete",
    "Microsoft.Resources/subscriptions/resourceGroups/read",
    "Microsoft.Resources/subscriptions/resourceGroups/write",
    "Microsoft.KeyVault/locations/deletedVaults/purge/action",
    "Microsoft.KeyVault/locations/operationResults/read",
    "Microsoft.KeyVault/vaults/delete",
    "Microsoft.KeyVault/vaults/read",
    "Microsoft.KeyVault/vaults/write"
  ],
  "DataActions": [
    "Microsoft.KeyVault/vaults/secrets/delete",
    "Microsoft.KeyVault/vaults/secrets/getSecret/action",
    "Microsoft.KeyVault/vaults/secrets/purge/action",
    "Microsoft.KeyVault/vaults/secrets/readMetadata/action",
    "Microsoft.KeyVault/vaults/secrets/setSecret/action"
  ],
  "AssignableScopes": [
    "/subscriptions/YOUR_SUBSCRIPTION_ID"
  ]
}

上記の JSON を使って custom role を作成します。

az role definition create --role-definition terraform-provider-for-azure-key-vault-tester.json

作成された custom role を付与しつつ service principal を作成します。

az ad sp create-for-rbac \
  --name terraform-provider-azurekv-tester \
  --role 'Terraform Provider for Azure Key Vault Tester' \
  --scopes /subscriptions/$YOUR_SUBSCRIPTION_ID

出力された appId が client ID、password が client secret になります。secret の期限はデフォルトで 1 年間です。

なお、CI では managed identity を作成し、同じ role を付与した上で OIDC を利用しています。

terraform plan 用 service principal

次のような JSON を用意します。あとはテスト用 service principal と同様です。

{
  "Name": "Terraform Provider for Azure Key Vault Planner",
  "Actions": [
    "Microsoft.KeyVault/vaults/read"
  ],
  "DataActions": [
    "Microsoft.KeyVault/vaults/secrets/readMetadata/action"
  ],
  "AssignableScopes": [
    "/subscriptions/YOUR_SUBSCRIPTION_ID"
  ]
}

terraform plan 用 service principal

次のような JSON を用意します。あとはテスト用 service principal と同様です。

{
  "Name": "Terraform Provider for Azure Key Vault Applier",
  "Actions": [
    "Microsoft.KeyVault/vaults/read"
  ],
  "DataActions": [
    "Microsoft.KeyVault/vaults/secrets/delete",
    "Microsoft.KeyVault/vaults/secrets/readMetadata/action",
    "Microsoft.KeyVault/vaults/secrets/setSecret/action"
  ],
  "AssignableScopes": [
    "/subscriptions/YOUR_SUBSCRIPTION_ID"
  ]
}