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:
- An Access Policy (
google_access_context_manager_access_policy) — an org-scoped container (parent = "organizations/<org_id>"). There is normally exactly one organization-wide access policy per org; everything else lives inside it. (Scoped policies on a folder/project are a newer exception.) - Access Levels (
google_access_context_manager_access_level) — reusable, named conditions describing trusted context: abasic { conditions { ip_subnetworks, regions, members, required_access_levels } }block combined withAND/OR. An access level says “requests from these corporate CIDRs, in these countries, from these identities are trusted”. - Service Perimeters (
google_access_context_manager_service_perimeter) — the boundary itself. APERIMETER_TYPE_REGULARperimeter has astatus { resources, restricted_services, access_levels, vpc_accessible_services { enable_restriction, allowed_services } }. Therestricted_serviceslist is what’s protected;access_levelspunch controlled holes for trusted context; andvpc_accessible_servicesrestricts which APIs are even reachable from inside the perimeter’s networks.
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
- You are implementing VPC Service Controls to stop data exfiltration from sensitive projects (a data lake, a regulated workload, a project holding PII) and need the access policy, access levels, and perimeter codified together.
- You want context-aware access: only allow API calls to restricted services from corporate IP ranges, specific countries, or named service accounts, expressed once as a reusable access level and attached to one or more perimeters.
- You need to lock down which Google APIs are even callable from inside a perimeter’s VMs (
vpc_accessible_services) so a compromised instance cannot reach an arbitrary API. - You are passing a compliance/audit requirement (HIPAA, PCI, data-residency) that mandates a hard network boundary around regulated data, and you want it as reviewed Terraform rather than console clicks.
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 config — live/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 config — live/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
- Keep one org-wide access policy and reuse it. An organization normally has a single access policy; creating a second one fragments your VPC-SC posture. Set
create_policy = falseand passexisting_policy_ideverywhere except the one stack that bootstraps it. - Always roll out in dry-run before enforcing. VPC-SC blocks are silent and brutal — a perimeter flipped straight to enforced will break legitimate cross-project pipelines. Use the perimeter’s dry-run
spec, watch the audit logs, fix the false positives, then enforce. - Reference projects by number, not ID.
perimeter_resourcesmust beprojects/<project_number>; project IDs change semantics and the API rejects them. Pull the number fromgoogle_project.number, not the human-friendly ID. - Make access levels reusable and least-context. Build narrow levels (corp CIDRs, allowed countries, specific service accounts) and reference them from multiple perimeters via
perimeter_access_levels, rather than baking one-off conditions into each perimeter. - Restrict in-perimeter API reachability. Turn on
enable_vpc_accessible_servicesand list only the APIs the workload truly calls — this stops a compromised VM inside the perimeter from reaching arbitrary Google APIs as a lateral-movement path. - Set the provider for ACM correctly. ACM is org-scoped; with user ADCs you generally need
user_project_override = trueand abilling_project, and the caller needsserviceusage.services.useplus Access Context Manager admin on the org — otherwise the API returns a 403 that looks like a Terraform bug.