PostgreSQLユーザをTerraformで管理する

# はじめに

こんにちは、Gakken LEAPインフラエンジニアの丹羽です。

先日、弊社サービスのShikaku Passで使っているPostgreSQL DBのユーザをTerraform管理下に置く取り組みを行ったので、その内容について解説したいと思います。

なお、PostgreSQLではユーザやグループを指す用語としてロールが使われていますが、以下では説明をシンプルにするためユーザという用語で統一しています。ご了承ください。

# 導入の経緯

サービスの成長に伴って、データベースユーザの権限管理が詳細化していくシーンは多いと思います。

Shikaku Passでも従来はそこまで細かい権限管理を求められなかったため手動で行ってきましたが、新機能追加のタイミングでアプリケーションで使用するユーザ、開発者がオペレーションで使用するユーザ、BIツールで使用するユーザなど、ケースごとにもう少し粒度の高い権限管理が必要となりました。

これを手動で管理していくのは煩雑ですし、ミスなども誘発する恐れがあるため、この機会にIaC化してコード上で管理できるようにしました。

# アプローチ

AWSリソースの管理にTerraformを用いていたので、自然とPostgreSQLユーザの権限管理にもTerraformを採用する方針になりました。幸いPostgreSQL用のプロバイダーもすでに[用意されています](https://github.com/cyrilgdn/terraform-provider-postgresql)。

問題は、どのようにTerraformからPostgreSQLに接続するかです。

Shikaku PassではTerraformの実行環境としてHCP Terraformを採用していますが、HCP TerraformはSaaSなので、VPC内にあるPostgreSQLに接続することはできません。HCP TerraformのIP帯は[公開されている](https://developer.hashicorp.com/terraform/cloud-docs/architectural-details/ip-ranges)ので、強いてできないことはありませんが、やはりセキュリティ的な懸念がつきまとうため、今回は別のアプローチを模索することにしました。

まず考えたのが、踏み台インスタンスを用意し、ローカル環境からこの踏み台を経由するポートフォワーディングを行うことでPostgreSQLへの接続を確保する方法です。直感的で分かりやすいですが、踏み台インスタンスの管理という作業が新たに発生します。

![ssm_ec2_bastion_to_rds.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3712998/62a3c120-ad85-4423-9fbb-b5416052f05a.png)

次に考えたのが、[Atlantis](https://www.runatlantis.io/)を使う方法です。AtlantisはTerraform用のCI/CDツールで、しかもセルフホスティングが可能です。AtlantisをVPC内にデプロイしておき、Terraformを実行させれば、PostgreSQLにも接続してくれそうです。

![terraform_via_atlantis_to_rds.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3712998/b227a64a-4e8a-47fb-ba48-1cacf68f4199.png)

Atlantisにはかなり心惹かれたのですが、実際の運用に耐えうるかどうか検証に時間がかかりそうだったので、今回は踏み台方式を採用することにしました。いずれ時間の余裕が十分にある時にAtlantisの方も試してみたいと思います。

# 実装

さて踏み台方式に決定しましたが、先述の通り新たに発生する管理の手間をなるべく抑えたいです。

セッションマネージャーの機能を確認したところ、EC2インスタンスだけでなくECS Fargateタスクも踏み台にできることを知りました。Shikaku Passにはもともと手動オペレーション用のECSサービスがあり、必要な時だけタスクを起動してECS Execでログインするという運用を行っていましたが、これらを組み合わせることで管理のいらない踏み台を用意できそうです。

![ssm_faragte_bastion_to_rds.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3712998/1a380e0d-06c0-46f5-af1c-21ffd46c613b.png)

具体的には、次のような流れになります。

1. 手動オペレーション用のECSサービスで踏み台用のECSタスクを起動する
2. タスクが起動するのを待ち、タスクIDおよびコンテナランタイムIDを取得する
3. セッションマネージャーで起動したタスクを経由するポートフォワーディングを実行する
4. Terraform plan/applyを実行する
5. ポートフォワーディングを終了する
6. ECSタスクを終了する

事前作業の1-3番、事後作業の5-6番をそれぞれスクリプト化し、Terraform実行の前後に実行することでローカル環境からPostgreSQLへの接続を実現することができました。

スクリプトの内容について特筆すべきことはありませんが、セッションマネージャーの接続先としてFargateを指定する際、よくあるARN指定ではなく独自のフォーマットで指定する点にやや注意が必要です。

```shell
aws ssm start-session --target "ecs:(ECSクラスター名)_(タスクID)_(コンテナランタイムID)" \
 --document-name AWS-StartPortForwardingSessionToRemoteHost \
 --parameters '{"host":["(PostgreSQLエンドポイント)"],"portNumber":["5432"], "localPortNumber":["15432"]}'
```

TerraformのPostgreSQLプロバイダーについても軽く触れておきます。

今回はユーザの管理が目的なので、ユーザを表す`postgresql_role`リソースと権限を表す`postgresql_grant`リソースを作成しました。

工夫としては、開発者が使用するユーザにはほとんどすべてのテーブル権限を与えつつ、機密情報などを含む特定テーブルだけは除外するというブラックリスト的な要件があったため、Dataリソースでフィルターをかけた既存テーブル一覧を取得し、フィルタリングしたテーブル一覧の権限を付与するという形をとりました。

```terraform
# not_like_all_patternsを使って特定テーブルを除外した既存テーブル一覧を取得する
data "postgresql_tables" "existing_tables" {
  database              = "mydb"
  schemas               = ["public"]
  table_types           = ["BASE TABLE"]
  not_like_all_patterns = ["excluded_table1", "excluded_table2"]
}

# テーブル名だけ抽出
locals {
  allowed_table_names = data.postgresql_tables.existing.tables[*].object_name
}

# フィルタリングしたテーブルに対して権限を付与
resource "postgresql_grant" "this_table" {
  database    = "mydb"
  role        = "myrole"
  schema      = "myschema"
  object_type = "table"
  objects     = local.allowed_table_names
  privileges = [ # ALL指定はできないので権限を並べている
    "SELECT",
    "INSERT",
    "UPDATE",
    "DELETE",
    "TRUNCATE",
    "REFERENCES",
    "TRIGGER"
  ]
}
```

# まとめ

まだまだ簡素ではありますが、PostgreSQLユーザと権限をTerraformから管理できるようになりました。

将来的には現在スクリプトで実現している部分をよりスムーズな形に改修する、あるいはカラムレベルのさらにきめ細やかな権限管理を行うといったことにも取り組んでいきたいと考えています。

# エンジニア募集

Gakken LEAP では教育をアップデートしていきたいエンジニアを絶賛大募集しています！！
ぜひお気軽にカジュアル面談へお越しください！！

https://gakken-leap.co.jp/recruit/