BigQueryの列レベルのアクセス制御とポリシータグの調査メモ

なぜ列レベルのアクセス制御とポリシータグが必要か

「テーブルの全てのカラムは見せたくない」「rawデータは見せたくなくて、統計量だけは許可したい」などセキュリティ面からの要求でこういったことを実現したい / しなければならない場面はそれなりに多い。個別にカスタマイズしようと思うと、承認済みビューがカスタマイズ性があり便利ではあるが、以下のような課題がある。

  • 用途毎にカスタマイズできてしまうがゆえに、都度承認済みビューを作る必要がある
    • データ基盤のチームが少人数の場合、運用負荷がバカにならない
    • 自分がこれまで所属していた組織でも結構辛かった
  • 権限の整合性を担保するのが難しい
    • 承認済みビューAではXさんにはこの情報を見せないようにしていたが、別の承認済みビューBではXさんにその情報が見えるようになっていた、ということが簡単に起きえる
    • 管理対象が増えてくると、これらの整合性を担保するのは難しいし、誰が何にアクセスできるのか管理側も把握するのが難しくなる
    • ガバナンス的にも辛い

こういった辛い面を解消する方法の一つに列レベルのアクセス制御ポリシータグがあり、今回はそれについて調査した内容についてまとめてみる。なお、ZOZOさんの資料がめちゃくちゃ参考になるので、まずこれらを見てみることをオススメする。

Terraformでポリシータグの作成および権限付与

列レベルのアクセス制御を行なう際にはまずポリシータグを作る必要がある。ポリシータグはtaxonomiesをまず作ってからそこに階層構造を作る、という形を取る。どのように階層構造を作っていけばいいかについては、公式から案内が出ているので、参考にするとよいだろう。

ポリシータグはリージョンレベルのリソースとなる。そのため、あるプロジェクトで定義した同名のポリシータグは定義できない。ポリシータグを定義するための管理用(データガバナンス用)のGCPプロジェクトを作って、そこにポリシータグの定義は集約していくのがよさそうに思えた。

ポリシータグを作成する前にdatacatalog.googleapis.comのAPIを有効にしておこう。Terraformでtaxonomyおよびpolicy_tagを作成するには、例えば以下のように書く。

resource "google_data_catalog_taxonomy" "basic_taxonomy" {
  region       = "asia-northeast1"
  display_name = "my_taxonomy"
  description  = "A collection of policy tags"

  # 管理者であっても権限付与を明示的に行なわないと見れなくなる
  # SELECT *している場合は権限がなくなるので注意が必要
  activated_policy_types = ["FINE_GRAINED_ACCESS_CONTROL"]
}

resource "google_data_catalog_policy_tag" "parent_policy_tag" {
  taxonomy     = google_data_catalog_taxonomy.basic_taxonomy.id
  display_name = "親のポリシータグ"
  description  = "親のポリシータグです"
}

resource "google_data_catalog_policy_tag" "child_policy_tag" {
  taxonomy          = google_data_catalog_taxonomy.basic_taxonomy.id
  display_name      = "子どものポリシータグ"
  description       = "子どものポリシータグです"
  parent_policy_tag = google_data_catalog_policy_tag.parent_policy_tag.name
}

実際に作成されたtaxonomypolicy_tagは以下のようになる。意図通り、階層構造ができていることが分かる。

resource "google_data_catalog_policy_tag_iam_member" "child_policy_name_viewer" {
  policy_tag = google_data_catalog_policy_tag.child_policy_tag.name
  role       = "roles/datacatalog.categoryFineGrainedReader"
  member     = "user:me@gmail.com"
}

ちなみにデータポリシー(google_bigquery_datapolicy_data_policy)というものがあり、「権限がない場合はNULLに置き換える」「集計はできるようにハッシュ化した値に置き換える」などのポリシーをさらに適用することもできる。ただし、データポリシーは組織(organization)に所属していないと定義できないことに注意。

実際にポリシータグを付与していく際は、付与するサービスアカウントなどに対してroles/datacatalog.viewerを付与することを忘れずに。taxonomyなどが参照できないと、ポリシータグがそもそも見えないので付与が失敗してしまう。

resource "google_project_iam_member" "my_service_account_is_datacatalog_viewer" {
  project = "my-project"
  role    = "roles/datacatalog.viewer"
  member  = "serviceAccount:my_service_account@my-project.iam.gserviceaccount.com"
}

ポリシータグの付与の仕方

dbt経由の場合

dbtは公式でBigQueryのポリシータグの付与がサポートされている。ポリシータグはdbt_project.yml内のvarsで宣言しておいて、あとからそれを参照する形がメンテナンスしやすいと思う(同一のポリシータグを複数箇所で使っていた場合のリファクタリングが容易になるので)。また、ポリシータグはカラムのメタ情報を修正することになるので、同じくdbt_project.yml内の+persist_docsを有効にしておくのも忘れずに。

vars:
  policy_tags:
    child_policy_tag1: "projects/my_project/locations/us/taxonomies/1111/policyTags/2222" 
    child_policy_tag2: "projects/my_project/locations/us/taxonomies/3333/policyTags/4444"

models:
  my_project:
    +persist_docs:
      relation: true
      columns: true

BigQueryのポシリータグはstringやintのような単純なフラットのカラムだけでなく、structやrecordの中の一部のカラムのみに対してポリシータグを付与できる。例えば、以下のような構造を持ったテーブルを想定しよう。

{{
    config(
        materialized="table",
    )
}}

select
    1 as shown,
    2 as hidden,
    struct('hoge' as hoge, struct('fuga' as fuga, 'piyo' as piyo) as aaa) as partially_hidden,
    [struct('fuga' as fuga, 'piyo' as piyo)] as record

この構造を持ったテーブルに対して、例えば以下のようにポリシータグを付与できる。modelのyaml中でvarを参照するのに苦労したけど、こちらを参考にしたら動いた。ちなみにpolicy_tagsとなっているので、一つのカラムに複数のポリシータグを付与できるかのように見えるが、実際に付与してみるとBigQueryのAPIからToo many policy tags on this field schema (2). The maximum number of policy tags is 1.と怒られる(じゃあ、なんで複数系で定義してあるんだ...)。

version: 2
models:
  - name: policy_tag_example
    columns:
      - name: shown 
        description: 管理者には見えるはずのカラム
        policy_tags:
          - '{{ var("policy_tags")["child_policy_tag"] }}'
      - name: hidden 
        description: 管理者であっても見えないはずのカラム
        policy_tags:
          - '{{ var("policy_tags")["parent_policy_tag"] }}'
      - name: partially_hidden.aaa.fuga
        description: 部分的に見える
        policy_tags:
          - '{{ var("policy_tags")["child_policy_tag"] }}'
      - name: partially_hidden.aaa.piyo
        description: 部分的に見えない
        policy_tags:
          - '{{ var("policy_tags")["parent_policy_tag"] }}'
      - name: record.fuga
        description: 部分的に見えない
        policy_tags:
          - '{{ var("policy_tags")["parent_policy_tag"] }}'
      - name: record.piyo
        description: 部分的に見える
        policy_tags:
          - '{{ var("policy_tags")["child_policy_tag"] }}'

これを元にいつも通りdbt runを動かすと、以下のようなスキーマが定義される。Policy tagsの列に注目で、カラム毎に適切にポリシータグが付与できているし、権限が足りずにアクセスできないカラムについてはそれが分かるような表示がされている。

スキーマ

ポリシータグを無視してSELECT *でアクセスしようとすると、こんな感じでどのカラムがどのポリシータグによりアクセスできないかをエラーメッセージで教えてくれる。SELECT *している既存のクエリは動かなくなるため、反映前に利用者へのアナウンスを忘れずに。

Access Denied: BigQuery BigQuery: User has neither fine-grained reader nor masked get permission to get data protected by policy tag "my_taxonomy : 親のポリシータグ" on columns my-project.test_test.policy_tag_example.hidden, my-project.test_test.policy_tag_example.partially_hidden.aaa.piyo, my-project.test_test.policy_tag_example.record.fuga.

クエリだけでなく、テーブルのプレビューでも自分が権限を持っているカラムしか表示されないので安心である。

テーブルのプレビュー

bq loadを使う場合

dbtメインで使っている組織であっても、データレイクへの取り込みはbq loadを使っている、ということはありえるだろう。テーブルのスキーマ定義を仮にterraformで行ないポリシータグを付与したとしても、テーブルが洗い替えになる場合はポリシータグを含むメタの情報が消えてしまうことには注意が必要だ。

この場合、公式ページでも書かれているが、以下のプロセスを踏む必要がある。

  • bq show --schema --format=prettyjsonでスキーマ情報を取得
  • schema.jsonに対して必要なカラムのpolicyTagsにポリシータグの情報を埋める
  • bq updateでポリシータグを反映させる

運用上の注意点

ここまで見てもらったら分かるように「テーブルの作成 / 更新は成功したが、ポリシータグの付与は失敗する」ということは十分にありえる。要因は権限の不足、BigQueryのinternal errorなど様々ありえるが、運用上は

  • A: ポリシータグの付与に失敗したことに気付けること
  • B: ポリシータグの付与に失敗した際に、本来守るべきカラムに誰がアクセスしてしまっていたか調べられること

が重要になってくると思う。

Aについてはデータの更新バッチに対してアラートを仕込んでおくのは当然として、運用が他のチームである場合などは権限を持っていないサービスアカウントから定期的にポリシータグが設定されているカラムにアクセスしてdenyされることを確認する、などが考えられるだろう(アクセスできてしまったら、何らかの原因でポリシータグが外れていることを意味する)。

Bについては監査ログから調査することができる。BigQueryの監査ログには世代があり「誰がどのカラムにアクセスした」というようなカラムレベルの監査ログは新しい世代の監査ログ(BigQueryAuditMetadata)にしか含まれない。ポリシータグの運用を始める前に、新しい世代の監査ログを有効にするとを忘れずにやっておこう。

新しい世代の監査ログはschema on readであり、JSONのparseが面倒ではあるが、以下のようなクエリで調査をすることができる。

SELECT
  resource.labels.project_id AS job_project_id,
  JSON_EXTRACT_SCALAR( PARSE_JSON(protopayload_auditlog.metadataJson), "$.tableDataRead.jobName" ) AS job_name,
  protopayload_auditlog.authenticationInfo.principalEmail AS user_email,
  protopayload_auditlog.resourceName, -- tableの情報
  JSON_EXTRACT_STRING_ARRAY(PARSE_JSON(protopayload_auditlog.metadataJson), "$.tableDataRead.fields") AS referenced_columns,
FROM
  `my-project.my_bq_audit_dataset.cloudaudit_googleapis_com_data_access`
WHERE
  TIMESTAMP_TRUNC(timestamp, DAY) = TIMESTAMP("2023-08-17")
  AND JSON_QUERY(protopayload_auditlog.metadataJson, "$.tableDataRead") IS NOT NULL
LIMIT
  1000

まとめ

「運用上の注意点」にも書いたようにまれにポリシータグが外れてしまうこともありえるが、管理の観点からは承認済みビューよりも楽になる側面は多分にあると思う。適材適所で列レベルのアクセス制御とポリシータグを採用していきたいと思う。