Identity-Aware ProxyでCloud Runサービスを社内限定公開する

September 08, 2024

目次

背景と課題

社内ツールや検証用の管理画面をCloud Runで公開すると、URLを知る全員がアクセスできてしまいます。認証をアプリ側に自前実装する方法もありますが、アプリのコードに認可ロジックを持ち込みたくないケースは少なくありません。

そこで有効なのが Cloud IAP(Identity-Aware Proxy) です。IAPをロードバランサーの前段に置けば、アプリを一切改修せずに「指定したGoogleアカウントだけがログインして到達できる」限定公開を実現できます。認証・認可をインフラ層に寄せられるのが最大の利点です。

ポイントは、Cloud Runにカスタムドメインを割り当てるだけでは完結しないことです。IAPを有効化するには、カスタムドメインを持つ組織アカウント、すなわち Google Workspace / Cloud Identity への登録と、そのアカウントでのGoogle Cloud組織の作成が前提になります。手順が多く依存関係も複雑なため、全体像を押さえてから着手するのが安全です。

全体像と前提

最終的な構成は次の流れになります。

  • ユーザー → カスタムドメイン(DNS) → 外部HTTPSロードバランサー → IAP認証 → Cloud Run

これを成立させるために、以下を順に揃えていきます。有料の登録(ドメイン・Workspace・Cloud Identity)が含まれる点に注意してください。

手順

  1. カスタムドメインの取得(有料)

IAPで使うカスタムドメインを、お名前.comなどのレジストラで取得します。組織・DNS検証の起点になります。

  1. Google Workspaceへの登録(有料)

取得したドメインを組織アカウントとして扱うために登録します。

  1. Cloud Identityへの登録(有料)

IAPが必要とする組織のIDプロバイダを用意します。

  1. Google Cloudの利用登録(無料枠あり)

作成したカスタムドメインのユーザーアカウントで、Google Cloudの利用登録を行い、組織を作成します。IAPは組織配下のプロジェクトで有効化するため、この組織の作成が要になります。

  1. TXTレコードの追加

Cloud Identity利用にあたり、Google側で払い出される確認コードを、レジストラの管理画面からDNSのTXTレコードに追加し、ドメイン所有を証明します。

  1. サンプルコードのダウンロード、Terraformのインストール

GCP公式の Cloud Run Explore を土台にします。Cloud Run・ロードバランサー・IAPを一括で構成するTerraformコードがここに揃っています。

  1. サービスのデプロイ

tfvarsに必要な入力値を設定し、terraform apply を実行します。これでCloud Run・外部ロードバランサー・IAPがまとめてデプロイされます。

  1. Aレコードの追加

作成されたロードバランサーのIPアドレスを、公開したいサブドメインに割り当てるため、レジストラのDNSにAレコードを追加します。反映はお名前.comの場合 数時間から24時間程度 かかることがあります(実測では1〜2時間で疎通しました)。

  1. サブドメインにアクセスする

指定したサブドメインへアクセスすると、Googleのログインページにリダイレクトされます。IAPで許可したアカウントでログインすると、保護されたサービスに到達できます。

run-iap

  1. サービスの片付け

検証後は課金を避けるため、terraform destroy でデプロイしたリソースを削除します。

コード

GCPのサンプルTerraformに手を加えた構成を掲載します。IAPクライアント・ブランド、外部HTTPSロードバランサー、Cloud Run、セキュリティポリシーを1セットで管理しています。

  • main.tf
main.tf
/**
 * Copyright 2023 Google LLC
 * Modifications Copyright 2024 Ryo M
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
locals {
  gclb_create  = var.custom_domain == null ? false : true
  iap_sa_email = try(google_project_service_identity.iap_sa[0].email, "")
  iap_brand    = var.iap.enabled ? "projects/${module.project.number}/brands/${var.existing_iap_brand_id}" : null
}

module "project" {
  source  = "../../../modules/project"
  billing_account = (var.project_create != null
    ? var.project_create.billing_account_id
    : null
  )
  parent = (var.project_create != null
    ? var.project_create.parent
    : null
  )
  name = var.project_id
  services = [
    "run.googleapis.com",
    "compute.googleapis.com",
    "iap.googleapis.com"
  ]
  project_create = var.project_create != null
}

module "cloud_run" {
  source     = "../../../modules/cloud-run"
  project_id = module.project.project_id
  name       = var.run_svc_name
  region     = var.region
  containers = {
    default = {
      image = var.image
    }
  }
  iam = {
    "roles/run.invoker" = (local.gclb_create && var.iap.enabled
      ? ["serviceAccount:${local.iap_sa_email}"]
      : ["allUsers"]
    )
  }
  ingress_settings = var.ingress_settings
}

resource "google_compute_global_address" "default" {
  count   = local.gclb_create ? 1 : 0
  project = module.project.project_id
  name    = "glb-ip"
}

module "glb" {
  source     = "../../../modules/net-lb-app-ext"
  count      = local.gclb_create ? 1 : 0
  project_id = module.project.project_id
  name       = "glb"
  address    = google_compute_global_address.default[0].address
  backend_service_configs = {
    default = {
      backends = [
        { backend = "neg-0" }
      ]
      health_checks = []
      port_name     = "http"
      security_policy = try(google_compute_security_policy.policy[0].name,
      null)
      iap_config = try({
        oauth2_client_id     = google_iap_client.iap_client[0].client_id,
        oauth2_client_secret = google_iap_client.iap_client[0].secret
      }, null)
    }
  }
  health_check_configs = {}
  neg_configs = {
    neg-0 = {
      cloudrun = {
        region = var.region
        target_service = {
          name = var.run_svc_name
        }
      }
    }
  }
  protocol = "HTTPS"
  ssl_certificates = {
    managed_configs = {
      default = {
        domains = [var.custom_domain]
      }
    }
  }
}

resource "google_compute_security_policy" "policy" {
  count   = local.gclb_create && var.security_policy.enabled ? 1 : 0
  name    = "cloud-run-policy"
  project = module.project.project_id
  rule {
    action   = "deny(403)"
    priority = 1000
    match {
      versioned_expr = "SRC_IPS_V1"
      config {
        src_ip_ranges = var.security_policy.ip_blacklist
      }
    }
    description = "Deny access to list of IPs"
  }
  rule {
    action   = "deny(403)"
    priority = 900
    match {
      expr {
        expression = "request.path.matches(\"${var.security_policy.path_blocked}\")"
      }
    }
    description = "Deny access to specific URL paths"
  }
  rule {
    action   = "allow"
    priority = "2147483647"
    match {
      versioned_expr = "SRC_IPS_V1"
      config {
        src_ip_ranges = ["*"]
      }
    }
    description = "Default rule"
  }
}

resource "google_iap_client" "iap_client" {
  count        = var.iap.enabled ? 1 : 0
  display_name = var.iap.oauth2_client_name
  brand        = local.iap_brand

  lifecycle {
    precondition {
      condition     = var.existing_iap_brand_id != ""
      error_message = "existing_iap_brand_id must be set when iap.enabled is true."
    }
  }
}

resource "google_iap_brand" "project_brand" {
  count              = var.iap.enabled && var.create_iap_brand ? 1 : 0
  support_email      = var.iap.email
  application_title  = var.iap.application_title
  project            = module.project.project_id
}

resource "google_iap_web_iam_member" "iap_iam" {
  count   = local.gclb_create && var.iap.enabled ? 1 : 0
  project = module.project.project_id
  role    = "roles/iap.httpsResourceAccessor"
  member  = "user:${var.iap.email}"
}

resource "google_project_service_identity" "iap_sa" {
  provider = google-beta
  count    = local.gclb_create && var.iap.enabled ? 1 : 0
  project  = module.project.project_id
  service  = "iap.googleapis.com"
}

output "iap_brand" {
  value = local.iap_brand
}

output "iap_client" {
  value = {
    enabled      = var.iap.enabled
    id           = var.iap.enabled ? google_iap_client.iap_client[0].id : null
    display_name = var.iap.enabled ? google_iap_client.iap_client[0].display_name : null
    client_id    = var.iap.enabled ? google_iap_client.iap_client[0].client_id : null
  }
}

output "project_number" {
  value = module.project.number
}
  • variables.tf
variables.tf
/**
 * Copyright 2023 Google LLC
 * Modifications Copyright 2024 Ryo M
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
variable "project_id" {
  type        = string
  description = "Project ID"
}

variable "project_create" {
  type = object({
    billing_account_id = string
    parent             = string
  })
  description = "Project creation parameters"
  default     = null
}

variable "run_svc_name" {
  type        = string
  description = "Cloud Run service name"
}

variable "region" {
  type        = string
  description = "Region for Cloud Run service"
}

variable "image" {
  type        = string
  description = "Container image for Cloud Run service"
}

variable "ingress_settings" {
  type        = string
  description = "Ingress settings for Cloud Run service"
  default     = "all"
}

variable "custom_domain" {
  type        = string
  description = "Custom domain for the load balancer"
  default     = null
}

variable "security_policy" {
  type = object({
    enabled      = bool
    ip_blacklist = list(string)
    path_blocked = string
  })
  description = "Security policy settings"
  default = {
    enabled      = false
    ip_blacklist = []
    path_blocked = ""
  }
}

variable "iap" {
  type = object({
    enabled             = bool
    email               = string
    oauth2_client_name  = string
    application_title   = string
  })
  description = "IAP settings"
}

variable "create_iap_brand" {
  type        = bool
  default     = false
  description = "Whether to create a new IAP brand"
}

variable "existing_iap_brand_id" {
  type        = string
  description = "The ID of the existing IAP brand (usually the project number)"
}
  • terraform.tfvars
terraform.tfvars
project_id    = "your_project_id"
run_svc_name  = "my-cloud-run-service"
region        = "europe-west1"
image         = "us-docker.pkg.dev/cloudrun/container/hello"
ingress_settings = "internal-and-cloud-load-balancing"
custom_domain = "your_domain"

security_policy = {
  enabled      = true
  ip_blacklist = ["79.149.0.0/16"]
  path_blocked = "/admin/*"
}

iap = {
  enabled             = true
  email               = "your_email"
  oauth2_client_name  = "Cloud Run IAP"
  application_title   = "My Application"
}

create_iap_brand      = false
existing_iap_brand_id = "your brand"

project_create = null
  • outputs.tf
outputs.tf
/**
 * Copyright 2023 Google LLC
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

# Custom domain for the Load Balancer. I'd prefer getting the value from the
# SSL certificate but it is not exported as output
output "custom_domain" {
  description = "Custom domain for the Load Balancer."
  value       = local.gclb_create ? var.custom_domain : "none"
}

output "default_URL" {
  description = "Cloud Run service default URL."
  value       = module.cloud_run.service.status[0].url
}

output "load_balancer_ip" {
  description = "LB IP that forwards to Cloud Run service."
  value       = local.gclb_create ? module.glb[0].address : "none"
}

Brand IDは、Method: projects.brands.list で取得できます。existing_iap_brand_id にはこの値(通常はプロジェクト番号ベースのブランドID)を設定します。

ハマりどころと勘所

  • カスタムドメインを付けるだけでは不十分です。IAPは組織配下のリソースを前提とするため、Workspace / Cloud Identity 登録と組織作成が事実上の必須要件になります。着手前にこの依存関係を把握しておくと手戻りを防げます。
  • IAPの有効化にはブランド(同意画面)とOAuthクライアントが必要です。ブランドが未作成だと existing_iap_brand_id が空になり、google_iap_client の precondition で弾かれます。既存ブランドがある場合は create_iap_brand = false としてIDを流用します。
  • DNSのAレコード反映には時間差があります。疎通しない場合はTTLと伝播待ちを疑い、しばらく置いてから再アクセスしてください。
  • 検証環境はロードバランサーやマネージド証明書などで課金が発生します。使い終わったら terraform destroy で確実に片付けます。

その他参考

さいごに

IAPを使えば、アプリを改修せずに認証・認可をインフラ層へ委ねた限定公開を構成できます。Workspace・Cloud Identity・DNS・Terraformが絡む依存関係さえ押さえれば、再現性の高いIaCとして運用に載せられます。クラウドの認証・限定公開やIaCのご相談は、お問い合わせから。


Please share it if you like!

Profile picture

Written by 松坂 龍 松坂総合研究所 代表 / フリーランスITエンジニア(クラウド・機械学習・データ基盤)