IaC GCP

Terraform Module: GCP VPC Service Controls — A reusable service perimeter around your data exfiltration boundary

Quick take — Build a reusable Terraform module for GCP VPC Service Controls with google_access_context_manager_service_perimeter — perimeters, access levels, ingress/egress policies and dry-run enforcement. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.

Quickstart (copy-paste)

Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):

provider "google" {
  project = "my-project"
  region  = "us-central1"
}

module "vpc_sc" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-vpc-sc?ref=v1.0.0"

  access_policy_id          = "..."           # Numeric ID of the org-level Access Context Manager acce…
  perimeter_name            = "..."           # Short, unique perimeter resource name (lowercase, numbe…
  perimeter_title           = "..."           # Human-readable title shown in the console.
  protected_project_numbers = ["...", "..."]  # GCP project **numbers** to enclose in the perimeter.
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

VPC Service Controls (VPC-SC) is GCP’s data-exfiltration defense for managed APIs. It draws a logical service perimeter around a set of projects so that requests to protected services — BigQuery, Cloud Storage, Pub/Sub, Vertex AI and dozens more — can only originate from inside that perimeter, or from explicitly allow-listed identities and networks. Unlike IAM (which answers “are you allowed?”) VPC-SC answers “are you calling from an approved boundary?”. The result is that even a leaked service-account key cannot pull data from a protected GCS bucket if the call comes from outside the perimeter.

The trouble is that a perimeter is rarely a single resource in isolation. A production perimeter pairs with an access policy, one or more access levels (the conditions under which external callers are trusted — corporate IP ranges, specific device posture, named principals), and granular ingress/egress rules that punch surgical holes for legitimate cross-perimeter traffic. Hand-rolling all of that per environment is error-prone, and a misconfigured perimeter either blocks production traffic or silently leaves a hole open. This module wraps google_access_context_manager_service_perimeter together with its access levels and directional rules behind a small, validated variable surface, and ships with dry-run mode so you can observe what would be blocked before you enforce it.

When to use it

Reach for something else if you only need identity-based controls (use IAM and Organization Policy) or you are not using GCP-managed APIs at all — VPC-SC protects the API surface, not raw VM-to-VM traffic.

Module structure

terraform-module-gcp-vpc-sc/
├── versions.tf
├── main.tf
├── variables.tf
└── outputs.tf
# versions.tf
terraform {
  required_version = ">= 1.5.0"

  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "~> 5.0"
    }
  }
}
# main.tf

locals {
  # A regular perimeter holds projects directly; "enforced" config is set only
  # when use_explicit_dry_run_spec = false. When true we keep the enforced
  # status minimal and put real rules under spec{} so nothing is blocked yet.
  resource_names = [for n in var.protected_project_numbers : "projects/${n}"]
}

# Access levels describe WHO from outside may be trusted (IP ranges, regions,
# named principals, device posture). They live on the access policy and are
# referenced by name from the perimeter's ingress rules / status.
resource "google_access_context_manager_access_level" "this" {
  for_each = var.access_levels

  parent = "accessPolicies/${var.access_policy_id}"
  name   = "accessPolicies/${var.access_policy_id}/accessLevels/${each.key}"
  title  = each.value.title

  basic {
    combining_function = each.value.combining_function

    conditions {
      ip_subnetworks         = each.value.ip_subnetworks
      required_access_levels = each.value.required_access_levels
      members                = each.value.members
      regions                = each.value.regions
      negate                 = each.value.negate
    }
  }
}

resource "google_access_context_manager_service_perimeter" "this" {
  parent = "accessPolicies/${var.access_policy_id}"
  name   = "accessPolicies/${var.access_policy_id}/servicePerimeters/${var.perimeter_name}"
  title  = var.perimeter_title

  perimeter_type = var.perimeter_type

  # Let the explicit dry-run spec drive enforcement transitions without churn.
  use_explicit_dry_run_spec = var.use_explicit_dry_run_spec

  # ENFORCED configuration. When use_explicit_dry_run_spec = true this is the
  # "live" config and we deliberately keep it permissive (no restricted
  # services) so production is not impacted while you validate in dry-run.
  status {
    resources           = local.resource_names
    restricted_services = var.use_explicit_dry_run_spec ? [] : var.restricted_services
    access_levels = var.use_explicit_dry_run_spec ? [] : [
      for k in var.perimeter_access_level_keys :
      google_access_context_manager_access_level.this[k].name
    ]

    dynamic "vpc_accessible_services" {
      for_each = var.use_explicit_dry_run_spec ? [] : (var.vpc_allowed_services == null ? [] : [1])
      content {
        enable_restriction = true
        allowed_services   = var.vpc_allowed_services
      }
    }

    dynamic "ingress_policies" {
      for_each = var.use_explicit_dry_run_spec ? [] : var.ingress_policies
      content {
        ingress_from {
          identity_type = ingress_policies.value.identity_type
          identities    = ingress_policies.value.identities

          dynamic "sources" {
            for_each = ingress_policies.value.source_access_levels
            content {
              access_level = "accessPolicies/${var.access_policy_id}/accessLevels/${sources.value}"
            }
          }

          dynamic "sources" {
            for_each = ingress_policies.value.source_resources
            content {
              resource = sources.value
            }
          }
        }

        ingress_to {
          resources = ingress_policies.value.to_resources

          dynamic "operations" {
            for_each = ingress_policies.value.operations
            content {
              service_name = operations.value.service_name
              dynamic "method_selectors" {
                for_each = operations.value.methods
                content {
                  method = method_selectors.value
                }
              }
            }
          }
        }
      }
    }

    dynamic "egress_policies" {
      for_each = var.use_explicit_dry_run_spec ? [] : var.egress_policies
      content {
        egress_from {
          identity_type = egress_policies.value.identity_type
          identities    = egress_policies.value.identities
        }
        egress_to {
          resources = egress_policies.value.to_resources

          dynamic "operations" {
            for_each = egress_policies.value.operations
            content {
              service_name = operations.value.service_name
              dynamic "method_selectors" {
                for_each = operations.value.methods
                content {
                  method = method_selectors.value
                }
              }
            }
          }
        }
      }
    }
  }

  # DRY-RUN spec. Populated only when use_explicit_dry_run_spec = true. GCP
  # evaluates this and emits "would have been denied" audit logs without
  # actually blocking, so you can validate restricted_services + rules safely.
  dynamic "spec" {
    for_each = var.use_explicit_dry_run_spec ? [1] : []
    content {
      resources           = local.resource_names
      restricted_services = var.restricted_services
      access_levels = [
        for k in var.perimeter_access_level_keys :
        google_access_context_manager_access_level.this[k].name
      ]

      dynamic "vpc_accessible_services" {
        for_each = var.vpc_allowed_services == null ? [] : [1]
        content {
          enable_restriction = true
          allowed_services   = var.vpc_allowed_services
        }
      }

      dynamic "ingress_policies" {
        for_each = var.ingress_policies
        content {
          ingress_from {
            identity_type = ingress_policies.value.identity_type
            identities    = ingress_policies.value.identities

            dynamic "sources" {
              for_each = ingress_policies.value.source_access_levels
              content {
                access_level = "accessPolicies/${var.access_policy_id}/accessLevels/${sources.value}"
              }
            }

            dynamic "sources" {
              for_each = ingress_policies.value.source_resources
              content {
                resource = sources.value
              }
            }
          }

          ingress_to {
            resources = ingress_policies.value.to_resources

            dynamic "operations" {
              for_each = ingress_policies.value.operations
              content {
                service_name = operations.value.service_name
                dynamic "method_selectors" {
                  for_each = operations.value.methods
                  content {
                    method = method_selectors.value
                  }
                }
              }
            }
          }
        }
      }

      dynamic "egress_policies" {
        for_each = var.egress_policies
        content {
          egress_from {
            identity_type = egress_policies.value.identity_type
            identities    = egress_policies.value.identities
          }
          egress_to {
            resources = egress_policies.value.to_resources

            dynamic "operations" {
              for_each = egress_policies.value.operations
              content {
                service_name = operations.value.service_name
                dynamic "method_selectors" {
                  for_each = operations.value.methods
                  content {
                    method = method_selectors.value
                  }
                }
              }
            }
          }
        }
      }
    }
  }

  lifecycle {
    # Perimeters are frequently mutated out-of-band by the
    # google_access_context_manager_service_perimeter_resource fan-out
    # resource. Guard against accidental destroy of a production boundary.
    create_before_destroy = false
  }
}
# variables.tf

variable "access_policy_id" {
  description = "Numeric ID of the org-level Access Context Manager access policy (e.g. \"123456789\"). Find it with: gcloud access-context-manager policies list --organization ORG_ID."
  type        = string

  validation {
    condition     = can(regex("^[0-9]+$", var.access_policy_id))
    error_message = "access_policy_id must be the numeric policy ID, not the full resource path."
  }
}

variable "perimeter_name" {
  description = "Short, unique perimeter resource name (the last path segment). Lowercase letters, numbers and underscores only."
  type        = string

  validation {
    condition     = can(regex("^[a-z][a-z0-9_]{1,49}$", var.perimeter_name))
    error_message = "perimeter_name must start with a letter and contain only lowercase letters, numbers and underscores (max 50 chars)."
  }
}

variable "perimeter_title" {
  description = "Human-readable title shown in the console for the perimeter."
  type        = string
}

variable "perimeter_type" {
  description = "PERIMETER_TYPE_REGULAR for a standalone boundary, or PERIMETER_TYPE_BRIDGE to allow projects in two regular perimeters to share data."
  type        = string
  default     = "PERIMETER_TYPE_REGULAR"

  validation {
    condition     = contains(["PERIMETER_TYPE_REGULAR", "PERIMETER_TYPE_BRIDGE"], var.perimeter_type)
    error_message = "perimeter_type must be PERIMETER_TYPE_REGULAR or PERIMETER_TYPE_BRIDGE."
  }
}

variable "protected_project_numbers" {
  description = "List of GCP project NUMBERS (not IDs) to enclose in the perimeter."
  type        = list(string)

  validation {
    condition     = length(var.protected_project_numbers) > 0
    error_message = "At least one project number must be supplied."
  }

  validation {
    condition     = alltrue([for n in var.protected_project_numbers : can(regex("^[0-9]+$", n))])
    error_message = "protected_project_numbers must contain project numbers (digits only), not project IDs."
  }
}

variable "restricted_services" {
  description = "Fully-qualified service endpoints to bring inside the perimeter, e.g. [\"storage.googleapis.com\", \"bigquery.googleapis.com\"]."
  type        = list(string)
  default     = ["storage.googleapis.com", "bigquery.googleapis.com"]
}

variable "vpc_allowed_services" {
  description = "Optional allow-list of services reachable from within the VPC (VPC accessible services). Set to null to leave VPC accessibility unrestricted."
  type        = list(string)
  default     = null
}

variable "use_explicit_dry_run_spec" {
  description = "When true, restricted_services and all rules are applied in DRY-RUN (spec) only — violations are logged but not blocked. Flip to false to enforce."
  type        = bool
  default     = true
}

variable "access_levels" {
  description = "Map of access levels to create on the access policy. Key = access level name."
  type = map(object({
    title                  = string
    combining_function     = optional(string, "AND")
    ip_subnetworks         = optional(list(string), [])
    required_access_levels = optional(list(string), [])
    members                = optional(list(string), [])
    regions                = optional(list(string), [])
    negate                 = optional(bool, false)
  }))
  default = {}
}

variable "perimeter_access_level_keys" {
  description = "Keys from var.access_levels to attach to the perimeter status/spec (callers satisfying any attached level may reach restricted services from outside)."
  type        = list(string)
  default     = []
}

variable "ingress_policies" {
  description = "Directional rules allowing external identities/sources to reach resources inside the perimeter."
  type = list(object({
    identity_type        = optional(string)          # "ANY_IDENTITY", "ANY_USER_ACCOUNT", "ANY_SERVICE_ACCOUNT" or null when using identities
    identities           = optional(list(string), [])
    source_access_levels = optional(list(string), [])
    source_resources     = optional(list(string), []) # e.g. ["projects/123456789"] or ["//cloudresourcemanager..."]
    to_resources         = optional(list(string), ["*"])
    operations = optional(list(object({
      service_name = string
      methods      = optional(list(string), ["*"])
    })), [])
  }))
  default = []
}

variable "egress_policies" {
  description = "Directional rules allowing in-perimeter identities to reach resources OUTSIDE the perimeter."
  type = list(object({
    identity_type = optional(string)
    identities    = optional(list(string), [])
    to_resources  = optional(list(string), ["*"])
    operations = optional(list(object({
      service_name = string
      methods      = optional(list(string), ["*"])
    })), [])
  }))
  default = []
}
# outputs.tf

output "perimeter_id" {
  description = "Fully-qualified resource ID of the service perimeter."
  value       = google_access_context_manager_service_perimeter.this.id
}

output "perimeter_name" {
  description = "Resource name (accessPolicies/.../servicePerimeters/...) of the perimeter."
  value       = google_access_context_manager_service_perimeter.this.name
}

output "perimeter_title" {
  description = "Human-readable title of the perimeter."
  value       = google_access_context_manager_service_perimeter.this.title
}

output "enforcement_mode" {
  description = "\"dry-run\" if violations are only logged, \"enforced\" if blocked."
  value       = var.use_explicit_dry_run_spec ? "dry-run" : "enforced"
}

output "protected_resources" {
  description = "List of projects enclosed by the perimeter (projects/NUMBER)."
  value       = [for n in var.protected_project_numbers : "projects/${n}"]
}

output "access_level_names" {
  description = "Map of access level key to its fully-qualified resource name."
  value       = { for k, al in google_access_context_manager_access_level.this : k => al.name }
}

How to use it

# Enclose the regulated-data project. Start in dry-run, allow only the
# corporate egress IP range and the CI service account through, and let
# the data-platform project's storage reach an external partner bucket.

module "vpc_service_controls" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-vpc-sc?ref=v1.0.0"

  access_policy_id          = "987654321098"
  perimeter_name            = "data_platform_prod"
  perimeter_title           = "Data Platform Production Perimeter"
  protected_project_numbers = ["111122223333", "444455556666"]

  restricted_services = [
    "storage.googleapis.com",
    "bigquery.googleapis.com",
    "pubsub.googleapis.com",
    "aiplatform.googleapis.com",
  ]

  # Trust requests from the corporate egress range for break-glass console use.
  access_levels = {
    corp_network = {
      title          = "Corporate Egress Network"
      ip_subnetworks = ["203.0.113.0/24", "198.51.100.0/24"]
    }
  }
  perimeter_access_level_keys = ["corp_network"]

  # Let the cross-project CI deployer publish to Pub/Sub from outside.
  ingress_policies = [
    {
      identity_type = "ANY_SERVICE_ACCOUNT"
      identities    = ["serviceAccount:ci-deployer@tooling-proj.iam.gserviceaccount.com"]
      to_resources  = ["*"]
      operations = [
        {
          service_name = "pubsub.googleapis.com"
          methods      = ["google.pubsub.v1.Publisher.Publish"]
        }
      ]
    }
  ]

  # Allow in-perimeter workloads to read one external partner bucket only.
  egress_policies = [
    {
      identity_type = "ANY_IDENTITY"
      to_resources  = ["projects/777788889999"]
      operations = [
        {
          service_name = "storage.googleapis.com"
          methods      = ["google.storage.objects.get", "google.storage.objects.list"]
        }
      ]
    }
  ]

  # Keep enforcing OFF until violation logs are clean.
  use_explicit_dry_run_spec = true
}

# Downstream: wire the perimeter into a monitoring alert policy that fires on
# VPC-SC violation audit logs scoped to this exact perimeter.
resource "google_monitoring_alert_policy" "vpcsc_violations" {
  display_name = "VPC-SC violations: ${module.vpc_service_controls.perimeter_title}"
  combiner     = "OR"

  conditions {
    display_name = "RESOURCES_EXCEEDED denied requests"
    condition_matched_log {
      filter = <<-EOT
        logName=~"logs/cloudaudit.googleapis.com%2Fpolicy"
        protoPayload.metadata.violationReason!=""
        protoPayload.metadata.securityPolicyInfo.servicePerimeterName="${module.vpc_service_controls.perimeter_name}"
      EOT
    }
  }

  alert_strategy {
    notification_rate_limit { period = "300s" }
  }
}

With Terragrunt

Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.

1. Root configlive/terragrunt.hcl (inherited by every module):

remote_state {
  backend = "gcs"
  generate = { path = "backend.tf", if_exists = "overwrite" }
  config = {
    # ...gcs state bucket/container + key per path...
  }
}

2. Module configlive/prod/vpc_sc/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-vpc-sc?ref=v1.0.0"
}

inputs = {
  access_policy_id = "..."
  perimeter_name = "..."
  perimeter_title = "..."
  protected_project_numbers = ["...", "..."]
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/vpc_sc && terragrunt apply        # this module
terragrunt run-all apply                      # every module under live/prod

Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.

Inputs

Name Type Default Required Description
access_policy_id string Yes Numeric ID of the org-level Access Context Manager access policy.
perimeter_name string Yes Short, unique perimeter resource name (lowercase, numbers, underscores).
perimeter_title string Yes Human-readable title shown in the console.
perimeter_type string "PERIMETER_TYPE_REGULAR" No PERIMETER_TYPE_REGULAR or PERIMETER_TYPE_BRIDGE.
protected_project_numbers list(string) Yes GCP project numbers to enclose in the perimeter.
restricted_services list(string) ["storage.googleapis.com", "bigquery.googleapis.com"] No Service endpoints brought inside the perimeter.
vpc_allowed_services list(string) null No Allow-list of services reachable from within the VPC; null leaves it unrestricted.
use_explicit_dry_run_spec bool true No true applies rules in dry-run (logged, not blocked); false enforces.
access_levels map(object) {} No Access levels to create (IP ranges, regions, members, device posture).
perimeter_access_level_keys list(string) [] No Keys from access_levels to attach to the perimeter.
ingress_policies list(object) [] No Rules allowing external sources to reach in-perimeter resources.
egress_policies list(object) [] No Rules allowing in-perimeter identities to reach external resources.

Outputs

Name Description
perimeter_id Fully-qualified resource ID of the service perimeter.
perimeter_name Resource name (accessPolicies/.../servicePerimeters/...).
perimeter_title Human-readable title of the perimeter.
enforcement_mode "dry-run" if violations are only logged, "enforced" if blocked.
protected_resources List of enclosed projects (projects/NUMBER).
access_level_names Map of access level key to its fully-qualified resource name.

Enterprise scenario

A healthcare analytics provider stores de-identified claims data in BigQuery and raw ingestion files in Cloud Storage across two GCP projects, and must satisfy a HIPAA control requiring network-level isolation of PHI-adjacent services. The platform team consumes this module once per region from their landing-zone repo, enclosing both projects and restricting storage, bigquery and pubsub. They run for two sprints with use_explicit_dry_run_spec = true, mine the VPC-SC violation logs through the bundled alert policy to find a forgotten Looker connection and a batch job calling from an on-prem range, add precise ingress rules for both, then flip the flag to false in a single PR — enforcing the boundary with zero resource recreation and a clean audit trail.

Best practices

TerraformGCPVPC Service ControlsModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading