C データエンジニアリング — 会員360度ビューのPIIガバナンス(列レベルセキュリティ(Data Catalog Policy Tag) × 動的データマスキング(EMAIL_MASK) × 行レベルセキュリティ(ROW ACCESS POLICY) × PIIなしgold層マート分離 × dbt grantsによるIAMコード管理 × 消去要求(個人情報保護法/GDPR)連携 × Data Access監査ログ+想定外アクセス検知)(MOps 会員360度ビュー Bad→Good)

2026-07-29 (Day 112) 水曜 C: データエンジニアリング ★★★★☆ BigQuery 列/行レベルセキュリティ・動的データマスキング dbt Core 1.9+ grants / Terraform 1.8+ (google-beta)

概要

🏷️

列レベルセキュリティ + 動的データマスキング

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 contract enforced が利用可)
  • Terraform 1.8+(google プロバイダー 5.x、Data Catalog / Data Policy 関連リソースは google-beta を使用してよい)
  • ソーステーブル member.stg_member_profilemember_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コンソールから手動付与されており、ソーステーブルにはポリシータグが一切設定されていない
期待する回答形式: 問題点の列挙(番号付き)+ 改善後 Terraform コード(Policy Tag / Data Masking / 監査ログ)+ 改善後 dbt モデル・schema.yml(grants / contract)+ 行レベルセキュリティのSQL + 設計意図の説明

悪いコード (Before)

このコードには 7つの設計・セキュリティ上の問題 が隠れています。見つけてみてください。
bad_member_360.sql — PII平文・行制限なし・消去未反映
{{ 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で承認済みの削除要求が
--        ここに一切反映されていない
bad_iam.tf — ClickOps権限付与・監査ログなし
# 問題⑤: 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")
  # ポリシータグの指定なし(問題①の根本原因)
}
問題点サマリー(7点)
1PII列に列レベルのアクセス制御なし — Policy Tag + categoryFineGrainedReaderへ
2マスキング機構なし — 動的データマスキング(EMAIL_MASK等)へ
3行レベルセキュリティなし — ROW ACCESS POLICYで地域制限へ
4集計用途にも生PIIマートを参照 — PIIなしgold層マートへ分離
5IAMをコンソールで手動付与 — dbt grantsでコード管理へ
6消去要求が反映されない — 除外フィルタ+post_hook削除へ
7監査ログ・異常検知なし — DATA_READ監査+アラートへ

ヒント(段階的開示)

ヒント1 — 方向性
問題は3層に分類できる。(1) 列・行単位のアクセス制御 — PIIカラムに列レベルのアクセス制御が一切なく、権限を持つ・持たないに関わらず誰でも生の氏名・メール・電話番号を閲覧できてしまう。加えて、CS担当者が自分の担当地域外の会員情報まで見えてしまう「行」単位の過剰露出もある。BigQueryの列レベルセキュリティ(Data Catalog Policy Tag)・動的データマスキング・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 POLICYregion = 担当地域 の行のみ許可する
  • 問題④: マーケティング分析ダッシュボードが集計指標のみ必要なのに、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点)

#問題点分類改善方法
1PIIカラムに列レベルのアクセス制御が一切ないセキュリティData Catalog ポリシータグ + categoryFineGrainedReader
2権限のない閲覧者へのマスキングがないセキュリティ/UXBigQuery 動的データマスキング(EMAIL_MASK等)
3CS担当者が地域外の会員も無制限に閲覧できるセキュリティROW ACCESS POLICYで担当地域のみ許可
4集計指標のみで足りる用途にもPII付き生マートを参照露出面/最小権限PIIなしgold層マートへ分離
5IAM権限をコンソールから手動付与(ClickOps)ガバナンスdbt grantsでコード管理
6退会者の削除要求(消去要求)が反映されないコンプライアンス除外フィルタ+物理削除post_hook+dbt test
7PIIへのアクセス監査ログ・異常検知がない監査Data Access監査ログ+ログベースメトリクス+アラート

アクセス制御の仕組み — Bad vs Good の比較

Bad(変更前)— 単一マート・無防備なPII mart_member_360(table) 問題①: 氏名・メール・電話・住所が全て平文 問題②: マスキング機構なし → 見えるか、全く見えないかの二択 問題③: 行レベルフィルタなし → 全地域の会員が見える 問題⑥: erasure_requests(承認済み)が未反映のまま残存 CS(関西担当) 関東・関西・ 全地域の生PIIが 丸見え マーケ分析者 集計だけで良いのに 生メール・電話まで 参照可能 IAM: BigQueryコンソールで手動付与 問題⑤: Terraform管理外 × 問題⑦: 監査ログなし × 誰でも生の氏名・メール・電話番号を閲覧できる × CSが地域外の会員情報まで無制限に見える × 集計用途の利用者までPII保護の対象範囲に入る × 権限変更・アクセスの履歴がどこにも残らない × 退会者の削除要求が反映されず残存し続ける Good(変更後)— 列/行制御 + gold層分離 mart_member_360_restricted(PII含む) 修正①②: Policy Tag + 動的データマスキング(EMAIL_MASK) 修正③: ROW ACCESS POLICY(region = 担当地域) 修正⑥: 消去対象を除外 + post_hookで既存行も物理削除 修正⑤: grantsでIAMをコード管理(cs-agents / marketing-analysts) CS(関西担当) 関西の会員のみ表示 生PII(権限あり) 関東の行は0件 マーケ分析者 修正④: mart_member_metrics _public(PIIなしgold層) を参照、生PII非対象 修正⑦: DATA_READ監査ログ + ログベースメトリクス 想定外principalによるアクセスをアラートで即検知 ✓ 権限のない閲覧者にはマスク済み値、権限者には生値 ✓ CSは自分の担当地域の会員しか見えない ✓ 集計用途の利用者はPII保護の対象範囲から外れる ✓ 権限変更はPull Requestでレビュー可能 ✓ 消去要求が確実に反映され、監査ログで検証可能 修正

模範解答

# 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]
}
Before — 単一マート・PII平文・行制限なし
{{ 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)
-- 行制限なし・消去要求未反映
After — grants + 消去反映 + gold層分離
-- 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
mart_member_metrics_public.sql — 修正④: PIIなしgold層マート
{{ 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-匿名性の簡易担保: 少人数セルの再識別リスクを抑制
macros/apply_row_access_policy.sql — 修正③: 行レベルセキュリティ
{% 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_requestsM999, approved が登録された翌日の増分実行後、post_hookDELETE により M999mart_member_360_restricted から物理削除され、以降のマート・ダッシュボードのどこにも現れない。dbt_utils.expression_is_true テストがこの状態を継続的に検証する。

ポイント解説

1列レベルセキュリティは「拒否」、動的データマスキングは「代替値を返す」——役割が異なる — ポリシータグと categoryFineGrainedReader の組み合わせだけでは、権限のないユーザーがその列を含むクエリを実行すると単純に権限エラーになる。実務ではCS向けダッシュボードのSQLがマーケティング分析者にも共有されることがあり、「列を含むだけでクエリ全体がエラーになる」のは使い勝手が悪い。動的データマスキングを追加で紐付けることで、権限のない閲覧者にはマスク済み値が返り、クエリ自体は成功する。両者は併用するのが実務的。
2行レベルセキュリティは「業務ルールをSQLに落とし込む」ための機構 — 「CSは担当地域のみ閲覧可」という業務ルールをアプリケーション側のWHERE句に頼ると、新しいBIツール・新しいクエリが増えるたびに実装漏れが起きうる。ROW ACCESS POLICY はテーブル側に1度定義すれば、以降どのクライアント・どのツールから参照しても機械的に適用される「テーブル自身が持つ行フィルタ」になる。
3PIIを「含む・含まない」でマートを分ける発想 — 1つのマートに全ての情報を詰め込むと、PIIを必要としない利用者(集計表示)まで、PIIを含むテーブルへのアクセス権限を要求する構造になる。集計専用のgold層マートを分離すれば、大多数の参照者はPII保護の対象範囲そのものから外れ、権限管理・監査の対象を最小化できる。HAVING COUNT(*) >= 5 は地域×ランクの組合せ人数が極端に少ない集計行から個人が再識別されるリスク(k-匿名性)を抑える簡易的な担保。
4dbt grants はIAMを「レビュー可能な差分」に変える — コンソールからの手動権限付与は、誰が・いつ・なぜ付与したかがコード上に残らず、退職者・異動者の権限剥奪漏れにも気づきにくい。dbtモデルの grants 設定は dbt run のたびに宣言された状態へ収束させるため、Pull Requestでレビューでき、Terraformの plan と同様に意図しない権限変更を事前に検知できる。
5消去要求は「取り込み時の除外」と「既存データの物理削除」の両方が必要WHERE member_id NOT IN (...) は新規増分データのみを守るフィルタであり、削除要求が承認された時点で既にテーブルに存在する行までは消せない。post_hook での DELETE によって既存行も確実に消去される。dbt test で継続的に検証することで、「消去したはずが特定の抽出条件でまだ残っていた」という実務でありがちな事故を防ぐ。
6ソーススキーマへのポリシータグ組み込みは「機械的な防波堤」 — schema.yml の meta.policy_tags はドキュメント上の意図表明に過ぎないが、実際にBigQueryのテーブルスキーマにタグを付与しておけば、将来新しいPII列(例: 生年月日)が追加された際にも、タキソノミー設計・IAM権限モデルの枠組みの中に自動的に組み込まれる。docstring頼みの「ルールを知っていれば守れる」状態から、「知らなくても機械的に守られる」状態への移行が本質。
7監査ログは「守れているか」を事後検証する最後の防波堤 — 列・行レベルセキュリティを設計しても、設定ミスや想定外のサービスアカウント権限付与によって守りが破られる可能性はゼロにできない。DATA_READ 監査ログとログベースメトリクスは、想定したprincipal以外がPIIマートにアクセスしたイベントを事後的に検知する仕組みであり、予防的統制(列・行レベルセキュリティ)と発見的統制(監査ログ)を両輪で設計するのがガバナンスの定石。

実務への応用

ECサイト MOps チームが扱う会員データは、氏名・メール・電話番号・住所といった典型的なPIIに加えて、購買履歴・チャーン予測スコアのような「行動から推定される機微情報」も含む。CS・マーケティング・データサイエンスなど複数チームが同じ会員データを異なる粒度・異なる目的で参照する組織では、1つの巨大な会員マートを作ってアクセス制御を後回しにすると、個人情報保護法・GDPR対応が後付けで極めて困難になる。列・行レベルセキュリティとgold層分離を最初のマート設計時点で組み込んでおくことが、後からの手戻りコストを最小化する。

消去要求(忘れられる権利)への対応は、退会処理バッチだけでなく、この会員マートのような下流の全てのデータ資産に波及する。erasure_requests を単一の真実の情報源とし、それを参照する dbt モデル側で除外・削除ロジックを統一しておけば、新しいマートが追加されるたびに個別に消去ロジックを実装する必要がなくなる。

他機能への横展開: dbt grants によるIAMのコード化は、Terraformでのインフラ管理と同じ思想をデータプラットフォームのアクセス制御にも適用するもので、SOC2・ISMSのような外部監査対応でも「権限変更の履歴がGitに残っている」ことが監査証跡として直接的に有効に働く。

今日のまとめ

会員360度ビューのPIIガバナンス7点チェックリスト: ① ポリシータグ+categoryFineGrainedReaderで列レベルアクセス制御 ② 動的データマスキング(EMAIL_MASK等)で権限のない閲覧者にも安全な代替値を返す ③ ROW ACCESS POLICYで地域担当の業務ルールをテーブル自身に持たせる ④ PIIを含まないgold層マートへ分離し露出面を最小化 ⑤ IAM手動付与→dbt grantsでコード管理・レビュー可能に ⑥ 消去要求は取り込み時の除外フィルタ+既存データのpost_hook削除+dbt testの二重防御 ⑦ Data Access監査ログ+ログベースメトリクスで想定外アクセスを事後検知。 列・行レベルセキュリティという予防的統制と、監査ログという発見的統制を両輪で設計し、PIIを扱うマートは「最初から」ガバナンスを組み込むのが実務の核心。

次のステップ

  • 発展問題: 現在は email 列のみ EMAIL_MASK を適用し、phone_numberaddress には汎用の 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)/ BigQuery ROW ACCESS POLICY / dbt grants 設定 / 個人情報保護法(消去請求権)・GDPR「忘れられる権利」/ k-匿名性

自己評価(あとで記入)