BigQueryのデータセットに対するアクセス権限が手動で追加されていました。手動だと編集された履歴の確認や特定時期の状態を復元ができないため、Terraform管理したい。しかし、データセットがあまりに大量にあって困ったので、スクリプトで楽をしようという話です。
背景
BigQueryはプロジェクト全体以外でもデータセットやテーブルの単位でアクセス権限を付与できます。例えば
- データセットAはメールグループXに対して
WRITER
の権限を付与 - テーブルBはメールグループYに対して
READER
の権限を付与 - テーブルBは個人Zに対して
READER
の権限を付与- 個々人に対して権限付与を行なう場合、個別対応は大変なので大抵はグループアドレスで権限設定をしていることが多いと思います
- とはいえ、個人情報などは可能な限りアクセスできる人数を最小限に抑えたいことが多いので、個々人に権限付与する場合もありえます
などです。こういった権限管理はBigQueryのWebUIから設定ができるのですが、Terraformで管理したくなりました*1。既存の権限付与の状況を確認しようと思って、bqコマンドを叩いてみると、以下のような情報が取得できます。
% bq show --format prettyjson --project_id my-project my-dataset | jq -r '.access' [ { "role": "WRITER", "specialGroup": "projectWriters" }, { "role": "WRITER", "userByEmail": "service@my-service.iam.gserviceaccount.com" }, { "role": "OWNER", "specialGroup": "projectOwners" }, { "role": "READER", "userByEmail": "syou6162@gmail.com" } ]
TerraformでデータセットのIAMの設定はできるのですが、データセットが数十、数百あると手で作業はしたくありません。既存のリソースをTerraformに取り込むためのツールとしてはterraformerがありますが、BigQueryのデータセットに対するIAMのimportは未対応です。仕方がないので、スクリプトを書いて対応することにします。
bqコマンドの出力を元に、Terraformファイルを生成する
ひとまず、bqのコマンド出力の結果からTerraformファイルを作ることにします。以下のことを前提とします。
- 1データセットに対して1つのTerraformファイルが存在すると仮定
- データセットが大量にあるため、ファイル分割しておきたい
- 権限管理はowner / editor / viewerの単位で管理
つまり、以下のようなTerraformの設定がデータセット毎に吐き出されて欲しいわけです。
resource "google_bigquery_dataset_iam_member" "owner" { for_each = toset(local.owner_members) dataset_id = google_bigquery_dataset.syou6162_test_dataset.dataset_id role = "roles/bigquery.dataOwner" member = each.key } resource "google_bigquery_dataset_iam_member" "editor" { for_each = toset(local.editor_members) dataset_id = google_bigquery_dataset.syou6162_test_dataset.dataset_id role = "roles/bigquery.dataEditor" member = each.key } resource "google_bigquery_dataset_iam_member" "viewer" { for_each = toset(local.viewer_members) dataset_id = google_bigquery_dataset.syou6162_test_dataset.dataset_id role = "roles/bigquery.dataViewer" member = each.key } locals { owner_members = ["serviceAccount:terraform-ml-news@ml-news.iam.gserviceaccount.com"] editor_members = [] viewer_members = ["user:syou6162@gmail.com", "user:syou6163@gmail.com"] }
このTerraformファイルを生成するスクリプトを用意します。気が向いたので、久しぶりにRubyで書いてます。
require "json" def is_service_account?(mail_address) mail_address.end_with?("gserviceaccount.com") end role_to_bigquery_detail_ole_map = { "owner" => "roles/bigquery.dataOwner", "editor" => "roles/bigquery.dataEditor", "viewer" => "roles/bigquery.dataViewer", } bigquery_access_primitive_to_role_map = { "OWNER" => "owner", "WRITER" => "editor", "READER" => "viewer", } result = { "owner" => [], "editor" => [], "viewer" => [] } dataset_id = ARGV.first while line = STDIN.gets json = JSON.parse($_) member = nil if json.has_key?("userByEmail") if is_service_account?(json["userByEmail"]) member = "serviceAccount:#{json["userByEmail"]}" else member = "user:#{json["userByEmail"]}" end elsif json.has_key?("groupByEmail") member = "group:#{json["groupByEmail"]}" end # specialGroupなどは別個管理するので、無視する if !member.nil? result[bigquery_access_primitive_to_role_map[json["role"]]].push(member) end end result.each{|role, members| resource = <<~EOS resource "google_bigquery_dataset_iam_member" "#{role}" { for_each = toset(local.#{role}_members) dataset_id = google_bigquery_dataset.#{dataset_id}.dataset_id role = "#{role_to_bigquery_detail_ole_map[role]}" member = each.key } EOS puts resource } puts <<EOS locals { owner_members = [#{result["owner"].map{|item| "\"#{item}\""}.join(",")}] editor_members = [#{result["editor"].map{|item| "\"#{item}\""}.join(",")}] viewer_members = [#{result["viewer"].map{|item| "\"#{item}\""}.join(",")}] } EOS
スクリプトの実行はこんな感じ。
% bq show --format json syou6162_test_dataset | \ jq -cM '.access[]' | \ ruby generate_bq_dataset_resource_terraform.rb syou6162_test_dataset | \ hcledit fmt >> terraform/main.tf
import用のコマンドを生成する
Terraformファイルは生成できましたが、import用のコマンドも用意する必要があります。for_each
でブン回しているわけなので、import用のコマンドもそれだけ実行する必要があります。Terraformファイルをparseして、必要な要素のみを取得してくる(terraform版のjqっぽいイメージ)hcledit
というツールがあるので、先程生成したTerraformファイルを元にimport用のコマンドを生成します。
#!/bin/sh set -eu -o pipefail PROJECT_ID=my-project DATASET_ID=$1 ROLES=( owner editor viewer ) for role in "${ROLES[@]}"; do cat main.tf | \ hcledit attribute get "locals.${role}_members" | \ jq \ -r \ --arg project_id $PROJECT_ID \ --arg dataset_id $DATASET_ID \ --arg role $role \ --arg roleCapital $(echo $role | perl -pe 's/\b(.)/\u\1/g') \ '.[] | "terraform import \"google_bigquery_dataset_iam_member." + $role + "[\\\"" + . + "\\\"]\" \"projects/" + $project_id + "/datasets/" + $dataset_id + " roles/bigquery.data" + $roleCapital + " " + . + "\""' done
実行すると、こういう感じのimportコマンドが列挙されるのでペタペタ実行していきましょう。
terraform import "google_bigquery_dataset_iam_member.owner[\"serviceAccount:terraform-ml-news@ml-news.iam.gserviceaccount.com\"]" "projects/ml-news/datasets/syou6162_test_dataset roles/bigquery.dataOwner serviceAccount:terraform-ml-news@ml-news.iam.gserviceaccount.com" terraform import "google_bigquery_dataset_iam_member.viewer[\"user:syou6162@gmail.com\"]" "projects/ml-news/datasets/syou6162_test_dataset roles/bigquery.dataViewer user:syou6162@gmail.com"
for_each
を使っている場合のimportの方法は以下が分かりやすかったです。
importが正常にできるかテストする
terraform import
はdry-runオプションは特にありません。terraform plan
を実行する前にはimportしておく必要がありますが、terraform import
はtfstateの内容を破壊的に変更します。レビューなどでリソース名を変更したくなった場合、import済みのものはterraform state mv
で丁寧にrenameしていく必要があります。とても面倒!!
remoteのtfstateを書き換えずにimport後の差分をplanで確認したいです。こういったケースでは
terraform state pull > terraform.tfstate
で一度tfstateファイルを手元に持ってくる- backendをlocalに一度戻す
としておくと、remoteのtfstateを書き換えずにterraform plan
で内容を確認することができます。より詳細なやり方は以下のエントリが参考になりました(state mv
が題材だけど、importも大体同じ)。
*1:最初からやっていれば...というツッコミはなしで