データベースの権限管理をTerraformで宣言的に管理する

こんにちは、Infrastructureチームの前多(@kencharos) です。AIの進化におびえながら、電気の資格の勉強でもしようかと考えている日々です。物理はいいですね。

さて、CloudSQLやAlloyDBで初回構築時に設定されるデフォルトユーザーをそのまま使っている方はいらっしゃいますか?いませんよね?

今回の話はAIネタではなくもっと地味なお話です。

データベースの権限設定のつらみ

スキーマの設定を自動マイグレーションするサードパーティのサービスをそのまま使う場合ならともかく、 自分たちで開発しているサービスであれば、マイグレーションとアプリケーションの実行でDBユーザーの権限は分けた方が安全ですし、 複数サービスでDBを共有する場合なら、データベースやスキーマレベルで権限を分けたくなります。

また、AIツールがSQLを実行することも今後はあり得ると思います。 その場合もAIツールが勢い余ってすごいSQLを発行しても大丈夫なように権限を絞ったDBユーザーを使うようにしておきたいものです。

Google CloudのCloudSQLやAlloyDBではDBユーザーやIAMユーザーの作成はできます。 しかし、RDBMS内の権限付与(Grantなど)は結局RDBMSにログインしてSQLを実行する必要があります。

この手のSQLはどうやって管理しているでしょうか? prismaなどのアプリケーションのスキーママイグレーションで管理するのも手段の1つです。

ですが最初にマイグレーション用の権限を持つユーザーは作っておく必要があります。

また何らかの理由で開発サイクルとは別のサイクルでDBユーザーを追加、修正することは多々あります。 レプリケーション用のユーザーが欲しいとか、新しいメンバーが参加したからそのIAMユーザーを追加したいとか、要はスキーマ定義とは独立して管理したいわけです。

筆者らは、このような権限付与を手順書にSQLを書いて手動で実行していました。 苦痛を伴う作業と認識しながら、よい代替手段が当時は思いつきませんでした。

これにはいくつか問題があります。

  • 複数環境で繰り返し実行が必要
  • 変更の都度、現状の状態から細かいGrantやRevokeが必要
  • 手順書の修正などで過去の権限付与の内容の修正などが面倒

などの理由から、微妙に権限付与の内容が環境ごとに異なってしまうことがあります。 意図した通りに権限付与ができているのかが把握しづらくなります。

サービスの拡大に伴ってデータベースの数も増えてきたこともありどうにかしなきゃと思い、解決策を探し始めました。

Terraform Postgres Provider の採用

権限管理のツール化を行うにあたり、要件は次の3つです。

  • 手動ではなくCICDなどから実行して作業を自動化できること
  • 宣言的に権限付与を管理できること
  • PostgreSQLの様々な機能(extension, database, schema, grant, default_privileges, replication slotなど)に対応できること

当初はYAMLに権限付与設定を書いて、自作しようかと思っていましたが、terraform の postgresql provider を見つけたのでこれでいいやとなりました。 ただし、posgresql providerはサードパーティのプラグインですので、扱いには気をつけてください。

要件と概ね一致していましたし、筆者らはTerraformを常用しています。 hclファイルで宣言的に権限設定を管理できるのが大きな理由でした。 (欲を言えば、hclよりも読みやすいフォーマットの方がよかったのですが、それも今ならClaudeなどでサマリできそうだし、まあいいやとなっています)

これを使って、以下のような権限設定を作成します。

  • ownerとなるmigration user、アプリケーション実行用のapplication userを作成する
  • database, schemaは独自のものを作る
  • application userにschema, tableへのgrantとdefault privilegesを設定する。

上記をhclで表現したものが次の通りです。

terraform {
  required_version = "1.10.5"

  backend "local" {
    path = "sample.state"
  }

  required_providers {
    postgresql = {
      source = "cyrilgdn/postgresql"
      version = "1.26.0"
    }
  }
}

// プロバイダの設定。ここではデフォルトユーザーやadminユーザーを指定する
provider "postgresql" {
  host            = "x.x.x.x"
  port            = 5432
  database        = "postgres"
  username        = "postgres"
  password        = var.admin_password
  sslmode         = "disable"
  connect_timeout = 15
}

// userの作成
resource "postgresql_role" "migration" {
  name                = "migration"
  login               = true
  # password_woはstateにパスワードを記録しない。パスワードを変更したい場合は password_wo_versionを変更する
  password_wo               = var.migration_password
  password_wo_version        = "1"
  bypass_row_level_security = true
}
resource "postgresql_role" "application" {
  name     = "application"
  login    = true
  password_wo         = var.application_password
  password_wo_version = "1"
}

// databaseの作成
resource "postgresql_database" "app_db" {
  name     = "app_db"
  owner    = postgresql_role.migration.name
  allow_connections = true
}

// schemaの作成
resource "postgresql_schema" "schema" {
  name     = "app"
  owner    = postgresql_role.migration.name
}

resource "postgresql_grant" "grant_to_schema" {
  database    = postgresql_database.app_db.name
  role        = postgresql_role.application.name
  schema      = postgresql_schema.schema.name
  object_type = "schema"
  privileges  = ["USAGE"]
}

# grant role to table
resource "postgresql_grant" "grant_to_application" {
  database    = postgresql_database.app_db.name
  role        = postgresql_role.application.name
  schema      = postgresql_schema.schema.name
  object_type = "table"
  # objectsが空の場合は all 
  # objects = []
  # privileges は明示する。ここに無いものは revoke 対象になる。
  privileges  = ["SELECT", "INSERT", "UPDATE", "DELETE", "REFERENCES", "TRIGGER", "TRUNCATE"]
}
# default privileges を指定して、application userに対して新規テーブルの権限を付与する
resource "postgresql_default_privileges" "default_privs" {
  role     = postgresql_role.application.name
  database = postgresql_database.app_db.name
  schema   = postgresql_schema.schema.name

  owner       = postgresql_role.migration.name
  object_type = "table"
  privileges  = ["SELECT", "INSERT", "UPDATE", "DELETE", "REFERENCES", "TRIGGER", "TRUNCATE"]
}

あとは、初期ユーザーがあるデータベースがあれば、terraform applyで想定通りの権限付与ができます。 宣言的に権限を記述しているので、例えば上記のgrantからprivilegesを足したり削除したりすると、それがapply時にgrantやrevokeとなって実行されるようになります。

基本的にはこれで十分なのですが、細かい点で気をつけたところを次に述べます。

パスワードの扱いについて

前述のサンプルでは、 password_woを用いてユーザーパスワードを設定しました。

resource "postgresql_role" "application" {
  name     = "application"
  login    = true
  password_wo         = var.application_password
  password_wo_version = "1"
}

これは事前にパスワードがわかっている場合に、 実行時引数や環境変数で application_password にパスワードを渡すようにすればstateにパスワードは残らなくなります。

一方でpostgresql provider 1.26.0の時点でこのリソースはephemeral resourceではありません。 ですので次のように、Terraformで動的に生成したパスワードを直接設定するような操作は現時点ではできません。

ephemeral "random_password" "password" {
  length      = var.length
  special     = false
  upper       = true
  lower       = true
  min_upper   = 1
  min_numeric = 1
  min_lower   = 1
}

resource "postgresql_role" "application" {
  name     = "application"
  login    = true
  # これはできない.
  password_wo         = ephemeral.random_password.password.result
  password_wo_version = "1"
}

そのため現時点では、筆者らはGoogle Secret Managerに一旦ランダムパスワードを生成・保存して、それを取り出して使うという設計としています。 ランダムパスワードの生成はCloudSQLやAlloyDBのインスタンス作成と同時に行う別のTerraformで行っているので、それと権限設定のTerraformとサイクルを分けています。

CI/CDでの実行

宣言的に権限を記述できるようになったので、バージョン管理もできるしCI/CDとの連携もできるとベストです。 ただし、筆者らの環境ではセキュリティの理由でデータベースサーバーにPublic IPを付与してないので、通常のGitHub ActionsからTerraformを実行してもデータベースに接続できません。

そこで以下の2案を考え、2の踏み台サーバーへ接続する方法を採用しました。

1. Argo CD + k8s での Job実行

GitHubのチェックアウトからterraform実行までをスクリプト化して、k8sのJobとして定義しArgo CDからJobを実行します。

GitHub Appsを準備して、次のようなスクリプトで動きます。

# checkout   repo
echo "${    _APPS_PRIVATE_KEY}" > /tmp/gh_cert
header=$(echo -n '{"alg":"RS256","typ":"JWT"}' | base64 -w 0)
now=$(date "+%s")
iat=$((${now} - 60))
exp=$((${now} + (10 * 60)))
payload=$(echo -n "{\"iat\":${iat},\"exp\":${exp},\"iss\":${GITHUB_APPS_ID}}" | base64 -w 0)

unsigned_token="${header}.${payload}"
signed_token=$(echo -n "${unsigned_token}" | openssl dgst -binary -sha256 -sign /tmp/gh_cert | base64 -w 0)
jwt="${unsigned_token}.${signed_token}"

access_token=$(
  curl -s -X POST \
    -H "Authorization: Bearer ${jwt}" \
    -H "Accept: application/vnd.github.v3+json" \
    "https://api.github.com/app/installations/${GITHUB_APPS_INSTALLATION_ID}/access_tokens" \
  | jq -r ".token"
)
#checkout
git clone https://x-access-token:${access_token}@github.com/<org>/<repo>
cd <repo>

export ADMIN_USER=${TF_VAR_admin_user}
export ADMIN_PASSWORD=${TF_VAR_admin_password}
export DB_HOST=${TF_VAR_db_host}
# run terraform
terraform init
terraform apply -auto-approve

ただ、できればGitHub Actionsで実行したかったので見送りました。

2. GitHub Actionsから踏み台サーバーに接続して実行する

筆者らはデータベースにアクセスする際はVPC内にある踏み台サーバーにアクセスしています。 それと同じことをGitHub Actionsから行います。

前提条件として、次のようなセキュリティ対策を行なっておくと良いです。

  • Workload Identityを使用する
  • Identity Aware Proxy(IAP) を設定する

この方法を採用するにあたって、以下の記事が参考になりました、ありがとうございます。

zenn.dev

ポイントとしては、記事にあるようにCloudSDKを別途インストールした上で、beta computeでSSHトンネルを作成することです。

# connect bastion server by beta
gcloud beta compute ssh "${bastion_host}" \
  --zone="${bastion_zone}" \
  --ssh-flag="-L 127.0.0.1:5432:${db_host}:5432" \
  --ssh-flag='-N' \
  --ssh-flag='-f' \
  --tunnel-through-iap \
  --quiet

sleep 10
        
export TF_VAR_admin_user=${admin_name}
export TF_VAR_admin_password=${admin_password}
# 踏み台に接続済みなので、localhostで良い。
export TF_VAR_db_host=localhost

terraform init
terraform apply --auto-apporve

これで、手動でSQLを実行することなく権限設定ができるようになります。

ownerの変更やowner指定には要注意

postgresql providerを使うと、記述の変更が差分反映のSQLとなるので便利ですが、あらゆる変更に対応できない場合があることには注意が必要です。 例えばdatabaseやschemaのownerを変えたりするには、superuserやowner自身である必要があります。

一方で、CloudSQL, AlloyDBのデフォルトユーザーは、 supueruserではなくそれと似て非なるcloudsqladminやalloydbadminというロールが付与されています。 それでsuperuserが必要とされるような変更操作ができないことがあります。

そんな変更が発生する頻度は少ないので、もしそうなった場合は素直にSQLを直接実行して対処する方が良いです。

1つだけ困ったのは、 publicationやreplication slotの作成です。これらのリソースの作成もsuperuserかownerでなくてはいけないので、 Terraformで管理したい場合はproviderに設定するユーザーをdatabaseのownerに切り替える必要があります。

そのためエイリアスを付与したproviderを用意して、特定リソースにそのproviderを指定します。

# 通常のprovider設定
provider "postgresql" {
  host            = var.db_host
  port            = var.db_port
  database        = var.default_database
  username        = var.admin_user
  password        = var.admin_password
}
# databaseオーナーのprovider設定
provider "postgresql" {
  alias = "relication"

  host            = var.db_host
  port            = var.db_port
  database        = var.default_database
  username        = var.replication_user
  password        = var.replication_user_password
}

resource "postgresql_replication_slot" "replication" {
  # relication slot作成用のproviderを指定する
  provider = postgresql.replication

  name     = "replication_slot"
  plugin   = "pgoutput"
  database = "xxxxx"
}

まとめ

GitHub Actionsから踏み台サーバーになかなか接続できなかったり、replication slotの作成でsuperuserじゃない問題に直面した時は頭を抱えましたが、 色々あってできるようになりました。

苦労はありましたが、その効果は絶大で新規サービスの開発で新しいデータベースができた時でも複数環境分のセットアップが迅速、かつ正確にできるようになりました。

興味があれば試してみてもらえたり、他にこんなやり方があるよというコメントもお待ちしています。

おまけ: 第三の選択肢、Crossplane

この記事を書くにあたって、改めて色々調べていたら、 Crossplaneのpostgres provider を見つけました。

Crossplaneはk8s上のカスタムリソースとして、インフラなどの様々なリソースを管理するプロダクトです。 なのでこれを使えば、YAMLでデータベースの各種権限を設定できるし、Argo CDを使ってGitOpsでマニフェストを管理できそうです。 Terraformを実行することなく、権限設定が同期できるのでとても便利だなと感じました。

Corssplaneを導入する計画は今のところないのですが、もし導入するならやってみたいと思います。