Terraform × Secrets Manager: セキュアで再利用可能なリソース定義

背景

AWS Secrets Managerは認証情報を安全に管理できる便利なサービスですが、Terraformで扱うとtfstateに値が平文で残るリスクがあります。IaCによる再現性や一元管理の利点を活かしつつ、この課題をどう回避するかが実運用のポイントです。

本記事では、筆者が考える実践的な管理方法を紹介します。

課題:tfstateと機密情報のリスク

Terraformはリソースの状態を terraform.tfstate ファイルに保存します。これによりインフラの差分管理や再現性が担保されますが、同時に「シークレットの値」もそのまま記録されてしまう可能性があります。

たとえば aws_secretsmanager_secret_version リソースによってシークレット作成をTerraformで行うと、パスワードやAPIキーが平文でstateファイルに残り、S3バックエンドやGit管理を通じてチーム全体に共有されてしまいます。これは情報漏洩につながる大きなリスクです。

悪い例

例えば、次のようなterraformのコード(main.tf)を作成し、Secrets Manager Secretを作成してみます。

resource "aws_secretsmanager_secret" "example" {
  name = "example-secret"
}

resource "aws_secretsmanager_secret_version" "example" {
  secret_id     = aws_secretsmanager_secret.example.id
  secret_string = jsonencode({
    username = "admin"
    password = "P@ssw0rd123!"
  })
}

コード内に機密情報をハードコードしている点が目に見えて良くないポイントです。
しかしもう一つ、見落としがちなポイントがあります。それがリソースの状態を記すterraform.tfstateファイルです。

リソースを作成したときに更新された、tfstateファイルの中身も確認してみます。見ての通り、secret_stringにシークレットの値が平文で記録されてしまいます。
tfstateファイルを厳重に管理しなければ、思いがけない情報漏洩を招いてしまいかねません。

このような課題に対処するためのアイデアを、本記事では紹介したいと思います。

実際に作成してみた

ディレクトリ構成は以下の通りです。

.
├── main.tf
├── modules
│   └── secrets-manager
│       ├── main.tf
│       └── variables.tf
├── provider.tf
├── terraform.tfvars.json
└── variables.tf

main.tfからmodules/secrets-managerを呼び出してSecrets Managerリソースを作成します。

シークレット値をプログラム中にハードコードするのは避け、terraform.tfvars.jsonに記述して外部から注入する構成としています。

terraform.tfvars.jsonは .gitignore に追加し、配布や保管にも注意してください。

本記事では簡単のために tfvars.json を利用しますが、理想的には GitHub Actions などのCI/CDでSecretsを安全に渡す運用 を推奨します。

  • providers.tf
    providerはテスト用のコードということでシンプルにしています。
provider "aws" {
  region  = var.aws_region
}
  • variables.tf
    variablesにはsecretの値を格納するためのsecrets_payloadsという変数を作成します。
    データ型はmapを2重の構造にすることで、複数のシークレットをまとめて管理できるようにしています。
variable "aws_region" {
  type        = string
  description = "AWS region"
  default     = "ap-northeast-1"
}

variable "secrets_payloads" {
  type      = map(map(any))
  sensitive = true
}
  • terraform.tfvars.json
    terraform.tfvars.json を使うと、変数に外部ファイルから値を渡せます。  
    JSONのキーをTerraformの変数名と揃えることで、自動的にその変数に値が割り当てられます。 JSONのオブジェクトは Terraformでは map 型として解釈される仕組みです。
{
  "secrets_payloads": {
    "example_secret_1": {
      "username": "user1",
      "password": "pass1"
    },
    "example_secret_2": {
      "username": "user2",
      "password": "pass2"
    }
  }
}
  • modules/secrets-manager/main.tf
    Secrets Managerのリソースを2つ定義しています。aws_secretsmanager_secretリソースでシークレットの入れ物を作成するイメージです。
    aws_secretsmanager_secret_version リソースで実際の値を登録しますが、その際に secret_string_wo 属性を利用することで、値が tfstate に保存されないようにしているところがポイントです。
    また、Secrets Managerが受け取れる文字列にするために、jsonencodeを使用してmap型文字列をJSON文字列に変換します。
    さらに var.secret_version を指定することで、バージョンを明示的に管理できるようにしています。
resource "aws_secretsmanager_secret" "main" {
 name = var.secret_name
}

resource "aws_secretsmanager_secret_version" "main" {
 secret_id                = aws_secretsmanager_secret.main.id
 secret_string_wo         = jsonencode(var.secret_payloads)
 secret_string_wo_version = var.secret_version
}
  • modules/secrets-manager/variables.tf
    secrets-managerモジュールで使用する変数です。ルートモジュールから値を渡します。
variable "secret_name" {
  type      = string
}

variable "secret_payloads" {
  type      = map(any)
}

variable "secret_version" {
  type      = string
}
  • main.tf
    ルートモジュール側のmain.tfです。
    localsのブロックでは、作成するシークレットごとに変更検知用のバージョンを作成しています。
    具体的には、sha256のハッシュ関数を使って、シークレットの値からハッシュを作成します。
    このとき、ハッシュ計算を行うためにnonsensitive関数を使って機密フラグを外しています。値そのものはsecrets-managerモジュールでwrite-onlyで渡すため、値がtfstate等で露見することはありません。
    さらに、substr関数でハッシュ値の先頭8桁を抽出し、parseint関数を使って数値型に変換しています。substr関数を使わないと桁数が多すぎるため先頭8文字を切り出す処理を入れています。
    このようにして、シークレットの値ごとに決定的なバージョニングを実装しています。

    for_eachを使用して、作成するシークレットごとにモジュールを増やしています。
    読み込んだterraform.tfvars.jsonから、secrets_name, secret_payloadsを抽出し、secretsmanagerモジュールに渡します。
    さらに、secret_versionはlocalsブロックで生成したバージョン値を渡す処理をしています。
locals {
  secrets_keys = keys(var.secrets_payloads)
  secret_versions = {
    for key in local.secrets_keys :
    key => parseint(substr(nonsensitive(sha256(jsonencode(var.secrets_payloads[key]))), 0, 8), 16)
  }
}

module "secretsmanager" {
  source = "./modules/secrets-manager"

  for_each = toset(nonsensitive(local.secrets_keys))

  secret_name = each.key

  secret_payloads = var.secrets_payloads[each.key]
  secret_version  = local.secret_versions[each.key]

}

動作確認

上記のコードでterraform applyを実行すると、Secrets Managerリソースが2つ作成されます。

% terraform apply

tfstateの中身も確認してみます。下図に示す通り、write only属性を指定しているため、tfstateの中には値が記録されなくなりました。
write only属性を有効化した場合、tfstateにシークレット値が記録されなくなるため、secret_string_wo_versionという値を使用してリソースの変更を管理する仕組みとなっています。

作成したterraformコードでは、シークレット値から作成したハッシュ値をもとにしてバージョン値を生成しています。
シークレットの値を変更した際に、バージョン値が変わることも確認してみましょう。

terraform.tfvars.jsonの、example_secret_1のpasswordをpass1からpass11に変更して、terraform applyを実行してみます。

{
  "secrets_payloads": {
    "example_secret_1": {
      "username": "user1",
      "password": "pass11"
    },
    "example_secret_2": {
      "username": "user2",
      "password": "pass2"
    }
  }
}

terraform applyを実行すると、module.secretsmanager[“example_secret_1”].aws_secretsmanager_secret_version.main must be replaced
の表示がでて、シークレット値の変更によるバージョンの更新が検知されたことが確認できるはずです。

% terraform apply


(中略)

  # module.secretsmanager["example_secret_1"].aws_secretsmanager_secret_version.main must be replaced


(中略)

Plan: 1 to add, 0 to change, 1 to destroy.

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: 

作成されたリソースをみると、値が更新されることが確認できます。

tfstateをみると、バージョンが自動的に更新されたことがわかります。
シークレットの値を更新する場合は、このようにバージョンを自動で更新する仕組みを作るか、手動でバージョンを更新する必要があります。
この点は、write only属性を使用する際の注意点だと思います。

コメント

タイトルとURLをコピーしました