IaC GCP

Terraform Module: GCP Access Context Manager — Access Levels and Service Perimeters for VPC Service Controls

Quick take — A reusable hashicorp/google ~> 5.0 module for google_access_context_manager_access_level and google_access_context_manager_service_perimeter: the org-scoped access policy, IP/region/identity access levels, and a regular service perimeter with restricted services and VPC-accessible-services restriction. 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"

  # ACM is org-scoped; with user ADCs you typically need these so the API
  # bills against a real project and returns data instead of a 403.
  user_project_override = true
  billing_project       = "my-project"
}

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

  org_id        = "..."  # Numeric organization ID that owns the access policy.
  policy_title  = "..."  # Human-readable title for the org access policy.
  perimeter_name = "..."  # Short name for the perimeter, e.g. "data_lake".

  # Projects (by NUMBER) enclosed by the perimeter.
  perimeter_resources = ["projects/123456789012"]

  # GCP services locked behind the perimeter boundary.
  restricted_services = ["storage.googleapis.com", "bigquery.googleapis.com"]
}

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

What this module is

Access Context Manager (ACM) is the foundation that VPC Service Controls (VPC-SC) is built on. Where IAM answers “who can call this API”, VPC-SC answers “from where and into which boundary can data move” — it draws a perimeter around a set of projects so that, even with valid IAM credentials, data cannot be exfiltrated from a project inside the perimeter to anything outside it. This stops the classic breach pattern where a leaked service account key is used to copy a BigQuery dataset or GCS bucket out to an attacker’s project.

ACM has three layered resources:

This module wires the policy, a map of access levels, and one regular perimeter together with the org-scoping, the restricted_services/resources plumbing, and the access-level cross-references handled for you — turning the most error-prone surface in GCP security into a reviewed, versioned module. Because the policy is org-wide and usually singular, the module can either create it or reuse an existing policy ID.

When to use it

Reach for plain IAM when you only need to control who can act; ACM/VPC-SC is the additional, orthogonal control over data egress and request context. This module owns the policy + access levels + a single regular perimeter; build perimeter bridges and dry-run specs on top once the foundation is stable.

Module structure

terraform-module-gcp-access-context-manager/
├── 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 {
  # Either create the org access policy or reuse an existing one. There is
  # normally a single org-wide policy, so reuse is the common case at scale.
  policy_name = var.create_policy ? google_access_context_manager_access_policy.this[0].name : var.existing_policy_id

  parent = "accessPolicies/${local.policy_name}"

  # Fully-qualified perimeter resource name (short_name must be letter-led,
  # alphanumeric + underscore only).
  perimeter_resource_name = "${local.parent}/servicePerimeters/${var.perimeter_name}"

  # Access-level names referenced by the perimeter, resolved to full names.
  access_level_names = [
    for k in var.perimeter_access_levels :
    "${local.parent}/accessLevels/${k}"
  ]
}

# Org-scoped access policy. Created only when create_policy = true. ACM is a
# global, org-wide control; restrictions apply across all projects in the org.
resource "google_access_context_manager_access_policy" "this" {
  count = var.create_policy ? 1 : 0

  parent = "organizations/${var.org_id}"
  title  = var.policy_title

  # Optional: scope the policy to specific folders/projects instead of org-wide.
  scopes = var.policy_scopes
}

# Reusable access levels describing trusted context (corporate IPs, regions,
# identities). Each is keyed by its short_name in the access_levels map.
resource "google_access_context_manager_access_level" "this" {
  for_each = var.access_levels

  parent = local.parent
  name   = "${local.parent}/accessLevels/${each.key}"
  title  = coalesce(each.value.title, each.key)

  basic {
    # AND = every condition must hold; OR = any one condition suffices.
    combining_function = each.value.combining_function

    dynamic "conditions" {
      for_each = each.value.conditions
      content {
        # Corporate / trusted CIDR ranges the request must originate from.
        ip_subnetworks = conditions.value.ip_subnetworks

        # ISO 3166-1 alpha-2 country codes the request must come from.
        regions = conditions.value.regions

        # Allowed identities (user:/serviceAccount:) for this condition.
        members = conditions.value.members

        # Other access levels (by full name) that must ALSO be granted.
        required_access_levels = [
          for rl in conditions.value.required_access_levels :
          "${local.parent}/accessLevels/${rl}"
        ]

        # Negate turns the condition into a NAND over its non-empty fields.
        negate = conditions.value.negate
      }
    }
  }
}

# The regular service perimeter: the actual data-exfiltration boundary.
resource "google_access_context_manager_service_perimeter" "this" {
  parent         = local.parent
  name           = local.perimeter_resource_name
  title          = var.perimeter_title
  description    = var.perimeter_description
  perimeter_type = var.perimeter_type

  status {
    # Projects (by NUMBER, projects/<num>) enclosed by the perimeter.
    resources = var.perimeter_resources

    # Services protected by the perimeter boundary.
    restricted_services = var.restricted_services

    # Access levels that grant controlled ingress from trusted context.
    access_levels = local.access_level_names

    # Restrict which APIs are reachable from inside the perimeter networks.
    dynamic "vpc_accessible_services" {
      for_each = var.enable_vpc_accessible_services ? [1] : []
      content {
        enable_restriction = true
        allowed_services   = var.vpc_allowed_services
      }
    }
  }

  lifecycle {
    # Access levels are often managed in waves; ignore membership churn driven
    # by other tooling if you delegate level management outside this module.
    create_before_destroy = true
  }
}

variables.tf

variable "org_id" {
  type        = string
  description = "Numeric organization ID that owns the access policy."

  validation {
    condition     = can(regex("^[0-9]+$", var.org_id))
    error_message = "org_id must be the numeric organization ID."
  }
}

variable "create_policy" {
  type        = bool
  description = "Create the org access policy. Set false to reuse an existing one via existing_policy_id (the common case at scale)."
  default     = true
}

variable "existing_policy_id" {
  type        = string
  description = "Existing access policy ID (the {policy_id} number) to reuse when create_policy = false."
  default     = null

  validation {
    condition     = var.existing_policy_id == null || can(regex("^[0-9]+$", var.existing_policy_id))
    error_message = "existing_policy_id must be the numeric policy ID or null."
  }
}

variable "policy_title" {
  type        = string
  description = "Human-readable title for the access policy (used only when create_policy = true)."
  default     = "Org Access Policy (managed by Terraform)"
}

variable "policy_scopes" {
  type        = list(string)
  description = "Optional folder/project scopes for the policy (folders/<id> or projects/<num>). Empty = org-wide."
  default     = []
}

variable "access_levels" {
  description = <<-EOT
    Map of access levels keyed by short_name. Each value:
      title              - display title (defaults to the key)
      combining_function - "AND" or "OR" across conditions (default "AND")
      conditions         - list of condition objects:
          ip_subnetworks         - list of trusted CIDRs (default [])
          regions                - list of ISO 3166-1 alpha-2 codes (default [])
          members                - list of user:/serviceAccount: (default [])
          required_access_levels - other access-level short_names (default [])
          negate                 - NAND the condition (default false)
  EOT
  type = map(object({
    title              = optional(string)
    combining_function = optional(string, "AND")
    conditions = list(object({
      ip_subnetworks         = optional(list(string), [])
      regions                = optional(list(string), [])
      members                = optional(list(string), [])
      required_access_levels = optional(list(string), [])
      negate                 = optional(bool, false)
    }))
  }))
  default = {}

  validation {
    condition = alltrue([
      for lvl in values(var.access_levels) :
      contains(["AND", "OR"], lvl.combining_function)
    ])
    error_message = "Each access level combining_function must be AND or OR."
  }
}

variable "perimeter_name" {
  type        = string
  description = "Short name for the service perimeter (letter-led, alphanumeric and underscore only)."

  validation {
    condition     = can(regex("^[a-zA-Z][a-zA-Z0-9_]*$", var.perimeter_name))
    error_message = "perimeter_name must start with a letter and contain only alphanumerics and underscores."
  }
}

variable "perimeter_title" {
  type        = string
  description = "Human-readable title for the perimeter. Defaults to perimeter_name."
  default     = null
}

variable "perimeter_description" {
  type        = string
  description = "Description of the perimeter and its purpose. Does not affect behavior."
  default     = "Managed by Terraform (kloudvin terraform-module-gcp-access-context-manager)."
}

variable "perimeter_type" {
  type        = string
  description = "Perimeter type: PERIMETER_TYPE_REGULAR (contains resources + services) or PERIMETER_TYPE_BRIDGE."
  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 "perimeter_resources" {
  type        = list(string)
  description = "Projects enclosed by the perimeter, by NUMBER (e.g. [\"projects/123456789012\"])."
  default     = []

  validation {
    condition     = alltrue([for r in var.perimeter_resources : can(regex("^projects/[0-9]+$", r))])
    error_message = "Each perimeter_resources entry must be of the form projects/<project_number>."
  }
}

variable "restricted_services" {
  type        = list(string)
  description = "GCP services protected by the perimeter (e.g. [\"storage.googleapis.com\", \"bigquery.googleapis.com\"])."
  default     = []
}

variable "perimeter_access_levels" {
  type        = list(string)
  description = "Access-level short_names (keys of access_levels) that grant trusted ingress to this perimeter."
  default     = []
}

variable "enable_vpc_accessible_services" {
  type        = bool
  description = "Restrict which APIs are reachable from inside the perimeter networks to vpc_allowed_services."
  default     = false
}

variable "vpc_allowed_services" {
  type        = list(string)
  description = "Services usable from inside the perimeter when enable_vpc_accessible_services = true."
  default     = []
}

outputs.tf

output "policy_id" {
  description = "The access policy ID (policy_id number) — created or reused."
  value       = var.create_policy ? google_access_context_manager_access_policy.this[0].name : var.existing_policy_id
}

output "policy_name" {
  description = "Fully-qualified access policy name (accessPolicies/<policy_id>)."
  value       = "accessPolicies/${var.create_policy ? google_access_context_manager_access_policy.this[0].name : var.existing_policy_id}"
}

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

output "perimeter_name" {
  description = "Fully-qualified service perimeter resource name."
  value       = google_access_context_manager_service_perimeter.this.name
}

output "perimeter_title" {
  description = "Title of the service perimeter."
  value       = google_access_context_manager_service_perimeter.this.title
}

output "restricted_services" {
  description = "Services protected by the perimeter boundary."
  value       = google_access_context_manager_service_perimeter.this.status[0].restricted_services
}

How to use it

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

  # Reuse the single org-wide policy rather than creating a second one.
  create_policy      = false
  existing_policy_id = "987654321098"
  org_id             = "123456789012"

  # Two reusable access levels: trusted corp network, and a break-glass identity.
  access_levels = {
    corp_network = {
      title              = "Corporate network + EU only"
      combining_function = "AND"
      conditions = [
        {
          ip_subnetworks = ["203.0.113.0/24", "198.51.100.0/24"]
          regions        = ["DE", "IE", "NL"]
        },
      ]
    }

    break_glass = {
      title = "Break-glass service accounts"
      conditions = [
        {
          members = [
            "serviceAccount:sec-breakglass@kv-sec-prod.iam.gserviceaccount.com",
          ]
        },
      ]
    }
  }

  # Regular perimeter around the data-lake project, protecting storage + BQ.
  perimeter_name        = "data_lake"
  perimeter_title       = "Data Lake Perimeter"
  perimeter_resources   = ["projects/555555555555"]
  restricted_services   = [
    "storage.googleapis.com",
    "bigquery.googleapis.com",
    "bigtable.googleapis.com",
  ]

  # Allow controlled ingress from the trusted contexts above.
  perimeter_access_levels = ["corp_network", "break_glass"]

  # Lock the perimeter's VMs to only the APIs they actually need.
  enable_vpc_accessible_services = true
  vpc_allowed_services = [
    "storage.googleapis.com",
    "bigquery.googleapis.com",
    "logging.googleapis.com",
    "monitoring.googleapis.com",
  ]
}

# Downstream: surface the perimeter name for the security runbook / SIEM config.
resource "google_secret_manager_secret_version" "perimeter_ref" {
  secret      = google_secret_manager_secret.vpc_sc_ref.id
  secret_data = module.vpc_sc.perimeter_name
}

Roll perimeters out in dry-run mode first (the resource supports a spec + use_explicit_dry_run_spec) so you can see which requests would be blocked in the audit logs before enforcing — flipping straight to enforced often breaks legitimate cross-project pipelines.

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-access-context-manager?ref=v1.0.0"
}

inputs = {
  org_id = "..."
  perimeter_name = "..."
  perimeter_resources = ["..."]
  restricted_services = ["..."]
}

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
org_id string Yes Numeric organization ID owning the policy.
perimeter_name string Yes Short name for the perimeter (letter-led, [A-Za-z0-9_]).
create_policy bool true No Create the org policy, or reuse an existing one.
existing_policy_id string null No Existing policy ID to reuse when create_policy = false.
policy_title string "Org Access Policy…" No Title used when creating the policy.
policy_scopes list(string) [] No Folder/project scopes; empty = org-wide.
access_levels map(object) {} No Access levels keyed by short_name (conditions + combining function).
perimeter_title string null No Perimeter title; defaults to perimeter_name.
perimeter_description string "Managed by Terraform…" No Perimeter description.
perimeter_type string "PERIMETER_TYPE_REGULAR" No PERIMETER_TYPE_REGULAR or PERIMETER_TYPE_BRIDGE.
perimeter_resources list(string) [] No Enclosed projects by number (projects/<num>).
restricted_services list(string) [] No Services protected by the perimeter.
perimeter_access_levels list(string) [] No Access-level short_names granting trusted ingress.
enable_vpc_accessible_services bool false No Restrict in-perimeter API reachability.
vpc_allowed_services list(string) [] No APIs usable from inside the perimeter when restriction is on.

Outputs

Name Description
policy_id The access policy ID (created or reused).
policy_name Fully-qualified policy name (accessPolicies/<id>).
access_level_names Map of short_name → fully-qualified access-level name.
perimeter_name Fully-qualified service perimeter resource name.
perimeter_title Perimeter title.
restricted_services Services protected by the perimeter.

Enterprise scenario

A healthcare analytics company holds de-identified patient data in a dedicated data-lake project (projects/555555555555) and must guarantee, for its HIPAA attestation, that no credential — leaked or insider — can copy a BigQuery dataset or GCS bucket out of that project. The org already has a single org-wide access policy, so the platform team sets create_policy = false and references it. They define a corp_network access level (their VPN egress CIDRs, restricted to EU regions) and a break_glass level for the security team’s emergency service account, then enclose the data-lake project in a PERIMETER_TYPE_REGULAR perimeter restricting storage, bigquery, and bigtable. enable_vpc_accessible_services = true further pins the perimeter’s analytics VMs to only storage, BigQuery, logging, and monitoring APIs. They first ship it as a dry-run spec for two weeks, watch the VPC-SC audit logs for false positives in their ETL pipelines, fix two cross-project service accounts, and only then enforce — so the boundary goes live with zero broken workloads and a clean exfiltration control for the auditors.

Best practices

TerraformGCPAccess Context ManagerModuleIaC
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