日本語で読む ›

Restricting a Cloud Run Service to Internal Users with Identity-Aware Proxy

September 08, 2024

Table of Contents

Background and Problem

When you publish an internal tool or a staging admin screen on Cloud Run, everyone who knows the URL can reach it. You could implement authentication yourself in the app, but there are plenty of cases where you would rather not bring authorization logic into the application code.

This is where Cloud IAP (Identity-Aware Proxy) helps. Placing IAP in front of the load balancer lets you achieve a private deployment — "only the Google accounts you designate can sign in and reach the service" — without modifying the app at all. Its biggest advantage is that it moves authentication and authorization down into the infrastructure layer.

The key point is that simply assigning a custom domain to Cloud Run is not enough. Enabling IAP presupposes an organization account that owns a custom domain — that is, registration with Google Workspace / Cloud Identity, and the creation of a Google Cloud organization under that account. There are many steps with complex dependencies, so it is safest to grasp the whole picture before you start.

The Big Picture and Prerequisites

The final architecture takes the following flow:

  • User → custom domain (DNS) → external HTTPS load balancer → IAP authentication → Cloud Run

To make this work, we assemble the following in order. Note that it includes paid registrations (domain, Workspace, Cloud Identity).

Steps

  1. Acquire a custom domain (paid)

Acquire the custom domain to use with IAP from a registrar such as a domain registrar of your choice. It becomes the starting point for organization and DNS verification.

  1. Register with Google Workspace (paid)

Register the acquired domain so it can be treated as an organization account.

  1. Register with Cloud Identity (paid)

Prepare the organization's identity provider that IAP requires.

  1. Sign up for Google Cloud (free tier available)

Using the user account of the custom domain you created, sign up for Google Cloud and create an organization. Because IAP is enabled on projects under the organization, creating this organization is essential.

  1. Add a TXT record

For Cloud Identity, add the verification code Google issues to a DNS TXT record from your registrar's control panel to prove domain ownership.

  1. Download the sample code and install Terraform

Use GCP's official Cloud Run Explore as the foundation. It provides Terraform code that configures Cloud Run, the load balancer, and IAP all at once.

  1. Deploy the service

Set the required input values in tfvars and run terraform apply. This deploys Cloud Run, the external load balancer, and IAP together.

  1. Add an A record

To assign the created load balancer's IP address to the subdomain you want to publish, add an A record to your registrar's DNS. Propagation can take anywhere from a few hours to about 24 hours (in practice it became reachable in 1–2 hours).

  1. Access the subdomain

When you access the designated subdomain, you are redirected to Google's sign-in page. Sign in with an account permitted by IAP, and you reach the protected service.

run-iap

  1. Clean up the service

After testing, delete the deployed resources with terraform destroy to avoid charges.

Code

Here is the configuration, adapted from GCP's sample Terraform. It manages the IAP client and brand, the external HTTPS load balancer, Cloud Run, and the security policy as a single set.

  • 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"
}

You can obtain the Brand ID via Method: projects.brands.list. Set existing_iap_brand_id to this value (usually the project-number-based brand ID).

Pitfalls and Key Points

  • Attaching a custom domain alone is not enough. Because IAP presupposes resources under an organization, Workspace / Cloud Identity registration and organization creation are effectively mandatory requirements. Understanding this dependency before you start prevents rework.
  • Enabling IAP requires a brand (consent screen) and an OAuth client. If the brand has not been created, existing_iap_brand_id will be empty and the google_iap_client precondition will reject it. If you already have a brand, set create_iap_brand = false and reuse its ID.
  • There is a lag before DNS A records propagate. If it is not reachable, suspect the TTL and propagation wait, and try again after a while.
  • A test environment incurs charges from the load balancer, managed certificate, and so on. When you are done, clean up reliably with terraform destroy.

Additional References

Closing

With IAP, you can configure a private deployment that delegates authentication and authorization to the infrastructure layer without modifying the app. Once you have a handle on the dependencies involving Workspace, Cloud Identity, DNS, and Terraform, you can run it in production as highly reproducible IaC. For consultation on cloud authentication, private deployments, or IaC, use the contact form.


Please share it if you like!

Profile picture

Written by 松坂 龍 松坂総合研究所 代表 / フリーランスITエンジニア(クラウド・機械学習・データ基盤) 統計・データ分析の解説をYouTubeでも発信しています。