コンテンツにスキップ

Terraform + Aurora Data APIでDBユーザー作成をIaC化した話

チェック

  • [ ] 本文を確認した
  • [ ] 概要を確認した
  • [ ] タグを確認した
  • [ ] inbox/ 直下へ移行した

概要

新規プロダクトの AWS インフラ構築を Terraform と Devin でかなり自動化している一方、Aurora MySQL の DB ユーザー作成と権限付与だけが手作業で残っていたため、Aurora Data API と Terraform の terraform_data を使って IaC 化した記事。 CREATE USERGRANTDROP USERREVOKE を Terraform から実行し、パスワードは SSM Parameter Store に置く。 さらに Terraform 1.10 の ephemeral values と AWS Provider 6 系の write-only attributes を使い、DB パスワードを Terraform state に残さない工夫も紹介している。

本文

Timee では、新規プロダクトを立ち上げる際に必要な AWS アカウント、VPC、ECS、Aurora、IAM、バックエンドアプリケーション、CI/CD、Datadog、監査ログなどの雛形構築を自動化している。 Devin を使ってかなりの部分を生成できるようになったが、最後に手作業として残っていたのが DB ユーザーの作成と権限付与だった。

従来は踏み台サーバーに入って SQL を実行していた。

CREATE USER 'app_user'@'%' IDENTIFIED BY '...';
GRANT SELECT, INSERT, UPDATE, DELETE ON app_db.* TO 'app_user'@'%';

この作業は頻度が低くても、手作業である以上、作業漏れ、権限の差分、監査しづらさが残る。 そこで、既存のインフラ管理と同じ Terraform に寄せ、DB ユーザー作成もコードで管理する方針を取っている。

Terraform を選んだ理由

DB ユーザー作成は Ansible やスクリプトでも実装できる。 しかし、対象の Aurora や SSM Parameter Store、IAM、ネットワークなどはすでに Terraform で管理されている。 ユーザー作成だけ別ツールに分けると、どの環境にどのユーザーがあるのか、何に依存しているのかが追いづらくなる。

Aurora MySQL 3.07 以降では RDS Data API が使える。 Data API を使えば、踏み台サーバーや直接の MySQL 接続なしで、AWS API 経由で SQL を実行できる。 Terraform から local-execaws rds-data execute-statement を呼び出すことで、DB ユーザー作成を Terraform のライフサイクルに組み込める。

DBユーザー作成リソース

実装の中心は terraform_dataterraform_data は、外部コマンド実行やローカルな副作用を Terraform の管理対象として扱いたいときに使える。

DB ユーザー作成では、Aurora クラスター ARN、Secrets Manager 上のマスターユーザー secret ARN、DB 名、ユーザー名、パスワードを保持する SSM Parameter 名を input に持たせる。 作成時には SSM Parameter Store からパスワードを復号して取り出し、Data API で CREATE USER IF NOT EXISTS を実行する。

resource "terraform_data" "db_user" {
  input = {
    rds_cluster_arn    = var.rds_cluster_arn
    rds_secret_arn     = var.rds_secret_arn
    database_name      = var.database_name
    username           = var.username
    ssm_parameter_name = var.ssm_parameter_name
  }

  provisioner "local-exec" {
    command = <<-EOT
      PASSWORD=$(aws ssm get-parameter \
        --name '${self.input.ssm_parameter_name}' \
        --with-decryption \
        --query 'Parameter.Value' \
        --output text)

      aws rds-data execute-statement \
        --resource-arn '${self.input.rds_cluster_arn}' \
        --secret-arn '${self.input.rds_secret_arn}' \
        --database '${self.input.database_name}' \
        --sql "CREATE USER IF NOT EXISTS '${self.input.username}'@'%' IDENTIFIED BY '$PASSWORD'"
    EOT
  }

  provisioner "local-exec" {
    when    = destroy
    command = <<-EOT
      aws rds-data execute-statement \
        --resource-arn '${self.input.rds_cluster_arn}' \
        --secret-arn '${self.input.rds_secret_arn}' \
        --database '${self.input.database_name}' \
        --sql "DROP USER IF EXISTS '${self.input.username}'@'%'"
    EOT
  }
}

破棄時には DROP USER IF EXISTS を実行する。 これにより、Terraform 上でユーザーリソースを削除したときに DB 上のユーザーも削除される。

権限付与リソース

ユーザー作成とは別に、権限付与も terraform_data で扱う。 権限は grants のリストとして受け取り、for_eachGRANT 文を実行する。

resource "terraform_data" "db_grant" {
  for_each = {
    for idx, grant in var.grants : idx => grant
  }

  input = {
    rds_cluster_arn = var.rds_cluster_arn
    rds_secret_arn  = var.rds_secret_arn
    database_name   = var.database_name
    username        = var.username
    privileges      = each.value.privileges
    grant_database  = coalesce(each.value.database, var.database_name)
    grant_table     = coalesce(each.value.table, "*")
  }

  depends_on = [terraform_data.db_user]

  provisioner "local-exec" {
    command = <<-EOT
      aws rds-data execute-statement \
        --resource-arn '${self.input.rds_cluster_arn}' \
        --secret-arn '${self.input.rds_secret_arn}' \
        --database '${self.input.database_name}' \
        --sql "GRANT ${self.input.privileges} ON ${self.input.grant_database}.${self.input.grant_table} TO '${self.input.username}'@'%'"
    EOT
  }

  provisioner "local-exec" {
    when    = destroy
    command = <<-EOT
      aws rds-data execute-statement \
        --resource-arn '${self.input.rds_cluster_arn}' \
        --secret-arn '${self.input.rds_secret_arn}' \
        --database '${self.input.database_name}' \
        --sql "REVOKE ${self.input.privileges} ON ${self.input.grant_database}.${self.input.grant_table} FROM '${self.input.username}'@'%'" || true
    EOT
  }
}

depends_on でユーザー作成後に権限付与されるようにしている。 破棄時は REVOKE を実行するが、すでにユーザーや権限が消えている可能性もあるため、失敗しても全体破棄が止まりすぎないように || true を付けている。

権限は DML と DDL を分けて渡せる。 たとえばアプリケーション用ユーザーに通常の読み書き権限と、必要に応じた DDL 権限を別々に定義できる。

モジュール利用例

記事で紹介されている利用イメージは、Aurora モジュールと SSM Parameter を作ったあと、DB ユーザーモジュールを呼び出す形。

module "db_user_app" {
  source = "../../modules/db_user"

  rds_cluster_arn    = module.aurora_main.cluster_arn
  rds_secret_arn     = module.aurora_main.cluster_master_user_secret[0].secret_arn
  database_name      = "hoge_db"
  username           = "hoge_app_user"
  ssm_parameter_name = aws_ssm_parameter.app_db_password.name

  grants = [
    {
      privileges = "SELECT, INSERT, UPDATE, DELETE"
    },
    {
      privileges = "CREATE, ALTER, DROP, INDEX, REFERENCES"
    }
  ]

  depends_on = [
    aws_ssm_parameter.app_db_password,
    module.aurora_main
  ]
}

これで、DB ユーザー、パスワードの取得元、権限付与、Aurora への依存関係が Terraform の中に残る。 新規環境を作るときも、踏み台から SQL を手で流す手順を省ける。

パスワードを state に残さない

Terraform でパスワードを扱うときの重要な問題は state。 sensitive = true を付けても plan や apply の表示がマスクされるだけで、値自体は state に保存される。

記事では、Terraform 1.10 の ephemeral values と Terraform 1.11 / AWS Provider 6 系の write-only attributes を使って、生成したパスワードを state に保存しない方法を紹介している。

ephemeral "random_password" "app_db" {
  length  = 32
  special = false
}

resource "aws_ssm_parameter" "app_db_password" {
  name  = "/${var.app_name}/${var.env}/db/app_user_password"
  type  = "SecureString"

  value_wo         = ephemeral.random_password.app_db.result
  value_wo_version = 1
}

ephemeral は Terraform の評価中だけ値を持ち、state に残さない。 value_wo は AWS Provider 側の write-only attribute で、SSM Parameter へ値を書き込むが state には保持しない。 これにより、DB パスワードを Terraform state から漏らさない構成にできる。

制約と割り切り

この実装には制約がある。 terraform_data の provisioner は create/destroy には使いやすいが、update 用の provisioner はない。 input が変わるとリソースの置き換えになり、DB ユーザーの場合は DROP USERCREATE USER が走る可能性がある。 パスワード変更やローテーションをこのモジュールだけで安全に扱うには向かない。

つまり、この仕組みは初期ユーザー作成と権限付与の自動化に向いている。 パスワードローテーションは別の運用、たとえば Secrets Manager のローテーションや専用の変更手順で扱う方が安全。

読み替え

この記事は、Terraform から DB 内部の状態を扱うときの実務的な割り切りが参考になる。 すべてを Terraform provider として美しく実装するのではなく、Data API と terraform_data を使って「初期作成に必要な副作用」を管理対象にする。

同時に、state に秘匿情報を残さないための Terraform 新機能も組み合わせている。 IaC 化の目的は、すべてを Terraform に閉じ込めることではなく、手作業を減らし、依存関係と監査可能性を上げ、危険な値を state に残さないことにある。

要点

  • Aurora MySQL 3.07+ の Data API を使うと、踏み台なしで SQL を AWS API 経由で実行できる。
  • terraform_datalocal-execCREATE USERGRANTDROP USERREVOKE を Terraform に組み込める。
  • DB パスワードは SSM Parameter Store に置き、Terraform 実行時に取得する。
  • sensitive = true だけでは state への保存は防げない。
  • Terraform 1.10 の ephemeral values と AWS Provider の write-only attributes を使うと、生成パスワードを state に残さず SSM に書ける。
  • provisioner は update に弱いため、パスワードローテーションまでこの方式で無理に扱わない。

タグ

terraform #aws #aurora #rds-data-api #iac #security