C データエンジニアリング — dbt Mesh クロスプロジェクトref × アクセス制御 × Model Contract(BigQuery完全修飾テーブル名直書き × 全モデルprotected放置で公開範囲未設計 × 公開予定モデルにPIIが混入 × Model Contract無しで列名リネームが無警告伝播 × データセット丸ごとdataViewer付与 × group所有権未設定 × 鮮度監視なし)(MOps/CRM クーポンターゲティング顧客マスタ連携 Bad→Good)

2026-08-19 (Day 135) 水曜 C: データエンジニアリング ★★★★☆ dbt Core 1.8+ / dbt Mesh BigQuery Authorized View / Terraform 1.8+

概要

🔗

refで繋げばリネージが追跡できる

BigQueryの完全修飾テーブル名を直書きすると、dbtのDAGから外れて依存関係が見えなくなる。dbt Meshのクロスプロジェクトrefで、他プロジェクトのモデル変更をCIで検知できる状態を保つ。

🚪

公開は「意図して」狭く設計する

全モデルがデフォルトprotectedのままでは公開意図が曖昧になる。本当に他プロジェクトへ渡す1モデルだけをpublicにし、PIIは公開契約から外す。

📜

Model Contractで暗黙の約束を検証可能にする

列名リネームのような変更は契約が無ければ実行時に初めて発覚する。contract.enforcedでCRM側のビルド時点に破壊的変更を押し戻す。

🔑

権限もモデルの公開範囲に合わせて絞る

データセット丸ごとのdataViewerは1テーブルのための過剰権限になりやすい。Authorized Viewで公開モデル相当の1点にBigQuery側の権限も一致させる。

問題

ECサイトでは、CRMチームが管理する dbt プロジェクト(crm)で顧客マスタのマート dim_customer(customer_id, email, phone, customer_segment, ltv_yen, updated_at 等)を日次で構築している。MOps チームは別の dbt プロジェクト(mops)で、クーポンのターゲティング配信リスト mart_coupon_targeting を作るために、この dim_customer を参照したい。

現状、MOps チームのエンジニアは以下のような実装で dim_customer を利用しており、7つの設計・データガバナンス上の問題が潜んでいる。

問題点を全て洗い出し、dbt Mesh のクロスプロジェクト ref()(プロジェクト名を明示した参照)× モデルの access 修飾子(public/protected/private)による公開範囲の限定 × group によるモデル所有権の明確化 × 公開モデルへの Model Contract 適用(列・型を固定しPIIを含めない)× BigQuery Authorized View によるデータセット越しアクセスの最小権限化 × 公開モデルの鮮度監視を活用したBad→Goodリファクタリングを行ってください。

制約・前提条件

  • dbt Core 1.8+(dbt Mesh のクロスプロジェクト ref()、access 修飾子、group、Model Contract がいずれも利用可能)
  • BigQuery Standard SQL、Terraform 1.8+(google プロバイダー 5.x)
  • CRM プロジェクトの dbt モデルは GCPプロジェクト crm-project のデータセット crm_marts に、MOps プロジェクトのモデルは mops-project のデータセット mops_marts にそれぞれ配置される(プロジェクト分離構成)
  • dim_customer には email, phone などのPIIカラムが含まれる。MOpsチームがクーポンターゲティングに必要なのは customer_id, customer_segment, ltv_yen のみで、PIIは不要
  • 現状、CRMチームの dim_customer に access 修飾子・group は一切設定されておらず、CRMプロジェクト配下の中間モデル(staging/intermediate)を含む全モデルがデフォルトの protectedのまま。MOps側は dbt の ref() 機構を使わず、BigQueryの完全修飾テーブル名を直書きしている
  • MOpsのサービスアカウント(mops-sa@mops-project.iam.gserviceaccount.com)には、crm_marts データセット全体に対する roles/bigquery.dataViewer がTerraformで付与されている(dim_customer 以外の全テーブル・全中間モデルも閲覧可能な状態)
  • CRMチームは1ヶ月前、dim_customer の列名を ltv → ltv_yen にリネームしたが、MOps側への事前連絡は無く、MOpsの mart_coupon_targeting が数日間サイレントに壊れていた(ltv 参照でNULL)という事故が過去に発生している
  • CRMチームの日次dbt実行が失敗した日でも、MOps側は前日分の古い dim_customer をそのまま使い続けてしまい、鮮度劣化に気づけない
期待する回答形式: 問題点の列挙(番号付き)+ 改善後 dbt 設定(CRM側: dbt_project.yml・schema.ymlの access/group/contract、MOps側: クロスプロジェクトref)+ 改善後 Terraform コード(Authorized View)+ 実行例(input→output)+ 設計意図の説明

悪いコード (Before)

この実装には 7つの設計・データガバナンス上の問題 が隠れています。見つけてみてください。
mops/models/marts/mart_coupon_targeting.sql — 完全修飾テーブル名直書き
-- 問題①: dbtのref()を使わずBigQuery完全修飾テーブル名を直書き
--        → CRM側の変更がdbtのDAG/CIで検知できない
-- 問題③: email/phoneまで丸ごとSELECTしておりPIIが越境
SELECT customer_id, email, phone, customer_segment, ltv_yen
FROM `crm-project.crm_marts.dim_customer`
WHERE customer_segment IN ('vip', 'regular')
crm/models/marts/dim_customer.sql — access/group/contract未設定
-- 問題②: access修飾子が未設定(デフォルトprotected)
--        公開意図がコード上に無い
-- 問題④: Model Contractが無く列名リネームが無警告で伝播
--        (1ヶ月前 ltv→ltv_yen でMOps側が数日間サイレント障害)
-- 問題⑥: groupが未設定で所有権が不明確
SELECT customer_id, email, phone, customer_segment,
       ltv_yen, updated_at
FROM {{ ref('int_customer_enriched') }}
問題点サマリー(7点)
1完全修飾テーブル名の直書き — dbtのDAGから外れリネージが切れる
5データセット丸ごとdataViewer — 中間モデルまで直接閲覧可能な過剰権限
7鮮度監視なし — CRM側実行失敗に気づけない

ヒント(段階的開示)

ヒント1 — 方向性
問題は3層に分類できる。(1) 参照方法そのもの——BigQueryの完全修飾テーブル名を直書きすると、dbtの依存関係グラフ(DAG)にCRM側モデルが乗らず、リネージ(データがどこから来ているか)も追跡不能になる。CRMチームが列を変更しても、MOps側のCIは何も検知できず、本番で初めて壊れる。(2) 公開範囲の設計——CRMプロジェクトの全モデルが protected(同一プロジェクト内のみ参照可)のままだと、本来はMOps用に「意図して公開する1モデル」だけをpublicにすべきところが曖昧になり、結果としてIAMレベルで丸ごとデータセットを開放するという粗い権限管理に頼ってしまっている。(3) 契約と鮮度——公開するモデルには「型・列名を変えたら壊れることをCI上で検知できる」Model Contractと、「データが古くなったら気づける」鮮度監視の両方が必要で、どちらか片方だけでは事故を防げない。
ヒント2 — アプローチ
  • 問題①: MOps側が crm-project.crm_marts.dim_customer を完全修飾名で直書き → dbt Meshのクロスプロジェクト ref('crm', 'dim_customer') に置き換え、dbtのDAGにCRM側モデルを乗せる
  • 問題②: CRMプロジェクトの全モデルが access 未設定(デフォルトprotected)で公開意図が明示されていない → dim_customer のみ access: public を明示し、それ以外(staging/intermediate)は access: private に格下げして誤参照を物理的に防ぐ
  • 問題③: publicにする dim_customer に email/phone などPIIカラムがそのまま含まれる → 公開用モデルからPII列を除外した契約を定義する(PIIが必要な処理はCRMプロジェクト内で完結させる)
  • 問題④: dim_customer にModel Contractが無く、列名リネーム(ltv→ltv_yen)が無警告でMOps側を壊した → config.contract.enforced: true と columns 定義でスキーマを固定し、破壊的変更時はdbtのビルドがCRM側CIで即座に失敗するようにする
  • 問題⑤: MOpsのSAが crm_marts データセット全体にdataViewerを持ち、dim_customer 以外の中間モデルも直接読める → データセット丸ごとのIAM付与を撤去し、BigQuery Authorized Viewで dim_customer 相当の1ビューのみをMOps側から参照可能にする
  • 問題⑥: dim_customer に group(所有権)が設定されておらず、誰が変更を承認すべきか不明確 → dbt_project.yml に groups を定義し、group: crm_team を付与してモデルの所有権をコードで明示する
  • 問題⑦: CRM側のdbt実行が失敗しても、MOps側が古いデータに気づけない → MOps側で dim_customer の updated_at を検証する鮮度チェック(singular test)を追加し、閾値超過でアラートする
ヒント3 — コードの骨格
# crm/models/marts/_marts.yml の骨格
models:
  - name: dim_customer
    access: public          # 意図して公開する唯一のモデル
    group: crm_team
    config:
      contract:
        enforced: true
    columns:
      - name: customer_id
        data_type: string
        constraints: [{ type: not_null }, { type: primary_key }]
      - name: customer_segment
        data_type: string
      - name: ltv_yen
        data_type: numeric
      # email/phoneは公開契約に含めない(PII非公開)
-- mops/models/marts/mart_coupon_targeting.sql の骨格
SELECT
    customer_id,
    customer_segment,
    ltv_yen
FROM {{ ref('crm', 'dim_customer') }}  -- クロスプロジェクトref
WHERE customer_segment IN ('vip', 'regular')

問題点分析(7点)

#問題点分類改善方法
1完全修飾テーブル名の直書き参照方法層クロスプロジェクトref('crm','dim_customer')
2全モデルprotectedのまま公開範囲未設計公開範囲層dim_customerのみaccess:publicを明示
3公開モデルにPII(email/phone)が混入公開範囲層公開契約からPII列を除外
4Model Contract無しで破壊的変更が無警告伝播契約層contract.enforced:trueでCI検知
5データセット丸ごとdataViewer付与権限層Authorized Viewで1ビューに限定
6group(所有権)未設定公開範囲層dbt_project.ymlのgroupsで明示
7鮮度監視が無い契約層updated_at検証のsingular test

プロジェクト境界図 — Bad vs Good の比較

Bad(変更前)— 境界が無い直結構成 crm-project.crm_marts(全モデルprotected・access/group未設定) 問題②⑥: dim_customerも中間モデルも同列に扱われる 問題③: email/phone等PIIがdim_customerに同居 問題④: Model Contract無し。列名リネームが無警告で伝播 roles/bigquery.dataViewer(crm_martsデータセット全体) 問題⑤: mops-saがdim_customer以外の全テーブルも直接閲覧可 dbtを経由しないBigQueryコンソール等からも越境可能 mops/models/marts/mart_coupon_targeting.sql 問題①: 完全修飾テーブル名を直書き。dbtのDAGから外れる CRM側の変更がMOps側のCIで検知不能 CRM側の日次実行が失敗した日 問題⑦: MOps側は前日データのまま気づけず配信を継続 実績: ltv→ltv_yenリネームで数日間サイレント障害が既発生 × プロジェクト間の境界がコード上に存在しない × 権限もモデル公開範囲もPIIも野放し × 破壊的変更・鮮度劣化のどちらも検知できない Good(変更後)— 境界を明示したMesh構成 crm-project.crm_marts(group: crm_team) 修正②⑥: staging/intermediateはaccess:private / dim_customerのみpublic 修正③: dim_customerはPII非公開契約(email/phone除外) 修正④: contract.enforced:trueで列変更をビルド時に検知 1ヶ月前のような無警告リネームはCRM側CIでその場で失敗 google_bigquery_dataset_access(Authorized View) 修正⑤: dim_customer_view経由のみ許可。dataViewer丸ごと付与は撤去 他の中間モデル・生テーブルはmopsから閲覧不能に mops/models/marts/mart_coupon_targeting.sql 修正①: ref('crm','dim_customer')でDAGに接続、リネージ復活 CRM側の破壊的変更をDiscoveryで機械的に特定可能 mops/tests/assert_dim_customer_is_fresh.sql 修正⑦: updated_atが24時間以上前ならテスト失敗でアラート CRM側実行失敗をMOps側で検知できる ✓ 公開範囲・PII・権限・契約・鮮度が一致した境界線 ✓ 破壊的変更はCRM側CIでその場で検知 ✓ 鮮度劣化もMOps側で検知しアラート可能 修正

模範解答

crm/dbt_project.yml(抜粋)
groups:
  - name: crm_team
    owner:
      name: CRM Team
      email: crm-team@example.com

models:
  crm:
    staging:
      +group: crm_team
      +access: private       # 修正②: 中間モデルは明示的に非公開化
    intermediate:
      +group: crm_team
      +access: private       # 修正②: 中間モデルは明示的に非公開化
    marts:
      +group: crm_team
      # dim_customerのみ個別にpublicへ上書き(下記schema.yml)
crm/models/marts/_marts.yml
version: 2

models:
  - name: dim_customer
    access: public            # 修正②: 意図して公開する唯一のモデル
    group: crm_team           # 修正⑥: 所有権をコードで明示
    description: >
      クーポンターゲティング等、他プロジェクトからの参照を想定した
      顧客マスタの公開ビュー。PII(email/phone)は含まない契約。
    config:
      contract:
        enforced: true        # 修正④: 破壊的な列変更をビルド時に検知
    columns:
      - name: customer_id
        data_type: string
        constraints: [{ type: not_null }, { type: primary_key }]
      - name: customer_segment
        data_type: string
        constraints: [{ type: not_null }]
      - name: ltv_yen
        data_type: numeric
      - name: updated_at
        data_type: timestamp
        constraints: [{ type: not_null }]
      # 修正③: email/phoneは公開契約の列定義に含めない
crm/models/marts/dim_customer.sql
-- 修正③: PIIカラムを公開モデルの出力から除外
-- (email/phoneが必要な処理はCRMプロジェクト内の
--  private模型: int_customer_with_pii 等で完結させる)
SELECT
    customer_id,
    customer_segment,
    ltv_yen,
    updated_at
FROM {{ ref('int_customer_enriched') }}   -- private中間モデル(PII含む)
Before — 完全修飾テーブル名直書き
SELECT customer_id, email, phone, customer_segment, ltv_yen
FROM `crm-project.crm_marts.dim_customer`
WHERE customer_segment IN ('vip', 'regular')
After — クロスプロジェクトref
-- 修正①: 完全修飾テーブル名の直書きをやめ、
--        dbt Meshのクロスプロジェクトrefでリネージを繋ぐ
SELECT
    customer_id,
    customer_segment,
    ltv_yen
FROM {{ ref('crm', 'dim_customer') }}
WHERE customer_segment IN ('vip', 'regular')
mops/tests/assert_dim_customer_is_fresh.sql
-- 修正⑦: CRM側の失敗・遅延に気づくための鮮度チェック(singular test)
-- 全件が24時間以上前のデータ = CRM側の更新が丸1日以上停止している状態
SELECT customer_id, updated_at
FROM {{ ref('crm', 'dim_customer') }}
WHERE updated_at < TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 24 HOUR)
HAVING COUNT(*) = (SELECT COUNT(*) FROM {{ ref('crm', 'dim_customer') }})
# infra/authorized_view.tf

# 修正⑤-1: MOps側のプロジェクトに、CRMのdim_customerを参照するビューを作成
#          (MOps側の権限で完結する自分のデータセット内のビュー)
resource "google_bigquery_table" "dim_customer_view" {
  project    = "mops-project"
  dataset_id = "mops_marts"
  table_id   = "dim_customer_view"

  view {
    query          = "SELECT customer_id, customer_segment, ltv_yen FROM `crm-project.crm_marts.dim_customer`"
    use_legacy_sql = false
  }
}

# 修正⑤-2: CRM側のデータセットに、上記ビューだけを「認可されたビュー」として登録
#          これによりMOpsのSAはdim_customer_viewを経由した閲覧のみ可能になり、
#          crm_martsデータセット全体へのIAM付与は不要になる
resource "google_bigquery_dataset_access" "mops_authorized_view" {
  project    = "crm-project"
  dataset_id = "crm_marts"

  view {
    project_id = google_bigquery_table.dim_customer_view.project
    dataset_id = google_bigquery_table.dim_customer_view.dataset_id
    table_id   = google_bigquery_table.dim_customer_view.table_id
  }
}

# 修正⑤-3: 撤去対象。crm_martsデータセット全体へのdataViewer付与は
#          このAuthorized View運用に置き換えるため削除する。
# resource "google_bigquery_dataset_iam_member" "mops_sa_crm_dataviewer" {
#   project    = "crm-project"
#   dataset_id = "crm_marts"
#   role       = "roles/bigquery.dataViewer"
#   member     = "serviceAccount:mops-sa@mops-project.iam.gserviceaccount.com"
# }
問題修正内容効果
① 完全修飾テーブル名直書きref('crm','dim_customer')dbtのDAGに乗りリネージが復活
② 全モデルprotected放置dim_customerのみaccess:public公開意図がコード上に明示される
③ 公開モデルにPII混入公開契約からemail/phoneを除外最小権限が権限設定より前段で担保される
④ Model Contract無しcontract.enforced:true列名リネームがCRM側CIでその場で失敗
⑤ データセット丸ごとdataViewerAuthorized Viewで1ビューに限定中間モデル・生テーブルへの越境を遮断
⑥ group未設定dbt_project.ymlのgroupsで明示変更承認の所有権がコードから読み取れる
⑦ 鮮度監視なしupdated_at検証のsingular testCRM側実行失敗をMOps側で検知

実行例(input → output)

input(改善前):

CRMチームが dim_customer の ltv_yen を将来さらに ltv_yen_ex_tax(税抜)へリネームしたとする。

output(Before・問題のある実装):

MOps側は完全修飾テーブル名を直書きしているためdbtのDAGに依存関係が乗っておらず、CRM側のCIはMOps側の破壊に気づかない。MOps側の mart_coupon_targeting はテーブル定義変更後の次回実行時に ltv_yen 列が存在せずエラー、あるいは列が残っていればサイレントにNULLが混入し、クーポン配信対象の絞り込みが数日単位で静かに誤動作する。

output(After・改善後):

CRM側で dim_customer の列を変更しようとすると、contract.enforced: true により、契約で定義された列(ltv_yen 等)を変更する dbt build がCRM側のCIでその場で失敗する。CRMチームはこの時点でMOpsチームへの事前連携が必要だと気づける。また、クロスプロジェクト ref('crm', 'dim_customer') により、dbtの依存関係グラフ上でMOps側のモデルがCRM側の変更に接続されているため、CRM側のDiscovery情報からMOps側で影響を受けるモデルを機械的に特定できる。

ポイント解説

1完全修飾テーブル名の直書きは「依存関係の隠蔽」である — BigQuery上は動くが、dbtのDAGという「変更の影響範囲を機械的に追跡する仕組み」から外れてしまう。クロスプロジェクトでも ref() を使うことが、リネージとCI検知の両方の前提条件になる。
2access 修飾子は「公開する意図」をコードで宣言する仕組み — デフォルトのprotectedのまま全モデルを放置すると、「本当に公開してよいもの」と「たまたま参照できてしまうもの」の区別が失われる。publicは狭く・意図的に付与するのが原則。
3公開契約にはPIIを含めない設計が最小権限の実践になる — BigQueryのIAMやAuthorized Viewで権限を絞る前に、そもそも公開するモデルの「列そのもの」を必要最小限に設計しておくことで、権限設定のミスがあっても実害が起きにくい多層防御になる。
4Model Contractは「他チームとの暗黙の約束」を明示的な検証可能項目に変える — 列名リネームのような変更は、契約が無ければ実行時に初めて発覚するが、契約があればCRM側のビルド時点で機械的に失敗するため、事前調整のきっかけを強制的に作れる。
5データセット丸ごとのIAM付与は「必要な1テーブル」のための過剰権限になりやすい — Authorized Viewは「このビューを経由した場合のみ閲覧可能」という粒度で権限を絞れるため、公開モデル1つのためにデータセット全体を開放する必要が無くなる。
6group によるモデル所有権の明示は、レビュー体制をコードに落とし込む手段 — 「誰がこのモデルの変更を承認すべきか」がコードから読み取れることで、クロスプロジェクト参照が増えても変更管理が属人化しにくくなる。
7公開モデルには「壊れたら分かる」だけでなく「古くなったら分かる」監視も必要 — Model Contractはスキーマの正しさを保証するが、データの鮮度は別軸の問題であり、両方揃って初めて「他チームが安心して参照できるデータ」になる。

実務への応用

MOpsチームは今後、CRM・在庫・注文など複数チームが所有するデータを横断的に参照する機会が増えていく。その際、BigQueryのテーブル名を直書きして「動けばよい」状態にすると、参照先チームの変更が見えないまま積み重なり、ある日突然サイレントに壊れるという事故が繰り返される。dbt Meshの access/group/contract は、複数チームが同じデータ基盤を使う際の「境界線」をコードとして明示する仕組みであり、複数チーム協業のプロジェクトでも同様の設計思想(公開範囲の明示・契約による破壊的変更の検知・鮮度の監視)がそのまま応用できる。

Authorized Datasetという選択肢: 今回は1テーブル相当のAuthorized Viewで最小権限化したが、公開対象がマート全体(複数テーブル)に広がる場合は、BigQueryの Authorized Dataset(データセット単位の認可)を使うと、ビューを1枚ずつ登録する運用コストを抑えられる。ただしその場合も、公開データセット側には「公開してよいモデルだけ」を意図的に集約しておく設計が前提になる。

今日のまとめ

dbt Meshによるクロスチームデータ連携の7点チェックリスト: ① 完全修飾テーブル名ではなくクロスプロジェクトrefでDAGに接続する ② 公開するモデルはaccess:publicで意図的に選び、それ以外はprivateへ格下げする ③ 公開契約にPIIを含めない ④ Model Contractで破壊的変更をビルド時に検知する ⑤ BigQueryの権限も公開モデルの範囲に合わせてAuthorized Viewで絞る ⑥ groupでモデルの所有権を明示する ⑦ 公開モデルの鮮度を監視し古いデータの使用に気づける仕組みを持つ。 複数プロジェクトでデータを共有する設計は、dbtのガバナンス機構とBigQuery側の権限設計を一致させて初めて「安心して他チームが参照できるデータ」になる。

次のステップ

  • 発展問題: CRMチームが dim_customer をさらに分割し、dim_customer_basic(customer_id, customer_segment)と dim_customer_ltv(customer_id, ltv_yen)の2モデルに分ける設計変更を行う場合、MOps側の mart_coupon_targeting への影響を最小化する移行手順(新旧モデルの並行稼働期間の設け方、access/contractの移行順序)を設計せよ。
  • 参考: dbt Mesh(クロスプロジェクト ref、access修飾子、group) / dbt Model Contracts / BigQuery Authorized View・Authorized Dataset / 最小権限の原則(Principle of Least Privilege)

自己評価(あとで記入)