概要
IAMとVPC-SCは直交する2つの防御層
IAMは「誰が・何に」アクセスできるかを制御するが、正規の権限で見えたデータを境界の外へ持ち出せるかは別問題。列・行レベルセキュリティ(07-29)で「見せる/見せない」を固めても、EXPORT DATA/bq cpでの持ち出しは塞がれない。
Dry-runは本番断ち切りを避ける必須ステップ
use_explicit_dry_run_spec = true で数週間実運用させ、想定外のブロック(=設計漏れの通信経路)を洗い出してからstatusへ昇格する。いきなりenforceにすると正当な業務フローごと止まるリスクがある。
IAPは「接続元」ではなく「接続元の状態」を評価する
従来のVPN+IPアリストは送信元IPしか見ない。Access Levelのdevice_policyはOSパッチ・暗号化状態・画面ロック・会社支給端末かどうかまで評価対象にできる。
アクセス権限は個人ではなくグループに付与する
roles/iap.httpsResourceAccessorを個人アカウントへ直接付与すると異動・退職のたびに削除漏れが起きやすい。IdP同期グループへ付与し、四半期Access Reviewを運用に組み込むことで権限残存を構造的に防ぐ。
問題
MOps チームは 07-29 に会員360度ビューへ Data Catalog ポリシータグ・動的マスキング・行レベルセキュリティ(ROW ACCESS POLICY)を導入し、BigQuery内部の列・行単位のPIIガバナンスを固めた。しかし今回、社内セキュリティ監査で次の指摘を受けた。
bigquery.dataViewer を持つ社員であれば、認証さえ通れば自分の個人GCPプロジェクトへ EXPORT DATA や bq cp でPIIデータをそのままコピーできてしまう。列・行レベルのアクセス制御は『見せる/見せない』は制御できても、『正規の権限で見えたデータをどこに持ち出せるか』は一切制御できていない。」
さらに、社外(自宅・カフェ等)から BigQuery Console や社内Admin UI(Looker Studio埋め込みダッシュボード)へのアクセスは、VPNクライアント経由の固定IPアリストのみで許可されており、端末がOSアップデート済みか・ディスク暗号化されているか等のデバイス状態は一切検証されていない(VPNさえ繋がれば私物PCからでもアクセス可能)。この2つの構造的な欠陥について、7つの問題点を洗い出し、VPC Service Controls(Service Perimeter)と Identity-Aware Proxy(Context-Aware Access)を使って改善せよ。
制約・前提条件
- GCP組織構成:
mops-prod(BigQuery/GCS本番),mops-analytics(Looker Studio/dbt実行基盤)の2プロジェクトが日常的にデータをやり取りする(dbt Mesh cross-project ref、07-22 参照) - 保護対象サービス:
bigquery.googleapis.com,storage.googleapis.com - 管理コンソール: 社内Admin UI(Cloud Run上でホスト、IAP未導入)、BigQuery Console
- Access Context Manager が利用可能(組織ポリシー管理者権限あり)
- Terraform 1.8+, google-beta provider(Access Context Manager系リソースはbetaを含む)
- 現状: VPC Service Controls不在で境界外へ自由に持ち出し可能/クロスプロジェクト連携未整理/いきなりenforce計画/VPN固定IPアリストのみでデバイス状態未検証/IAP権限の個人付与+棚卸し未実施/Access LevelがIPのみ/VPC_SC_DENIEDにアラートなし
悪い構成 (Before)
# 問題①: VPC Service Controlsそのものが存在しない
# → mops-prod の bigquery.dataViewer を持つ全員が、
# 個人プロジェクトへ EXPORT DATA / bq cp で自由にPIIデータを持ち出せる
# 問題②: mops-prod/mops-analytics間のdbt Mesh連携経路も未整理のまま
# 問題③: 導入するとしてもいきなりenforceで有効化する計画(影響範囲の事前可視化なし)
resource "google_cloud_run_v2_service" "admin_console" {
name = "mops-admin-console"
location = "asia-northeast1"
ingress = "INGRESS_TRAFFIC_ALL" # 問題④: VPNさえ繋がれば私物PCからも到達可能
template {
containers {
image = "asia-northeast1-docker.pkg.dev/mops-prod/admin/console:1.2.0"
}
}
}
# デバイスの暗号化状態・OSパッチ適用状況は一切検証されない
# 問題⑤: IAM権限を個人アカウントへ直接付与(棚卸し運用なし)
resource "google_cloud_run_v2_service_iam_member" "invoker" {
for_each = toset([
"user:taro.yamada@example.com",
"user:hanako.sato@example.com", # 実は半年前に異動済み → 権限残存
])
name = google_cloud_run_v2_service.admin_console.name
role = "roles/run.invoker"
member = each.value
}
# 問題⑥: アクセス条件を作るとしてもIPアドレスのみで、地理的異常アクセスを検知できない
# 問題⑦: VPC_SC_DENIED監査ログにアラート設定がなく、予兆・誤設定に気づけない
ヒント(段階的開示)
ヒント1 — 方向性
EXPORT・COPY 等をブロックする、IAMとは独立したもう一段の防御層。(2) 管理コンソールへの接続元の信頼性検証の欠如 — 従来のVPN+IPアリストは「どこから来たか」しか見ておらず、「その端末が信頼できる状態か」(パッチ適用済み・暗号化済み等)を見ていない。IAP の Context-Aware Access(Access Level)は、IP・地域・デバイスポリシーを組み合わせた条件をリクエスト単位で評価する。どちらも「一度信頼したら無条件に許可し続ける」のではなく、アクセスのたびに複数条件を再評価するゼロトラストの考え方が共通軸になる。
ヒント2 — アプローチ
- 問題①: VPC Service Controls不在 →
google_access_context_manager_service_perimeterでbigquery.googleapis.com/storage.googleapis.comを対象に Perimeter を作成し、境界外への EXPORT/COPY をブロックする - 問題②: mops-prod/mops-analytics間の正当な連携経路が未整理 → 両プロジェクトを同一 Perimeter に含めるか、Perimeter Bridge(相互アクセス許可)を設定する
- 問題③: いきなりenforceで有効化する計画 →
use_explicit_dry_run_spec = trueのDry-runモードで数週間ログを観察し、想定外のブロックがないことを確認してからstatusへ昇格する - 問題④: VPN固定IPアリストのみでデバイス状態未検証 → 管理コンソール(Cloud Run)の前段に IAP を有効化し、Access Level の
device_policy(画面ロック・会社支給端末・暗号化状態)を条件に含める - 問題⑤: IAPアクセス権限の棚卸し未実施 →
roles/iap.httpsResourceAccessorを個人付与ではなくグループ(IdPと同期)に付与し、四半期ごとの Access Review を運用に組み込む - 問題⑥: Access LevelがIPのみ →
ip_subnetworksに加えてregions(許可国コード)・device_policyをAND条件で組み合わせる - 問題⑦: VPC_SC_DENIEDの監査ログにアラートなし → Cloud Logging の該当ログエントリをログベースメトリクス化し、Monitoring Alert Policy で即時通知する
ヒント3 — コードの骨格
# Access Level(デバイスポリシー + IP + 地域)
resource "google_access_context_manager_access_level" "corp_trusted" {
parent = "accessPolicies/${var.access_policy_id}"
name = "accessPolicies/${var.access_policy_id}/accessLevels/corp_trusted"
title = "corp_trusted"
basic {
conditions {
ip_subnetworks = ["203.0.113.0/24"] # 修正⑥: 社内VPN出口IP
regions = ["JP"] # 修正⑥: 許可国コード
device_policy {
require_screen_lock = true # 修正④
require_corp_owned = true # 修正④
}
}
}
}
# Service Perimeter(Dry-runで開始, 修正①③)
resource "google_access_context_manager_service_perimeter" "mops_perimeter" {
parent = "accessPolicies/${var.access_policy_id}"
name = "accessPolicies/${var.access_policy_id}/servicePerimeters/mops_data_perimeter"
title = "mops_data_perimeter"
perimeter_type = "PERIMETER_TYPE_REGULAR"
use_explicit_dry_run_spec = true # 修正③: まずDry-runで影響範囲を可視化
spec {
resources = ["projects/${var.mops_prod_project_number}", "projects/${var.mops_analytics_project_number}"] # 修正②
restricted_services = ["bigquery.googleapis.com", "storage.googleapis.com"]
}
}
問題点分析(7点)
| # | 問題点 | 分類 | 改善方法 |
|---|---|---|---|
| 1 | Service Perimeter不在で境界外へ自由に持ち出し可能 | データ流出防止 | bigquery/storage APIを対象にPerimeter作成 |
| 2 | mops-prod/mops-analytics連携経路が未整理 | 境界設計 | 両プロジェクトを同一Perimeterに包含 |
| 3 | いきなりenforceで有効化する計画 | 段階移行 | use_explicit_dry_run_specでDry-run先行 |
| 4 | VPN固定IPアリストのみでデバイス状態未検証 | ゼロトラスト | IAP+Access Level device_policy |
| 5 | IAP/IAM権限が個人付与で棚卸しなし | 権限管理 | グループ付与+四半期Access Review |
| 6 | Access LevelがIPのみ | 多要素条件 | ip_subnetworks+regions+device_policy AND |
| 7 | VPC_SC_DENIED監査ログにアラートなし | 検知 | ログベースメトリクス+Monitoring Alert Policy |
構成図 — Bad vs Good(SVG)
模範解答
# VPC Service Controls関連リソースが一切定義されていない
# → mops-prod の bigquery.dataViewer 権限があれば、
# 個人プロジェクトへ EXPORT DATA / bq cp で自由にPIIデータを持ち出せる
# → mops-analytics側との連携経路も未整理のまま
# → 導入するとしてもいきなりenforceで有効化する計画
# 修正①②③: VPC Service Controls(Dry-run開始 → mops-prod/mops-analytics両プロジェクトを対象)
resource "google_access_context_manager_service_perimeter" "mops_perimeter" {
parent = "accessPolicies/${var.access_policy_id}"
name = "accessPolicies/${var.access_policy_id}/servicePerimeters/mops_data_perimeter"
title = "mops_data_perimeter"
perimeter_type = "PERIMETER_TYPE_REGULAR"
# 修正③: 本番影響ゼロで有効化。VPC_SC_DENIEDになるはずだったログだけを記録する
use_explicit_dry_run_spec = true
spec {
resources = [
"projects/${var.mops_prod_project_number}",
"projects/${var.mops_analytics_project_number}", # 修正②: dbt Mesh連携先を境界内に同居させる
]
restricted_services = ["bigquery.googleapis.com", "storage.googleapis.com"]
vpc_accessible_services {
enable_restriction = true
allowed_services = ["bigquery.googleapis.com", "storage.googleapis.com"]
}
}
}
# 数週間のDry-run観察後、影響がないことを確認してstatusへ昇格(別PRで段階適用)
# resource "google_access_context_manager_service_perimeter" "mops_perimeter" {
# ...
# status { resources = [...], restricted_services = [...] } # ここでenforce化
# }
resource "google_cloud_run_v2_service" "admin_console" {
name = "mops-admin-console"
location = "asia-northeast1"
ingress = "INGRESS_TRAFFIC_ALL" # VPNさえ繋がれば私物PCからも到達可能
template {
containers {
image = "asia-northeast1-docker.pkg.dev/mops-prod/admin/console:1.2.0"
}
}
}
resource "google_cloud_run_v2_service_iam_member" "invoker" {
for_each = toset([
"user:taro.yamada@example.com",
"user:hanako.sato@example.com", # 半年前に異動済み → 権限残存
])
name = google_cloud_run_v2_service.admin_console.name
role = "roles/run.invoker"
member = each.value
}
# 修正④⑥: Access Level(デバイスポリシー + IP + 地域の複合条件)
resource "google_access_context_manager_access_level" "corp_trusted" {
parent = "accessPolicies/${var.access_policy_id}"
name = "accessPolicies/${var.access_policy_id}/accessLevels/corp_trusted"
title = "corp_trusted"
basic {
combining_function = "AND"
conditions {
ip_subnetworks = ["203.0.113.0/24"] # 社内VPN出口IP
regions = ["JP"] # 修正⑥: 日本国内以外からのアクセスを拒否
device_policy {
require_screen_lock = true # 修正④: 画面ロック必須
require_corp_owned = true # 修正④: 会社支給端末のみ(私物PC排除)
allowed_encryption_statuses = ["ENCRYPTED"]
}
}
}
}
# 修正④: 管理コンソール(Cloud Run)の前段にIAPを有効化。直接公開を廃止しLB経由に限定
resource "google_cloud_run_v2_service" "admin_console" {
name = "mops-admin-console"
location = "asia-northeast1"
ingress = "INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER"
template {
containers {
image = "asia-northeast1-docker.pkg.dev/mops-prod/admin/console:1.2.0"
}
}
}
resource "google_iap_web_backend_service_iam_binding" "admin_console_access" {
project = var.mops_prod_project
web_backend_service = google_compute_backend_service.admin_console_lb.name
role = "roles/iap.httpsResourceAccessor"
members = ["group:mops-team@example.com"] # 修正⑤: 個人ではなくグループ付与
}
# 修正⑦: VPC_SC_DENIED 監査ログのアラート
resource "google_logging_metric" "vpc_sc_denied" {
name = "vpc_sc_denied_count"
project = var.mops_prod_project
filter = <<-EOT
protoPayload.metadata."@type"="type.googleapis.com/google.identity.accesscontextmanager.v1.AuditLog"
protoPayload.metadata.violationReason="RESOURCE_NOT_IN_SAME_SERVICE_PERIMETER" OR
protoPayload.metadata.violationReason="NO_MATCHING_ACCESS_LEVEL"
EOT
}
resource "google_monitoring_alert_policy" "vpc_sc_denied_alert" {
display_name = "VPC Service Controls violation detected"
combiner = "OR"
conditions {
display_name = "VPC_SC_DENIED count > 0"
condition_threshold {
filter = "resource.type=\"audited_resource\" AND metric.type=\"logging.googleapis.com/user/${google_logging_metric.vpc_sc_denied.name}\""
comparison = "COMPARISON_GT"
threshold_value = 0
duration = "0s"
aggregations {
alignment_period = "300s"
per_series_aligner = "ALIGN_COUNT"
}
}
}
notification_channels = [var.slack_notification_channel_id]
}
# 適用(Dry-runモードで開始)
terraform apply -target=google_access_context_manager_service_perimeter.mops_perimeter \
-target=google_access_context_manager_access_level.corp_trusted
# Dry-runログの確認(実際にブロックされたはずのアクセスを可視化)
gcloud logging read \
'protoPayload.metadata.dryRun=true AND protoPayload.metadata."@type"="type.googleapis.com/google.identity.accesscontextmanager.v1.AuditLog"' \
--project=mops-prod --limit=50 --format=json
# 個人プロジェクトへのEXPORT DATAが境界内でブロックされることを確認(enforce化後)
bq query --use_legacy_sql=false \
'EXPORT DATA OPTIONS(uri="gs://personal-project-bucket/exfil/*.csv", format="CSV") AS SELECT * FROM `mops-prod.gold.mart_member_metrics_public` LIMIT 10'
# → Error: Request is prohibited by organization's policy (VPC_SC_DENIED) が返ることを確認
# IAPアクセス権限の棚卸し(グループメンバー一覧の確認)
gcloud identity groups memberships list --group-email=mops-team@example.com
| 問題 | 修正内容 | 効果 |
|---|---|---|
| ① Service Perimeter不在 | bigquery/storage APIを対象にPerimeter作成 | 正規のIAM権限があっても境界外へ持ち出し不可 |
| ② クロスプロジェクト連携未整理 | mops-prod/mops-analyticsを同一Perimeterに包含 | dbt Mesh連携を巻き添えで遮断しない |
| ③ いきなりenforce計画 | use_explicit_dry_run_specで段階移行 | 本番断ち切りを避け影響範囲を事前可視化 |
| ④ VPN固定IPのみ | IAP + Access Level device_policy | 端末の信頼状態まで評価するゼロトラスト化 |
| ⑤ 個人付与+棚卸しなし | グループ付与+四半期Access Review | 異動・退職の権限残存を構造的に防止 |
| ⑥ Access LevelがIPのみ | ip_subnetworks+regions+device_policy AND | 単一要素の突破だけでは通らない設計 |
| ⑦ 違反ログにアラートなし | ログベースメトリクス+Monitoring Alert Policy | 攻撃の予兆・設定ミスに即座に気づける |
ポイント解説
mops-prod/mops-analyticsのように正当なクロスプロジェクト連携(dbt Mesh)がある場合、境界の外に置くと連携ごと遮断される。両方を同一Perimeterに含めるか、Perimeter Bridgeで橋渡しする設計判断が必要。use_explicit_dry_run_spec = true で数週間実運用させ、想定外のブロック(=設計漏れの通信経路)を洗い出してからstatusへ昇格することで、いきなりenforceにして障害を起こすリスクを避けられる。device_policyはOSパッチ状況・暗号化状態・画面ロック設定・会社支給端末かどうかまで評価対象にできる。私物PCでVPNに繋がるだけでは通らない設計にする。roles/iap.httpsResourceAccessorを個人アカウントへ直接付与すると、異動・退職のたびにTerraform側の削除漏れが起きやすい。IdP同期のグループに付与し、四半期ごとのAccess Reviewを回すことで、権限残存を構造的に防ぐ。combining_function = "AND"で組み合わせることで、単一要素の突破だけでは通らない設計にする。実務への応用
会員360度ビューのようなPIIマートは、07-29でBigQuery内部のガバナンス(列・行レベル制御、マスキング)を固めても、「権限を持つ社員が正規のクエリ結果をどこへ運べるか」という境界防御が抜けていると、内部不正・アカウント乗っ取り・設定ミスのいずれでもデータ流出しうる。特にMOpsチームのように多くのメンバーがBigQuery Console・Looker Studio・分析用ノートブック環境に日常的にアクセスする組織では、「正規のIAM権限を持つアカウントからの持ち出し」が最も現実的な流出経路になりやすい。
また、管理コンソールのIAP化は、単なるセキュリティ強化ではなく「VPNクライアントの配布・維持コスト」を削減する副次効果もある。IAPはVPNなしでゼロトラストのアクセス制御を実現できるため、リモートワーク環境の多様化(私物端末の業務利用検討等)にも対応しやすい構成になる。
今日のまとめ
次のステップ
- 発展問題:
mops-prodの一部チーム(データサイエンスチーム)だけがBigQuery MLモデルの学習のために境界外の外部Kaggle環境とデータをやり取りする必要がある場合、Perimeter全体を緩めずに、この用途だけを許可するgoogle_access_context_manager_service_perimetersの Egress Policy(egressFrom/egressTo)を設計せよ。 - 参考: VPC Service Controls公式ドキュメント(Service Perimeter / Access Level / Egress-Ingress Policy)、Identity-Aware Proxy(Context-Aware Access)、BeyondCorp Enterprise、Access Context Manager API