BigQueryのデータセットに対する大量のアクセス権限をTerraformで管理する

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:最初からやっていれば...というツッコミはなしで