概要
列レベルセキュリティ + 動的データマスキング
PIIカラム(氏名・メール・電話番号・住所)に Data Catalog ポリシータグを付与し、categoryFineGrainedReader を持つグループのみ生値を閲覧可能にする。権限のない閲覧者にはクエリをエラーにせず、EMAIL_MASK 等でマスク済み値を返す。
行レベルセキュリティ(ROW ACCESS POLICY)
「CS担当者は自分の担当地域の会員のみ閲覧可」という業務ルールを、アプリ側のWHERE句ではなくテーブル自身に持たせる。どのツール・どのクエリから参照しても機械的に地域フィルタが適用される。
PIIなし gold 層マートへの分離
マーケティング分析は集計指標のみで足りるのに、PIIを含む生マートをそのまま参照させると露出面が不必要に広がる。集計専用の mart_member_metrics_public を分離し、PII保護の対象範囲を最小化する。
dbt grants × 消去要求連携 × 監査ログ
コンソールでの手動IAM付与を dbt grants でコード管理。承認済み消去要求は取り込み時の除外+post_hook削除の二重防御で反映。DATA_READ監査ログ+ログベースメトリクスで想定外アクセスを検知する。
問題
ECサイト MOps チームでは、CS(カスタマーサポート)・マーケティング分析チームが共通で参照する「会員360度ビュー」マート(mart_member_360)を BigQuery × dbt Core 1.9+ で運用している。氏名・メールアドレス・電話番号・住所などの個人情報(PII)に加えて、注文サマリーやチャーン予測スコアを1つのマートに集約している。
以下の dbt モデル・Terraform コードには 7つの設計・セキュリティ・ガバナンス上の問題 が潜んでいる。問題点を全て洗い出し、BigQuery 列レベルセキュリティ(Data Catalog Policy Tag)× 動的データマスキング(BigQuery Data Policy)× 行レベルセキュリティ(ROW ACCESS POLICY)× PIIを含まないgold層マートの分離 × dbt grants によるIAMのコード管理 × 消去要求(個人情報保護法・GDPR対応)との連携 × Data Access監査ログ + 想定外アクセス検知アラート を活用した Bad→Good リファクタリングを行ってください。
制約・前提条件
- BigQuery Standard SQL(GA機能のみ。列レベルセキュリティ・データマスキング・行アクセスポリシーは全てGA機能)
- dbt Core 1.9+(
grants設定、model contractenforcedが利用可) - Terraform 1.8+(
googleプロバイダー 5.x、Data Catalog / Data Policy 関連リソースはgoogle-betaを使用してよい) - ソーステーブル
member.stg_member_profile(member_id,full_name,email,phone_number,address,region,member_rank) - CS担当者は自分の担当地域(region)の会員のみ閲覧してよい業務ルールがある
- マーケティング分析チームは会員ランク・地域別の集計指標のみ必要で、生のPIIは不要
- 会員から退会・個人情報削除の申請があった場合、
erasure_requestsテーブル(member_id,status)にレコードが追加される。承認済み(status = 'approved')の会員は、以降このマートに現れてはならない - 現状、CS/マーケティング双方への
roles/bigquery.dataViewerはBigQueryコンソールから手動付与されており、ソーステーブルにはポリシータグが一切設定されていない
悪いコード (Before)
{{ config(materialized='table') }}
-- 問題①②: PIIカラムが平文のまま格納され、
-- 列レベルのアクセス制御・マスキングが一切ない
-- 問題④: マーケティング分析は集計指標だけで足りるのに、
-- この生マートをそのまま参照させている
SELECT
m.member_id,
m.full_name,
m.email,
m.phone_number,
m.address,
m.region,
m.member_rank,
o.lifetime_order_count,
o.lifetime_gmv,
c.churn_score
FROM `my-project.member.stg_member_profile` m
LEFT JOIN `my-project.orders.mart_member_order_summary` o
USING (member_id)
LEFT JOIN `my-project.ml.mart_churn_score` c
USING (member_id)
-- 問題③: 地域担当CSが自分の担当地域以外の会員も
-- 無制限に閲覧できる(行レベルセキュリティなし)
-- 問題⑥: erasure_requestsで承認済みの削除要求が
-- ここに一切反映されていない
# 問題⑤: BigQueryコンソールから手動で
# CS/マーケティングに roles/bigquery.dataViewer を付与
# → Terraformで管理されておらず、
# 誰がいつ何の権限を付与したか追跡不能
# 問題⑦: BigQueryへのData Access監査ログ(DATA_READ)が
# 有効化されておらず、誰がPIIレコードを
# 閲覧したか事後追跡できない
resource "google_bigquery_table" "stg_member_profile" {
dataset_id = "member"
table_id = "stg_member_profile"
schema = file("schemas/stg_member_profile.json")
# ポリシータグの指定なし(問題①の根本原因)
}
ヒント(段階的開示)
ヒント1 — 方向性
ROW ACCESS POLICY の3つを組み合わせて、「誰が」「どの列・行を」見られるかを宣言的に制御する。(2) 露出面の最小化 — マーケティング分析には集計指標しか要らないのに、PIIを含む生マートをそのまま参照させている。用途ごとに「PIIを含む制限マート」と「PIIを含まない公開マート(gold層)」を分離する。(3) ガバナンス・コンプライアンス — IAM権限をコンソールで手動付与(ClickOps)している、退会者の削除要求を反映する仕組みがない、誰がPIIにアクセスしたかの監査ログがない、といった「事後追跡・自動化の欠如」を dbt grants・消去パイプライン・監査ログで埋める。
ヒント2 — アプローチ
- 問題①: PIIカラムが平文で格納され、列レベルのアクセス制御が一切ない → Data Catalog のポリシータキソノミー・ポリシータグをソーステーブルの列に付与し、
roles/datacatalog.categoryFineGrainedReaderを持つグループのみ生値を閲覧可能にする - 問題②: 権限のないユーザーがそのPII列にアクセスすると単純にクエリがエラーになり実用性を欠く → BigQuery 動的データマスキング(Data Policy)をポリシータグに紐付け、権限のない閲覧者には
EMAIL_MASK等のマスク済み値を返す - 問題③: CS担当者が自分の担当地域外の会員も無制限に閲覧できる →
CREATE ROW ACCESS POLICYでregion = 担当地域の行のみ許可する - 問題④: マーケティング分析ダッシュボードが集計指標のみ必要なのに、PIIを含む生マートをそのまま参照している → PIIを含む制限マート(
mart_member_360_restricted)とPIIを含まない集計専用のgold層マート(mart_member_metrics_public)に分離する - 問題⑤:
roles/bigquery.dataViewerをコンソールから手動付与しており追跡できない → dbtモデルのgrants設定でIAM付与をコードで宣言・レビュー可能にする - 問題⑥:
erasure_requestsで承認された削除要求が反映される仕組みがない → 消去対象を除外するフィルタ + 過去分を物理削除するpost_hook+ dbt test での再発防止チェック - 問題⑦: 誰がいつPIIマートにアクセスしたかの監査ログがない → BigQuery Data Access監査ログ(
DATA_READ)を有効化し、想定principal以外のアクセスをログベースメトリクス + アラートで検知する
ヒント3 — コードの骨格
# ポリシータグ + 動的データマスキングの骨格
resource "google_data_catalog_taxonomy" "pii" {
display_name = "member-pii-taxonomy"
activated_policy_types = ["FINE_GRAINED_ACCESS_CONTROL"]
}
resource "google_data_catalog_policy_tag" "pii_restricted" {
taxonomy = google_data_catalog_taxonomy.pii.id
display_name = "PII_Restricted"
}
resource "google_bigquery_datapolicy_data_policy" "email_mask" { # google-beta
policy_tag = google_data_catalog_policy_tag.pii_restricted.name
data_policy_type = "DATA_MASKING_POLICY"
data_masking_policy { predefined_expression = "EMAIL_MASK" }
}
-- 行レベルセキュリティの骨格
CREATE OR REPLACE ROW ACCESS POLICY cs_region_filter
ON `mart_member_360_restricted`
GRANT TO ("group:cs-agents@example.com")
FILTER USING (
region = (SELECT assigned_region FROM `dim_cs_agent_region`
WHERE agent_email = SESSION_USER())
)
問題点分析(7点)
| # | 問題点 | 分類 | 改善方法 |
|---|---|---|---|
| 1 | PIIカラムに列レベルのアクセス制御が一切ない | セキュリティ | Data Catalog ポリシータグ + categoryFineGrainedReader |
| 2 | 権限のない閲覧者へのマスキングがない | セキュリティ/UX | BigQuery 動的データマスキング(EMAIL_MASK等) |
| 3 | CS担当者が地域外の会員も無制限に閲覧できる | セキュリティ | ROW ACCESS POLICYで担当地域のみ許可 |
| 4 | 集計指標のみで足りる用途にもPII付き生マートを参照 | 露出面/最小権限 | PIIなしgold層マートへ分離 |
| 5 | IAM権限をコンソールから手動付与(ClickOps) | ガバナンス | dbt grantsでコード管理 |
| 6 | 退会者の削除要求(消去要求)が反映されない | コンプライアンス | 除外フィルタ+物理削除post_hook+dbt test |
| 7 | PIIへのアクセス監査ログ・異常検知がない | 監査 | Data Access監査ログ+ログベースメトリクス+アラート |
アクセス制御の仕組み — Bad vs Good の比較
模範解答
# infra/pii_governance.tf
# 修正①: PIIタキソノミー・ポリシータグを定義し、ソーステーブルの列に機械的に紐付ける
resource "google_data_catalog_taxonomy" "pii" {
project = var.project_id
region = "asia-northeast1"
display_name = "member-pii-taxonomy"
description = "会員PII分類(氏名・メール・電話番号・住所)"
activated_policy_types = ["FINE_GRAINED_ACCESS_CONTROL"]
}
resource "google_data_catalog_policy_tag" "pii_restricted" {
taxonomy = google_data_catalog_taxonomy.pii.id
display_name = "PII_Restricted"
description = "個人を直接特定できる情報。CS/マーケティング権限保持者のみ生値を閲覧可能"
}
# 修正①: ポリシータグへの閲覧権限をグループ単位で最小権限付与
resource "google_data_catalog_policy_tag_iam_member" "cs_reader" {
policy_tag = google_data_catalog_policy_tag.pii_restricted.name
role = "roles/datacatalog.categoryFineGrainedReader"
member = "group:cs-agents@example.com"
}
resource "google_data_catalog_policy_tag_iam_member" "marketing_reader" {
policy_tag = google_data_catalog_policy_tag.pii_restricted.name
role = "roles/datacatalog.categoryFineGrainedReader"
member = "group:marketing-analysts@example.com"
}
# 修正②: ポリシータグに動的データマスキングを紐付け、権限のない閲覧者にはマスク済み値を返す
resource "google_bigquery_datapolicy_data_policy" "email_mask" {
provider = google-beta
project = var.project_id
location = "asia-northeast1"
data_policy_id = "email-mask-policy"
policy_tag = google_data_catalog_policy_tag.pii_restricted.name
data_policy_type = "DATA_MASKING_POLICY"
data_masking_policy {
predefined_expression = "EMAIL_MASK" # 例: yamada.taro@example.com → a***@***.com
}
}
resource "google_bigquery_datapolicy_data_policy" "default_mask" {
provider = google-beta
project = var.project_id
location = "asia-northeast1"
data_policy_id = "default-mask-policy"
policy_tag = google_data_catalog_policy_tag.pii_restricted.name
data_policy_type = "DATA_MASKING_POLICY"
data_masking_policy {
predefined_expression = "DEFAULT_MASKING_VALUE" # 電話番号・住所・氏名はNULL相当でマスク
}
}
# 修正①: ソーステーブルのスキーマにポリシータグを組み込む(schemas/stg_member_profile.json 抜粋)
# {
# "name": "email", "type": "STRING",
# "policyTags": { "names": ["${google_data_catalog_policy_tag.pii_restricted.name}"] }
# }
# 修正⑦: BigQueryへのData Read監査ログを有効化
resource "google_project_iam_audit_config" "bigquery_data_access" {
project = var.project_id
service = "bigquery.googleapis.com"
audit_log_config { log_type = "DATA_READ" }
}
# 修正⑦: PIIマートへの想定外アクセスを検知するログベースメトリクス + アラート
resource "google_logging_metric" "pii_mart_unexpected_access" {
project = var.project_id
name = "pii-mart-unexpected-access"
filter = <<-EOT
resource.type="bigquery_dataset"
protoPayload.resourceName:"tables/mart_member_360_restricted"
protoPayload.authenticationInfo.principalEmail!~"cs-agents@example\\.com|marketing-analysts@example\\.com|dbt-runner@"
EOT
metric_descriptor { metric_kind = "DELTA" value_type = "INT64" }
}
resource "google_monitoring_alert_policy" "pii_access_alert" {
project = var.project_id
display_name = "PIIマート想定外アクセス検知"
combiner = "OR"
conditions {
display_name = "想定外principalによるアクセス発生"
condition_threshold {
filter = "metric.type=\"logging.googleapis.com/user/${google_logging_metric.pii_mart_unexpected_access.name}\""
comparison = "COMPARISON_GT"
threshold_value = 0
duration = "0s"
}
}
notification_channels = [var.slack_notification_channel_id]
}
{{ config(materialized='table') }}
SELECT
m.member_id, m.full_name, m.email,
m.phone_number, m.address, m.region,
m.member_rank,
o.lifetime_order_count, o.lifetime_gmv,
c.churn_score
FROM `my-project.member.stg_member_profile` m
LEFT JOIN `my-project.orders.mart_member_order_summary` o
USING (member_id)
LEFT JOIN `my-project.ml.mart_churn_score` c
USING (member_id)
-- 行制限なし・消去要求未反映
-- models/marts/mart_member_360_restricted.sql
-- 修正⑤: grantsでIAM付与をコード管理
{{ config(
materialized='incremental',
incremental_strategy='merge',
unique_key='member_id',
grants={
'roles/bigquery.dataViewer': [
"group:cs-agents@example.com",
"group:marketing-analysts@example.com"
]
},
post_hook=[
-- 修正⑥: 承認済み消去要求の会員を
-- 増分実行のたびに物理削除
"DELETE FROM {{ this }}
WHERE member_id IN (
SELECT member_id FROM {{ ref('erasure_requests') }}
WHERE status = 'approved'
)"
]
) }}
WITH erasure_targets AS (
-- 修正⑥: 削除承認済み会員は取り込み対象から除外
SELECT member_id
FROM {{ ref('erasure_requests') }}
WHERE status = 'approved'
),
source AS (
SELECT
m.member_id,
m.full_name, -- 修正①②: Policy Tag + 動的マスキング
m.email,
m.phone_number,
m.address,
m.region,
m.member_rank,
o.lifetime_order_count,
o.lifetime_gmv,
c.churn_score
FROM {{ ref('stg_member_profile') }} m
LEFT JOIN {{ ref('mart_member_order_summary') }} o USING (member_id)
LEFT JOIN {{ ref('mart_churn_score') }} c USING (member_id)
WHERE m.member_id NOT IN (SELECT member_id FROM erasure_targets)
)
SELECT * FROM source
{{ config(materialized='view') }}
SELECT
region,
member_rank,
COUNT(*) AS member_count,
AVG(lifetime_gmv) AS avg_lifetime_gmv,
AVG(churn_score) AS avg_churn_score
FROM {{ ref('mart_member_360_restricted') }}
GROUP BY region, member_rank
HAVING COUNT(*) >= 5 -- k-匿名性の簡易担保: 少人数セルの再識別リスクを抑制
{% macro apply_row_access_policy() %}
{% set sql %}
CREATE OR REPLACE ROW ACCESS POLICY cs_region_filter
ON {{ ref('mart_member_360_restricted') }}
GRANT TO ("group:cs-agents@example.com")
FILTER USING (
region = (
SELECT assigned_region
FROM {{ ref('dim_cs_agent_region') }}
WHERE agent_email = SESSION_USER()
)
)
{% endset %}
{% do run_query(sql) %}
{% endmacro %}
-- BigQueryにネイティブのTerraformリソースが無いため
-- dbt run-operation apply_row_access_policy で適用する
# models/marts/_marts.yml
version: 2
models:
- name: mart_member_360_restricted
description: "会員360度ビュー(PII含む)。CS/マーケティング権限保持者のみアクセス可(Policy Tag + Row Access Policy + 動的データマスキング)。"
config:
contract:
enforced: true
columns:
- name: member_id
data_type: string
constraints:
- type: not_null
- name: email
data_type: string
meta:
policy_tags: ["pii_restricted"] # 修正①: model contractでポリシータグ適用をドキュメント上も強制
- name: phone_number
data_type: string
meta:
policy_tags: ["pii_restricted"]
- name: full_name
data_type: string
meta:
policy_tags: ["pii_restricted"]
- name: address
data_type: string
meta:
policy_tags: ["pii_restricted"]
- name: region
data_type: string
constraints:
- type: not_null
tests:
- dbt_utils.expression_is_true: # 修正⑥: 承認済み消去要求が残っていないことをbuild時に検証
expression: >
member_id NOT IN (
SELECT member_id FROM {{ ref('erasure_requests') }}
WHERE status = 'approved'
)
- name: mart_member_metrics_public
description: "PIIを含まない集計専用gold層マート。マーケティングダッシュボードの参照元。"
columns:
- name: member_count
tests:
- dbt_utils.accepted_range:
min_value: 5 # k-匿名性の担保をtestでも二重に保証
| 問題 | 修正内容 | 効果 |
|---|---|---|
| ① 列レベルアクセス制御なし | Data Catalog Policy Tag + categoryFineGrainedReader | 権限のないユーザーは生PIIを取得不能 |
| ② マスキング機構なし | 動的データマスキング(EMAIL_MASK等) | クエリをエラーにせず安全な代替値を返す |
| ③ 行レベルセキュリティなし | ROW ACCESS POLICY(地域フィルタ) | 業務ルールをテーブル自身に持たせ機械的に適用 |
| ④ 集計用途にも生PIIマートを参照 | PIIなしgold層マート分離 | PII保護の対象範囲を最小化 |
| ⑤ IAM手動付与(ClickOps) | dbt grantsでコード管理 | 権限変更をPull Requestでレビュー可能に |
| ⑥ 消去要求未反映 | 除外フィルタ+post_hook削除+dbt test | 退会者PIIの残存を二重防御で防止 |
| ⑦ 監査ログ・異常検知なし | DATA_READ監査ログ+ログベースメトリクス+アラート | 想定外アクセスをリアルタイムに近い形で検知 |
実行例(input → output)
input(mart_member_360_restricted 生データ、簡略化):
member_id full_name email region status(erasure)
M100 山田太郎 yamada.taro@example.com 関東 -
M200 佐藤花子 sato.hanako@example.com 関西 -
M999 鈴木一郎 suzuki.ichiro@example.com 関東 approved(削除要求承認済み)
output ① 列レベルセキュリティ + 動的データマスキング:
SELECT member_id, email FROM mart_member_360_restricted WHERE member_id = 'M100'
| 実行者 | 結果 |
|---|---|
cs-agents@example.com グループ所属(categoryFineGrainedReaderあり) | yamada.taro@example.com(生値) |
| 一般アナリスト(BigQuery Data Viewerのみ、ポリシータグ権限なし) | a***@***.com(EMAIL_MASKによりマスク済み値) |
output ② 行レベルセキュリティ:
-- 関東地域担当のCSエージェントが実行
SELECT member_id, region FROM mart_member_360_restricted
M100(関東)は返るが M200(関西)は ROW ACCESS POLICY のフィルタにより結果セットから除外される。
output ③ 消去要求の反映:
erasure_requests に M999, approved が登録された翌日の増分実行後、post_hook の DELETE により M999 は mart_member_360_restricted から物理削除され、以降のマート・ダッシュボードのどこにも現れない。dbt_utils.expression_is_true テストがこの状態を継続的に検証する。
ポイント解説
categoryFineGrainedReader の組み合わせだけでは、権限のないユーザーがその列を含むクエリを実行すると単純に権限エラーになる。実務ではCS向けダッシュボードのSQLがマーケティング分析者にも共有されることがあり、「列を含むだけでクエリ全体がエラーになる」のは使い勝手が悪い。動的データマスキングを追加で紐付けることで、権限のない閲覧者にはマスク済み値が返り、クエリ自体は成功する。両者は併用するのが実務的。ROW ACCESS POLICY はテーブル側に1度定義すれば、以降どのクライアント・どのツールから参照しても機械的に適用される「テーブル自身が持つ行フィルタ」になる。HAVING COUNT(*) >= 5 は地域×ランクの組合せ人数が極端に少ない集計行から個人が再識別されるリスク(k-匿名性)を抑える簡易的な担保。grants 設定は dbt run のたびに宣言された状態へ収束させるため、Pull Requestでレビューでき、Terraformの plan と同様に意図しない権限変更を事前に検知できる。WHERE member_id NOT IN (...) は新規増分データのみを守るフィルタであり、削除要求が承認された時点で既にテーブルに存在する行までは消せない。post_hook での DELETE によって既存行も確実に消去される。dbt test で継続的に検証することで、「消去したはずが特定の抽出条件でまだ残っていた」という実務でありがちな事故を防ぐ。meta.policy_tags はドキュメント上の意図表明に過ぎないが、実際にBigQueryのテーブルスキーマにタグを付与しておけば、将来新しいPII列(例: 生年月日)が追加された際にも、タキソノミー設計・IAM権限モデルの枠組みの中に自動的に組み込まれる。docstring頼みの「ルールを知っていれば守れる」状態から、「知らなくても機械的に守られる」状態への移行が本質。DATA_READ 監査ログとログベースメトリクスは、想定したprincipal以外がPIIマートにアクセスしたイベントを事後的に検知する仕組みであり、予防的統制(列・行レベルセキュリティ)と発見的統制(監査ログ)を両輪で設計するのがガバナンスの定石。実務への応用
ECサイト MOps チームが扱う会員データは、氏名・メール・電話番号・住所といった典型的なPIIに加えて、購買履歴・チャーン予測スコアのような「行動から推定される機微情報」も含む。CS・マーケティング・データサイエンスなど複数チームが同じ会員データを異なる粒度・異なる目的で参照する組織では、1つの巨大な会員マートを作ってアクセス制御を後回しにすると、個人情報保護法・GDPR対応が後付けで極めて困難になる。列・行レベルセキュリティとgold層分離を最初のマート設計時点で組み込んでおくことが、後からの手戻りコストを最小化する。
消去要求(忘れられる権利)への対応は、退会処理バッチだけでなく、この会員マートのような下流の全てのデータ資産に波及する。erasure_requests を単一の真実の情報源とし、それを参照する dbt モデル側で除外・削除ロジックを統一しておけば、新しいマートが追加されるたびに個別に消去ロジックを実装する必要がなくなる。
今日のまとめ
次のステップ
- 発展問題: 現在は
email列のみEMAIL_MASKを適用し、phone_numberとaddressには汎用のDEFAULT_MASKING_VALUE(NULL相当)を割り当てている。CS業務で「電話番号の下4桁だけは本人確認のために必要」という要件が追加された場合、BigQueryのカスタムマスキング式(data_masking_policy.routine)でどう実現するかを設計せよ。また、Cloud KMSでのカラム暗号化(AEAD.ENCRYPT/AEAD.DECRYPT)と動的データマスキングの使い分けの判断基準を整理せよ。 - 参考: BigQuery 列レベルセキュリティ(Data Catalog Policy Tag)公式ドキュメント / BigQuery 動的データマスキング(
predefined_expression)/ BigQueryROW ACCESS POLICY/ dbtgrants設定 / 個人情報保護法(消去請求権)・GDPR「忘れられる権利」/ k-匿名性